diff --git a/modal_backend/routes/notes.py b/modal_backend/routes/notes.py index 7b32245..d832e85 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,50 @@ 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, + 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]]: + """ + Возвращает ответы пользователя по модалке. + + Для типов `type_id=2`, `type_id=3`, `type_id=4` возвращаются только те поля, + которые заполнены в записи ответа: `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) + 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).offset(offset).limit(limit).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 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()