From 66dc959ea6a1b60901f5f8c374211fc764ae0f96 Mon Sep 17 00:00:00 2001 From: Artem Bratyashin Date: Tue, 18 Aug 2026 22:06:03 +0300 Subject: [PATCH 1/4] Added handle get/notif/id --- modal_backend/routes/notes.py | 38 ++++++++++++++++++++++++++++++++- modal_backend/schemas/models.py | 19 +++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/modal_backend/routes/notes.py b/modal_backend/routes/notes.py index 7b32245..278b4fe 100644 --- a/modal_backend/routes/notes.py +++ b/modal_backend/routes/notes.py @@ -5,7 +5,7 @@ from fastapi_sqlalchemy import db from modal_backend import settings -from modal_backend.models.db import ModalStatus, Note, NoteTypeEnum +from modal_backend.models.db import ModalStatus, Note, NoteResponse, NoteTypeEnum from modal_backend.schemas.base import StatusResponseModel from modal_backend.schemas.models import ( NoteChoiceGet, @@ -17,6 +17,9 @@ NoteInfoPost, NoteRatingGet, NoteRatingPost, + NoteResponseChoiceGet, + NoteResponseRatingGet, + NoteResponseTextGet, NoteStatus, NoteTextGet, NoteTextPost, @@ -209,6 +212,39 @@ async def create_note_images( return NoteImageGet.model_validate(new_note) +@note.get( + "/{id}/responses", + response_model=list[Union[NoteResponseRatingGet, NoteResponseTextGet, NoteResponseChoiceGet]], + response_model_exclude_none=True, +) +async def get_note_responses( + id: int, user=Depends(UnionAuth(scopes=["modal.note.read"])) +) -> list[Union[NoteResponseRatingGet, NoteResponseTextGet, NoteResponseChoiceGet]]: + """ + Возвращает ответы пользователя по модалке. + + Для типов `type_id=2`, `type_id=3`, `type_id=4` возвращаются только те поля, + которые заполнены в записи ответа: `rating`, `text` или `selected_choices`. + Для типов `type_id=1` и `type_id=5` пустой список. + + Права: `["modal.note.read"]` + """ + note_obj = Note.get(session=db.session, id=id) + if note_obj.type_id in (NoteTypeEnum.INFO, NoteTypeEnum.IMAGE): + return [] + + schema_type = { + NoteTypeEnum.RATING: NoteResponseRatingGet, + NoteTypeEnum.TEXT: NoteResponseTextGet, + NoteTypeEnum.CHOICE: NoteResponseChoiceGet, + }.get(note_obj.type_id) + if schema_type is None: + return [] + + responses = NoteResponse.query(session=db.session).filter(NoteResponse.note_id == id).all() + return [schema_type.model_validate(response) for response in responses] + + @note.patch("/{id}/status", response_model=NoteStatus) async def update_note_status(id: int, user=Depends(UnionAuth(scopes=["modal.note.patch"]))) -> NoteStatus: """ diff --git a/modal_backend/schemas/models.py b/modal_backend/schemas/models.py index 8e2ef44..8c6f155 100644 --- a/modal_backend/schemas/models.py +++ b/modal_backend/schemas/models.py @@ -113,3 +113,22 @@ class GroupPost(Base): class NoteStatus(Base): id: int status: ModalStatus + + +class NoteResponseGet(Base): + id: int + note_id: int + user_id: int + created_at: datetime.datetime + + +class NoteResponseRatingGet(NoteResponseGet): # type_id=2 + rating: int | None = None + + +class NoteResponseTextGet(NoteResponseGet): # type_id=3 + text: str | None = None + + +class NoteResponseChoiceGet(NoteResponseGet): # type_id=4 + selected_choices: list[dict] | None = None From 59e4979da279ca9166e931fba93700306fd5af80 Mon Sep 17 00:00:00 2001 From: Artem Bratyashin Date: Fri, 28 Aug 2026 20:14:11 +0300 Subject: [PATCH 2/4] Added limit and offset to get notes comments --- modal_backend/routes/notes.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/modal_backend/routes/notes.py b/modal_backend/routes/notes.py index 278b4fe..3eb9277 100644 --- a/modal_backend/routes/notes.py +++ b/modal_backend/routes/notes.py @@ -218,7 +218,10 @@ async def create_note_images( response_model_exclude_none=True, ) async def get_note_responses( - id: int, user=Depends(UnionAuth(scopes=["modal.note.read"])) + id: int, + limit: int = Query(10, ge=0, description="Лимит записей"), + offset: int = Query(0, ge=0, description="Смещение записей на N+offset, где N - первая запись"), + # user=Depends(UnionAuth(scopes=["modal.note.read"])) ) -> list[Union[NoteResponseRatingGet, NoteResponseTextGet, NoteResponseChoiceGet]]: """ Возвращает ответы пользователя по модалке. @@ -227,6 +230,12 @@ async def get_note_responses( которые заполнены в записи ответа: `rating`, `text` или `selected_choices`. Для типов `type_id=1` и `type_id=5` пустой список. + `limit` - максимальное количество возвращаемых ответов. + + `offset` - смещение, определяющее, с какого по порядку ответа начинать выборку. + Если без смещения возвращается ответ с условным номером N, + то при значении offset = X будет возвращаться ответ с номером N + X. + Права: `["modal.note.read"]` """ note_obj = Note.get(session=db.session, id=id) @@ -241,7 +250,13 @@ async def get_note_responses( if schema_type is None: return [] - responses = NoteResponse.query(session=db.session).filter(NoteResponse.note_id == id).all() + responses = ( + NoteResponse.query(session=db.session) + .filter(NoteResponse.note_id == id) + .offset(offset) + .limit(limit) + .all() + ) return [schema_type.model_validate(response) for response in responses] From be980f8f75eb7677de0bfed3c3a5430182fe4d6b Mon Sep 17 00:00:00 2001 From: Artem Bratyashin Date: Fri, 28 Aug 2026 20:18:53 +0300 Subject: [PATCH 3/4] code style for get notes comments handler --- modal_backend/routes/notes.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/modal_backend/routes/notes.py b/modal_backend/routes/notes.py index 3eb9277..d832e85 100644 --- a/modal_backend/routes/notes.py +++ b/modal_backend/routes/notes.py @@ -221,7 +221,7 @@ async def get_note_responses( id: int, limit: int = Query(10, ge=0, description="Лимит записей"), offset: int = Query(0, ge=0, description="Смещение записей на N+offset, где N - первая запись"), - # user=Depends(UnionAuth(scopes=["modal.note.read"])) + user=Depends(UnionAuth(scopes=["modal.note.read"])), ) -> list[Union[NoteResponseRatingGet, NoteResponseTextGet, NoteResponseChoiceGet]]: """ Возвращает ответы пользователя по модалке. @@ -230,11 +230,11 @@ async def get_note_responses( которые заполнены в записи ответа: `rating`, `text` или `selected_choices`. Для типов `type_id=1` и `type_id=5` пустой список. - `limit` - максимальное количество возвращаемых ответов. + `limit` - максимальное количество возвращаемых модалок - `offset` - смещение, определяющее, с какого по порядку ответа начинать выборку. - Если без смещения возвращается ответ с условным номером N, - то при значении offset = X будет возвращаться ответ с номером N + X. + `offset` - смещение, определяющее, с какой по порядку модалки начинать выборку. + Если без смещения возвращается модалка с условным номером N, + то при значении offset = X будет возвращаться модалка с номером N + X Права: `["modal.note.read"]` """ @@ -251,11 +251,7 @@ async def get_note_responses( return [] responses = ( - NoteResponse.query(session=db.session) - .filter(NoteResponse.note_id == id) - .offset(offset) - .limit(limit) - .all() + NoteResponse.query(session=db.session).filter(NoteResponse.note_id == id).offset(offset).limit(limit).all() ) return [schema_type.model_validate(response) for response in responses] From c493f2b8ebaaef3d426323dec20f604956f6fa49 Mon Sep 17 00:00:00 2001 From: Artem Bratyashin Date: Fri, 28 Aug 2026 20:59:52 +0300 Subject: [PATCH 4/4] Tests for get notes comments --- tests/test_routes/test_note_responses.py | 139 +++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/test_routes/test_note_responses.py diff --git a/tests/test_routes/test_note_responses.py b/tests/test_routes/test_note_responses.py new file mode 100644 index 0000000..cf529fb --- /dev/null +++ b/tests/test_routes/test_note_responses.py @@ -0,0 +1,139 @@ +from datetime import datetime, timezone + +import pytest +from starlette import status + +from modal_backend.models.db import NoteResponse +from modal_backend.schemas.models import NoteResponseChoiceGet, NoteResponseRatingGet, NoteResponseTextGet + +url: str = "/notification" + + +@pytest.mark.parametrize( + "status_code, note_index, response_data, response_model, response_field, response_value, absent_fields", + [ + ( + status.HTTP_200_OK, + 1, + {"rating": 5}, + NoteResponseRatingGet, + "rating", + 5, + {"text", "selected_choices"}, + ), + ( + status.HTTP_200_OK, + 2, + {"text": "Тестовый ответ"}, + NoteResponseTextGet, + "text", + "Тестовый ответ", + {"rating", "selected_choices"}, + ), + ( + status.HTTP_200_OK, + 3, + {"selected_choices": [{"id": 1, "text": "Да"}]}, + NoteResponseChoiceGet, + "selected_choices", + [{"id": 1, "text": "Да"}], + {"rating", "text"}, + ), + ], +) +def test_get_note_responses_by_note_type( + client, + dbsession, + notes, + status_code, + note_index, + response_data, + response_model, + response_field, + response_value, + absent_fields, +): + note = notes[note_index] + note_response = NoteResponse( + note_id=note.id, + user_id=101, + created_at=datetime.now(timezone.utc).replace(tzinfo=None), + **response_data, + ) + dbsession.add(note_response) + dbsession.commit() + + try: + response = client.get(f"{url}/{note.id}/responses") + assert response.status_code == status_code + + response_data = response.json() + assert len(response_data) == 1 + response_model.model_validate(response_data[0], extra="forbid") + response_item = response_data[0] + assert response_item[response_field] == response_value + assert absent_fields.isdisjoint(response_item) + assert all(value is not None for value in response_item.values()) + finally: + dbsession.delete(note_response) + dbsession.commit() + + +@pytest.mark.parametrize("note_index", [0, 4]) +def test_get_note_responses_returns_empty_for_info_and_image(client, dbsession, notes, note_index): + note = notes[note_index] + note_response = NoteResponse( + note_id=note.id, + user_id=101, + text="Ответ не должен возвращаться", + created_at=datetime.now(timezone.utc).replace(tzinfo=None), + ) + dbsession.add(note_response) + dbsession.commit() + + try: + response = client.get(f"{url}/{note.id}/responses") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + finally: + dbsession.delete(note_response) + dbsession.commit() + + +@pytest.mark.parametrize( + "status_code, limit, offset", + [ + (status.HTTP_200_OK, 1, 1), + (status.HTTP_200_OK, 10, 0), + (status.HTTP_200_OK, 0, 0), + (status.HTTP_422_UNPROCESSABLE_CONTENT, -1, 0), + (status.HTTP_422_UNPROCESSABLE_CONTENT, 10, -1), + ], +) +def test_get_note_responses_limit_and_offset(client, dbsession, notes, status_code, limit, offset): + note = notes[2] + note_responses = [ + NoteResponse( + note_id=note.id, + user_id=user_id, + text=f"Ответ {user_id}", + created_at=datetime.now(timezone.utc).replace(tzinfo=None), + ) + for user_id in [101, 102, 103] + ] + dbsession.add_all(note_responses) + dbsession.commit() + + try: + response = client.get(f"{url}/{note.id}/responses", params={"limit": limit, "offset": offset}) + assert response.status_code == status_code + + if status_code == status.HTTP_200_OK: + response_data = response.json() + assert len(response_data) == min(limit, max(0, len(note_responses) - offset)) + for response_item in response_data: + NoteResponseTextGet.model_validate(response_item, extra="forbid") + finally: + for note_response in note_responses: + dbsession.delete(note_response) + dbsession.commit()