diff --git a/docs/modules/chats.md b/docs/modules/chats.md index acd41526..4918c2b6 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -1,165 +1,331 @@ # Chats -# Документация по вебсокетам чатов -## Общая инфа -URL для всего вебсокет-релейтед - `/ws/` +## Назначение -В данный момент есть только 1 Consumer (т.е. View, но для вебсокетов). Это ChatConsumer, живет на `/ws/chat/`. +Модуль `chats` отвечает за личные чаты, чаты проектов, историю сообщений, +прикрепленные к сообщениям файлы, признаки прочтения и WebSocket-события +онлайна и сообщений. -`/ws/chat/` +Модуль состоит из двух частей: -### Подключение -Чтобы законнектиться, укажите в хедерах авторизацию по Bearer токену (как и для всех других запросов в REST API). +- REST API для списков чатов, истории сообщений, файлов и проверки непрочитанных + сообщений; +- WebSocket `/ws/chat/` для realtime-событий: создание, чтение, редактирование, + удаление сообщений, typing и online/offline. -### Events -Есть два типа ивентов, которые можно кидать - general events и chat-related events. Первые состоят только из user_online и user_offline, вторые содержат все остальное: новое сообщение, печатание, чтение и удаление (пока без редактирования) +## Статус модуля -Структура любого Event, который должен кидаться на вебсокет выглядит так: -```py -class Event: - type: EventType - content: dict -``` -И соответственно EventType вот такой: -```py -# эти строки указывать в {"type": event_type} - -class EventType(str, Enum): - # CHAT RELATED EVENTS - NEW_MESSAGE = "new_message" - DELETE_MESSAGE = "delete_message" - READ_MESSAGE = "message_read" - TYPING = "user_typing" - EDIT_MESSAGE = "edit_message" - - # GENERAL EVENTS - SET_ONLINE = "set_online" - SET_OFFLINE = "set_offline" -``` -Пример того, как выглядит Event на новое сообщение -```json -{ - "type": "new_message", - "content": { - "chat_type": "direct", - "chat_id": "12_23", - "message": "hello world", - "reply_to": 54, - "is_edited": false - } -} -``` +Модуль рабочий и подключен в публичный API через `/chats/`, а WebSocket route +подключен через ASGI routing на `/ws/chat/`. -## Методы e.g. Ивенты +Критичные WebSocket-flow покрыты тестами для личных и проектных чатов. При этом +модуль остается технически сложным: REST API и WebSocket-логика частично +дублируют ответственность, а часть старых views технически допускает `POST`, но +основной рабочий сценарий создания сообщений идет через WebSocket. -### SET_ONLINE/SET_OFFLINE -Без параметров. +## Основные возможности -### NEW_MESSAGE -- `chat_type: str`\ -`"direct"` или `"project"`, зависит от типа чата -- `chat_id: int/str`\ -Если тип `"project"`, то тип будет `int` и это айди проекта, которому принадлежит чат. Если тип `"direct"`, то это `str`. Выглядит как `{user1_id}_{user2_id}`, **где первое число всегда меньше второго**. -- `message: str` текст сообщения -- `reply_to: Optional[int]` айди сообщения, на которое кидается ответ. Если его нет, то обязательно кидать `None` +- список личных чатов пользователя; +- открытие личного чата по паре пользователей; +- список проектных чатов пользователя; +- карточка проектного чата; +- история сообщений личного или проектного чата; +- список файлов из сообщений чата; +- проверка наличия непрочитанных сообщений; +- realtime-создание сообщений; +- realtime-редактирование и удаление сообщений; +- отметка сообщения как прочитанного; +- typing-события; +- online/offline-события пользователей. -### EDIT_MESSAGE -- `chat_type: str` -- `chat_id` см выше -- `message_id: int` айди сообщение, которое прочитали -- `message: str` текст сообщения +## Архитектура -### TYPING -- `chat_type` см выше -- `chat_id` см выше +- `chats/models.py` - модели чатов, сообщений и связи файла с сообщением. +- `chats/views.py` - REST endpoints списков, detail, истории, файлов и + непрочитанных сообщений. +- `chats/serializers.py` - response serializers чатов и сообщений. +- `chats/permissions.py` - проверки доступа к личным и проектным чатам. +- `chats/consumers/chat.py` - основной WebSocket consumer. +- `chats/consumers/event_types/` - обработчики событий личных и проектных + чатов. +- `chats/utils.py` - async ORM wrappers, создание сообщений, валидация текста, + связь файлов и сообщений. +- `chats/routing.py` - WebSocket route `/ws/chat/`. +- `chats/pagination.py` - limit/offset pagination истории сообщений. +- `chats/tests/` - текущие тесты WebSocket-flow и permissions. -### READ_MESSAGE -- `chat_type` см выше -- `chat_id` см выше -- `message_id: int` айди сообщение, которое прочитали +## Ключевые сущности -#### General events +- `DirectChat` - личный чат двух пользователей. `id` хранится строкой + `_`. +- `ProjectChat` - чат проекта. `id` совпадает с `project.id`. +- `DirectChatMessage` - сообщение в личном чате. +- `ProjectChatMessage` - сообщение в проектном чате. +- `FileToMessage` - связь `UserFile` с личным или проектным сообщением. + +Общие поля сообщений: + +- `text`; +- `is_read`; +- `is_deleted`; +- `is_edited`; +- `created_at`; +- `reply_to`. + +## REST API + +- `GET /chats/directs/` - список личных чатов текущего пользователя. +- `GET /chats/directs//` - detail личного чата. Если чат между двумя + существующими пользователями еще не создан, он создается при открытии. +- `GET /chats/directs//messages/` - история сообщений личного чата. +- `GET /chats/directs//files/` - файлы из сообщений личного чата. +- `GET /chats/projects/` - список проектных чатов пользователя. +- `GET /chats/projects//` - detail проектного чата. +- `GET /chats/projects//messages/` - история сообщений проектного чата. +- `GET /chats/projects//files/` - файлы из сообщений проектного чата. +- `GET /chats/has-unreads/` - признак наличия непрочитанных сообщений. + +История сообщений использует `MessageListPagination`: + +- `limit`, по умолчанию `20`; +- `offset`. + +## WebSocket + +WebSocket endpoint: + +```text +/ws/chat/ +``` -- EventType.SET_ONLINE -- EventType.SET_OFFLINE +Аутентификация выполняется через `TokenAuthMiddleware`. Клиент должен передать +Bearer token так же, как для REST API. -Структура этих event'ов одинаковая. +Формат входящего события: ```json { - "type": "set_offline", - "content": { - - } + "type": "new_message", + "content": {} } ``` -#### Chat-related events +Типы чатов: + +- `direct` - личный чат; +- `project` - чат проекта. + +### Event Types -##### EventType.NEW_MESSAGE +- `new_message` - создание сообщения. +- `message_read` - отметка сообщения как прочитанного. +- `delete_message` - soft-delete сообщения. +- `edit_message` - редактирование сообщения. +- `user_typing` - typing-событие. +- `set_online` - пользователь онлайн. +- `set_offline` - пользователь офлайн. + +### New Message ```json { - "type": "new_message", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message": {{string}}, - "reply_to": number | null - } + "type": "new_message", + "content": { + "chat_type": "direct", + "chat_id": "1_2", + "text": "hello world", + "reply_to": null, + "file_urls": [] + } } ``` -![New message event](../img/event_new_message.png "New message event") +Для `project` в `chat_id` передается id проекта/проектного чата: -##### EventType.TYPING +```json +{ + "type": "new_message", + "content": { + "chat_type": "project", + "chat_id": 10, + "text": "hello project", + "reply_to": null, + "file_urls": [] + } +} +``` + +### Read Message ```json { - "type": "typing", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - } + "type": "message_read", + "content": { + "chat_type": "direct", + "chat_id": "1_2", + "message_id": 100 + } } ``` -##### EventType.READ_MESSAGE +### Edit Message ```json { - "type": "typing", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message_id": {{number}} - } + "type": "edit_message", + "content": { + "chat_type": "project", + "chat_id": 10, + "message_id": 100, + "text": "updated text" + } } ``` -##### EventType.DELETE_MESSAGE +### Delete Message ```json { - "type": "typing", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message_id": {{number}} - } + "type": "delete_message", + "content": { + "chat_type": "direct", + "chat_id": "1_2", + "message_id": 100 + } } ``` -##### EventType.EDIT_MESSAGE +### Typing ```json { - "type": "edit_message", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message_id": {{number}}, - "message": {{string}} - } + "type": "user_typing", + "content": { + "chat_type": "direct", + "chat_id": "1_2" + } } ``` + +## Основные сценарии + +### 1. Пользователь открывает личный чат + +Фронт обращается к `GET /chats/directs//`, где `` имеет формат +`_`. + +Backend: + +- проверяет, что текущий пользователь входит в пару id; +- проверяет существование обоих пользователей; +- создает `DirectChat`, если его еще нет; +- возвращает данные чата и opponent. + +### 2. Пользователь отправляет личное сообщение + +Клиент отправляет WebSocket event `new_message` с `chat_type = "direct"`. + +Backend: + +- нормализует id личного чата; +- создает чат, если его нет; +- создает `DirectChatMessage`; +- связывает переданные `file_urls` с сообщением через `FileToMessage`; +- отправляет событие автору и второму пользователю, если второй пользователь + сейчас подключен. + +### 3. Пользователь отправляет сообщение в проектный чат + +Клиент отправляет WebSocket event `new_message` с `chat_type = "project"`. + +Backend: + +- находит `ProjectChat`; +- проверяет, что пользователь является лидером проекта или collaborator; +- создает `ProjectChatMessage`; +- связывает файлы; +- отправляет событие в группу проектного чата. + +### 4. Пользователь читает сообщение + +`message_read` переводит `is_read=True`. + +Для личного чата прочитать можно только сообщение второго пользователя в своем +чате. Для проектного чата пользователь должен быть участником проекта. + +### 5. Пользователь редактирует или удаляет сообщение + +`edit_message` и `delete_message` доступны только автору сообщения. + +Удаление является soft-delete: сообщение получает `is_deleted=True`, история +REST API фильтрует такие сообщения. + +### 6. Пользователь подключается к WebSocket + +При подключении: + +- канал пользователя сохраняется в cache; +- пользователь добавляется в общий список online users; +- отправляется `set_online`; +- пользователь подписывается на general events; +- вне тестов пользователь также подписывается на группы своих проектных чатов. + +При отключении пользователь удаляется из online cache и отправляется +`set_offline`. + +## Связи с другими модулями + +- `users` - участники личных чатов, авторы сообщений и источник списка + проектных чатов через `CustomUser.get_project_chats()`. +- `projects` - проектный чат создается для проекта; доступ определяется через + лидера и collaborators. +- `files` - файлы прикрепляются к сообщениям через `UserFile` и `FileToMessage`. +- `metrics` - метрики онлайна читают cache-ключи, которые обновляет + `ChatConsumer`. +- `core` - constants/cache helpers для online users. + +## Ограничения и риски + +- `DirectChatMessageList` и `ProjectChatMessageList` технически наследуются от + `ListCreateAPIView`, но основной рабочий сценарий создания сообщений сейчас + WebSocket, а не REST `POST`. +- `DirectChatDetail` создает чат при `GET`, то есть чтение имеет side effect. +- `ProjectChatMessageList.get_queryset()` для несуществующего проектного чата + возвращает пустой список, а не 404. +- `DirectChatList.get()` молча пропускает чат, если у него некорректный состав + пользователей. +- `FileToMessage` допускает одновременную пустоту или неоднозначность + `direct_message` / `project_message` на уровне модели; это держится на + вызывающем коде. +- `match_files_and_messages()` прикрепляет `UserFile` по id без явной проверки, + что файл принадлежит текущему пользователю. +- `get_all_files()` собирает файлы Python-циклом по сообщениям и может быть + дорогим на больших историях. +- `DirectChat.get_avatar()` ожидает второго пользователя и может сломаться на + некорректном личном чате. +- `NotificationConsumer` существует как заготовка и не реализован. +- В `chats/managers.py` остался закомментированный legacy-код. + +## Тесты + +Текущие тесты лежат в `chats/tests/`. + +Они проверяют: + +- подключение к `ChatConsumer`; +- создание личных сообщений; +- запрет отправки в чужой личный чат; +- чтение личного сообщения вторым пользователем; +- запрет чтения своего сообщения и сообщения из чужого чата; +- редактирование своего личного сообщения; +- запрет редактирования чужого личного сообщения; +- удаление своего личного сообщения; +- запрет удаления чужого личного сообщения; +- создание сообщения в проектном чате лидером и collaborator; +- запрет сообщения в чужой проектный чат; +- чтение сообщения в проектном чате участником; +- запрет чтения сообщения вне проекта; +- редактирование и удаление своего проектного сообщения; +- запрет редактирования и удаления чужого проектного сообщения; +- доступ к detail проектного чата для лидера и collaborator; +- запрет detail проектного чата для outsider. diff --git a/docs/modules/core.md b/docs/modules/core.md index 9fbc65bf..13636804 100644 --- a/docs/modules/core.md +++ b/docs/modules/core.md @@ -1,3 +1,233 @@ # Core -TODO +## Назначение + +Модуль `core` содержит общие сущности и инфраструктурные helper'ы, которые +переиспользуются другими доменными модулями Procollab. + +В модуле находятся: + +- generic-модели лайков, просмотров и ссылок; +- справочники навыков и специализаций; +- generic-связи навыков и специализаций с объектами; +- REST endpoints справочника навыков; +- общие serializers, permissions и pagination; +- helpers для Excel-выгрузок; +- cache-ключи онлайна пользователей; +- WebSocket JWT middleware; +- logging middleware. + +## Статус модуля + +`core` подключен в публичный API через `/core/`, но публичная API-поверхность +сейчас ограничена endpoints навыков. + +Модуль является shared-слоем: изменения в нем могут затронуть `users`, +`projects`, `news`, `feed`, `vacancy`, `partner_programs`, `courses`, +`project_rates`, `metrics` и `chats`. + +Собственных тестов у `core` сейчас нет. Часть поведения косвенно покрывается +тестами зависимых модулей. + +## Основные возможности + +- хранение generic-лайков через `Like`; +- хранение generic-просмотров через `View`; +- хранение generic-ссылок через `Link`; +- справочник навыков `SkillCategory` / `Skill`; +- generic-привязка навыков через `SkillToObject`; +- справочник специализаций `SpecializationCategory` / `Specialization`; +- generic-привязка специализаций через `SpecializationToObject`; +- получение навыков nested-списком по категориям; +- получение навыков плоским paginated-списком с фильтром по названию; +- подготовка XLSX-файлов в памяти; +- безопасная подготовка имени файла и значений Excel-ячеек; +- построение download-response для XLSX; +- формирование ключей online-cache; +- JWT-аутентификация WebSocket через subprotocol; +- перехват стандартного logging в loguru. + +## Архитектура + +- `core/models.py` - generic-модели, навыки и специализации. +- `core/views.py` - API справочника навыков. +- `core/serializers.py` - serializers навыков и общие request serializers. +- `core/services.py` - лайки, просмотры, ссылки и Base64 image encoder. +- `core/utils.py` - email helper, online-cache keys и Excel helpers. +- `core/permissions.py` - общие permissions. +- `core/pagination.py` - общий limit/offset pagination. +- `core/filters.py` - фильтр навыков. +- `core/fields.py` - кастомное поле списка для comma-separated значений. +- `core/auth/middleware.py` - WebSocket JWT auth middleware. +- `core/log/` - интеграция стандартного logging с loguru. +- `core/admin.py` - Django admin для core-сущностей. + +## Ключевые сущности + +- `Like` - generic-лайк пользователя к объекту через `ContentType`. +- `View` - generic-просмотр пользователя к объекту через `ContentType`. +- `Link` - generic-ссылка, привязанная к объекту через `ContentType`. +- `SkillCategory` - категория навыка. +- `Skill` - навык внутри категории. +- `SkillToObject` - generic-связь навыка с пользователем, вакансией, проектом + или другим объектом. +- `SpecializationCategory` - категория специализации. +- `Specialization` - специализация внутри категории. +- `SpecializationToObject` - generic-связь специализации с объектом. + +## API + +- `GET /core/skills/nested/` - категории навыков со вложенным списком навыков. +- `GET /core/skills/inline/` - плоский список навыков с pagination. + +Фильтр для `/core/skills/inline/`: + +- `name__icontains` - поиск навыка по части названия. + +Pagination: + +- `limit`, по умолчанию `10`; +- `offset`. + +Справочник специализаций физически хранится в `core`, но endpoints находятся в +модуле `users`: + +- `GET /auth/users/specializations/nested/`; +- `GET /auth/users/specializations/inline/`. + +## Основные сценарии + +### 1. Фронт получает справочник навыков + +Для отображения навыков по категориям используется: + +```text +GET /core/skills/nested/ +``` + +Для поиска и autocomplete используется: + +```text +GET /core/skills/inline/?name__icontains=python +``` + +### 2. Модуль привязывает навыки к объекту + +Доменные модули создают `SkillToObject` через `ContentType`. + +Например: + +- `users` хранит навыки пользователя; +- `vacancy` хранит требуемые навыки вакансии; +- serializers используют `SkillToObjectSerializer` для единого response + формата навыка. + +### 3. Модуль фиксирует лайк или просмотр + +`core.services` предоставляет функции: + +- `set_like(obj, user, is_liked)`; +- `add_like(obj, user)`; +- `remove_like(obj, user)`; +- `is_fan(obj, user)`; +- `get_likes_count(obj)`; +- `set_viewed(obj, user, is_viewed)`; +- `add_view(obj, user)`; +- `remove_view(obj, user)`; +- `is_viewer(obj, user)`; +- `get_views_count(obj)`. + +Эти функции используются в `news`, `feed`, `partner_programs`, +`project_rates` и других местах, где нужен generic-счетчик. + +Важно: не все лайки в проекте уже переведены на generic-модель `core.Like`. +Например, у проектов и мероприятий еще есть отдельные legacy-модели лайков. + +### 4. Модуль формирует XLSX-выгрузку + +Для выгрузок используются: + +- `XlsxFileToExport`; +- `sanitize_excel_value`; +- `build_xlsx_download_response`. + +Эти helpers применяются в `partner_programs`, `project_rates`, `courses`, +`users` и `vacancy`. + +### 5. WebSocket подключение проходит JWT-аутентификацию + +`TokenAuthMiddleware` подключен в `procollab/asgi.py`. + +Он ожидает WebSocket subprotocols в формате: + +```text +["Bearer", ""] +``` + +После проверки JWT middleware записывает пользователя в `scope["user"]`. +Этим пользуется `chats.ChatConsumer`. + +### 6. Чаты обновляют online-cache + +`core.utils` содержит функции: + +- `get_user_online_cache_key(user)`; +- `get_users_online_cache_key()`. + +`chats` пишет в эти ключи при подключении и отключении пользователя, а +`metrics` читает aggregate-ключ для отображения количества пользователей онлайн. + +## Связи с другими модулями + +- `users` - навыки, специализации, online-флаги, Excel-выгрузки и permissions. +- `vacancy` - required skills через `SkillToObject`, admin inline и выгрузки. +- `projects` - общие serializers/permissions, счетчики просмотров, online + данные пользователей. +- `news` - generic likes/views. +- `feed` - generic likes/views для записей ленты. +- `partner_programs` - generic likes/views и Excel-выгрузки. +- `project_rates` - счетчики просмотров проектов и выгрузки. +- `courses` - Excel-выгрузка результатов. +- `chats` - WebSocket auth и online-cache keys. +- `metrics` - чтение online-cache. +- `industries` и `events` - переиспользуют общие permissions. + +## Ограничения и риски + +- У `core` нет собственных тестов; shared-поведение проверяется в основном + косвенно через другие модули. +- `remove_link()` в `core.services` фильтрует `Like`, а не `Link`; это выглядит + как баг. +- `get_views_count()` кеширует значение, но `add_view()` / `remove_view()` не + инвалидируют кеш. +- `get_likes_count()` не использует кеш, хотя `LIKES_CACHING_TIMEOUT` объявлен. +- `Skill`, `SkillCategory`, `Specialization` и `SpecializationCategory` не имеют + уникальности по `name`. +- `SkillToObject` и `SpecializationToObject` не ограничивают дубли на уровне + модели. +- `Base64ImageEncoder.get_encoded_base64_from_url()` использует `urlopen` без + timeout. +- `TokenAuthentication.authenticate()` не обрабатывает отсутствие пользователя + после декодирования JWT. +- `CustomLoguruMiddleware` пишет логи в директорию `log/` внутри `BASE_DIR`; + окружение должно гарантировать доступность этой директории. +- `CustomListField` преобразует список в строку через запятую и обратно; формат + подходит не для всех типов значений. + +## Тесты + +Собственных тестов у модуля сейчас нет: + +```text +DEBUG=True .venv/bin/python manage.py test core +``` + +Текущий запуск находит `0` тестов. + +Поведение `core` частично покрывается тестами зависимых модулей: + +- `news` и `feed` проверяют generic likes/views; +- `vacancy` и `users` проверяют работу навыков; +- `metrics` проверяет online-cache keys; +- `partner_programs`, `project_rates`, `courses` проверяют Excel-выгрузки через + общие helpers. diff --git a/docs/modules/mailing.md b/docs/modules/mailing.md index 3efbc016..57ccbd2d 100644 --- a/docs/modules/mailing.md +++ b/docs/modules/mailing.md @@ -1,3 +1,139 @@ # Mailing -TODO +## Назначение + +Модуль `mailing` отвечает за email-рассылки и шаблоны писем: хранение схем +старой админской формы рассылки, подготовку данных письма, отправку сообщений +через email backend и автоматические сценарии рассылок по партнерским +программам. + +## Статус модуля + +Модуль используется в рабочих сценариях, но не подключен как публичный API. +Основные активные точки использования: + +- celery-задача `run_program_mailings`; +- старая форма рассылки из админки партнерских программ; +- общие helper-функции отправки писем, которые используют другие модули. + +## Основные возможности + +- хранение схемы письма в `MailingSchema`; +- рендеринг старой админской формы рассылки; +- подготовка данных письма из формы или typed dataclass; +- массовая отправка писем по строковому шаблону; +- массовая отправка писем по Django template; +- группировка писем батчами; +- сценарные рассылки участникам партнерских программ; +- логирование результата сценарных рассылок в `MailingScenarioLog`. + +## Архитектура + +- `mailing/models.py` - модели схем писем и логов сценарных рассылок. +- `mailing/utils.py` - подготовка данных письма и низкоуровневые функции + отправки. +- `mailing/scenarios.py` - декларативное описание сценариев рассылки по + программам. +- `mailing/tasks.py` - celery-задача запуска сценариев. +- `mailing/rendering.py` - подстановка базовых placeholders в темы и тексты. +- `mailing/views.py` - старые views для формы рассылки; сейчас не подключены в + публичный URLConf. +- `mailing/urls.py` - старые routes формы рассылки, не подключенные в + `procollab/urls.py`. +- `mailing/tests/` - regression-тесты моделей, rendering/helpers и сценариев. + +## Ключевые сущности + +- `MailingSchema` - схема шаблона письма и HTML-шаблон для старой формы + рассылки. +- `MailingScenarioLog` - лог отправки сценарного письма конкретному участнику + программы за конкретную дату. +- `Scenario` - dataclass с кодом сценария, триггером, правилом выбора + получателей, шаблоном и builder-контекстом. +- `EmailDataToPrepare` - typed input для подготовки данных письма из кода. + +## API и внешние точки входа + +Публичных endpoints модуля `mailing` сейчас нет: `mailing.urls` не подключен в +корневой `procollab/urls.py`. + +Связанные внешние точки: + +- `/anymail/` - webhook routes библиотеки Anymail; +- админка партнерской программы вызывает `MailingTemplateRender` напрямую через + custom admin view; +- celery beat запускает `mailing.tasks.run_program_mailings` каждый день в + 10:00. + +## Основные сценарии + +### 1. Сценарная рассылка по партнерским программам + +`run_program_mailings()` проходит по сценариям из `SCENARIOS`. + +Для каждого сценария: + +- вычисляется целевая дата; +- выбираются программы по дате регистрации, окончанию регистрации или дедлайну + подачи проекта; +- выбираются получатели по правилу сценария; +- создаются `MailingScenarioLog` в статусе `pending`; +- письмо отправляется через `send_mass_mail_from_template`; +- статус лога меняется на `sent` или `failed` по `anymail_status`. + +Повторная отправка за ту же дату не дублирует письма со статусом `pending` или +`sent`. + +### 2. Старая админская рассылка + +`MailingTemplateRender` строит контекст формы: + +- доступные `MailingSchema`; +- выбранные и невыбранные пользователи; +- поля шаблона из JSON-схемы. + +Сейчас этот renderer используется из админки партнерских программ. + +### 3. Отправка письма из других модулей + +Другие модули могут подготовить `EmailDataToPrepare`, получить данные через +`prepare_mail_data()` и отправить письмо через `send_mass_mail()`. + +Такой flow сейчас использует `vacancy.tasks.send_email`, который также +переиспользуется партнерскими программами и оценками проектов. + +## Связи с другими модулями + +- `partner_programs` - сценарные рассылки выбирают программы и участников через + selectors; админка программ использует старый renderer формы рассылки. +- `vacancy` - задачи вакансий используют mailing helpers для email-уведомлений. +- `project_rates` - переиспользует общий notification flow через + `vacancy.tasks.send_email`. +- `users` - получатели писем. +- `anymail` / Unisender Go - фактическая отправка писем в production. + +## Ограничения и риски + +- `mailing/urls.py` содержит старые routes, но они не подключены наружу. +- Если старые routes будут снова подключены, для них нужно отдельно проверить + permissions и безопасность массовой отправки. +- В `mailing/urls.py` есть историческая опечатка `template_fileds`; менять ее + без проверки старого UI не стоит. +- `vacancy.tasks.send_email` фактически является общим helper для уведомлений, + но находится в модуле вакансий. +- `MailingScenarioLog` пока не зарегистрирован в Django admin. + +## Тесты + +Текущие regression-тесты проверяют: + +- строковое представление `MailingSchema` и `MailingScenarioLog`; +- подстановку placeholders в subject и template values; +- контекст старого renderer формы рассылки; +- подготовку данных письма из `EmailDataToPrepare`; +- группировку писем батчами; +- рендеринг и отправку писем по строковому шаблону; +- отправку писем по Django template с `status_callback`; +- выбор участников с неактивными аккаунтами для сценариев программ; +- успешную сценарную рассылку без повторной отправки; +- перевод сценарного лога в `failed` при ошибочном `anymail_status`. diff --git a/docs/modules/metrics.md b/docs/modules/metrics.md index b4301618..b40fbdf4 100644 --- a/docs/modules/metrics.md +++ b/docs/modules/metrics.md @@ -1,3 +1,97 @@ # Metrics -TODO +## Назначение + +Metrics отвечает за внутренний admin-only endpoint с базовыми счетчиками +сервиса. + +Модуль нужен для быстрой технической сводки: сколько в системе пользователей, +ролевых профилей, проектов, вакансий и сколько пользователей сейчас считаются +online через websocket-чаты. + +## Статус модуля + +Модуль рабочий и подключен в корень API через `GET /`. + +Endpoint доступен только staff-пользователям. Для anonymous и обычных +authenticated пользователей доступ закрыт. + +## Основные возможности + +- подсчет общего количества пользователей; +- подсчет количества ролевых профилей пользователей; +- подсчет количества проектов; +- подсчет количества вакансий; +- подсчет текущих online-пользователей по cache-ключу websocket-чата. + +## Архитектура + +- `metrics/views.py` - HTTP endpoint метрик. +- `metrics/services.py` - сбор response payload. +- `metrics/urls.py` - route модуля. +- `metrics/tests/` - regression-тесты и helpers модуля. + +## API + +- `GET /` - получить внутренние метрики сервиса. + +Response содержит поля: + +- `total_CustomUser_count` - количество пользователей; +- `total_Expert_count` - количество экспертных профилей; +- `total_Investor_count` - количество профилей инвесторов; +- `total_Member_count` - количество профилей участников; +- `total_Mentor_count` - количество профилей менторов; +- `total_Project_count` - количество проектов; +- `total_Vacancy_count` - количество вакансий; +- `current_online_users` - количество пользователей online по данным cache. + +## Основные сценарии + +### Staff открывает внутреннюю сводку + +Staff-пользователь отправляет `GET /` и получает текущие счетчики. + +Счетчики `total_*_count` считаются через `objects.count()` соответствующих +моделей. + +### Подсчет online-пользователей + +`current_online_users` считается по cache-ключу `online_users`. + +Этот ключ наполняется модулем `chats`: при подключении пользователя к +websocket-чату пользователь добавляется в set online-пользователей, при +отключении удаляется. + +## Связи с другими модулями + +- `users` - счетчики пользователей и ролевых профилей. +- `projects` - счетчик проектов. +- `vacancy` - счетчик вакансий. +- `chats` - источник данных для `current_online_users`. +- `core` - cache helpers для online-ключей. + +## Ограничения и риски + +- Endpoint подключен к корню API: `GET /`. Это текущий контракт, но он + неочевиден для отдельного модуля метрик. +- `current_online_users` показывает только пользователей, которые считаются + online через websocket-чаты. Пользователь, который делает только HTTP-запросы, + в этот счетчик не попадет. +- Online-счетчик зависит от cache. После очистки cache значение будет `0`, пока + пользователи снова не подключатся к websocket. +- Поля response имеют технические имена моделей и сохранены для совместимости. + +## Тесты + +Текущие тесты лежат в `metrics/tests/`. + +Проверяется: + +- anonymous пользователь не получает доступ к метрикам; +- обычный authenticated пользователь не получает доступ к метрикам; +- staff-пользователь получает payload метрик; +- service считает пользователей, ролевые профили, проекты, вакансии и + online-пользователей; +- пустой online-cache возвращает `current_online_users = 0`; +- helper подсчета модели сохраняет уже собранный payload. diff --git a/metrics/tests.py b/mailing/tests/__init__.py similarity index 100% rename from metrics/tests.py rename to mailing/tests/__init__.py diff --git a/mailing/tests/helpers.py b/mailing/tests/helpers.py new file mode 100644 index 00000000..a54f5c92 --- /dev/null +++ b/mailing/tests/helpers.py @@ -0,0 +1,72 @@ +from datetime import datetime, time, timedelta + +from django.utils import timezone + +from mailing.models import MailingSchema +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from users.models import CustomUser + + +def aware_datetime(dt_date, hour: int = 12): + return timezone.make_aware( + datetime.combine(dt_date, time(hour=hour)), + timezone.get_current_timezone(), + ) + + +def create_user(email: str = "mailing-user@example.com", **overrides) -> CustomUser: + defaults = { + "email": email, + "password": "test-password-12345", + "first_name": "Test", + "last_name": "User", + "birthday": "2000-01-01", + "is_active": True, + } + defaults.update(overrides) + return CustomUser.objects.create_user(**defaults) + + +def create_program(**overrides) -> PartnerProgram: + today = timezone.localdate() + defaults = { + "name": "Mailing Program", + "tag": "mailing-program", + "city": "Moscow", + "datetime_registration_ends": aware_datetime(today + timedelta(days=10)), + "datetime_started": aware_datetime(today - timedelta(days=10)), + "datetime_finished": aware_datetime(today + timedelta(days=40)), + } + defaults.update(overrides) + return PartnerProgram.objects.create(**defaults) + + +def register_program_user( + user: CustomUser, + program: PartnerProgram, + registered_on, +) -> PartnerProgramUserProfile: + profile = PartnerProgramUserProfile.objects.create( + user=user, + partner_program=program, + partner_program_data={}, + ) + PartnerProgramUserProfile.objects.filter(id=profile.id).update( + datetime_created=aware_datetime(registered_on) + ) + profile.refresh_from_db() + return profile + + +def create_mailing_schema(**overrides) -> MailingSchema: + defaults = { + "name": "Default mailing schema", + "schema": { + "title": {"title": "Title", "default": "Default title"}, + "text": {"title": "Text"}, + "button_text": {"title": "Button text", "default": "Open"}, + }, + "template": "

