mirror of
https://github.com/koloideal/Quizzi.git
synced 2026-06-10 18:35:28 +03:00
update
This commit is contained in:
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user