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..3945f56 --- /dev/null +++ b/modal_backend/routes/user.py @@ -0,0 +1,29 @@ +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..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, + ) 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