{{ title }}

{{ text }}

{{ user.email }}", + } + defaults.update(overrides) + return MailingSchema.objects.create(**defaults) diff --git a/mailing/tests/test_models_rendering.py b/mailing/tests/test_models_rendering.py new file mode 100644 index 00000000..f0f19dfe --- /dev/null +++ b/mailing/tests/test_models_rendering.py @@ -0,0 +1,84 @@ +from django.test import TestCase +from django.utils import timezone + +from mailing.models import MailingScenarioLog +from mailing.rendering import render_subject, render_template_value +from mailing.views import MailingTemplateRender + +from .helpers import create_mailing_schema, create_program, create_user + + +class MailingModelsTests(TestCase): + def test_mailing_schema_string_representation(self): + schema = create_mailing_schema(name="Program reminder") + + self.assertEqual(str(schema), "MailingSchema") + + def test_mailing_scenario_log_string_representation(self): + program = create_program() + user = create_user() + log = MailingScenarioLog.objects.create( + scenario_code="program_registration_plus_3_inactive_account", + program=program, + user=user, + scheduled_for=timezone.localdate(), + status=MailingScenarioLog.Status.PENDING, + ) + + self.assertIn("program_registration_plus_3_inactive_account", str(log)) + self.assertIn(f"program={program.id}", str(log)) + self.assertIn(f"user={user.id}", str(log)) + self.assertIn("status=pending", str(log)) + + +class MailingRenderingTests(TestCase): + def test_render_subject_replaces_program_name(self): + program = create_program(name="Case Cup") + + subject = render_subject("{program_name}: reminder", program) + + self.assertEqual(subject, "Case Cup: reminder") + + def test_render_template_value_replaces_known_placeholders(self): + program = create_program(name="Case Cup") + user = create_user() + + value = render_template_value( + "/program/{program_id}/users/{user_id}/{program_name}", + program, + user, + ) + + self.assertEqual(value, f"/program/{program.id}/users/{user.id}/Case Cup") + + def test_template_render_context_contains_schema_users_and_fields(self): + schema = create_mailing_schema( + name="Participant email", + schema={ + "title": {"title": "Title", "default": "Default title"}, + "text": {"title": "Text"}, + }, + ) + picked_user = create_user(email="picked@example.com") + unpicked_user = create_user(email="unpicked@example.com") + + context = MailingTemplateRender._get_context( + schema.id, + picked_users=[picked_user], + unpicked_users=[unpicked_user], + ) + + selected_schema = context["schemas"][0] + self.assertEqual(selected_schema["id"], schema.id) + self.assertTrue(selected_schema["selected"]) + self.assertEqual(context["picked_users"][0]["id"], picked_user.id) + self.assertTrue(context["picked_users"][0]["picked"]) + self.assertEqual(context["unpicked_users"][0]["id"], unpicked_user.id) + self.assertFalse(context["unpicked_users"][0]["picked"]) + self.assertEqual( + context["template_fields"], + [ + {"key": "title", "title": "Title", "default": "Default title"}, + {"key": "text", "title": "Text", "default": ""}, + ], + ) diff --git a/mailing/tests.py b/mailing/tests/test_program_scenarios.py similarity index 51% rename from mailing/tests.py rename to mailing/tests/test_program_scenarios.py index b724d217..354cc19d 100644 --- a/mailing/tests.py +++ b/mailing/tests/test_program_scenarios.py @@ -1,4 +1,4 @@ -from datetime import datetime, time, timedelta +from datetime import timedelta from unittest.mock import patch from django.test import TestCase @@ -6,23 +6,24 @@ from mailing.models import MailingScenarioLog from mailing.tasks import run_program_mailings -from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from partner_programs.selectors import ( program_participants_with_inactive_account, program_participants_with_inactive_account_registered_on, ) from users.models import CustomUser +from .helpers import aware_datetime, create_program, create_user, register_program_user + class _SentStatus: - def __init__(self, message_id: str): + def __init__(self, message_id: str, status="sent"): self.message_id = message_id - self.status = "sent" + self.status = status class _SentMessage: - def __init__(self, user_id: int): - self.anymail_status = _SentStatus(f"msg-{user_id}") + def __init__(self, user_id: int, status="sent", message_id: str | None = None): + self.anymail_status = _SentStatus(message_id or f"msg-{user_id}", status) def _fake_send_mass_mail_from_template( @@ -38,62 +39,45 @@ def _fake_send_mass_mail_from_template( return len(users) +def _fake_failed_send_mass_mail_from_template( + users, + subject, + template_name, + context_builder=None, + status_callback=None, +): + for user in users: + if status_callback: + status_callback(user, _SentMessage(user.id, status="rejected")) + return len(users) + + class ProgramInactiveAccountSelectorsTests(TestCase): def setUp(self): self.today = timezone.localdate() - def _dt(self, dt_date): - return timezone.make_aware( - datetime.combine(dt_date, time(hour=12)), - timezone.get_current_timezone(), - ) + def test_participants_with_inactive_account(self): + program = create_program() - def _create_user(self, email: str): - return CustomUser.objects.create_user( - email=email, - password="very_strong_password", - first_name="Иван", - last_name="Иванов", - birthday="2000-01-01", - is_active=True, - ) + inactive_no_activity = create_user("inactive-no-activity@example.com") + inactive_old_login = create_user("inactive-old-login@example.com") + active_recent_activity = create_user("active-recent@example.com") - def _create_program(self): - return PartnerProgram.objects.create( - name="FinFor", - tag="finfor", - city="Moscow", - datetime_registration_ends=self._dt(self.today + timedelta(days=10)), - datetime_started=self._dt(self.today - timedelta(days=10)), - datetime_finished=self._dt(self.today + timedelta(days=40)), + register_program_user( + inactive_no_activity, program, self.today - timedelta(days=4) ) - - def _register_user(self, user: CustomUser, program: PartnerProgram, registered_on): - profile = PartnerProgramUserProfile.objects.create( - user=user, - partner_program=program, - partner_program_data={}, + register_program_user( + inactive_old_login, program, self.today - timedelta(days=4) ) - PartnerProgramUserProfile.objects.filter(id=profile.id).update( - datetime_created=self._dt(registered_on) + register_program_user( + active_recent_activity, program, self.today - timedelta(days=4) ) - def test_participants_with_inactive_account(self): - program = self._create_program() - - inactive_no_activity = self._create_user("inactive-no-activity@example.com") - inactive_old_login = self._create_user("inactive-old-login@example.com") - active_recent_activity = self._create_user("active-recent@example.com") - - self._register_user(inactive_no_activity, program, self.today - timedelta(days=4)) - self._register_user(inactive_old_login, program, self.today - timedelta(days=4)) - self._register_user(active_recent_activity, program, self.today - timedelta(days=4)) - CustomUser.objects.filter(id=inactive_old_login.id).update( - last_login=self._dt(self.today - timedelta(days=15)) + last_login=aware_datetime(self.today - timedelta(days=15)) ) CustomUser.objects.filter(id=active_recent_activity.id).update( - last_activity=self._dt(self.today - timedelta(days=1)) + last_activity=aware_datetime(self.today - timedelta(days=1)) ) recipients = program_participants_with_inactive_account( @@ -106,14 +90,18 @@ def test_participants_with_inactive_account(self): self.assertNotIn(active_recent_activity.id, recipient_ids) def test_participants_with_inactive_account_registered_on_date(self): - program = self._create_program() + program = create_program() target_date = self.today - timedelta(days=3) - registered_on_target = self._create_user("registered-on-target@example.com") - registered_other_day = self._create_user("registered-other-day@example.com") + registered_on_target = create_user("registered-on-target@example.com") + registered_other_day = create_user("registered-other-day@example.com") - self._register_user(registered_on_target, program, target_date) - self._register_user(registered_other_day, program, self.today - timedelta(days=2)) + register_program_user(registered_on_target, program, target_date) + register_program_user( + registered_other_day, + program, + self.today - timedelta(days=2), + ) recipients = program_participants_with_inactive_account_registered_on( program.id, target_date, program.datetime_started @@ -128,32 +116,6 @@ class ProgramInactiveAccountScenariosTests(TestCase): def setUp(self): self.today = timezone.localdate() - def _dt(self, dt_date): - return timezone.make_aware( - datetime.combine(dt_date, time(hour=12)), - timezone.get_current_timezone(), - ) - - def _create_user(self, email: str): - return CustomUser.objects.create_user( - email=email, - password="very_strong_password", - first_name="Иван", - last_name="Иванов", - birthday="2000-01-01", - is_active=True, - ) - - def _register_user(self, user: CustomUser, program: PartnerProgram, registered_on): - profile = PartnerProgramUserProfile.objects.create( - user=user, - partner_program=program, - partner_program_data={}, - ) - PartnerProgramUserProfile.objects.filter(id=profile.id).update( - datetime_created=self._dt(registered_on) - ) - @patch( "mailing.tasks.send_mass_mail_from_template", side_effect=_fake_send_mass_mail_from_template, @@ -161,29 +123,27 @@ def _register_user(self, user: CustomUser, program: PartnerProgram, registered_o def test_registration_plus_3_inactive_account_scenario(self, send_mail_mock): target_registration_date = self.today - timedelta(days=3) - program = PartnerProgram.objects.create( + program = create_program( name="FinFor", tag="finfor", - city="Moscow", - datetime_registration_ends=self._dt(self.today + timedelta(days=20)), - datetime_started=self._dt(self.today - timedelta(days=15)), - datetime_finished=self._dt(self.today + timedelta(days=40)), + datetime_registration_ends=aware_datetime(self.today + timedelta(days=20)), + datetime_started=aware_datetime(self.today - timedelta(days=15)), ) - inactive_user = self._create_user("inactive-user@example.com") - active_user = self._create_user("active-user@example.com") - registered_other_day_user = self._create_user("other-day-user@example.com") + inactive_user = create_user("inactive-user@example.com") + active_user = create_user("active-user@example.com") + registered_other_day_user = create_user("other-day-user@example.com") - self._register_user(inactive_user, program, target_registration_date) - self._register_user(active_user, program, target_registration_date) - self._register_user( + register_program_user(inactive_user, program, target_registration_date) + register_program_user(active_user, program, target_registration_date) + register_program_user( registered_other_day_user, program, self.today - timedelta(days=2), ) CustomUser.objects.filter(id=active_user.id).update( - last_activity=self._dt(self.today - timedelta(days=1)) + last_activity=aware_datetime(self.today - timedelta(days=1)) ) sent_count = run_program_mailings() @@ -221,23 +181,22 @@ def test_registration_plus_3_inactive_account_scenario(self, send_mail_mock): def test_registration_end_plus_3_inactive_account_scenario(self, send_mail_mock): target_registration_end_date = self.today - timedelta(days=3) - program = PartnerProgram.objects.create( + program = create_program( name="FinFor", tag="finfor", - city="Moscow", - datetime_registration_ends=self._dt(target_registration_end_date), - datetime_started=self._dt(self.today - timedelta(days=15)), - datetime_finished=self._dt(self.today + timedelta(days=20)), + datetime_registration_ends=aware_datetime(target_registration_end_date), + datetime_started=aware_datetime(self.today - timedelta(days=15)), + datetime_finished=aware_datetime(self.today + timedelta(days=20)), ) - inactive_user = self._create_user("inactive-end-user@example.com") - active_user = self._create_user("active-end-user@example.com") + inactive_user = create_user("inactive-end-user@example.com") + active_user = create_user("active-end-user@example.com") - self._register_user(inactive_user, program, self.today - timedelta(days=10)) - self._register_user(active_user, program, self.today - timedelta(days=10)) + register_program_user(inactive_user, program, self.today - timedelta(days=10)) + register_program_user(active_user, program, self.today - timedelta(days=10)) CustomUser.objects.filter(id=active_user.id).update( - last_login=self._dt(self.today - timedelta(days=1)) + last_login=aware_datetime(self.today - timedelta(days=1)) ) sent_count = run_program_mailings() @@ -267,3 +226,29 @@ def test_registration_end_plus_3_inactive_account_scenario(self, send_mail_mock) all_logs.first().status, MailingScenarioLog.Status.SENT, ) + + @patch( + "mailing.tasks.send_mass_mail_from_template", + side_effect=_fake_failed_send_mass_mail_from_template, + ) + def test_failed_provider_status_marks_scenario_log_failed(self, send_mail_mock): + target_registration_date = self.today - timedelta(days=3) + program = create_program( + datetime_registration_ends=aware_datetime(self.today + timedelta(days=20)), + datetime_started=aware_datetime(self.today - timedelta(days=15)), + ) + inactive_user = create_user("inactive-failed@example.com") + register_program_user(inactive_user, program, target_registration_date) + + sent_count = run_program_mailings() + + self.assertEqual(sent_count, 0) + log = MailingScenarioLog.objects.get( + scenario_code="program_registration_plus_3_inactive_account", + program=program, + scheduled_for=self.today, + user=inactive_user, + ) + self.assertEqual(log.status, MailingScenarioLog.Status.FAILED) + self.assertIn("anymail_status=rejected", log.error) + self.assertEqual(send_mail_mock.call_count, 1) diff --git a/mailing/tests/test_utils.py b/mailing/tests/test_utils.py new file mode 100644 index 00000000..9a45254f --- /dev/null +++ b/mailing/tests/test_utils.py @@ -0,0 +1,110 @@ +from unittest.mock import Mock, patch + +from django.test import TestCase + +from mailing.typing import EmailDataToPrepare +from mailing.utils import ( + create_message_groups, + prepare_mail_data, + send_mass_mail, + send_mass_mail_from_template, +) + +from .helpers import create_mailing_schema, create_user + + +class MailingPrepareDataTests(TestCase): + def test_prepare_mail_data_from_dataclass_uses_schema_and_requested_users(self): + user = create_user("recipient@example.com") + other_user = create_user("other@example.com") + schema = create_mailing_schema( + schema={ + "title": {"title": "Title"}, + "text": {"title": "Text"}, + }, + template="

