From 8fec825376294e944b1fed12a1214bb2b6f5540c Mon Sep 17 00:00:00 2001 From: Maksim Ushakov Date: Fri, 17 Oct 2025 14:57:23 +0300 Subject: [PATCH] feat: cabinet filtering --- app/classrooms/models/classrooms_db.py | 42 ++- .../routes/classrooms_student_rst.py | 22 +- app/classrooms/routes/classrooms_tutor_rst.py | 22 +- app/classrooms/services/classrooms_svc.py | 40 +++ .../functional/test_classrooms_list_rst.py | 278 ++++++++++++++---- 5 files changed, 346 insertions(+), 58 deletions(-) create mode 100644 app/classrooms/services/classrooms_svc.py diff --git a/app/classrooms/models/classrooms_db.py b/app/classrooms/models/classrooms_db.py index a8604469..c2794c6c 100644 --- a/app/classrooms/models/classrooms_db.py +++ b/app/classrooms/models/classrooms_db.py @@ -1,10 +1,10 @@ from datetime import datetime from enum import StrEnum, auto -from typing import Annotated, ClassVar, Literal +from typing import Annotated, Any, ClassVar, Literal -from pydantic import AwareDatetime, Field +from pydantic import AwareDatetime, BaseModel, Field from pydantic_marshals.sqlalchemy import MappedModel -from sqlalchemy import DateTime, Enum, String, Text, select, update +from sqlalchemy import DateTime, Enum, Select, String, Text, select, update from sqlalchemy.orm import Mapped, mapped_column from app.common.config import Base @@ -31,6 +31,22 @@ class ClassroomStatus(StrEnum): ] +class ClassroomCursorSchema(BaseModel): + created_at: AwareDatetime + + +class ClassroomFiltersSchema(BaseModel): + statuses: Annotated[set[ClassroomStatus], Field(min_length=1)] | None = None + kinds: Annotated[set[ClassroomKind], Field(min_length=1)] | None = None + subject_ids: Annotated[set[int], Field(min_length=1, max_length=10)] | None = None + + +class ClassroomSearchRequestSchema(BaseModel): + cursor: ClassroomCursorSchema | None = None + limit: Annotated[int, Field(gt=0, le=100)] = 50 + filters: ClassroomFiltersSchema = ClassroomFiltersSchema() + + class Classroom(Base): __tablename__: str | None = "classrooms" @@ -71,6 +87,26 @@ class Classroom(Base): ], ) + @classmethod + def select_by_search_params( + cls, + stmt: Select[Any], + search_params: ClassroomSearchRequestSchema, + ) -> Select[tuple[Any]]: + if search_params.filters.statuses is not None: + stmt = stmt.filter(cls.status.in_(search_params.filters.statuses)) + + if search_params.filters.kinds is not None: + stmt = stmt.filter(cls.kind.in_(search_params.filters.kinds)) + + if search_params.filters.subject_ids is not None: + stmt = stmt.filter(cls.subject_id.in_(search_params.filters.subject_ids)) + + if search_params.cursor is not None: + stmt = stmt.filter(cls.created_at < search_params.cursor.created_at) + + return stmt.order_by(cls.created_at.desc()).limit(search_params.limit) + class IndividualClassroom(Classroom): __tablename__ = None diff --git a/app/classrooms/routes/classrooms_student_rst.py b/app/classrooms/routes/classrooms_student_rst.py index c99709c3..21661bdf 100644 --- a/app/classrooms/routes/classrooms_student_rst.py +++ b/app/classrooms/routes/classrooms_student_rst.py @@ -9,10 +9,12 @@ from app.classrooms.models.classrooms_db import ( AnyClassroom, Classroom, + ClassroomSearchRequestSchema, IndividualClassroom, StudentClassroomResponseSchema, ) from app.classrooms.models.enrollments_db import Enrollment +from app.classrooms.services import classrooms_svc from app.common.dependencies.authorization_dep import AuthorizationData from app.common.fastapi_ext import APIRouterExt from app.common.sqlalchemy_ext import db @@ -23,9 +25,10 @@ @router.get( path="/roles/student/classrooms/", response_model=list[StudentClassroomResponseSchema], - summary="List paginated student classrooms for the current user", + summary="Use POST /roles/student/classrooms/searches/ instead", + deprecated=True, ) -async def list_classrooms( +async def list_classrooms_old( auth_data: AuthorizationData, created_before: AwareDatetime | None = None, limit: Annotated[int, Field(gt=0, le=100)] = 50, @@ -45,6 +48,21 @@ async def list_classrooms( return await db.get_all(stmt.order_by(Classroom.created_at.desc()).limit(limit)) +@router.post( + path="/roles/student/classrooms/searches/", + response_model=list[StudentClassroomResponseSchema], + summary="List paginated student classrooms for the current user", +) +async def list_classrooms( + auth_data: AuthorizationData, + data: ClassroomSearchRequestSchema, +) -> Sequence[Classroom]: + return await classrooms_svc.retrieve_paginated_classrooms_by_student_id( + student_id=auth_data.user_id, + search_params=data, + ) + + @router.get( path="/roles/student/classrooms/{classroom_id}/", response_model=StudentClassroomResponseSchema, diff --git a/app/classrooms/routes/classrooms_tutor_rst.py b/app/classrooms/routes/classrooms_tutor_rst.py index 8b40f9c4..34e90941 100644 --- a/app/classrooms/routes/classrooms_tutor_rst.py +++ b/app/classrooms/routes/classrooms_tutor_rst.py @@ -14,12 +14,14 @@ from app.classrooms.models.classrooms_db import ( AnyClassroom, Classroom, + ClassroomSearchRequestSchema, GroupClassroom, IndividualClassroom, TutorClassroomResponseSchema, UserClassroomStatus, ) from app.classrooms.models.tutorships_db import Tutorship +from app.classrooms.services import classrooms_svc from app.common.bridges.autocomplete_bdg import SubjectNotFoundException from app.common.config_bdg import autocomplete_bridge from app.common.dependencies.authorization_dep import AuthorizationData @@ -32,9 +34,10 @@ @router.get( path="/roles/tutor/classrooms/", response_model=list[TutorClassroomResponseSchema], - summary="List paginated tutor classrooms for the current user", + summary="Use POST /roles/tutor/classrooms/searches/ instead", + deprecated=True, ) -async def list_classrooms( +async def list_classrooms_old( auth_data: AuthorizationData, created_before: AwareDatetime | None = None, limit: Annotated[int, Field(gt=0, le=100)] = 50, @@ -45,6 +48,21 @@ async def list_classrooms( return await db.get_all(stmt.order_by(Classroom.created_at.desc()).limit(limit)) +@router.post( + path="/roles/tutor/classrooms/searches/", + response_model=list[TutorClassroomResponseSchema], + summary="List paginated tutor classrooms for the current user", +) +async def list_classrooms( + auth_data: AuthorizationData, + data: ClassroomSearchRequestSchema, +) -> Sequence[Classroom]: + return await classrooms_svc.retrieve_paginated_classrooms_by_tutor_id( + tutor_id=auth_data.user_id, + search_params=data, + ) + + class SubjectResponses(Responses): SUBJECT_NOT_FOUND = status.HTTP_404_NOT_FOUND, "Subject not found" diff --git a/app/classrooms/services/classrooms_svc.py b/app/classrooms/services/classrooms_svc.py new file mode 100644 index 00000000..7505c227 --- /dev/null +++ b/app/classrooms/services/classrooms_svc.py @@ -0,0 +1,40 @@ +from collections.abc import Sequence + +from sqlalchemy import or_, select + +from app.classrooms.models.classrooms_db import ( + Classroom, + ClassroomSearchRequestSchema, + IndividualClassroom, +) +from app.classrooms.models.enrollments_db import Enrollment +from app.common.sqlalchemy_ext import db + + +async def retrieve_paginated_classrooms_by_student_id( + student_id: int, + search_params: ClassroomSearchRequestSchema, +) -> Sequence[Classroom]: + stmt = Classroom.select_by_search_params( + stmt=select(Classroom) + .join(Enrollment, isouter=True) + .filter( + or_( + Enrollment.student_id == student_id, + IndividualClassroom.student_id == student_id, + ) + ), + search_params=search_params, + ) + return await db.get_all(stmt=stmt) + + +async def retrieve_paginated_classrooms_by_tutor_id( + tutor_id: int, + search_params: ClassroomSearchRequestSchema, +) -> Sequence[Classroom]: + stmt = Classroom.select_by_search_params( + stmt=select(Classroom).filter_by(tutor_id=tutor_id), + search_params=search_params, + ) + return await db.get_all(stmt=stmt) diff --git a/tests/classrooms/functional/test_classrooms_list_rst.py b/tests/classrooms/functional/test_classrooms_list_rst.py index f7d493ae..81d7d3da 100644 --- a/tests/classrooms/functional/test_classrooms_list_rst.py +++ b/tests/classrooms/functional/test_classrooms_list_rst.py @@ -1,5 +1,7 @@ +import random from collections.abc import AsyncIterator, Iterator from datetime import datetime, timedelta, timezone +from itertools import product from typing import assert_never import pytest @@ -8,6 +10,11 @@ from app.classrooms.models.classrooms_db import ( AnyClassroom, + ClassroomCursorSchema, + ClassroomFiltersSchema, + ClassroomKind, + ClassroomSearchRequestSchema, + ClassroomStatus, GroupClassroom, IndividualClassroom, ) @@ -20,7 +27,12 @@ pytestmark = pytest.mark.anyio -CLASSROOMS_LIST_SIZE = 8 +CLASSROOMS_STATUSES = list(ClassroomStatus) +CLASSROOMS_KINDS = list(ClassroomKind) +CLASSROOMS_SUBJECT_IDS = [*random.sample(range(1, 999), k=2), None] +CLASSROOMS_LIST_SIZE = ( + len(CLASSROOMS_SUBJECT_IDS) * len(CLASSROOMS_KINDS) * len(CLASSROOMS_STATUSES) +) async def create_tutor_classrooms( @@ -31,21 +43,35 @@ async def create_tutor_classrooms( last_created_at: datetime = faker.date_time_between(tzinfo=timezone.utc) async with active_session(): for i in range(CLASSROOMS_LIST_SIZE): - if i % 2 == 0: - yield await IndividualClassroom.create( - **factories.IndividualClassroomInputFactory.build_python(), - tutor_id=tutor_user_id, - student_id=tutor_user_id + i + 1, - tutor_name=faker.name(), - student_name=faker.name(), - created_at=last_created_at, - ) - else: - yield await GroupClassroom.create( - **factories.GroupClassroomInputFactory.build_python(), - tutor_id=tutor_user_id, - created_at=last_created_at, - ) + kind = CLASSROOMS_KINDS[ + i // (CLASSROOMS_LIST_SIZE // 2) % len(CLASSROOMS_KINDS) + ] + match kind: + case ClassroomKind.INDIVIDUAL: + yield await IndividualClassroom.create( + **factories.IndividualClassroomInputFactory.build_python( + subject_id=CLASSROOMS_SUBJECT_IDS[ + i % len(CLASSROOMS_SUBJECT_IDS) + ], + ), + status=CLASSROOMS_STATUSES[i % len(CLASSROOMS_STATUSES)], + tutor_id=tutor_user_id, + student_id=tutor_user_id + i + 1, + tutor_name=faker.name(), + student_name=faker.name(), + created_at=last_created_at, + ) + case ClassroomKind.GROUP: + yield await GroupClassroom.create( + **factories.GroupClassroomInputFactory.build_python( + subject_id=CLASSROOMS_SUBJECT_IDS[ + i % len(CLASSROOMS_SUBJECT_IDS) + ], + ), + status=CLASSROOMS_STATUSES[i % len(CLASSROOMS_STATUSES)], + tutor_id=tutor_user_id, + created_at=last_created_at, + ) last_created_at -= timedelta(minutes=faker.random_int(min=1)) @@ -86,7 +112,7 @@ def convert_tutor_classrooms(tutor_classrooms: list[AnyClassroom]) -> Iterator[A assert_never(classroom) -@pytest.mark.parametrize( +classroom_requests_parametrization_old = pytest.mark.parametrize( ("offset", "limit"), [ pytest.param(0, CLASSROOMS_LIST_SIZE, id="start_to_end"), @@ -98,7 +124,23 @@ def convert_tutor_classrooms(tutor_classrooms: list[AnyClassroom]) -> Iterator[A ), ], ) -async def test_tutor_classrooms_listing( + +classroom_requests_parametrization = pytest.mark.parametrize( + ("offset", "limit"), + [ + pytest.param(None, CLASSROOMS_LIST_SIZE, id="start_to_end"), + pytest.param(None, CLASSROOMS_LIST_SIZE // 2, id="start_to_middle"), + pytest.param( + CLASSROOMS_LIST_SIZE // 2, + CLASSROOMS_LIST_SIZE, + id="middle_to_end", + ), + ], +) + + +@classroom_requests_parametrization_old +async def test_tutor_classrooms_listing_old( tutor_client: TestClient, tutor_classrooms: list[AnyClassroom], offset: int, @@ -124,6 +166,82 @@ async def test_tutor_classrooms_listing( ) +classroom_requests_filter = pytest.mark.parametrize( + ("kinds", "statuses", "subject_ids"), + [ + *[ + pytest.param(kind, status, subject_id, id=f"{kind}_{status}_{subject_id}") + for kind, status, subject_id in product( + (None, {ClassroomKind.INDIVIDUAL.value}), + (None, {ClassroomStatus.ACTIVE.value}), + (None, {CLASSROOMS_SUBJECT_IDS[0]}), + ) + ], + pytest.param(None, set(CLASSROOMS_STATUSES[:2]), None, id="multiple_statuses"), + pytest.param(set(CLASSROOMS_KINDS[:2]), None, None, id="multiple_kinds"), + pytest.param( + None, None, set(CLASSROOMS_SUBJECT_IDS[:2]), id="multiple_subject_ids" + ), + pytest.param(set(CLASSROOMS_KINDS), None, None, id="all_kinds"), + pytest.param(None, set(CLASSROOMS_STATUSES), None, id="all_statuses"), + pytest.param( + set(CLASSROOMS_KINDS), + set(CLASSROOMS_STATUSES), + set(CLASSROOMS_SUBJECT_IDS[:2]), + id="all_filter", + ), + ], +) + + +@classroom_requests_filter +@classroom_requests_parametrization +async def test_tutor_classrooms_listing( + tutor_client: TestClient, + tutor_classrooms: list[AnyClassroom], + kinds: set[ClassroomKind] | None, + statuses: set[ClassroomStatus] | None, + subject_ids: set[int] | None, + offset: int | None, + limit: int, +) -> None: + cursor = None if offset is None else tutor_classrooms[offset] + filtered_tutor_classroom = [ + classroom + for classroom in tutor_classrooms + if all( + ( + statuses is None or classroom.status in statuses, + kinds is None or classroom.kind in kinds, + subject_ids is None or classroom.subject_id in subject_ids, + cursor is None or classroom.created_at < cursor.created_at, + ) + ) + ] + + assert_response( + tutor_client.post( + "/api/protected/classroom-service/roles/tutor/classrooms/searches/", + json=remove_none_values( + ClassroomSearchRequestSchema( + filters=ClassroomFiltersSchema( + statuses=statuses, + kinds=kinds, + subject_ids=subject_ids, + ), + cursor=( + None + if cursor is None + else ClassroomCursorSchema(created_at=cursor.created_at) + ), + limit=limit, + ).model_dump(mode="json") + ), + ), + expected_json=list(convert_tutor_classrooms(filtered_tutor_classroom))[:limit], + ) + + async def create_student_classrooms( faker: Faker, active_session: ActiveSession, @@ -132,26 +250,45 @@ async def create_student_classrooms( last_created_at: datetime = faker.date_time_between(tzinfo=timezone.utc) async with active_session(): for i in range(CLASSROOMS_LIST_SIZE): - if i % 2 == 0: - yield await IndividualClassroom.create( - **factories.IndividualClassroomInputFactory.build_python(), - tutor_id=student_user_id + i + 1, - student_id=student_user_id, - tutor_name=faker.name(), - student_name=faker.name(), - created_at=last_created_at, - ) - else: - group_classroom = await GroupClassroom.create( - **factories.GroupClassroomInputFactory.build_python(), - tutor_id=student_user_id + i + 1, - created_at=last_created_at, - ) - await Enrollment.create( - group_classroom_id=group_classroom.id, - student_id=student_user_id, - ) - yield group_classroom + kind = CLASSROOMS_KINDS[ + i + // (CLASSROOMS_LIST_SIZE // len(CLASSROOMS_KINDS)) + % len(CLASSROOMS_KINDS) + ] + match kind: + case ClassroomKind.INDIVIDUAL: + yield await IndividualClassroom.create( + **factories.IndividualClassroomInputFactory.build_python( + subject_id=CLASSROOMS_SUBJECT_IDS[ + (i // len(CLASSROOMS_STATUSES)) + % len(CLASSROOMS_SUBJECT_IDS) + ], + ), + status=CLASSROOMS_STATUSES[i % len(CLASSROOMS_STATUSES)], + tutor_id=student_user_id + i + 1, + student_id=student_user_id, + tutor_name=faker.name(), + student_name=faker.name(), + created_at=last_created_at, + ) + case ClassroomKind.GROUP: + group_classroom = await GroupClassroom.create( + **factories.GroupClassroomInputFactory.build_python( + subject_id=CLASSROOMS_SUBJECT_IDS[ + (i // len(CLASSROOMS_STATUSES)) + % len(CLASSROOMS_SUBJECT_IDS) + ], + ), + status=CLASSROOMS_STATUSES[i % len(CLASSROOMS_STATUSES)], + tutor_id=student_user_id + i + 1, + created_at=last_created_at, + ) + await Enrollment.create( + group_classroom_id=group_classroom.id, + student_id=student_user_id, + ) + yield group_classroom + last_created_at -= timedelta(minutes=faker.random_int(min=1)) @@ -194,19 +331,8 @@ def convert_student_classrooms( assert_never(classroom) -@pytest.mark.parametrize( - ("offset", "limit"), - [ - pytest.param(0, CLASSROOMS_LIST_SIZE, id="start_to_end"), - pytest.param(0, CLASSROOMS_LIST_SIZE // 2, id="start_to_middle"), - pytest.param( - CLASSROOMS_LIST_SIZE // 2, - CLASSROOMS_LIST_SIZE, - id="middle_to_end", - ), - ], -) -async def test_student_classrooms_listing( +@classroom_requests_parametrization_old +async def test_student_classrooms_listing_old( student_client: TestClient, student_classrooms: list[AnyClassroom], offset: int, @@ -230,3 +356,53 @@ async def test_student_classrooms_listing( convert_student_classrooms(student_classrooms=student_classrooms) )[offset:limit], ) + + +@classroom_requests_filter +@classroom_requests_parametrization +async def test_student_classrooms_listing( + student_client: TestClient, + student_classrooms: list[AnyClassroom], + kinds: set[ClassroomKind] | None, + statuses: set[ClassroomStatus] | None, + subject_ids: set[int] | None, + offset: int, + limit: int, +) -> None: + cursor = None if offset is None else student_classrooms[offset] + filtered_student_classroom = [ + classroom + for classroom in student_classrooms + if all( + ( + statuses is None or classroom.status in statuses, + kinds is None or classroom.kind in kinds, + subject_ids is None or classroom.subject_id in subject_ids, + cursor is None or classroom.created_at < cursor.created_at, + ) + ) + ] + + assert_response( + student_client.post( + "/api/protected/classroom-service/roles/student/classrooms/searches/", + json=remove_none_values( + ClassroomSearchRequestSchema( + filters=ClassroomFiltersSchema( + statuses=statuses, + kinds=kinds, + subject_ids=subject_ids, + ), + cursor=( + None + if cursor is None + else ClassroomCursorSchema(created_at=cursor.created_at) + ), + limit=limit, + ).model_dump(mode="json") + ), + ), + expected_json=list(convert_student_classrooms(filtered_student_classroom))[ + :limit + ], + )