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/services.py b/modal_backend/routes/services.py index 8f3d97b..a039eeb 100644 --- a/modal_backend/routes/services.py +++ b/modal_backend/routes/services.py @@ -13,9 +13,7 @@ @service.get("", response_model=list[ServiceGet]) -async def get_services( - user=Depends(UnionAuth()), -) -> list[ServiceGet]: +async def get_services(user=Depends(UnionAuth())) -> list[ServiceGet]: """ Получить список всех сервисов. """ @@ -25,8 +23,7 @@ async def get_services( @service.post("", response_model=ServiceGet) async def create_service( - service_info: ServicePost, - user=Depends(UnionAuth(scopes=["modal.service.create"])), + service_info: ServicePost, user=Depends(UnionAuth(scopes=["modal.service.create"])) ) -> ServiceGet: """ Создает новый сервис. @@ -39,9 +36,7 @@ async def create_service( @service.patch("/{id}", response_model=ServiceGet) async def update_service( - id: int, - service_info: ServicePost, - user=Depends(UnionAuth(scopes=["modal.service.update"])), + id: int, service_info: ServicePost, user=Depends(UnionAuth(scopes=["modal.service.update"])) ) -> ServiceGet: """ Обновляет сервис по `id`. diff --git a/modal_backend/routes/user.py b/modal_backend/routes/user.py new file mode 100644 index 0000000..b1d67b0 --- /dev/null +++ b/modal_backend/routes/user.py @@ -0,0 +1,25 @@ +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.user_logic import UserService + +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 ещё нет — создаёт. + + Повторный вызов не ошибка + """ + 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 f77c8b0..49802b1 100644 --- a/modal_backend/utils/services.py +++ b/modal_backend/utils/services.py @@ -39,7 +39,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 @@ -104,7 +104,9 @@ 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 @@ -132,7 +134,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/modal_backend/utils/user_logic.py b/modal_backend/utils/user_logic.py new file mode 100644 index 0000000..22362ed --- /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: + NoteView.update( + note_view.id, + session=db.session, + shown_count=note_view.shown_count + 1, + last_visit_number=visit_count, + last_shown_at=now, + ) + else: + 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, + ) diff --git a/tests/test_routes/test_user.py b/tests/test_routes/test_user.py new file mode 100644 index 0000000..f4ba8a5 --- /dev/null +++ b/tests/test_routes/test_user.py @@ -0,0 +1,51 @@ +import pytest +from starlette import status + +from modal_backend.models.db import NoteView + +url = "/user" + + +@pytest.fixture() +def note_view_cleanup(dbsession, authlib_user_data): + yield + dbsession.query(NoteView).filter(NoteView.user_id == authlib_user_data["id"]).delete() + dbsession.commit() + + +@pytest.mark.parametrize( + "note_index, view_count, expected_status, expected_shown_count", + [ + pytest.param(0, 1, status.HTTP_200_OK, 1, id="first_view_creates_note_view"), + pytest.param(0, 2, status.HTTP_200_OK, 2, id="second_view_increments_shown_count"), + pytest.param(None, 1, status.HTTP_404_NOT_FOUND, None, id="nonexistent_note_returns_404"), + pytest.param(3, 1, status.HTTP_403_FORBIDDEN, None, id="archived_note_returns_403"), + ], +) +def test_mark_note_view( + client, + dbsession, + notes, + services, + authlib_user_data, + note_view_cleanup, + note_index, + view_count, + expected_status, + expected_shown_count, +): + note_id = notes[note_index].id if note_index is not None else 999999 + service_id = services[0].service_id + + for _ in range(view_count): + response = client.post(f"{url}/{note_id}/view", params={"service_id": service_id}) + assert response.status_code == expected_status + + if expected_shown_count is not None: + 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 == expected_shown_count