{{ title }}

{{ text }}

", + ) + + mail_data = prepare_mail_data( + EmailDataToPrepare( + users_ids=[user.id], + subject="Subject", + schema_id=schema.id, + context_data={ + "title": "Custom title", + "text": "Custom text", + "button_link": "https://example.com", + "button_text": "Open", + }, + ) + ) + + self.assertEqual(list(mail_data["users"]), [user]) + self.assertNotIn(other_user, list(mail_data["users"])) + self.assertEqual(mail_data["subject"], "Subject") + self.assertEqual(mail_data["template_string"], schema.template) + self.assertEqual( + mail_data["template_context"], + {"title": "Custom title", "text": "Custom text"}, + ) + + +class MailingSendUtilsTests(TestCase): + def test_create_message_groups_uses_configured_batch_size(self): + messages = list(range(205)) + + groups = create_message_groups(messages) + + self.assertEqual([len(group) for group in groups], [100, 100, 5]) + + @patch("mailing.utils.send_group_messages") + def test_send_mass_mail_renders_template_for_each_user(self, send_group_messages): + send_group_messages.side_effect = lambda messages: len(messages) + first_user = create_user("first@example.com") + second_user = create_user("second@example.com") + + sent_count = send_mass_mail( + [first_user, second_user], + "Subject", + "Hello {{ user.email }}: {{ title }}", + {"title": "Reminder"}, + ) + + self.assertEqual(sent_count, 2) + send_group_messages.assert_called_once() + messages = send_group_messages.call_args.args[0] + self.assertEqual(messages[0].to, [first_user.email]) + self.assertEqual(messages[0].subject, "Subject") + self.assertIn("Hello first@example.com: Reminder", messages[0].body) + self.assertEqual(messages[1].to, [second_user.email]) + self.assertIn("Hello second@example.com: Reminder", messages[1].body) + + @patch("mailing.utils.send_group_messages") + @patch("mailing.utils.get_template") + def test_send_mass_mail_from_template_calls_status_callback( + self, + get_template, + send_group_messages, + ): + template = Mock() + template.render.side_effect = lambda context: f"Hello {context['user'].email}" + get_template.return_value = template + send_group_messages.side_effect = lambda messages: len(messages) + user = create_user("template-recipient@example.com") + handled_user_ids = [] + + sent_count = send_mass_mail_from_template( + [user], + "Subject", + "email/template.html", + status_callback=lambda handled_user, msg: handled_user_ids.append( + handled_user.id + ), + ) + + self.assertEqual(sent_count, 1) + get_template.assert_called_once_with("email/template.html") + self.assertEqual(handled_user_ids, [user.id]) + message = send_group_messages.call_args.args[0][0] + self.assertEqual(message.to, [user.email]) + self.assertIn("Hello template-recipient@example.com", message.body) diff --git a/metrics/services.py b/metrics/services.py new file mode 100644 index 00000000..f9ac78da --- /dev/null +++ b/metrics/services.py @@ -0,0 +1,32 @@ +from django.contrib.auth import get_user_model +from django.core.cache import cache + +from core.utils import get_users_online_cache_key +from projects.models import Project +from users.models import Expert, Investor, Member, Mentor +from vacancy.models import Vacancy + +User = get_user_model() + +METRIC_MODELS = (User, Expert, Investor, Member, Mentor, Project, Vacancy) + + +def add_total_count(data: dict[str, int], model) -> dict[str, int]: + new_data = dict(data) + new_data[f"total_{model.__name__}_count"] = model.objects.count() + return new_data + + +def get_current_online_users_count() -> int: + users_online_list_key = get_users_online_cache_key() + return len(cache.get_or_set(users_online_list_key, set())) + + +def collect_metrics_payload() -> dict[str, int]: + data = {} + + for model in METRIC_MODELS: + data = add_total_count(data, model) + + data["current_online_users"] = get_current_online_users_count() + return data diff --git a/metrics/tests/__init__.py b/metrics/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/metrics/tests/helpers.py b/metrics/tests/helpers.py new file mode 100644 index 00000000..d03213ad --- /dev/null +++ b/metrics/tests/helpers.py @@ -0,0 +1,47 @@ +from datetime import date +from uuid import uuid4 + +from projects.models import Project +from users.models import CustomUser +from vacancy.models import Vacancy + + +def unique_suffix() -> str: + return uuid4().hex[:8] + + +def create_user( + *, + prefix: str = "metrics-user", + is_staff: bool = False, + is_superuser: bool = False, + user_type: int = CustomUser.MEMBER, +) -> CustomUser: + return CustomUser.objects.create_user( + email=f"{prefix}-{unique_suffix()}@example.com", + password="test_password_123", + first_name="Иван", + last_name="Иванов", + birthday=date(2000, 1, 1), + user_type=user_type, + is_active=True, + is_staff=is_staff or is_superuser, + is_superuser=is_superuser, + ) + + +def create_project(*, leader: CustomUser | None = None) -> Project: + return Project.objects.create( + leader=leader or create_user(prefix="metrics-leader"), + name=f"Metrics project {unique_suffix()}", + description="Проект для метрик", + draft=False, + ) + + +def create_vacancy(*, project: Project | None = None) -> Vacancy: + return Vacancy.objects.create( + project=project or create_project(), + role=f"Metrics vacancy {unique_suffix()}", + description="Вакансия для метрик", + ) diff --git a/metrics/tests/test_metrics_api.py b/metrics/tests/test_metrics_api.py new file mode 100644 index 00000000..cbd0cf96 --- /dev/null +++ b/metrics/tests/test_metrics_api.py @@ -0,0 +1,41 @@ +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient + +from core.utils import get_users_online_cache_key +from metrics.tests.helpers import create_project, create_user, create_vacancy + + +class MetricsAPITests(TestCase): + def setUp(self): + self.client = APIClient() + cache.clear() + + def test_anonymous_user_cannot_access_metrics(self): + response = self.client.get("/") + + self.assertEqual(response.status_code, 401) + + def test_staff_user_can_get_metrics_payload(self): + staff = create_user(prefix="metrics-staff", is_staff=True) + user = create_user(prefix="metrics-regular") + project = create_project(leader=user) + create_vacancy(project=project) + cache.set(get_users_online_cache_key(), {staff.id, user.id}) + self.client.force_authenticate(staff) + + response = self.client.get("/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["total_CustomUser_count"], 2) + self.assertEqual(response.data["total_Project_count"], 1) + self.assertEqual(response.data["total_Vacancy_count"], 1) + self.assertEqual(response.data["current_online_users"], 2) + + def test_non_staff_user_cannot_access_metrics(self): + user = create_user(prefix="metrics-regular") + self.client.force_authenticate(user) + + response = self.client.get("/") + + self.assertEqual(response.status_code, 403) diff --git a/metrics/tests/test_metrics_services.py b/metrics/tests/test_metrics_services.py new file mode 100644 index 00000000..dfdf9472 --- /dev/null +++ b/metrics/tests/test_metrics_services.py @@ -0,0 +1,53 @@ +from django.core.cache import cache +from django.test import TestCase + +from core.utils import get_users_online_cache_key +from metrics.services import add_total_count, collect_metrics_payload +from metrics.tests.helpers import create_project, create_user, create_vacancy +from users.models import CustomUser + + +class MetricsServiceTests(TestCase): + def setUp(self): + cache.clear() + + def test_collect_metrics_payload_counts_supported_models_and_online_users(self): + online_user = create_user( + prefix="metrics-online", + user_type=CustomUser.ADMIN, + ) + create_user(prefix="metrics-member") + create_user(prefix="metrics-mentor", user_type=CustomUser.MENTOR) + create_user(prefix="metrics-expert", user_type=CustomUser.EXPERT) + create_user(prefix="metrics-investor", user_type=CustomUser.INVESTOR) + leader = create_user( + prefix="metrics-leader", + user_type=CustomUser.ADMIN, + ) + project = create_project(leader=leader) + create_vacancy(project=project) + cache.set(get_users_online_cache_key(), {online_user.id, leader.id}) + + payload = collect_metrics_payload() + + self.assertEqual(payload["total_CustomUser_count"], 6) + self.assertEqual(payload["total_Member_count"], 1) + self.assertEqual(payload["total_Mentor_count"], 1) + self.assertEqual(payload["total_Expert_count"], 1) + self.assertEqual(payload["total_Investor_count"], 1) + self.assertEqual(payload["total_Project_count"], 1) + self.assertEqual(payload["total_Vacancy_count"], 1) + self.assertEqual(payload["current_online_users"], 2) + + def test_collect_metrics_payload_returns_zero_online_users_for_empty_cache(self): + payload = collect_metrics_payload() + + self.assertEqual(payload["current_online_users"], 0) + + def test_add_total_count_preserves_existing_payload(self): + create_user(prefix="metrics-counted") + + payload = add_total_count({"existing": 1}, CustomUser) + + self.assertEqual(payload["existing"], 1) + self.assertEqual(payload["total_CustomUser_count"], 1) diff --git a/metrics/views.py b/metrics/views.py index 8bb1311c..0db70580 100644 --- a/metrics/views.py +++ b/metrics/views.py @@ -1,14 +1,8 @@ -from django.contrib.auth import get_user_model -from core.utils import get_users_online_cache_key -from projects.models import Project from rest_framework import permissions from rest_framework.response import Response from rest_framework.views import APIView -from users.models import Expert, Investor, Member, Mentor -from vacancy.models import Vacancy -from django.core.cache import cache -User = get_user_model() +from metrics.services import collect_metrics_payload class MetricsView(APIView): @@ -21,36 +15,4 @@ class MetricsView(APIView): permission_classes = [permissions.IsAdminUser] def get(self, request, format=None): - data = {} - - models = [User, Expert, Investor, Member, Mentor, Project, Vacancy] - - for model in models: - data = self._update_total_counts(data, model) - - users_online_list_key = get_users_online_cache_key() - data["current_online_users"] = len(cache.get_or_set(users_online_list_key, set())) - - return Response(data) - - def _update_total_counts(self, data, model) -> dict[str, int]: - """ - Updates the total counts of the given model. - - Args: - data: dict with data. - model: model to get count from. - - Returns: - dict: A dictionary with the updated data. - - For example: - { - "total_Investor_count": 3, - } - """ - - new_data = dict(data) - new_data[f"total_{model.__name__}_count"] = model.objects.count() - - return new_data + return Response(collect_metrics_payload())