Skip to content

Commit 0eccd3d

Browse files
committed
Задокументирован модуль Metrics и вынесен сбор метрик в сервис
1 parent ded499a commit 0eccd3d

7 files changed

Lines changed: 270 additions & 41 deletions

File tree

docs/modules/metrics.md

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,97 @@
11
# Metrics
22

3-
TODO
3+
## Назначение
4+
5+
Metrics отвечает за внутренний admin-only endpoint с базовыми счетчиками
6+
сервиса.
7+
8+
Модуль нужен для быстрой технической сводки: сколько в системе пользователей,
9+
ролевых профилей, проектов, вакансий и сколько пользователей сейчас считаются
10+
online через websocket-чаты.
11+
12+
## Статус модуля
13+
14+
Модуль рабочий и подключен в корень API через `GET /`.
15+
16+
Endpoint доступен только staff-пользователям. Для anonymous и обычных
17+
authenticated пользователей доступ закрыт.
18+
19+
## Основные возможности
20+
21+
- подсчет общего количества пользователей;
22+
- подсчет количества ролевых профилей пользователей;
23+
- подсчет количества проектов;
24+
- подсчет количества вакансий;
25+
- подсчет текущих online-пользователей по cache-ключу websocket-чата.
26+
27+
## Архитектура
28+
29+
- `metrics/views.py` - HTTP endpoint метрик.
30+
- `metrics/services.py` - сбор response payload.
31+
- `metrics/urls.py` - route модуля.
32+
- `metrics/tests/` - regression-тесты и helpers модуля.
33+
34+
## API
35+
36+
- `GET /` - получить внутренние метрики сервиса.
37+
38+
Response содержит поля:
39+
40+
- `total_CustomUser_count` - количество пользователей;
41+
- `total_Expert_count` - количество экспертных профилей;
42+
- `total_Investor_count` - количество профилей инвесторов;
43+
- `total_Member_count` - количество профилей участников;
44+
- `total_Mentor_count` - количество профилей менторов;
45+
- `total_Project_count` - количество проектов;
46+
- `total_Vacancy_count` - количество вакансий;
47+
- `current_online_users` - количество пользователей online по данным cache.
48+
49+
## Основные сценарии
50+
51+
### Staff открывает внутреннюю сводку
52+
53+
Staff-пользователь отправляет `GET /` и получает текущие счетчики.
54+
55+
Счетчики `total_*_count` считаются через `objects.count()` соответствующих
56+
моделей.
57+
58+
### Подсчет online-пользователей
59+
60+
`current_online_users` считается по cache-ключу `online_users`.
61+
62+
Этот ключ наполняется модулем `chats`: при подключении пользователя к
63+
websocket-чату пользователь добавляется в set online-пользователей, при
64+
отключении удаляется.
65+
66+
## Связи с другими модулями
67+
68+
- `users` - счетчики пользователей и ролевых профилей.
69+
- `projects` - счетчик проектов.
70+
- `vacancy` - счетчик вакансий.
71+
- `chats` - источник данных для `current_online_users`.
72+
- `core` - cache helpers для online-ключей.
73+
74+
## Ограничения и риски
75+
76+
- Endpoint подключен к корню API: `GET /`. Это текущий контракт, но он
77+
неочевиден для отдельного модуля метрик.
78+
- `current_online_users` показывает только пользователей, которые считаются
79+
online через websocket-чаты. Пользователь, который делает только HTTP-запросы,
80+
в этот счетчик не попадет.
81+
- Online-счетчик зависит от cache. После очистки cache значение будет `0`, пока
82+
пользователи снова не подключатся к websocket.
83+
- Поля response имеют технические имена моделей и сохранены для совместимости.
84+
85+
## Тесты
86+
87+
Текущие тесты лежат в `metrics/tests/`.
88+
89+
Проверяется:
90+
91+
- anonymous пользователь не получает доступ к метрикам;
92+
- обычный authenticated пользователь не получает доступ к метрикам;
93+
- staff-пользователь получает payload метрик;
94+
- service считает пользователей, ролевые профили, проекты, вакансии и
95+
online-пользователей;
96+
- пустой online-cache возвращает `current_online_users = 0`;
97+
- helper подсчета модели сохраняет уже собранный payload.

