From 2578104eeb683343292112e8ba912e99c9bfec10 Mon Sep 17 00:00:00 2001 From: Aiz0r Date: Fri, 21 Aug 2026 21:29:03 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=84=D0=B8=D0=BA=D1=81=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8E=20+=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modal_backend/routes/base.py | 2 + modal_backend/routes/user.py | 37 +++++++++++++ modal_backend/utils/services.py | 98 +++++++++++++++++++++++++++++---- tests/test_routes/test_user.py | 53 ++++++++++++++++++ 4 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 modal_backend/routes/user.py create mode 100644 tests/test_routes/test_user.py diff --git a/modal_backend/routes/base.py b/modal_backend/routes/base.py index fadc022..e538740 100644 --- a/modal_backend/routes/base.py +++ b/modal_backend/routes/base.py @@ -6,6 +6,7 @@ from modal_backend.routes.groups import group from modal_backend.routes.notes import note from modal_backend.routes.services import service +from modal_backend.routes.user import user_router from modal_backend.settings import get_settings settings = get_settings() @@ -37,3 +38,4 @@ app.include_router(note) app.include_router(service) app.include_router(group) +app.include_router(user_router) diff --git a/modal_backend/routes/user.py b/modal_backend/routes/user.py new file mode 100644 index 0000000..b4ed3fb --- /dev/null +++ b/modal_backend/routes/user.py @@ -0,0 +1,37 @@ +from auth_lib.fastapi import UnionAuth +from fastapi import APIRouter, Depends +from fastapi_sqlalchemy import db + +from modal_backend.schemas.base import StatusResponseModel +from modal_backend.settings import Settings, get_settings +from modal_backend.utils.services import NoteViewService + +settings: Settings = get_settings() +user_router = APIRouter(prefix="/user", tags=["User"]) + + +@user_router.post("/{id}/view", response_model=StatusResponseModel) +async def mark_note_view( + id: int, + service_id: int, + user=Depends(UnionAuth()), +) -> StatusResponseModel: + """ + Отмечает, что модалка реально была отрисована пользователю. + + Увеличивает shown_count в note_view и запоминает номер захода + (last_visit_number), от которого потом считается frequency. + Если записи в note_view ещё нет — создаёт. + + Повторный вызов не ошибка + + Исключение ObjectNotFound(404), если модалки с `id` не существует + + Исключение ForbiddenAction(403), если модалка не активна + """ + await NoteViewService.mark_view( + db, note_id=id, user_id=user.get("id"), service_id=service_id + ) + return StatusResponseModel( + status="success", message="View recorded", ru="Показ засчитан" + ) diff --git a/modal_backend/utils/services.py b/modal_backend/utils/services.py index f77c8b0..e3d1f46 100644 --- a/modal_backend/utils/services.py +++ b/modal_backend/utils/services.py @@ -3,7 +3,14 @@ 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.models.db import ( + Group, + ModalStatus, + Note, + NoteView, + Service, + UserVisit, +) from modal_backend.schemas.base import StatusResponseModel from modal_backend.schemas.models import GroupPost, ServicePost @@ -39,7 +46,7 @@ async def get_notes_by_filters( notes = notes_query.limit(limit).offset(offset).all() if not notes: - raise ObjectNotFound(Note, 'all') + raise ObjectNotFound(Note, "all") return notes @@ -47,13 +54,19 @@ async def get_notes_by_filters( async def update_status(cls, db: Session, id: int) -> Note: note = Note.get(session=db.session, id=id) if note.status == ModalStatus.ACTIVE: - updated_note = Note.update(id=id, session=db.session, status=ModalStatus.ARCHIVED) + updated_note = Note.update( + id=id, session=db.session, status=ModalStatus.ARCHIVED + ) return updated_note else: group_ids = note.group_ids new_group_ids = [] for group_id in group_ids: - group = db.session.query(Group).filter(Group.id == group_id, Group.is_deleted == False).one_or_none() + group = ( + db.session.query(Group) + .filter(Group.id == group_id, Group.is_deleted == False) + .one_or_none() + ) if group is not None: new_group_ids.append(group.id) if new_group_ids == []: @@ -86,6 +99,57 @@ async def update_status(cls, db: Session, id: int) -> Note: return updated_note +class NoteViewService: + """ + Сервис для учёта показов модалок + """ + + @classmethod + async def mark_view( + cls, db: Session, note_id: int, user_id: int, service_id: int + ) -> NoteView: + note = Note.get(session=db.session, id=note_id) + if note.status != ModalStatus.ACTIVE: + raise ForbiddenAction(Note) + + user_visit = ( + db.session.query(UserVisit) + .filter(UserVisit.user_id == user_id, UserVisit.service_id == service_id) + .one_or_none() + ) + + if user_visit is not None: + visit_count = user_visit.visit_count + else: + visit_count = 0 + + now = datetime.now(timezone.utc).replace(tzinfo=None) + + note_view = ( + db.session.query(NoteView) + .filter(NoteView.note_id == note_id, NoteView.user_id == user_id) + .one_or_none() + ) + if note_view is None: + note_view = NoteView.create( + session=db.session, + note_id=note_id, + user_id=user_id, + shown_count=1, + last_visit_number=visit_count, + rejected_count=0, + first_shown_at=now, + last_shown_at=now, + ) + else: + note_view.shown_count += 1 + note_view.last_visit_number = visit_count + note_view.last_shown_at = now + db.session.flush() + + return note_view + + class ServiceManager: """ Сервис для работы с логикой Service и базой данных @@ -93,10 +157,16 @@ class ServiceManager: @classmethod async def create_service(cls, db: Session, service_id: int, name: str): - service = Service.query(session=db.session).filter(Service.service_id == service_id).first() + service = ( + Service.query(session=db.session) + .filter(Service.service_id == service_id) + .first() + ) if service: raise AlreadyExists(Service, service_id) - new_service = Service.create(session=db.session, service_id=service_id, name=name) + new_service = Service.create( + session=db.session, service_id=service_id, name=name + ) return new_service @classmethod @@ -104,13 +174,17 @@ async def delete_service(cls, db: Session, id: int): Service.get(session=db.session, id=id) Service.delete(session=db.session, id=id) return StatusResponseModel( - status="Success", message="Service has been successfully deleted", ru="Сервис успешно удален" + status="Success", + message="Service has been successfully deleted", + ru="Сервис успешно удален", ) @classmethod async def update_service(cls, db: Session, id: int, service_info: ServicePost): Service.get(session=db.session, id=id) - updated_service = Service.update(id, session=db.session, **service_info.model_dump()) + updated_service = Service.update( + id, session=db.session, **service_info.model_dump() + ) return updated_service @@ -121,7 +195,9 @@ class GroupService: @classmethod async def create_group(cls, db: Session, group_id: int, name: str): - group = Group.query(session=db.session).filter(Group.group_id == group_id).first() + group = ( + Group.query(session=db.session).filter(Group.group_id == group_id).first() + ) if group: raise AlreadyExists(Group, group_id) new_group = Group.create(session=db.session, group_id=group_id, name=name) @@ -132,7 +208,9 @@ async def delete_group(cls, db: Session, id: int): Group.get(session=db.session, id=id) Group.delete(session=db.session, id=id) return StatusResponseModel( - status="Success", message="Group has been successfully deleted", ru="Группа успешно удалена" + status="Success", + message="Group has been successfully deleted", + ru="Группа успешно удалена", ) @classmethod diff --git a/tests/test_routes/test_user.py b/tests/test_routes/test_user.py new file mode 100644 index 0000000..b9bb89e --- /dev/null +++ b/tests/test_routes/test_user.py @@ -0,0 +1,53 @@ +from starlette import status + +from modal_backend.models.db import NoteView + +url = "/user" + + +def test_first_view_creates_note_view(client, dbsession, notes, authlib_user_data): + note = notes[0] + + response = client.post(f"{url}/{note.id}/view", params={"service_id": 1}) + assert response.status_code == status.HTTP_200_OK + + view = ( + dbsession.query(NoteView) + .filter(NoteView.note_id == note.id, NoteView.user_id == authlib_user_data["id"]) + .one_or_none() + ) + assert view is not None + assert view.shown_count == 1 + + dbsession.delete(view) + dbsession.commit() + + +def test_second_view_increments_shown_count(client, dbsession, notes, authlib_user_data): + note = notes[0] + + client.post(f"{url}/{note.id}/view", params={"service_id": 1}) + response = client.post(f"{url}/{note.id}/view", params={"service_id": 1}) + assert response.status_code == status.HTTP_200_OK + + view = ( + dbsession.query(NoteView) + .filter(NoteView.note_id == note.id, NoteView.user_id == authlib_user_data["id"]) + .one_or_none() + ) + assert view.shown_count == 2 + + dbsession.delete(view) + dbsession.commit() + + +def test_nonexistent_note_returns_404(client): + response = client.post(f"{url}/999999/view", params={"service_id": 1}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_archived_note_returns_403(client, notes): + archived_note = notes[3] + + response = client.post(f"{url}/{archived_note.id}/view", params={"service_id": 1}) + assert response.status_code == status.HTTP_403_FORBIDDEN From 3efa078b84e287b0749ca46ea6bd76683854c3ee Mon Sep 17 00:00:00 2001 From: Aiz0r Date: Sun, 23 Aug 2026 18:57:48 +0300 Subject: [PATCH 2/3] fixed format --- modal_backend/routes/user.py | 8 ++------ modal_backend/utils/services.py | 36 ++++++++------------------------- 2 files changed, 10 insertions(+), 34 deletions(-) diff --git a/modal_backend/routes/user.py b/modal_backend/routes/user.py index b4ed3fb..d46d217 100644 --- a/modal_backend/routes/user.py +++ b/modal_backend/routes/user.py @@ -29,9 +29,5 @@ async def mark_note_view( Исключение ForbiddenAction(403), если модалка не активна """ - await NoteViewService.mark_view( - db, note_id=id, user_id=user.get("id"), service_id=service_id - ) - return StatusResponseModel( - status="success", message="View recorded", ru="Показ засчитан" - ) + await NoteViewService.mark_view(db, note_id=id, user_id=user.get("id"), service_id=service_id) + return StatusResponseModel(status="success", message="View recorded", ru="Показ засчитан") diff --git a/modal_backend/utils/services.py b/modal_backend/utils/services.py index e3d1f46..16e38d3 100644 --- a/modal_backend/utils/services.py +++ b/modal_backend/utils/services.py @@ -54,19 +54,13 @@ async def get_notes_by_filters( async def update_status(cls, db: Session, id: int) -> Note: note = Note.get(session=db.session, id=id) if note.status == ModalStatus.ACTIVE: - updated_note = Note.update( - id=id, session=db.session, status=ModalStatus.ARCHIVED - ) + updated_note = Note.update(id=id, session=db.session, status=ModalStatus.ARCHIVED) return updated_note else: group_ids = note.group_ids new_group_ids = [] for group_id in group_ids: - group = ( - db.session.query(Group) - .filter(Group.id == group_id, Group.is_deleted == False) - .one_or_none() - ) + group = db.session.query(Group).filter(Group.id == group_id, Group.is_deleted == False).one_or_none() if group is not None: new_group_ids.append(group.id) if new_group_ids == []: @@ -105,9 +99,7 @@ class NoteViewService: """ @classmethod - async def mark_view( - cls, db: Session, note_id: int, user_id: int, service_id: int - ) -> NoteView: + async def mark_view(cls, db: Session, note_id: int, user_id: int, service_id: int) -> NoteView: note = Note.get(session=db.session, id=note_id) if note.status != ModalStatus.ACTIVE: raise ForbiddenAction(Note) @@ -126,9 +118,7 @@ async def mark_view( now = datetime.now(timezone.utc).replace(tzinfo=None) note_view = ( - db.session.query(NoteView) - .filter(NoteView.note_id == note_id, NoteView.user_id == user_id) - .one_or_none() + db.session.query(NoteView).filter(NoteView.note_id == note_id, NoteView.user_id == user_id).one_or_none() ) if note_view is None: note_view = NoteView.create( @@ -157,16 +147,10 @@ class ServiceManager: @classmethod async def create_service(cls, db: Session, service_id: int, name: str): - service = ( - Service.query(session=db.session) - .filter(Service.service_id == service_id) - .first() - ) + service = Service.query(session=db.session).filter(Service.service_id == service_id).first() if service: raise AlreadyExists(Service, service_id) - new_service = Service.create( - session=db.session, service_id=service_id, name=name - ) + new_service = Service.create(session=db.session, service_id=service_id, name=name) return new_service @classmethod @@ -182,9 +166,7 @@ async def delete_service(cls, db: Session, id: int): @classmethod async def update_service(cls, db: Session, id: int, service_info: ServicePost): Service.get(session=db.session, id=id) - updated_service = Service.update( - id, session=db.session, **service_info.model_dump() - ) + updated_service = Service.update(id, session=db.session, **service_info.model_dump()) return updated_service @@ -195,9 +177,7 @@ class GroupService: @classmethod async def create_group(cls, db: Session, group_id: int, name: str): - group = ( - Group.query(session=db.session).filter(Group.group_id == group_id).first() - ) + group = Group.query(session=db.session).filter(Group.group_id == group_id).first() if group: raise AlreadyExists(Group, group_id) new_group = Group.create(session=db.session, group_id=group_id, name=name) From e29a46dcabbd60e7410b6d62422685e1ae82c2c4 Mon Sep 17 00:00:00 2001 From: Aiz0r Date: Tue, 1 Sep 2026 21:43:33 +0300 Subject: [PATCH 3/3] fixed commit --- modal_backend/routes/user.py | 12 +++---- modal_backend/utils/services.py | 56 +----------------------------- modal_backend/utils/user_logic.py | 57 +++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 63 deletions(-) create mode 100644 modal_backend/utils/user_logic.py diff --git a/modal_backend/routes/user.py b/modal_backend/routes/user.py index d46d217..3945f56 100644 --- a/modal_backend/routes/user.py +++ b/modal_backend/routes/user.py @@ -4,7 +4,7 @@ from modal_backend.schemas.base import StatusResponseModel from modal_backend.settings import Settings, get_settings -from modal_backend.utils.services import NoteViewService +from modal_backend.utils.user_logic import UserService settings: Settings = get_settings() user_router = APIRouter(prefix="/user", tags=["User"]) @@ -17,17 +17,13 @@ async def mark_note_view( user=Depends(UnionAuth()), ) -> StatusResponseModel: """ - Отмечает, что модалка реально была отрисована пользователю. + Отмечает, что модалка реально была показана пользователю. - Увеличивает shown_count в note_view и запоминает номер захода + Увеличивает shown_count в таблице note_view и запоминает номер захода (last_visit_number), от которого потом считается frequency. Если записи в note_view ещё нет — создаёт. Повторный вызов не ошибка - - Исключение ObjectNotFound(404), если модалки с `id` не существует - - Исключение ForbiddenAction(403), если модалка не активна """ - await NoteViewService.mark_view(db, note_id=id, user_id=user.get("id"), service_id=service_id) + await UserService.mark_view(db, note_id=id, user_id=user.get("id"), service_id=service_id) return StatusResponseModel(status="success", message="View recorded", ru="Показ засчитан") diff --git a/modal_backend/utils/services.py b/modal_backend/utils/services.py index 16e38d3..49802b1 100644 --- a/modal_backend/utils/services.py +++ b/modal_backend/utils/services.py @@ -3,14 +3,7 @@ from requests import Session from modal_backend.exceptions import AlreadyExists, ForbiddenAction, ObjectNotFound -from modal_backend.models.db import ( - Group, - ModalStatus, - Note, - NoteView, - Service, - UserVisit, -) +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 @@ -93,53 +86,6 @@ async def update_status(cls, db: Session, id: int) -> Note: return updated_note -class NoteViewService: - """ - Сервис для учёта показов модалок - """ - - @classmethod - async def mark_view(cls, db: Session, note_id: int, user_id: int, service_id: int) -> NoteView: - note = Note.get(session=db.session, id=note_id) - if note.status != ModalStatus.ACTIVE: - raise ForbiddenAction(Note) - - user_visit = ( - db.session.query(UserVisit) - .filter(UserVisit.user_id == user_id, UserVisit.service_id == service_id) - .one_or_none() - ) - - if user_visit is not None: - visit_count = user_visit.visit_count - else: - visit_count = 0 - - now = datetime.now(timezone.utc).replace(tzinfo=None) - - note_view = ( - db.session.query(NoteView).filter(NoteView.note_id == note_id, NoteView.user_id == user_id).one_or_none() - ) - if note_view is None: - note_view = NoteView.create( - session=db.session, - note_id=note_id, - user_id=user_id, - shown_count=1, - last_visit_number=visit_count, - rejected_count=0, - first_shown_at=now, - last_shown_at=now, - ) - else: - note_view.shown_count += 1 - note_view.last_visit_number = visit_count - note_view.last_shown_at = now - db.session.flush() - - return note_view - - class ServiceManager: """ Сервис для работы с логикой Service и базой данных diff --git a/modal_backend/utils/user_logic.py b/modal_backend/utils/user_logic.py new file mode 100644 index 0000000..eab34c9 --- /dev/null +++ b/modal_backend/utils/user_logic.py @@ -0,0 +1,57 @@ +from datetime import datetime, timezone + +from requests import Session + +from modal_backend.exceptions import ForbiddenAction, ObjectNotFound +from modal_backend.models.db import ModalStatus, Note, NoteView, Service, UserVisit + + +class UserService: + """ + Пользовательский сервис для учёта показов модалок + """ + + @classmethod + async def mark_view(cls, db: Session, note_id: int, user_id: int, service_id: int): + note = Note.get(session=db.session, id=note_id) + if note.status != ModalStatus.ACTIVE: + raise ForbiddenAction(Note) + + now = datetime.now(timezone.utc).replace(tzinfo=None) + if note.is_always == False and now >= note.end_ts: + raise ForbiddenAction(Note) + + service = Service.query(session=db.session).filter(Service.service_id == service_id).one_or_none() + if service is None: + raise ObjectNotFound(Service, service_id) + + user_visit = ( + UserVisit.query(session=db.session) + .filter(UserVisit.user_id == user_id, UserVisit.service_id == service_id) + .one_or_none() + ) + visit_count = user_visit.visit_count if user_visit else 0 + + note_view = ( + NoteView.query(session=db.session) + .filter(NoteView.note_id == note_id, NoteView.user_id == user_id) + .one_or_none() + ) + if note_view is None: + NoteView.create( + session=db.session, + note_id=note_id, + user_id=user_id, + shown_count=1, + last_visit_number=1, + first_shown_at=now, + last_shown_at=now, + ) + else: + NoteView.update( + note_view.id, + session=db.session, + shown_count=note_view.shown_count + 1, + last_visit_number=visit_count, + last_shown_at=now, + )