From 8d5154c011624939781778e490a707b215fbf787 Mon Sep 17 00:00:00 2001 From: petrCher <88943157+petrCher@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:16:40 +0300 Subject: [PATCH 1/2] added validation when create --- modal_backend/routes/notes.py | 20 ++++++++++---------- modal_backend/utils/services.py | 27 ++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/modal_backend/routes/notes.py b/modal_backend/routes/notes.py index d832e85..605e593 100644 --- a/modal_backend/routes/notes.py +++ b/modal_backend/routes/notes.py @@ -23,7 +23,6 @@ NoteStatus, NoteTextGet, NoteTextPost, - NotificationGet, ) from modal_backend.settings import Settings, get_settings from modal_backend.utils.services import NoteService @@ -98,9 +97,7 @@ async def get_note( @note.post("/info", response_model=NoteInfoGet) -async def create_note_info( - note: NoteInfoPost, user=Depends(UnionAuth(scopes=["modal.note.create"])) -) -> NotificationGet: +async def create_note_info(note: NoteInfoPost, user=Depends(UnionAuth(scopes=["modal.note.create"]))) -> NoteInfoGet: """ Создает новую модалку. @@ -110,6 +107,7 @@ async def create_note_info( Права: `["modal.note.create"]` """ + await NoteService.validate_note(db, note=note) new_note = Note.create( session=db.session, type_id=NoteTypeEnum.INFO, @@ -123,7 +121,7 @@ async def create_note_info( @note.post("/rating", response_model=NoteRatingGet) async def create_note_rating( note: NoteRatingPost, user=Depends(UnionAuth(scopes=["modal.note.create"])) -) -> NotificationGet: +) -> NoteRatingGet: """ Создает новую модалку. @@ -133,6 +131,7 @@ async def create_note_rating( Права: `["modal.note.create"]` """ + await NoteService.validate_note(db, note=note) new_note = Note.create( session=db.session, type_id=NoteTypeEnum.RATING, @@ -144,9 +143,7 @@ async def create_note_rating( @note.post("/text", response_model=NoteTextGet) -async def create_note_text( - note: NoteTextPost, user=Depends(UnionAuth(scopes=["modal.note.create"])) -) -> NotificationGet: +async def create_note_text(note: NoteTextPost, user=Depends(UnionAuth(scopes=["modal.note.create"]))) -> NoteTextGet: """ Создает новую модалку. @@ -156,6 +153,7 @@ async def create_note_text( Права: `["modal.note.create"]` """ + await NoteService.validate_note(db, note=note) new_note = Note.create( session=db.session, type_id=NoteTypeEnum.TEXT, @@ -169,7 +167,7 @@ async def create_note_text( @note.post("/choice", response_model=NoteChoiceGet) async def create_note_choice( note: NoteChoicePost, user=Depends(UnionAuth(scopes=["modal.note.create"])) -) -> NotificationGet: +) -> NoteChoiceGet: """ Создает новую модалку. @@ -179,6 +177,7 @@ async def create_note_choice( Права: `["modal.note.create"]` """ + await NoteService.validate_note(db, note=note) new_note = Note.create( session=db.session, type_id=NoteTypeEnum.CHOICE, @@ -192,7 +191,7 @@ async def create_note_choice( @note.post("/image", response_model=NoteImageGet) async def create_note_images( note: NoteImagePost, user=Depends(UnionAuth(scopes=["modal.note.create"])) -) -> NotificationGet: +) -> NoteImageGet: """ Создает новую модалку. @@ -202,6 +201,7 @@ async def create_note_images( Права: `["modal.note.create"]` """ + await NoteService.validate_note(db, note=note) new_note = Note.create( session=db.session, type_id=NoteTypeEnum.IMAGE, diff --git a/modal_backend/utils/services.py b/modal_backend/utils/services.py index 49802b1..287ed57 100644 --- a/modal_backend/utils/services.py +++ b/modal_backend/utils/services.py @@ -1,11 +1,20 @@ from datetime import datetime, timezone +from typing import Union from requests import Session from modal_backend.exceptions import AlreadyExists, ForbiddenAction, ObjectNotFound from modal_backend.models.db import Group, ModalStatus, Note, Service from modal_backend.schemas.base import StatusResponseModel -from modal_backend.schemas.models import GroupPost, ServicePost +from modal_backend.schemas.models import ( + GroupPost, + NoteChoicePost, + NoteImagePost, + NoteInfoPost, + NoteRatingPost, + NoteTextPost, + ServicePost, +) class NoteService: @@ -13,6 +22,22 @@ class NoteService: Сервис для работы с логикой Notifications и базой данных """ + @classmethod + async def validate_note( + cls, db: Session, note: Union[NoteInfoPost, NoteRatingPost, NoteTextPost, NoteChoicePost, NoteImagePost] + ): + """Валидация полей при создании note""" + if isinstance(note, NoteInfoPost): + pass + elif isinstance(note, NoteRatingPost): + pass + elif isinstance(note, NoteTextPost): + pass + elif isinstance(note, NoteChoicePost): + pass + else: # NoteImagePost there + pass + @classmethod async def get_notes_by_filters( cls, From 17ef578b5fe419d93875ab1438b29853b31a1dad Mon Sep 17 00:00:00 2001 From: Aiz0r Date: Sat, 19 Sep 2026 13:10:27 +0300 Subject: [PATCH 2/2] added note validation --- modal_backend/schemas/models.py | 2 +- modal_backend/utils/services.py | 40 ++++++++++++---- tests/test_routes/test_notes.py | 81 ++++++++++++++++++++++++++++++--- 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/modal_backend/schemas/models.py b/modal_backend/schemas/models.py index 8c6f155..bd2c684 100644 --- a/modal_backend/schemas/models.py +++ b/modal_backend/schemas/models.py @@ -8,7 +8,7 @@ class NoteGet(Base): id: int type_id: int header: str - end_ts: datetime.datetime + end_ts: datetime.datetime | None # по тз может быть null, если is_always = true status: ModalStatus diff --git a/modal_backend/utils/services.py b/modal_backend/utils/services.py index 287ed57..f4e55d1 100644 --- a/modal_backend/utils/services.py +++ b/modal_backend/utils/services.py @@ -3,7 +3,7 @@ from requests import Session -from modal_backend.exceptions import AlreadyExists, ForbiddenAction, ObjectNotFound +from modal_backend.exceptions import AlreadyExists, ForbiddenAction, ObjectNotFound, ValueError from modal_backend.models.db import Group, ModalStatus, Note, Service from modal_backend.schemas.base import StatusResponseModel from modal_backend.schemas.models import ( @@ -27,16 +27,36 @@ async def validate_note( cls, db: Session, note: Union[NoteInfoPost, NoteRatingPost, NoteTextPost, NoteChoicePost, NoteImagePost] ): """Валидация полей при создании note""" - if isinstance(note, NoteInfoPost): - pass - elif isinstance(note, NoteRatingPost): - pass - elif isinstance(note, NoteTextPost): - pass + if note.is_always: + note.end_ts = None + elif note.end_ts is None or (note.start_ts is not None and note.start_ts >= note.end_ts): + raise ValueError("Invalid end_ts value") + + if note.frequency < 1: + raise ValueError("Frequency must be greater than 0") + if not note.group_ids: + raise ValueError("Group ids must not be empty") + if not note.service_ids: + raise ValueError("Service ids must not be empty") + + if isinstance(note, NoteRatingPost): + if note.rating_max is None or not (2 <= note.rating_max <= 10): + raise ValueError("Rating max must be between 2 and 10") elif isinstance(note, NoteChoicePost): - pass - else: # NoteImagePost there - pass + if note.choice_options is None or len(note.choice_options) < 2: + raise ValueError("Choice options must contain at least 2 options") + if len({opt.id for opt in note.choice_options}) != len(note.choice_options): + raise ValueError("Choice options must have unique ids") + if not all( + opt.text for opt in note.choice_options + ): # я так понял, что текст из пробелов это тоже текст. Поправьте если нет + raise ValueError("Choice options must have non-empty text") + elif isinstance(note, NoteImagePost): + if not note.images: + raise ValueError("Images must not be empty") + elif isinstance(note, NoteTextPost): + if note.max_length is None or not (1 <= note.max_length <= 5000): + raise ValueError("Max length must be between 1 and 5000") @classmethod async def get_notes_by_filters( diff --git a/tests/test_routes/test_notes.py b/tests/test_routes/test_notes.py index 1f9ea39..476d25a 100644 --- a/tests/test_routes/test_notes.py +++ b/tests/test_routes/test_notes.py @@ -38,7 +38,7 @@ def resolve_items(n_list: list | Any, items: list) -> list | Any: "header": "string", "group_ids": [0], # индексы "service_ids": [0], # индексы - "frequency": 0, + "frequency": 1, "start_ts": "2026-08-04T17:21:45.694Z", "end_ts": "2026-08-04T17:22:45.694Z", "is_always": False, @@ -53,11 +53,11 @@ def resolve_items(n_list: list | Any, items: list) -> list | Any: "header": "string", "group_ids": [0], # индексы "service_ids": [0], # индексы - "frequency": 0, + "frequency": 1, "start_ts": "2026-08-04T17:21:45.694Z", "end_ts": "2026-08-04T17:22:45.694Z", "is_always": True, - "rating_max": 0, + "rating_max": 5, }, NoteRatingGet, "/rating", @@ -68,7 +68,7 @@ def resolve_items(n_list: list | Any, items: list) -> list | Any: "header": "string", "group_ids": [0], # индексы "service_ids": [0], # индексы - "frequency": 0, + "frequency": 1, "start_ts": "2026-08-04T17:21:45.694Z", "end_ts": "2026-08-04T17:22:45.694Z", "is_always": False, @@ -84,11 +84,11 @@ def resolve_items(n_list: list | Any, items: list) -> list | Any: "header": "string", "group_ids": [0], # индексы "service_ids": [0], # индексы - "frequency": 0, + "frequency": 1, "start_ts": "2026-08-04T17:21:45.694Z", "end_ts": "2026-08-04T17:22:45.694Z", "is_always": True, - "choice_options": [{"id": 0, "text": "string"}], + "choice_options": [{"id": 0, "text": "string"}, {"id": 1, "text": "string"}], "is_multiple": True, }, NoteChoiceGet, @@ -100,7 +100,7 @@ def resolve_items(n_list: list | Any, items: list) -> list | Any: "header": "string", "group_ids": [0], # индексы "service_ids": [0], # индексы - "frequency": 0, + "frequency": 1, "start_ts": "2026-08-04T17:21:45.694Z", "end_ts": "2026-08-04T17:22:45.694Z", "is_always": False, @@ -211,11 +211,78 @@ def test_create_all_type_of_note(client, dbsession, groups, services, status_cod assert response_model.status == note.status assert response_model.admin_id == note.admin_id + if json_body["is_always"]: + assert note.end_ts is None + if type_model is NoteTextGet: assert len(note.text) <= note.max_length dbsession.delete(note) +VALID_NOTE_BODIES: dict[str, dict] = { + "/info": {"info_text": "string"}, + "/rating": {"rating_max": 5}, + "/text": {"text": "string", "max_length": 6}, + "/choice": {"choice_options": [{"id": 1, "text": "a"}, {"id": 2, "text": "b"}], "is_multiple": False}, + "/image": {"images": ["string"]}, +} +COMMON_NOTE_BODY: dict = { + "header": "string", + "group_ids": [1], + "service_ids": [1], + "frequency": 1, + "start_ts": "2026-08-04T17:21:45.694Z", + "end_ts": "2026-08-04T17:22:45.694Z", + "is_always": False, +} + + +@pytest.mark.parametrize( + "path, overrides", + [ + pytest.param("/info", {"end_ts": None}, id="end_ts_required_if_not_always"), + pytest.param("/info", {"end_ts": "2026-08-04T17:21:45.694Z"}, id="start_ts_equals_end_ts"), + pytest.param("/info", {"end_ts": "2026-08-04T17:00:00.000Z"}, id="start_ts_after_end_ts"), + pytest.param("/info", {"frequency": 0}, id="frequency_zero"), + pytest.param("/info", {"frequency": -1}, id="frequency_negative"), + pytest.param("/info", {"group_ids": []}, id="group_ids_empty"), + pytest.param("/info", {"group_ids": None}, id="group_ids_null"), + pytest.param("/info", {"service_ids": []}, id="service_ids_empty"), + pytest.param("/info", {"service_ids": None}, id="service_ids_null"), + pytest.param("/rating", {"rating_max": 1}, id="rating_max_below_min"), + pytest.param("/rating", {"rating_max": 11}, id="rating_max_above_max"), + pytest.param("/rating", {"rating_max": None}, id="rating_max_missing"), + pytest.param("/text", {"max_length": 0}, id="max_length_below_min"), + pytest.param("/text", {"max_length": 5001}, id="max_length_above_max"), + pytest.param("/text", {"max_length": None}, id="max_length_missing"), + pytest.param("/choice", {"choice_options": [{"id": 1, "text": "a"}]}, id="choice_options_one_option"), + pytest.param("/choice", {"choice_options": None}, id="choice_options_missing"), + pytest.param( + "/choice", {"choice_options": [{"id": 1, "text": "a"}, {"id": 1, "text": "b"}]}, id="choice_options_same_id" + ), + pytest.param( + "/choice", + {"choice_options": [{"id": 1, "text": "a"}, {"id": 2, "text": ""}]}, + id="choice_options_empty_text", + ), + pytest.param("/image", {"images": []}, id="images_empty"), + pytest.param("/image", {"images": None}, id="images_missing"), + ], +) +def test_create_note_validation_error(client, path, overrides): + """ + Нарушение правил валидации из ТЗ даёт 422 в формате StatusResponseModel. + Проверка `status == "Error"` отличает нашу валидацию от стандартной ошибки pydantic (`{"detail": [...]}`). + Ошибки возникают до обращения к БД, поэтому группы и сервисы в БД не нужны. + """ + body = {**COMMON_NOTE_BODY, **VALID_NOTE_BODIES[path], **overrides} + + response = client.post(f"{url}{path}", json=body) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert response.json()["status"] == "Error" + + def calculate_expected_len( all_notes, type_id: int | None,