metrics/services.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from django.contrib.auth import get_user_model
2+
from django.core.cache import cache
3+
4+
from core.utils import get_users_online_cache_key
5+
from projects.models import Project
6+
from users.models import Expert, Investor, Member, Mentor
7+
from vacancy.models import Vacancy
8+
9+
User = get_user_model()
10+
11+
METRIC_MODELS = (User, Expert, Investor, Member, Mentor, Project, Vacancy)
12+
13+
14+
def add_total_count(data: dict[str, int], model) -> dict[str, int]:
15+
new_data = dict(data)
16+
new_data[f"total_{model.__name__}_count"] = model.objects.count()
17+
return new_data
18+
19+
20+
def get_current_online_users_count() -> int:
21+
users_online_list_key = get_users_online_cache_key()
22+
return len(cache.get_or_set(users_online_list_key, set()))
23+
24+
25+
def collect_metrics_payload() -> dict[str, int]:
26+
data = {}
27+
28+
for model in METRIC_MODELS:
29+
data = add_total_count(data, model)
30+
31+
data["current_online_users"] = get_current_online_users_count()
32+
return data

metrics/tests/helpers.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from datetime import date
2+
from uuid import uuid4
3+
4+
from projects.models import Project
5+
from users.models import CustomUser
6+
from vacancy.models import Vacancy
7+
8+
9+
def unique_suffix() -> str:
10+
return uuid4().hex[:8]
11+
12+
13+
def create_user(
14+
*,
15+
prefix: str = "metrics-user",
16+
is_staff: bool = False,
17+
is_superuser: bool = False,
18+
user_type: int = CustomUser.MEMBER,
19+
) -> CustomUser:
20+
return CustomUser.objects.create_user(
21+
email=f"{prefix}-{unique_suffix()}@example.com",
22+
password="test_password_123",
23+
first_name="Иван",
24+
last_name="Иванов",
25+
birthday=date(2000, 1, 1),
26+
user_type=user_type,
27+
is_active=True,
28+
is_staff=is_staff or is_superuser,
29+
is_superuser=is_superuser,
30+
)
31+
32+
33+
def create_project(*, leader: CustomUser | None = None) -> Project:
34+
return Project.objects.create(
35+
leader=leader or create_user(prefix="metrics-leader"),
36+
name=f"Metrics project {unique_suffix()}",
37+
description="Проект для метрик",
38+
draft=False,
39+
)
40+
41+
42+
def create_vacancy(*, project: Project | None = None) -> Vacancy:
43+
return Vacancy.objects.create(
44+
project=project or create_project(),
45+
role=f"Metrics vacancy {unique_suffix()}",
46+
description="Вакансия для метрик",
47+
)

