Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions modal_backend/routes/notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
NoteStatus,
NoteTextGet,
NoteTextPost,
NotificationGet,
)
from modal_backend.settings import Settings, get_settings
from modal_backend.utils.services import NoteService
Expand Down Expand Up @@ -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:
"""
Создает новую модалку.

Expand All @@ -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,
Expand All @@ -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:
"""
Создает новую модалку.

Expand All @@ -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,
Expand All @@ -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:
"""
Создает новую модалку.

Expand All @@ -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,
Expand All @@ -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:
"""
Создает новую модалку.

Expand All @@ -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,
Expand All @@ -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:
"""
Создает новую модалку.

Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion modal_backend/schemas/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
49 changes: 47 additions & 2 deletions modal_backend/utils/services.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,63 @@
from datetime import datetime, timezone
from typing import Union

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 GroupPost, ServicePost
from modal_backend.schemas.models import (
GroupPost,
NoteChoicePost,
NoteImagePost,
NoteInfoPost,
NoteRatingPost,
NoteTextPost,
ServicePost,
)


class NoteService:
"""
Сервис для работы с логикой Notifications и базой данных
"""

@classmethod
async def validate_note(
cls, db: Session, note: Union[NoteInfoPost, NoteRatingPost, NoteTextPost, NoteChoicePost, NoteImagePost]
):
"""Валидация полей при создании note"""
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):
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(
cls,
Expand Down
81 changes: 74 additions & 7 deletions tests/test_routes/test_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading