This commit is contained in:
2026-01-06 18:06:51 +03:00
parent 326ced233b
commit efe3f4ab43
71 changed files with 245 additions and 245 deletions
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,13 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
def new_session_maker(db_url: str) -> async_sessionmaker[AsyncSession]:
engine = create_async_engine(
db_url,
pool_size=15,
max_overflow=15,
connect_args={
"timeout": 5,
},
)
return async_sessionmaker(engine, class_=AsyncSession, autoflush=False, expire_on_commit=False)
@@ -0,0 +1,5 @@
from .option import OptionDAO as OptionDAO
from .question import QuestionDAO as QuestionDAO
from .test import TestDAO as TestDAO
from .user import UserDAO as UserDAO
@@ -0,0 +1,73 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from quizzi.domain.schemas import Group as DomainGroup
from quizzi.infrastructure.database.dto.group import GroupDTO
from quizzi.infrastructure.database.models import Group
class GroupDAO:
def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session
async def get_by_id(self, group_id: int) -> DomainGroup | None:
result = await self.session.execute(
select(Group).where(Group.id == group_id)
)
model = result.scalar_one_or_none()
return GroupDTO(model).to_domain() if model else None
async def get_by_number(self, number: int) -> DomainGroup | None:
result = await self.session.execute(
select(Group).where(Group.number == number)
)
model = result.scalar_one_or_none()
return GroupDTO(model).to_domain() if model else None
async def get_all(self) -> list[DomainGroup]:
result = await self.session.execute(select(Group))
models = list(result.scalars().all())
return [GroupDTO(model).to_domain() for model in models]
async def create(
self,
number: int,
) -> DomainGroup:
group = Group(
number=number,
)
self.session.add(group)
await self.session.flush()
await self.session.refresh(group)
return GroupDTO(group).to_domain()
async def update(
self,
group_id: int,
number: int | None = None
) -> DomainGroup | None:
result = await self.session.execute(
select(Group).where(Group.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
return None
if number is not None:
group.number = number
await self.session.flush()
await self.session.refresh(group)
return GroupDTO(group).to_domain()
async def delete(self, group_id: int) -> bool:
result = await self.session.execute(
select(Group).where(Group.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
return False
await self.session.delete(group)
await self.session.flush()
return True
@@ -0,0 +1,78 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from quizzi.domain.schemas import Option as DomainOption
from quizzi.infrastructure.database.dto.option import OptionDTO
from quizzi.infrastructure.database.models import Option
class OptionDAO:
def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session
async def get_by_id(self, option_id: int) -> DomainOption | None:
result = await self.session.execute(
select(Option).where(Option.id == option_id)
)
model = result.scalar_one_or_none()
return OptionDTO(model).to_domain() if model else None
async def get_all(self) -> list[DomainOption]:
result = await self.session.execute(select(Option))
models = list(result.scalars().all())
return [OptionDTO(model).to_domain() for model in models]
async def create(
self,
question_id: int,
text: str,
is_correct: bool = False,
explanation: str | None = None,
) -> DomainOption:
option = Option(
question_id=question_id,
text=text,
is_correct=is_correct,
explanation=explanation,
)
self.session.add(option)
await self.session.flush()
await self.session.refresh(option)
return OptionDTO(option).to_domain()
async def update(
self,
option_id: int,
text: str | None = None,
is_correct: bool | None = None,
explanation: str | None = None,
) -> DomainOption | None:
result = await self.session.execute(
select(Option).where(Option.id == option_id)
)
option = result.scalar_one_or_none()
if not option:
return None
if text is not None:
option.text = text
if is_correct is not None:
option.is_correct = is_correct
if explanation is not None:
option.explanation = explanation
await self.session.flush()
await self.session.refresh(option)
return OptionDTO(option).to_domain()
async def delete(self, option_id: int) -> bool:
result = await self.session.execute(
select(Option).where(Option.id == option_id)
)
option = result.scalar_one_or_none()
if not option:
return False
await self.session.delete(option)
await self.session.flush()
return True
@@ -0,0 +1,88 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from quizzi.domain.schemas import Question as DomainQuestion
from quizzi.domain.schemas import QuestionType
from quizzi.infrastructure.database.dto.question import QuestionDTO
from quizzi.infrastructure.database.models import Question
class QuestionDAO:
def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session
async def get_by_id(self, question_id: int) -> DomainQuestion | None:
result = await self.session.execute(
select(Question).where(Question.id == question_id)
)
model = result.scalar_one_or_none()
return QuestionDTO(model).to_domain() if model else None
async def get_all(self) -> list[DomainQuestion]:
result = await self.session.execute(select(Question))
models = list(result.scalars().all())
return [QuestionDTO(model).to_domain() for model in models]
async def create(
self,
test_id: int,
text: str,
position: int = 0,
question_type: str | QuestionType = QuestionType.SINGLE,
tg_file_id: str | None = None,
) -> DomainQuestion:
if isinstance(question_type, str):
question_type = QuestionType(question_type)
question = Question(
test_id=test_id,
text=text,
position=position,
question_type=question_type,
tg_file_id=tg_file_id,
)
self.session.add(question)
await self.session.flush()
await self.session.refresh(question)
return QuestionDTO(question).to_domain()
async def update(
self,
question_id: int,
text: str | None = None,
position: int | None = None,
question_type: str | QuestionType | None = None,
tg_file_id: str | None = None,
) -> DomainQuestion | None:
result = await self.session.execute(
select(Question).where(Question.id == question_id)
)
question = result.scalar_one_or_none()
if not question:
return None
if text is not None:
question.text = text
if position is not None:
question.position = position
if question_type is not None:
if isinstance(question_type, str):
question_type = QuestionType(question_type)
question.question_type = question_type
if tg_file_id is not None:
question.tg_file_id = tg_file_id
await self.session.flush()
await self.session.refresh(question)
return QuestionDTO(question).to_domain()
async def delete(self, question_id: int) -> bool:
result = await self.session.execute(
select(Question).where(Question.id == question_id)
)
question = result.scalar_one_or_none()
if not question:
return False
await self.session.delete(question)
await self.session.flush()
return True
@@ -0,0 +1,135 @@
from datetime import datetime
from typing import NotRequired, TypedDict, Unpack
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from quizzi.domain.schemas import Test as DomainTest
from quizzi.infrastructure.database.dto.test import TestDTO
from quizzi.infrastructure.database.models import Test
class _UNSET:
"""Sentinel для различения None и "не передано"."""
pass
UNSET = _UNSET()
class TestUpdateFields(TypedDict, total=False):
title: str
description: str | None
for_group: int | None
password: str | None
expires_at: datetime | None
attempts: int | None
is_active: bool
are_results_viewable: bool
class TestDAO:
def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session
async def get_by_id(self, test_id: int) -> DomainTest | None:
result = await self.session.execute(
select(Test).where(Test.id == test_id)
)
model = result.scalar_one_or_none()
return TestDTO(model).to_domain() if model else None
async def get_all(self) -> list[DomainTest]:
result = await self.session.execute(
select(Test).order_by(Test.created_at.desc())
)
models = list(result.scalars().all())
return [TestDTO(model).to_domain() for model in models]
async def get_expired_active_tests(self, now: datetime) -> list[DomainTest]:
result = await self.session.execute(
select(Test)
.where(Test.is_active == True)
.where(Test.expires_at.isnot(None))
.where(Test.expires_at < now)
)
models = list(result.scalars().all())
return [TestDTO(model).to_domain() for model in models]
async def create(
self,
title: str,
description: str | None = None,
for_group: int | None = None,
password: str | None = None,
expires_at: datetime | None = None,
attempts: int | None = None,
is_active: bool = True,
are_results_viewable: bool = False,
) -> DomainTest:
test = Test(
title=title,
description=description,
for_group=for_group,
password=password,
expires_at=expires_at,
attempts=attempts,
is_active=is_active,
are_results_viewable=are_results_viewable,
)
self.session.add(test)
await self.session.flush()
await self.session.refresh(test)
return TestDTO(test).to_domain()
async def update(
self,
test_id: int,
title: str | _UNSET = UNSET,
description: str | None | _UNSET = UNSET,
for_group: int | None | _UNSET = UNSET,
password: str | None | _UNSET = UNSET,
expires_at: datetime | None | _UNSET = UNSET,
attempts: int | None | _UNSET = UNSET,
is_active: bool | _UNSET = UNSET,
are_results_viewable: bool | _UNSET = UNSET,
) -> DomainTest | None:
result = await self.session.execute(
select(Test).where(Test.id == test_id)
)
test = result.scalar_one_or_none()
if not test:
return None
if not isinstance(title, _UNSET):
test.title = title
if not isinstance(description, _UNSET):
test.description = description
if not isinstance(for_group, _UNSET):
test.for_group = for_group
if not isinstance(password, _UNSET):
test.password = password
if not isinstance(expires_at, _UNSET):
test.expires_at = expires_at
if not isinstance(attempts, _UNSET):
test.attempts = attempts
if not isinstance(is_active, _UNSET):
test.is_active = is_active
if not isinstance(are_results_viewable, _UNSET):
test.are_results_viewable = are_results_viewable
await self.session.flush()
await self.session.refresh(test)
return TestDTO(test).to_domain()
async def delete(self, test_id: int) -> bool:
result = await self.session.execute(
select(Test).where(Test.id == test_id)
)
test = result.scalar_one_or_none()
if not test:
return False
await self.session.delete(test)
await self.session.flush()
return True
@@ -0,0 +1,87 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from quizzi.domain.schemas import TestAttempt as DomainTestAttempt
from quizzi.infrastructure.database.dto.test_attempt import TestAttemptDTO
from quizzi.infrastructure.database.models import TestAttempt
class TestAttemptDAO:
def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session
async def get_by_id(self, attempt_id: int) -> DomainTestAttempt | None:
result = await self.session.execute(
select(TestAttempt).where(TestAttempt.id == attempt_id)
)
model = result.scalar_one_or_none()
return TestAttemptDTO(model).to_domain() if model else None
async def get_all(self) -> list[DomainTestAttempt]:
result = await self.session.execute(select(TestAttempt))
models = list(result.scalars().all())
return [TestAttemptDTO(model).to_domain() for model in models]
async def create(
self,
user_id: int,
test_id: int,
score: int = 0,
is_passed: bool = False,
) -> DomainTestAttempt:
attempt = TestAttempt(
user_id=user_id,
test_id=test_id,
score=score,
is_passed=is_passed,
)
self.session.add(attempt)
await self.session.flush()
await self.session.refresh(attempt)
return TestAttemptDTO(attempt).to_domain()
async def update(
self,
attempt_id: int,
finished_at: datetime | None = None,
score: int | None = None,
is_passed: bool | None = None,
) -> DomainTestAttempt | None:
result = await self.session.execute(
select(TestAttempt).where(TestAttempt.id == attempt_id)
)
attempt = result.scalar_one_or_none()
if not attempt:
return None
if finished_at is not None:
attempt.finished_at = finished_at
if score is not None:
attempt.score = score
if is_passed is not None:
attempt.is_passed = is_passed
await self.session.flush()
await self.session.refresh(attempt)
return TestAttemptDTO(attempt).to_domain()
async def delete(self, attempt_id: int) -> bool:
result = await self.session.execute(
select(TestAttempt).where(TestAttempt.id == attempt_id)
)
attempt = result.scalar_one_or_none()
if not attempt:
return False
await self.session.delete(attempt)
await self.session.flush()
return True
async def get_by_user_id(self, user_id: int) -> list[DomainTestAttempt]:
result = await self.session.execute(
select(TestAttempt).where(TestAttempt.user_id == user_id)
)
models = list(result.scalars().all())
return [TestAttemptDTO(model).to_domain() for model in models]
@@ -0,0 +1,157 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from quizzi.domain.schemas import User as DomainUser
from quizzi.infrastructure.database.dto.user import UserDTO
from quizzi.infrastructure.database.models import User
class _UNSET:
"""Sentinel для различения None и "не передано"."""
pass
UNSET = _UNSET()
class UserDAO:
def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session
async def get_by_id(self, user_id: int) -> DomainUser | None:
result = await self.session.execute(
select(User).where(User.id == user_id)
)
model = result.scalar_one_or_none()
return UserDTO(model).to_domain() if model else None
async def get_by_username(self, username: str) -> DomainUser | None:
result = await self.session.execute(
select(User).where(User.username == username)
)
model = result.scalar_one_or_none()
return UserDTO(model).to_domain() if model else None
async def get_all(self) -> list[DomainUser]:
result = await self.session.execute(
select(User).order_by(User.created_at.desc())
)
models = list(result.scalars().all())
return [UserDTO(model).to_domain() for model in models]
async def create(
self,
user_id: int,
first_name: str,
username: str | None = None,
last_name: str | None = None,
name: str | None = None,
group: int | None = None,
is_admin: bool = False,
) -> DomainUser:
user = User(
id=user_id,
username=username,
first_name=first_name,
last_name=last_name,
name=name,
group=group,
is_admin=is_admin,
)
self.session.add(user)
await self.session.flush()
await self.session.refresh(user)
return UserDTO(user).to_domain()
async def update(
self,
user_id: int,
username: str | None | _UNSET = UNSET,
first_name: str | _UNSET = UNSET,
last_name: str | None | _UNSET = UNSET,
name: str | None | _UNSET = UNSET,
group: int | None | _UNSET = UNSET,
is_admin: bool | _UNSET = UNSET,
name_updated_at: datetime | None | _UNSET = UNSET,
group_updated_at: datetime | None | _UNSET = UNSET,
) -> DomainUser | None:
result = await self.session.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
return None
if not isinstance(username, _UNSET):
user.username = username
if not isinstance(first_name, _UNSET):
user.first_name = first_name
if not isinstance(last_name, _UNSET):
user.last_name = last_name
if not isinstance(name, _UNSET):
user.name = name
if not isinstance(group, _UNSET):
user.group = group
if not isinstance(is_admin, _UNSET):
user.is_admin = is_admin
if not isinstance(name_updated_at, _UNSET):
user.name_updated_at = name_updated_at
if not isinstance(group_updated_at, _UNSET):
user.group_updated_at = group_updated_at
await self.session.flush()
await self.session.refresh(user)
return UserDTO(user).to_domain()
async def delete(self, user_id: int) -> bool:
result = await self.session.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
return False
await self.session.delete(user)
await self.session.flush()
return True
async def upsert(
self,
user_id: int,
first_name: str,
username: str | None = None,
last_name: str | None = None,
name: str | None | _UNSET = UNSET,
group: int | None | _UNSET = UNSET,
is_admin: bool | _UNSET = UNSET,
) -> DomainUser:
result = await self.session.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user:
user.username = username
user.first_name = first_name
user.last_name = last_name
if not isinstance(name, _UNSET):
user.name = name
if not isinstance(group, _UNSET):
user.group = group
if not isinstance(is_admin, _UNSET):
user.is_admin = is_admin
await self.session.flush()
await self.session.refresh(user)
return UserDTO(user).to_domain()
return await self.create(
user_id=user_id,
username=username,
first_name=first_name,
last_name=last_name,
name=name if not isinstance(name, _UNSET) else None,
group=group if not isinstance(group, _UNSET) else None,
is_admin=is_admin if not isinstance(is_admin, _UNSET) else False,
)
@@ -0,0 +1,80 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from quizzi.domain.schemas import UserAnswer as DomainUserAnswer
from quizzi.infrastructure.database.dto.user_answer import UserAnswerDTO
from quizzi.infrastructure.database.models import UserAnswer
class UserAnswerDAO:
def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session
async def get_by_id(self, answer_id: int) -> DomainUserAnswer | None:
result = await self.session.execute(
select(UserAnswer).where(UserAnswer.id == answer_id)
)
model = result.scalar_one_or_none()
return UserAnswerDTO(model).to_domain() if model else None
async def get_all(self) -> list[DomainUserAnswer]:
result = await self.session.execute(select(UserAnswer))
models = list(result.scalars().all())
return [UserAnswerDTO(model).to_domain() for model in models]
async def create(
self,
attempt_id: int,
question_id: int,
selected_option_id: int | None = None,
text_answer: str | None = None,
is_correct: bool = False,
) -> DomainUserAnswer:
answer = UserAnswer(
attempt_id=attempt_id,
question_id=question_id,
selected_option_id=selected_option_id,
text_answer=text_answer,
is_correct=is_correct,
)
self.session.add(answer)
await self.session.flush()
await self.session.refresh(answer)
return UserAnswerDTO(answer).to_domain()
async def update(
self,
answer_id: int,
selected_option_id: int | None = None,
text_answer: str | None = None,
is_correct: bool | None = None,
) -> DomainUserAnswer | None:
result = await self.session.execute(
select(UserAnswer).where(UserAnswer.id == answer_id)
)
answer = result.scalar_one_or_none()
if not answer:
return None
if selected_option_id is not None:
answer.selected_option_id = selected_option_id
if text_answer is not None:
answer.text_answer = text_answer
if is_correct is not None:
answer.is_correct = is_correct
await self.session.flush()
await self.session.refresh(answer)
return UserAnswerDTO(answer).to_domain()
async def delete(self, answer_id: int) -> bool:
result = await self.session.execute(
select(UserAnswer).where(UserAnswer.id == answer_id)
)
answer = result.scalar_one_or_none()
if not answer:
return False
await self.session.delete(answer)
await self.session.flush()
return True
@@ -0,0 +1,15 @@
from quizzi.domain.schemas import Group as DomainGroup
from quizzi.infrastructure.database.models import Group as GroupModel
class GroupDTO:
def __init__(self, model: GroupModel) -> None:
self.model: GroupModel = model
def to_domain(self) -> DomainGroup:
return DomainGroup(
id=self.model.id,
number=self.model.number,
created_at=self.model.created_at,
updated_at=self.model.updated_at,
)
@@ -0,0 +1,16 @@
from quizzi.domain.schemas import Option as DomainOption
from quizzi.infrastructure.database.models import Option as OptionModel
class OptionDTO:
def __init__(self, model: OptionModel) -> None:
self.model: OptionModel = model
def to_domain(self) -> DomainOption:
return DomainOption(
id=self.model.id,
question_id=self.model.question_id,
text=self.model.text,
is_correct=self.model.is_correct,
explanation=self.model.explanation,
)
@@ -0,0 +1,17 @@
from quizzi.domain.schemas import Question as DomainQuestion
from quizzi.infrastructure.database.models import Question as QuestionModel
class QuestionDTO:
def __init__(self, model: QuestionModel) -> None:
self.model: QuestionModel = model
def to_domain(self) -> DomainQuestion:
return DomainQuestion(
id=self.model.id,
test_id=self.model.test_id,
text=self.model.text,
position=self.model.position,
question_type=self.model.question_type,
tg_file_id=self.model.tg_file_id,
)
@@ -0,0 +1,22 @@
from quizzi.domain.schemas import Test as DomainTest
from quizzi.infrastructure.database.models import Test as TestModel
class TestDTO:
def __init__(self, model: TestModel) -> None:
self.model: TestModel = model
def to_domain(self) -> DomainTest:
return DomainTest(
id=self.model.id,
title=self.model.title,
description=self.model.description,
for_group=self.model.for_group,
password=self.model.password,
expires_at=self.model.expires_at,
attempts=self.model.attempts,
is_active=self.model.is_active,
are_results_viewable=self.model.are_results_viewable,
created_at=self.model.created_at,
updated_at=self.model.updated_at,
)
@@ -0,0 +1,18 @@
from quizzi.domain.schemas import TestAttempt as DomainTestAttempt
from quizzi.infrastructure.database.models import TestAttempt as TestAttemptModel
class TestAttemptDTO:
def __init__(self, model: TestAttemptModel) -> None:
self.model: TestAttemptModel = model
def to_domain(self) -> DomainTestAttempt:
return DomainTestAttempt(
id=self.model.id,
user_id=self.model.user_id,
test_id=self.model.test_id,
started_at=self.model.started_at,
finished_at=self.model.finished_at,
score=self.model.score,
is_passed=self.model.is_passed,
)
@@ -0,0 +1,22 @@
from quizzi.domain.schemas import User as DomainUser
from quizzi.infrastructure.database.models import User as UserModel
class UserDTO:
def __init__(self, model: UserModel) -> None:
self.model: UserModel = model
def to_domain(self) -> DomainUser:
return DomainUser(
id=self.model.id,
username=self.model.username,
first_name=self.model.first_name,
last_name=self.model.last_name,
name=self.model.name,
group=self.model.group,
is_admin=self.model.is_admin,
name_updated_at=self.model.name_updated_at,
group_updated_at=self.model.group_updated_at,
created_at=self.model.created_at,
updated_at=self.model.updated_at,
)
@@ -0,0 +1,17 @@
from quizzi.domain.schemas import UserAnswer as DomainUserAnswer
from quizzi.infrastructure.database.models import UserAnswer as UserAnswerModel
class UserAnswerDTO:
def __init__(self, model: UserAnswerModel) -> None:
self.model: UserAnswerModel = model
def to_domain(self) -> DomainUserAnswer:
return DomainUserAnswer(
id=self.model.id,
attempt_id=self.model.attempt_id,
question_id=self.model.question_id,
selected_option_id=self.model.selected_option_id,
text_answer=self.model.text_answer,
is_correct=self.model.is_correct,
)
@@ -0,0 +1,132 @@
from datetime import datetime
from typing import final
from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from quizzi.domain.schemas import QuestionType
class Base(DeclarativeBase):
pass
@final
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
username: Mapped[str | None] = mapped_column(String(32), index=True)
first_name: Mapped[str] = mapped_column(String(64))
last_name: Mapped[str | None] = mapped_column(String(64))
name: Mapped[str | None] = mapped_column(String(128))
group: Mapped[int | None] = mapped_column(CheckConstraint("group >= 1000 AND group <= 9999"), index=True)
is_admin: Mapped[bool] = mapped_column(default=False)
name_updated_at: Mapped[datetime | None] = mapped_column(default=None)
group_updated_at: Mapped[datetime | None] = mapped_column(default=None)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
@final
class Group(Base):
__tablename__ = "groups"
id: Mapped[int] = mapped_column(primary_key=True)
number: Mapped[int] = mapped_column(Integer, unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
__table_args__ = (
CheckConstraint("number >= 1000 AND number <= 9999", name="check_group_number"),
)
@final
class Test(Base):
__tablename__ = "tests"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text)
for_group: Mapped[int | None] = mapped_column(default=None)
password: Mapped[str | None] = mapped_column(String(255), default=None)
expires_at: Mapped[datetime | None] = mapped_column(default=None)
attempts: Mapped[int | None] = mapped_column(Integer, default=None)
is_active: Mapped[bool] = mapped_column(default=True)
are_results_viewable: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
questions: Mapped[list["Question"]] = relationship(
back_populates="test",
cascade="all, delete-orphan",
order_by="Question.position"
)
@final
class Question(Base):
__tablename__ = "questions"
id: Mapped[int] = mapped_column(primary_key=True)
test_id: Mapped[int] = mapped_column(ForeignKey("tests.id"), index=True)
text: Mapped[str] = mapped_column(Text)
position: Mapped[int] = mapped_column(Integer, default=0)
question_type: Mapped[QuestionType] = mapped_column(default=QuestionType.SINGLE)
tg_file_id: Mapped[str | None] = mapped_column(String(255))
test: Mapped["Test"] = relationship(back_populates="questions")
options: Mapped[list["Option"]] = relationship(
back_populates="question",
cascade="all, delete-orphan"
)
@final
class Option(Base):
__tablename__ = "options"
id: Mapped[int] = mapped_column(primary_key=True)
question_id: Mapped[int] = mapped_column(ForeignKey("questions.id"), index=True)
text: Mapped[str] = mapped_column(String(255))
is_correct: Mapped[bool] = mapped_column(default=False)
explanation: Mapped[str | None] = mapped_column(Text)
question: Mapped["Question"] = relationship(back_populates="options")
@final
class TestAttempt(Base):
__tablename__ = "test_attempts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"), index=True)
test_id: Mapped[int] = mapped_column(ForeignKey("tests.id"), index=True)
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
finished_at: Mapped[datetime | None] = mapped_column(default=None)
score: Mapped[int] = mapped_column(Integer, default=0)
is_passed: Mapped[bool] = mapped_column(default=False)
user: Mapped["User"] = relationship()
test: Mapped["Test"] = relationship()
answers: Mapped[list["UserAnswer"]] = relationship(
back_populates="attempt",
cascade="all, delete-orphan"
)
@final
class UserAnswer(Base):
__tablename__ = "user_answers"
id: Mapped[int] = mapped_column(primary_key=True)
attempt_id: Mapped[int] = mapped_column(ForeignKey("test_attempts.id"), index=True)
question_id: Mapped[int] = mapped_column(ForeignKey("questions.id"), index=True)
selected_option_id: Mapped[int | None] = mapped_column(ForeignKey("options.id"), default=None)
text_answer: Mapped[str | None] = mapped_column(Text, default=None)
is_correct: Mapped[bool] = mapped_column(default=False)
attempt: Mapped["TestAttempt"] = relationship(back_populates="answers")
question: Mapped["Question"] = relationship()
selected_option: Mapped["Option | None"] = relationship()
@@ -0,0 +1,5 @@
from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository
from quizzi.infrastructure.database.repo.user import UserRepository
__all__ = ["TestRepository", "TestAttemptRepository", "UserRepository"]
@@ -0,0 +1,209 @@
from typing import final
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from quizzi.domain.schemas import Option, Question, Test
from quizzi.infrastructure.database.dao.option import OptionDAO
from quizzi.infrastructure.database.dao.question import QuestionDAO
from quizzi.infrastructure.database.dao.test import TestDAO
from quizzi.infrastructure.database.dto.option import OptionDTO
from quizzi.infrastructure.database.dto.question import QuestionDTO
from quizzi.infrastructure.database.dto.test import TestDTO
from quizzi.infrastructure.database.models import Option as OptionModel
from quizzi.infrastructure.database.models import Question as QuestionModel
from quizzi.infrastructure.database.models import Test as TestModel
@final
class TestRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.test_dao = TestDAO(session)
self.question_dao = QuestionDAO(session)
self.option_dao = OptionDAO(session)
async def get_active_tests(self) -> list[Test]:
result = await self.session.execute(
select(TestModel).where(TestModel.is_active == True)
)
models = list(result.scalars().all())
return [TestDTO(model).to_domain() for model in models]
async def get_tests_by_group(self, group: int) -> list[Test]:
result = await self.session.execute(
select(TestModel).where(TestModel.for_group == group)
)
models = list(result.scalars().all())
return [TestDTO(model).to_domain() for model in models]
async def get_active_tests_by_group(self, group: int) -> list[Test]:
result = await self.session.execute(
select(TestModel)
.where(TestModel.for_group == group)
.where(TestModel.is_active == True)
)
models = list(result.scalars().all())
return [TestDTO(model).to_domain() for model in models]
async def get_test_with_questions(self, test_id: int) -> tuple[Test | None, list[Question]]:
test = await self.test_dao.get_by_id(test_id)
if not test:
return None, []
result = await self.session.execute(
select(TestModel)
.where(TestModel.id == test_id)
.options(selectinload(TestModel.questions))
)
test_model = result.scalar_one_or_none()
if not test_model:
return test, []
questions = [QuestionDTO(q).to_domain() for q in sorted(test_model.questions, key=lambda x: x.position)]
return test, questions
async def get_question_with_options(self, question_id: int) -> tuple[Question | None, list[Option]]:
question = await self.question_dao.get_by_id(question_id)
if not question:
return None, []
result = await self.session.execute(
select(QuestionModel)
.where(QuestionModel.id == question_id)
.options(selectinload(QuestionModel.options))
)
question_model = result.scalar_one_or_none()
if not question_model:
return question, []
options = [OptionDTO(o).to_domain() for o in question_model.options]
return question, options
async def get_correct_options_for_question(self, question_id: int) -> list[Option]:
result = await self.session.execute(
select(OptionModel)
.where(OptionModel.question_id == question_id)
.where(OptionModel.is_correct == True)
)
models = list(result.scalars().all())
return [OptionDTO(model).to_domain() for model in models]
async def get_full_test(self, test_id: int) -> tuple[Test | None, list[tuple[Question, list[Option]]]]:
test = await self.test_dao.get_by_id(test_id)
if not test:
return None, []
result = await self.session.execute(
select(TestModel)
.where(TestModel.id == test_id)
.options(
selectinload(TestModel.questions).selectinload(QuestionModel.options)
)
)
test_model = result.scalar_one_or_none()
if not test_model:
return test, []
questions_with_options: list[tuple[Question, list[Option]]] = []
for question_model in sorted(test_model.questions, key=lambda x: x.position):
question = QuestionDTO(question_model).to_domain()
options = [OptionDTO(o).to_domain() for o in question_model.options]
questions_with_options.append((question, options))
return test, questions_with_options
async def count_questions_in_test(self, test_id: int) -> int:
result = await self.session.execute(
select(func.count(QuestionModel.id))
.where(QuestionModel.test_id == test_id)
)
count = result.scalar_one()
return count
async def get_questions_with_options_by_ids(
self, question_ids: list[int]
) -> dict[int, tuple[Question, list[Option]]]:
"""Загружает вопросы с опциями по списку ID за один запрос."""
if not question_ids:
return {}
result = await self.session.execute(
select(QuestionModel)
.where(QuestionModel.id.in_(question_ids))
.options(selectinload(QuestionModel.options))
)
question_models = list(result.scalars().all())
questions_dict: dict[int, tuple[Question, list[Option]]] = {}
for qm in question_models:
question = QuestionDTO(qm).to_domain()
options = [OptionDTO(o).to_domain() for o in qm.options]
questions_dict[qm.id] = (question, options)
return questions_dict
async def duplicate_test(self, test_id: int, new_title: str) -> Test | None:
test, questions_with_options = await self.get_full_test(test_id)
if not test:
return None
new_test = await self.test_dao.create(
title=new_title,
description=test.description,
for_group=test.for_group,
is_active=False,
)
for question, options in questions_with_options:
new_question = await self.question_dao.create(
test_id=new_test.id,
text=question.text,
position=question.position,
question_type=question.question_type,
tg_file_id=question.tg_file_id,
)
for option in options:
await self.option_dao.create(
question_id=new_question.id,
text=option.text,
is_correct=option.is_correct,
explanation=option.explanation,
)
return new_test
async def get_available_tests_for_user(self, user_id: int, user_group: int | None) -> list[Test]:
from quizzi.infrastructure.database.models import TestAttempt
subquery = (
select(
TestAttempt.test_id,
func.count(TestAttempt.id).label("attempts_count")
)
.where(TestAttempt.user_id == user_id)
.where(TestAttempt.finished_at.isnot(None))
.group_by(TestAttempt.test_id)
.subquery()
)
query = (
select(TestModel)
.outerjoin(subquery, TestModel.id == subquery.c.test_id)
.where(TestModel.is_active == True)
.where(
(TestModel.for_group == user_group) | (TestModel.for_group.is_(None))
)
.where(
(TestModel.attempts.is_(None)) |
(subquery.c.attempts_count.is_(None)) |
(subquery.c.attempts_count < TestModel.attempts)
)
.order_by(TestModel.created_at.desc())
)
result = await self.session.execute(query)
models = list(result.scalars().all())
return [TestDTO(model).to_domain() for model in models]
@@ -0,0 +1,235 @@
from typing import final
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from quizzi.domain.schemas import TestAttempt, UserAnswer
from quizzi.infrastructure.database.dao.test_attempt import TestAttemptDAO
from quizzi.infrastructure.database.dao.user_answer import UserAnswerDAO
from quizzi.infrastructure.database.dto.test_attempt import TestAttemptDTO
from quizzi.infrastructure.database.dto.user_answer import UserAnswerDTO
from quizzi.infrastructure.database.models import TestAttempt as TestAttemptModel
from quizzi.infrastructure.database.models import UserAnswer as UserAnswerModel
from quizzi.infrastructure.utils.timezone import now_msk_naive
@final
class TestAttemptRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.attempt_dao = TestAttemptDAO(session)
self.answer_dao = UserAnswerDAO(session)
async def get_user_attempts(self, user_id: int) -> list[TestAttempt]:
result = await self.session.execute(
select(TestAttemptModel)
.where(TestAttemptModel.user_id == user_id)
.order_by(TestAttemptModel.started_at.desc())
)
models = list(result.scalars().all())
return [TestAttemptDTO(model).to_domain() for model in models]
async def get_test_attempts(self, test_id: int) -> list[TestAttempt]:
result = await self.session.execute(
select(TestAttemptModel)
.where(TestAttemptModel.test_id == test_id)
.order_by(TestAttemptModel.started_at.desc())
)
models = list(result.scalars().all())
return [TestAttemptDTO(model).to_domain() for model in models]
async def get_user_test_attempts(self, user_id: int, test_id: int) -> list[TestAttempt]:
result = await self.session.execute(
select(TestAttemptModel)
.where(TestAttemptModel.user_id == user_id)
.where(TestAttemptModel.test_id == test_id)
.order_by(TestAttemptModel.started_at.desc())
)
models = list(result.scalars().all())
return [TestAttemptDTO(model).to_domain() for model in models]
async def get_active_attempt(self, user_id: int, test_id: int) -> TestAttempt | None:
result = await self.session.execute(
select(TestAttemptModel)
.where(TestAttemptModel.user_id == user_id)
.where(TestAttemptModel.test_id == test_id)
.where(TestAttemptModel.finished_at == None)
.order_by(TestAttemptModel.started_at.desc())
)
model = result.scalar_one_or_none()
return TestAttemptDTO(model).to_domain() if model else None
async def get_attempt_with_answers(self, attempt_id: int) -> tuple[TestAttempt | None, list[UserAnswer]]:
attempt = await self.attempt_dao.get_by_id(attempt_id)
if not attempt:
return None, []
result = await self.session.execute(
select(TestAttemptModel)
.where(TestAttemptModel.id == attempt_id)
.options(selectinload(TestAttemptModel.answers))
)
attempt_model = result.scalar_one_or_none()
if not attempt_model:
return attempt, []
answers = [UserAnswerDTO(answer).to_domain() for answer in attempt_model.answers]
return attempt, answers
async def get_answers_for_attempt(self, attempt_id: int) -> list[UserAnswer]:
result = await self.session.execute(
select(UserAnswerModel)
.where(UserAnswerModel.attempt_id == attempt_id)
)
models = list(result.scalars().all())
return [UserAnswerDTO(model).to_domain() for model in models]
async def count_user_attempts(self, user_id: int, test_id: int | None = None) -> int:
query = select(func.count(TestAttemptModel.id)).where(TestAttemptModel.user_id == user_id)
if test_id is not None:
query = query.where(TestAttemptModel.test_id == test_id)
result = await self.session.execute(query)
count = result.scalar_one()
return count
async def count_passed_attempts(self, user_id: int, test_id: int | None = None) -> int:
query = (
select(func.count(TestAttemptModel.id))
.where(TestAttemptModel.user_id == user_id)
.where(TestAttemptModel.is_passed == True)
)
if test_id is not None:
query = query.where(TestAttemptModel.test_id == test_id)
result = await self.session.execute(query)
count = result.scalar_one()
return count
async def get_best_attempt(self, user_id: int, test_id: int) -> TestAttempt | None:
result = await self.session.execute(
select(TestAttemptModel)
.where(TestAttemptModel.user_id == user_id)
.where(TestAttemptModel.test_id == test_id)
.where(TestAttemptModel.finished_at != None)
.order_by(TestAttemptModel.score.desc(), TestAttemptModel.started_at.asc())
)
model = result.scalar_one_or_none()
return TestAttemptDTO(model).to_domain() if model else None
async def get_latest_attempt(self, user_id: int, test_id: int) -> TestAttempt | None:
result = await self.session.execute(
select(TestAttemptModel)
.where(TestAttemptModel.user_id == user_id)
.where(TestAttemptModel.test_id == test_id)
.order_by(TestAttemptModel.started_at.desc())
)
model = result.scalar_one_or_none()
return TestAttemptDTO(model).to_domain() if model else None
async def finish_attempt(self, attempt_id: int, score: int, is_passed: bool) -> TestAttempt | None:
return await self.attempt_dao.update(
attempt_id=attempt_id,
finished_at=now_msk_naive(),
score=score,
is_passed=is_passed
)
async def calculate_attempt_score(self, attempt_id: int) -> int:
result = await self.session.execute(
select(func.count(UserAnswerModel.id))
.where(UserAnswerModel.attempt_id == attempt_id)
.where(UserAnswerModel.is_correct == True)
)
count = result.scalar_one()
return count
async def get_incorrect_answers(self, attempt_id: int) -> list[UserAnswer]:
result = await self.session.execute(
select(UserAnswerModel)
.where(UserAnswerModel.attempt_id == attempt_id)
.where(UserAnswerModel.is_correct == False)
)
models = list(result.scalars().all())
return [UserAnswerDTO(model).to_domain() for model in models]
async def get_question_statistics(self, question_id: int) -> dict[str, int]:
result = await self.session.execute(
select(
func.count(UserAnswerModel.id).label("total"),
func.sum(func.cast(UserAnswerModel.is_correct, func.Integer)).label("correct")
)
.where(UserAnswerModel.question_id == question_id)
)
row = result.one()
total = row.total or 0
correct = row.correct or 0
return {
"total_answers": total,
"correct_answers": correct,
"incorrect_answers": total - correct,
}
async def get_most_difficult_questions(self, test_id: int, limit: int = 10) -> list[tuple[int, float]]:
from quizzi.infrastructure.database.models import Question as QuestionModel
result = await self.session.execute(
select(
UserAnswerModel.question_id,
func.count(UserAnswerModel.id).label("total"),
func.sum(func.cast(UserAnswerModel.is_correct, func.Integer)).label("correct")
)
.join(QuestionModel, UserAnswerModel.question_id == QuestionModel.id)
.where(QuestionModel.test_id == test_id)
.group_by(UserAnswerModel.question_id)
.having(func.count(UserAnswerModel.id) > 0)
.order_by((func.sum(func.cast(UserAnswerModel.is_correct, func.Integer)) / func.count(UserAnswerModel.id)).asc())
.limit(limit)
)
rows = result.all()
return [(row.question_id, row.correct / row.total if row.total > 0 else 0.0) for row in rows]
async def get_user_stats(self, user_id: int) -> dict:
result = await self.session.execute(
select(
func.count(TestAttemptModel.id).label("total_attempts"),
func.avg(TestAttemptModel.score).label("avg_score"),
).where(
TestAttemptModel.user_id == user_id,
TestAttemptModel.finished_at.isnot(None)
)
)
row = result.one()
return {
"total_attempts": row.total_attempts or 0,
"avg_score": round(row.avg_score, 1) if row.avg_score else 0,
}
async def get_finished_attempts_with_tests(self, user_id: int) -> list[tuple[TestAttempt, str]]:
from quizzi.infrastructure.database.models import Test as TestModel
result = await self.session.execute(
select(TestAttemptModel, TestModel.title)
.join(TestModel, TestAttemptModel.test_id == TestModel.id)
.where(TestAttemptModel.user_id == user_id)
.where(TestAttemptModel.finished_at.isnot(None))
.order_by(TestAttemptModel.finished_at.desc())
)
rows = result.all()
return [(TestAttemptDTO(row[0]).to_domain(), row[1]) for row in rows]
async def get_test_attempts_with_users(self, test_id: int) -> list[tuple[TestAttempt, str]]:
from quizzi.infrastructure.database.models import User as UserModel
result = await self.session.execute(
select(TestAttemptModel, UserModel.name, UserModel.first_name)
.join(UserModel, TestAttemptModel.user_id == UserModel.id)
.where(TestAttemptModel.test_id == test_id)
.where(TestAttemptModel.finished_at.isnot(None))
.order_by(TestAttemptModel.finished_at.desc())
)
rows = result.all()
return [(TestAttemptDTO(row[0]).to_domain(), row[1] or row[2]) for row in rows]
@@ -0,0 +1,61 @@
from typing import final
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from quizzi.domain.schemas import User
from quizzi.infrastructure.database.dao.user import UserDAO
from quizzi.infrastructure.database.dto.user import UserDTO
from quizzi.infrastructure.database.models import User as UserModel
@final
class UserRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.user_dao = UserDAO(session)
async def get_admins(self) -> list[User]:
result = await self.session.execute(
select(UserModel).where(UserModel.is_admin == True)
)
models = list(result.scalars().all())
return [UserDTO(model).to_domain() for model in models]
async def get_users_by_group(self, group: int) -> list[User]:
result = await self.session.execute(
select(UserModel).where(UserModel.group == group)
)
models = list(result.scalars().all())
return [UserDTO(model).to_domain() for model in models]
async def get_users_without_group(self) -> list[User]:
result = await self.session.execute(
select(UserModel).where(UserModel.group == None)
)
models = list(result.scalars().all())
return [UserDTO(model).to_domain() for model in models]
async def is_admin(self, user_id: int) -> bool:
user = await self.user_dao.get_by_id(user_id)
return user.is_admin if user else False
async def has_group(self, user_id: int) -> bool:
user = await self.user_dao.get_by_id(user_id)
return user.group is not None if user else False
async def count_users_by_group(self, group: int) -> int:
result = await self.session.execute(
select(func.count(UserModel.id))
.where(UserModel.group == group)
)
count = result.scalar_one()
return count
async def count_admins(self) -> int:
result = await self.session.execute(
select(func.count(UserModel.id))
.where(UserModel.is_admin == True)
)
count = result.scalar_one()
return count
+96
View File
@@ -0,0 +1,96 @@
import logging
from collections.abc import AsyncIterable
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from dishka import AsyncContainer, Provider, Scope, provide
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from quizzi.infrastructure.database.config import new_session_maker
from quizzi.infrastructure.database.dao.group import GroupDAO
from quizzi.infrastructure.database.dao.option import OptionDAO
from quizzi.infrastructure.database.dao.question import QuestionDAO
from quizzi.infrastructure.database.dao.test import TestDAO
from quizzi.infrastructure.database.dao.test_attempt import TestAttemptDAO
from quizzi.infrastructure.database.dao.user import UserDAO
from quizzi.infrastructure.database.dao.user_answer import UserAnswerDAO
from quizzi.infrastructure.database.repo.test import TestRepository
from quizzi.infrastructure.database.repo.test_attempt import TestAttemptRepository
from quizzi.infrastructure.database.repo.user import UserRepository
from quizzi.infrastructure.scheduling.tasks import deactivate_expired_tests
from quizzi.infrastructure.utils.config import Config
from quizzi.infrastructure.utils.rate_limiter import PasswordRateLimiter
class DatabaseProvider(Provider):
@provide(scope=Scope.APP)
def get_session_maker(self, config: Config) -> async_sessionmaker[AsyncSession]:
return new_session_maker(config.database.url)
@provide(scope=Scope.APP)
def get_password_rate_limiter(self) -> PasswordRateLimiter:
return PasswordRateLimiter()
@provide(scope=Scope.REQUEST)
async def get_session(
self, session_maker: async_sessionmaker[AsyncSession]
) -> AsyncIterable[AsyncSession]:
async with session_maker() as session:
yield session
await session.commit()
@provide(scope=Scope.REQUEST)
def get_user_dao(self, session: AsyncSession) -> UserDAO:
return UserDAO(session)
@provide(scope=Scope.REQUEST)
def get_group_dao(self, session: AsyncSession) -> GroupDAO:
return GroupDAO(session)
@provide(scope=Scope.REQUEST)
def get_test_dao(self, session: AsyncSession) -> TestDAO:
return TestDAO(session)
@provide(scope=Scope.REQUEST)
def get_question_dao(self, session: AsyncSession) -> QuestionDAO:
return QuestionDAO(session)
@provide(scope=Scope.REQUEST)
def get_option_dao(self, session: AsyncSession) -> OptionDAO:
return OptionDAO(session)
@provide(scope=Scope.REQUEST)
def get_test_attempt_dao(self, session: AsyncSession) -> TestAttemptDAO:
return TestAttemptDAO(session)
@provide(scope=Scope.REQUEST)
def get_user_answer_dao(self, session: AsyncSession) -> UserAnswerDAO:
return UserAnswerDAO(session)
@provide(scope=Scope.REQUEST)
def get_user_repository(self, session: AsyncSession) -> UserRepository:
return UserRepository(session)
@provide(scope=Scope.REQUEST)
def get_test_repository(self, session: AsyncSession) -> TestRepository:
return TestRepository(session)
@provide(scope=Scope.REQUEST)
def get_test_attempt_repository(self, session: AsyncSession) -> TestAttemptRepository:
return TestAttemptRepository(session)
class SchedulerProvider(Provider):
@provide(scope = Scope.APP)
def get_scheduler(self, container: AsyncContainer) -> AsyncIOScheduler:
logging.getLogger('apscheduler').setLevel(logging.WARNING)
scheduler = AsyncIOScheduler()
scheduler.add_job(
deactivate_expired_tests,
'interval',
minutes=5,
args=[container],
id='deactivate_expired_tests',
)
return scheduler
@@ -0,0 +1 @@
@@ -0,0 +1,19 @@
import logging
from dishka import AsyncContainer
from quizzi.infrastructure.database.dao.test import TestDAO
from quizzi.infrastructure.utils.timezone import now_msk_naive
logger = logging.getLogger(__name__)
async def deactivate_expired_tests(container: AsyncContainer) -> None:
async with container() as request_container:
test_dao = await request_container.get(TestDAO)
expired_tests = await test_dao.get_expired_active_tests(now_msk_naive())
for test in expired_tests:
await test_dao.update(test.id, is_active=False)
logger.info("Деактивирован истёкший тест: id=%d, title=%s", test.id, test.title)
@@ -0,0 +1 @@
@@ -0,0 +1,32 @@
from aiogram import Bot
from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeChat
from quizzi.infrastructure.database.repo.user import UserRepository
from quizzi.infrastructure.utils.config import Config
async def setup_bot_commands(bot: Bot, config: Config, user_repo: UserRepository) -> None:
await bot.set_my_commands(
commands=[
BotCommand(command="start", description="Главное меню"),
],
scope=BotCommandScopeAllPrivateChats(),
)
admins = await user_repo.get_admins()
for admin in admins:
await bot.set_my_commands(
commands=[
BotCommand(command="start", description="Главное меню"),
BotCommand(command="admin", description="Админ-панель"),
],
scope=BotCommandScopeChat(chat_id=admin.id),
)
await bot.set_my_commands(
commands=[
BotCommand(command="start", description="Главное меню"),
BotCommand(command="creator", description="Панель создателя"),
],
scope=BotCommandScopeChat(chat_id=config.bot.creator_id),
)
@@ -0,0 +1,61 @@
import asyncio
import logging
from dataclasses import dataclass
from aiogram import Bot
from aiogram.exceptions import (
TelegramAPIError,
TelegramBadRequest,
TelegramForbiddenError,
TelegramNetworkError,
TelegramRetryAfter,
)
from quizzi.infrastructure.database.dao.user import UserDAO
logger = logging.getLogger(__name__)
@dataclass
class BroadcastStats:
success: int
failed: int
total: int
async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: UserDAO) -> BroadcastStats:
users = await user_dao.get_all()
success = 0
failed = 0
logger.info("Starting broadcast: message_id=%d, total_users=%d", message_id, len(users))
for user in users:
try:
await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id)
success += 1
except TelegramRetryAfter as e:
logger.warning("Rate limited, waiting %d seconds", e.retry_after)
await asyncio.sleep(e.retry_after)
try:
await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id)
success += 1
except TelegramAPIError:
failed += 1
except TelegramForbiddenError:
logger.debug("Broadcast failed (forbidden): user_id=%d", user.id)
failed += 1
except TelegramBadRequest as e:
logger.debug("Broadcast failed (bad request): user_id=%d, error=%s", user.id, e)
failed += 1
except TelegramNetworkError as e:
logger.warning("Network error during broadcast: user_id=%d, error=%s", user.id, e)
failed += 1
except TelegramAPIError as e:
logger.warning("Telegram API error during broadcast: user_id=%d, error=%s", user.id, e)
failed += 1
await asyncio.sleep(0.05)
logger.info("Broadcast completed: success=%d, failed=%d, total=%d", success, failed, len(users))
return BroadcastStats(success=success, failed=failed, total=len(users))
+63
View File
@@ -0,0 +1,63 @@
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Self
@dataclass
class BotConfig:
token: str
creator_id: int
@dataclass
class SecurityConfig:
encode_key: str
encoded_string_length: int = 8
@dataclass
class DatabaseConfig:
host: str
port: int | str
user: str
password: str
database: str
@property
def url(self) -> str:
return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
@dataclass
class Config:
bot: BotConfig
database: DatabaseConfig
security: SecurityConfig
@classmethod
def from_toml(cls, path: str | Path) -> Self:
with open(path, "rb") as f:
data: dict[str, dict[str, str | int]] = tomllib.load(f)
bot_data: dict[str, str | int] = data["bot"]
db_data: dict[str, str | int] = data["database"]
security_data: dict[str, str | int] = data["security"]
return cls(
bot=BotConfig(
token=str(bot_data["token"]),
creator_id=int(bot_data["creator_id"])
),
database=DatabaseConfig(
host=str(db_data["host"]),
port=db_data["port"],
user=str(db_data["user"]),
password=str(db_data["password"]),
database=str(db_data["database"])
),
security=SecurityConfig(
encode_key=str(security_data["encode_key"]),
encoded_string_length=int(security_data.get("encoded_string_length", 8))
)
)
@@ -0,0 +1,10 @@
import io
import qrcode
def generate_qr_bytes(text: str) -> bytes:
img = qrcode.make(text)
with io.BytesIO() as buffer:
img.save(buffer)
return buffer.getvalue()
@@ -0,0 +1,57 @@
import asyncio
import time
from dataclasses import dataclass
@dataclass
class UserBucket:
tokens: float
last_updated: float
class RateLimiter:
def __init__(self, rate: int, period: int):
self.rate = rate
self.period = period
self.fill_rate = rate / period
self.buckets: dict[int, UserBucket] = {}
self._lock = asyncio.Lock()
async def check(self, user_id: int) -> tuple[bool, float]:
async with self._lock:
now = time.time()
if user_id not in self.buckets:
self.buckets[user_id] = UserBucket(
tokens=self.rate - 1,
last_updated=now
)
return True, 0.0
bucket = self.buckets[user_id]
elapsed = now - bucket.last_updated
added_tokens = elapsed * self.fill_rate
bucket.tokens = min(self.rate, bucket.tokens + added_tokens)
bucket.last_updated = now
if bucket.tokens >= 1:
bucket.tokens -= 1
return True, 0.0
else:
wait_time = (1 - bucket.tokens) / self.fill_rate
return False, wait_time
async def cleanup(self) -> None:
async with self._lock:
full_buckets = [
user_id for user_id, bucket in self.buckets.items()
if bucket.tokens >= self.rate
]
for user_id in full_buckets:
del self.buckets[user_id]
class PasswordRateLimiter(RateLimiter):
def __init__(self):
super().__init__(rate=5, period=3600)
@@ -0,0 +1,69 @@
import hashlib
import hmac
import string
ALPHABET = string.ascii_letters
def _feistel_round(val: int, key: bytes, rounds: int) -> int:
msg = f"{val}:{rounds}".encode()
h = hmac.new(key, msg, hashlib.sha256).digest()
return int.from_bytes(h[:4], 'big')
def permute_id(n: int, key_str: str, bits: int) -> int:
key = key_str.encode()
split = bits // 2
mask = (1 << split) - 1
left = (n >> split) & mask
right = n & mask
for i in range(6):
new_left = right
f_val = _feistel_round(right, key, i)
new_right = left ^ (f_val & mask)
left, right = new_left, new_right
return (left << split) | right
def unpermute_id(n: int, key_str: str, bits: int) -> int:
key = key_str.encode()
split = bits // 2
mask = (1 << split) - 1
left = (n >> split) & mask
right = n & mask
for i in reversed(range(6)):
new_right = left
f_val = _feistel_round(left, key, i)
new_left = right ^ (f_val & mask)
left, right = new_left, new_right
return (left << split) | right
def encode_id(n: int, key: str, length: int = 8) -> str:
bits = length * 5
if length >= 8: bits = 44
elif length == 7: bits = 38
permuted = permute_id(n, key, bits=bits)
chars = []
for _ in range(length):
permuted, rem = divmod(permuted, 52)
chars.append(ALPHABET[rem])
return "".join(chars)
def decode_id(s: str, key: str) -> int:
num = 0
for char in reversed(s):
num = num * 52 + ALPHABET.index(char)
length = len(s)
bits = length * 5
if length >= 8: bits = 44
elif length == 7: bits = 38
return unpermute_id(num, key, bits=bits)
@@ -0,0 +1,21 @@
from datetime import datetime
from zoneinfo import ZoneInfo
MSK_TZ = ZoneInfo("Europe/Moscow")
def now_msk() -> datetime:
return datetime.now(MSK_TZ)
def now_msk_naive() -> datetime:
"""Возвращает текущее время в МСК без timezone info (для сохранения в БД)."""
return datetime.now(MSK_TZ).replace(tzinfo=None)
def to_msk(dt: datetime | None) -> datetime | None:
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=MSK_TZ)
return dt.astimezone(MSK_TZ)