metrics/tests/test_metrics_api.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from django.core.cache import cache
2+
from django.test import TestCase
3+
from rest_framework.test import APIClient
4+
5+
from core.utils import get_users_online_cache_key
6+
from metrics.tests.helpers import create_project, create_user, create_vacancy
7+
8+
9+
class MetricsAPITests(TestCase):
10+
def setUp(self):
11+
self.client = APIClient()
12+
cache.clear()
13+
14+
def test_anonymous_user_cannot_access_metrics(self):
15+
response = self.client.get("/")
16+
17+
self.assertEqual(response.status_code, 401)
18+
19+
def test_staff_user_can_get_metrics_payload(self):
20+
staff = create_user(prefix="metrics-staff", is_staff=True)
21+
user = create_user(prefix="metrics-regular")
22+
project = create_project(leader=user)
23+
create_vacancy(project=project)
24+
cache.set(get_users_online_cache_key(), {staff.id, user.id})
25+
self.client.force_authenticate(staff)
26+
27+
response = self.client.get("/")
28+
29+
self.assertEqual(response.status_code, 200)
30+
self.assertEqual(response.data["total_CustomUser_count"], 2)
31+
self.assertEqual(response.data["total_Project_count"], 1)
32+
self.assertEqual(response.data["total_Vacancy_count"], 1)
33+
self.assertEqual(response.data["current_online_users"], 2)
34+
35+
def test_non_staff_user_cannot_access_metrics(self):
36+
user = create_user(prefix="metrics-regular")
37+
self.client.force_authenticate(user)
38+
39+
response = self.client.get("/")
40+
41+
self.assertEqual(response.status_code, 403)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from django.core.cache import cache
2+
from django.test import TestCase
3+
4+
from core.utils import get_users_online_cache_key
5+
from metrics.services import add_total_count, collect_metrics_payload
6+
from metrics.tests.helpers import create_project, create_user, create_vacancy
7+
from users.models import CustomUser
8+
9+
10+
class MetricsServiceTests(TestCase):
11+
def setUp(self):
12+
cache.clear()
13+
14+
def test_collect_metrics_payload_counts_supported_models_and_online_users(self):
15+
online_user = create_user(
16+
prefix="metrics-online",
17+
user_type=CustomUser.ADMIN,
18+
)
19+
create_user(prefix="metrics-member")
20+
create_user(prefix="metrics-mentor", user_type=CustomUser.MENTOR)
21+
create_user(prefix="metrics-expert", user_type=CustomUser.EXPERT)
22+
create_user(prefix="metrics-investor", user_type=CustomUser.INVESTOR)
23+
leader = create_user(
24+
prefix="metrics-leader",
25+
user_type=CustomUser.ADMIN,
26+
)
27+
project = create_project(leader=leader)
28+
create_vacancy(project=project)
29+
cache.set(get_users_online_cache_key(), {online_user.id, leader.id})
30+
31+
payload = collect_metrics_payload()
32+
33+
self.assertEqual(payload["total_CustomUser_count"], 6)
34+
self.assertEqual(payload["total_Member_count"], 1)
35+
self.assertEqual(payload["total_Mentor_count"], 1)
36+
self.assertEqual(payload["total_Expert_count"], 1)
37+
self.assertEqual(payload["total_Investor_count"], 1)
38+
self.assertEqual(payload["total_Project_count"], 1)
39+
self.assertEqual(payload["total_Vacancy_count"], 1)
40+
self.assertEqual(payload["current_online_users"], 2)
41+
42+
def test_collect_metrics_payload_returns_zero_online_users_for_empty_cache(self):
43+
payload = collect_metrics_payload()
44+
45+
self.assertEqual(payload["current_online_users"], 0)
46+
47+
def test_add_total_count_preserves_existing_payload(self):
48+
create_user(prefix="metrics-counted")
49+
50+
payload = add_total_count({"existing": 1}, CustomUser)
51+
52+
self.assertEqual(payload["existing"], 1)
53+
self.assertEqual(payload["total_CustomUser_count"], 1)

metrics/views.py

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
1-
from django.contrib.auth import get_user_model
2-
from core.utils import get_users_online_cache_key
3-
from projects.models import Project
41
from rest_framework import permissions
52
from rest_framework.response import Response
63
from rest_framework.views import APIView
7-
from users.models import Expert, Investor, Member, Mentor
8-
from vacancy.models import Vacancy
9-
from django.core.cache import cache
104

11-
User = get_user_model()
5+
from metrics.services import collect_metrics_payload
126

137

148
class MetricsView(APIView):
@@ -21,36 +15,4 @@ class MetricsView(APIView):
2115
permission_classes = [permissions.IsAdminUser]
2216

2317
def get(self, request, format=None):
24-
data = {}
25-
26-
models = [User, Expert, Investor, Member, Mentor, Project, Vacancy]
27-
28-
for model in models:
29-
data = self._update_total_counts(data, model)
30-
31-
users_online_list_key = get_users_online_cache_key()
32-
data["current_online_users"] = len(cache.get_or_set(users_online_list_key, set()))
33-
34-
return Response(data)
35-
36-
def _update_total_counts(self, data, model) -> dict[str, int]:
37-
"""
38-
Updates the total counts of the given model.
39-
40-
Args:
41-
data: dict with data.
42-
model: model to get count from.
43-
44-
Returns:
45-
dict: A dictionary with the updated data.
46-
47-
For example:
48-
{
49-
"total_Investor_count": 3,
50-
}
51-
"""
52-
53-
new_data = dict(data)
54-
new_data[f"total_{model.__name__}_count"] = model.objects.count()
55-
56-
return new_data
18+
return Response(collect_metrics_payload())

0 commit comments

Comments
 (0)