Skip to content

Commit ca7de70

Browse files
committed
feat: add metrix, now we collect how many users uses our service on admin page
1 parent 576b6e7 commit ca7de70

6 files changed

Lines changed: 262 additions & 4 deletions

File tree

backend/yet_another_calendar/tests/test_bulk.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,7 @@ def test_views_response_type_handling_logic(bulk_fixture_content):
10631063

10641064

10651065
@pytest.mark.asyncio
1066-
async def test_views_get_calendar_cached_response_type():
1066+
async def test_views_get_calendar_cached_response_type(fake_redis_pool):
10671067
"""Test views get_calendar function with different response types (lines 33-40)."""
10681068
# Mock dependencies
10691069
body = modeus_schema.ModeusTimeBody(
@@ -1090,7 +1090,8 @@ async def test_views_get_calendar_cached_response_type():
10901090
lms_user=lms_user,
10911091
cookies=cookies,
10921092
donor_token="test_token",
1093-
modeus_person_id="550e8400-e29b-41d4-a716-446655440000"
1093+
modeus_person_id="550e8400-e29b-41d4-a716-446655440000",
1094+
redis=fake_redis_pool
10941095
)
10951096

10961097
# Should call change_timezone and return CalendarResponse (line 38)
@@ -1108,7 +1109,8 @@ async def test_views_get_calendar_cached_response_type():
11081109
lms_user=lms_user,
11091110
cookies=cookies,
11101111
donor_token="test_token",
1111-
modeus_person_id="550e8400-e29b-41d4-a716-446655440000"
1112+
modeus_person_id="550e8400-e29b-41d4-a716-446655440000",
1113+
redis=fake_redis_pool
11121114
)
11131115

11141116
# Should validate dict and return CalendarResponse (line 40)

backend/yet_another_calendar/web/api/bulk/integration.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from starlette import status
1111
from pydantic import ValidationError
1212
from loguru import logger
13+
from redis.asyncio import ConnectionPool, Redis
1314

1415
from yet_another_calendar.settings import settings
1516
from . import schema
@@ -22,6 +23,48 @@
2223
from ...cache_builder import key_builder
2324

2425

26+
async def count_keys_by_prefix(redis_pool: ConnectionPool, prefix: str = settings.redis_week_metrix_prefix) -> int:
27+
"""
28+
Count Redis keys matching a given prefix pattern.
29+
30+
Uses scan_iter which is production-safe (non-blocking).
31+
Similar to CLI: redis-cli KEYS "prefix*" | wc -l
32+
33+
:param redis_pool: Redis connection pool
34+
:param prefix: Key prefix pattern (e.g., "calendar:*", "user:*")
35+
:return: Number of keys matching the prefix
36+
"""
37+
async with Redis(connection_pool=redis_pool) as redis:
38+
count = 0
39+
async for _ in redis.scan_iter(match=f"{prefix}*", count=1000):
40+
count += 1
41+
logger.debug(f"Found {count} keys matching prefix '{prefix}'")
42+
return count
43+
44+
async def save_user_was_there(
45+
redis_pool: ConnectionPool, user_id: str,
46+
prefix: str = settings.redis_week_metrix_prefix) -> None:
47+
"""
48+
Mark that a user accessed the calendar by storing their ID in Redis.
49+
50+
Creates a temporary key-value pair with automatic expiration to track
51+
user visits. The key uses the user_id as name and stores a boolean True value.
52+
53+
This is useful for:
54+
- Tracking unique visitors over a time period
55+
- Monitoring user activity
56+
- Analytics and usage statistics
57+
58+
The key automatically expires after redis_events_time_live (14 days by default),
59+
so it acts as a sliding window for recent user activity.
60+
61+
:param redis_pool: Redis connection pool from get_redis_pool dependency
62+
:param user_id: Unique identifier for the user (e.g., email, person_id, etc.)
63+
:return: None
64+
"""
65+
async with Redis(connection_pool=redis_pool) as redis:
66+
await redis.set(name=f"{prefix}:{user_id}", value=0, ex=settings.redis_week_live)
67+
2568

2669
def create_ics_event(title: str, starts_at: datetime.datetime, ends_at: datetime.datetime,
2770
lesson_id: Any, description: str | None = None,

backend/yet_another_calendar/web/api/bulk/views.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
from fastapi import APIRouter, Header
77
from fastapi.params import Depends
88
from starlette.responses import StreamingResponse
9+
from redis.asyncio import ConnectionPool
910

1011
from yet_another_calendar.settings import settings
12+
from yet_another_calendar.web.api.auth.utils import verify_tutor_token
1113
from . import integration, schema
1214
from ..lms import schema as lms_schema
1315
from ..modeus import schema as modeus_schema
1416
from ..modeus import integration as modeus_integration
1517
from ..netology import schema as netology_schema
18+
from ...lifespan import get_redis_pool
1619

1720
router = APIRouter()
1821

@@ -24,12 +27,14 @@ async def get_calendar(
2427
cookies: Annotated[netology_schema.NetologyCookies, Depends(netology_schema.get_cookies_from_headers)],
2528
donor_token: Annotated[str, Depends(modeus_integration.get_donor_token)],
2629
modeus_person_id: Annotated[str, Header()],
30+
redis: Annotated[ConnectionPool, Depends(get_redis_pool)],
2731
calendar_id: int = settings.netology_default_course_id,
2832
time_zone: str = "Europe/Moscow",
2933
) -> schema.CalendarResponse:
3034
"""
3135
Get events from Netology and Modeus, cached.
3236
"""
37+
await integration.save_user_was_there(redis_pool=redis, user_id=modeus_person_id)
3338
cached_calendar = await integration.get_cached_calendar(
3439
body, calendar_id, modeus_person_id,
3540
cookies=cookies, lms_user=lms_user, modeus_jwt_token=donor_token,
@@ -77,3 +82,14 @@ async def export_ics(
7782
)
7883
calendar_with_timezone = calendar.change_timezone(time_zone)
7984
return StreamingResponse(integration.export_to_ics(calendar_with_timezone))
85+
86+
87+
@router.get("/user_metrix/")
88+
async def get_user_metrix(
89+
redis: Annotated[ConnectionPool, Depends(get_redis_pool)],
90+
_: Annotated[None, Depends(verify_tutor_token)],
91+
) -> int:
92+
"""
93+
Get week users metrix
94+
"""
95+
return await integration.count_keys_by_prefix(redis_pool=redis)

frontend/src/pages/ModeusDaySchedulePage.jsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useEffect, useCallback } from 'react';
22
import { toast } from 'react-toastify';
3-
import { getDayEvents, saveLinkToEvent, getTutorTokenFromLocalStorage, getMtsLinks } from '../services/api';
3+
import { getDayEvents, saveLinkToEvent, getTutorTokenFromLocalStorage, getMtsLinks, getWeeklyUsersCount } from '../services/api';
44
import Loader from "../elements/Loader";
55
import ExitBtn from "../components/Calendar/ExitBtn";
66

@@ -42,6 +42,8 @@ const ModeusDaySchedulePage = () => {
4242
return false;
4343
}
4444
});
45+
const [weeklyUsers, setWeeklyUsers] = useState(null);
46+
const [loadingStats, setLoadingStats] = useState(false);
4547

4648
// Генерируем список годов (2023-2026)
4749
const yearOptions = [];
@@ -143,6 +145,32 @@ const ModeusDaySchedulePage = () => {
143145
fetchEvents();
144146
}, [selectedDate, selectedYear, profileName, specialtyCode, fetchEvents]);
145147

148+
// Fetch weekly users count on component mount
149+
useEffect(() => {
150+
const fetchWeeklyUsers = async () => {
151+
const tutorToken = getTutorTokenFromLocalStorage();
152+
if (!tutorToken) {
153+
return; // Don't fetch if no tutor token
154+
}
155+
156+
setLoadingStats(true);
157+
try {
158+
const data = await getWeeklyUsersCount();
159+
// Handle both formats: {weekly_users: N} or just N
160+
const count = typeof data === 'number' ? data : (data.weekly_users || 0);
161+
setWeeklyUsers(count);
162+
debug.log('Weekly users count:', count);
163+
} catch (error) {
164+
debug.error('Error fetching weekly users:', error);
165+
setWeeklyUsers(0);
166+
} finally {
167+
setLoadingStats(false);
168+
}
169+
};
170+
171+
fetchWeeklyUsers();
172+
}, []); // Only run once on mount
173+
146174
const handleDateChange = (e) => {
147175
setSelectedDate(e.target.value);
148176
};
@@ -566,6 +594,19 @@ const ModeusDaySchedulePage = () => {
566594
<div className="shedule-export">
567595
<span className="modeus-page-title">Расписание Modeus на день</span>
568596
</div>
597+
{weeklyUsers !== null && (
598+
<div className="weekly-users-badge">
599+
<div className="badge-content">
600+
<span className="badge-icon">👥</span>
601+
<div className="badge-info">
602+
<span className="badge-label">Пользователей за неделю</span>
603+
<span className="badge-count">
604+
{loadingStats ? '...' : weeklyUsers.toLocaleString('ru-RU')}
605+
</span>
606+
</div>
607+
</div>
608+
</div>
609+
)}
569610
<ExitBtn />
570611
</div>
571612

frontend/src/services/api.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,27 @@ export async function getMtsLinks(lessonIds) {
212212
} catch (e) {
213213
return e.response;
214214
}
215+
}
216+
217+
// Statistics API functions
218+
export async function getWeeklyUsersCount() {
219+
try {
220+
const tutorToken = getTutorTokenFromLocalStorage();
221+
222+
if (!tutorToken) {
223+
throw new Error('Tutor token not found');
224+
}
225+
226+
const response = await axios.get(`${BACKEND_URL}/api/bulk/user_metrix/`, {
227+
headers: {
228+
'Content-Type': 'application/json',
229+
'Authorization': `Bearer ${tutorToken}`
230+
}
231+
});
232+
233+
return response.data;
234+
} catch (e) {
235+
debug.error('Error fetching weekly users count:', e);
236+
return { weekly_users: 0 };
237+
}
215238
}

frontend/src/style/modeus.scss

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2035,3 +2035,136 @@ input.has-saved-link {
20352035
}
20362036
}
20372037
}
2038+
2039+
// Weekly Users Badge - Modern glassmorphism matching page style
2040+
.weekly-users-badge {
2041+
display: flex;
2042+
align-items: center;
2043+
margin-left: 18px;
2044+
animation: slideInUp 0.5s ease-out;
2045+
2046+
.badge-content {
2047+
position: relative;
2048+
display: flex;
2049+
align-items: center;
2050+
gap: 12px;
2051+
padding: 10px 18px;
2052+
min-height: 48px;
2053+
2054+
// Glassmorphism background with gradient - matching event cards
2055+
background:
2056+
radial-gradient(circle at 15% 25%, rgba(124, 58, 237, 0.1) 0%, transparent 50%),
2057+
radial-gradient(circle at 85% 75%, rgba(59, 130, 246, 0.08) 0%, transparent 50%),
2058+
rgba(255, 255, 255, 0.9);
2059+
backdrop-filter: blur(15px) saturate(150%);
2060+
border-radius: 16px;
2061+
border: 1.5px solid rgba(124, 58, 237, 0.25);
2062+
font-family: "Roboto", sans-serif;
2063+
2064+
// Beautiful layered shadows
2065+
box-shadow:
2066+
0 10px 20px rgba(124, 58, 237, 0.15),
2067+
0 5px 10px rgba(59, 130, 246, 0.1),
2068+
0 2px 5px rgba(0, 0, 0, 0.08),
2069+
inset 0 1px 0 rgba(255, 255, 255, 0.6);
2070+
2071+
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
2072+
cursor: default;
2073+
2074+
&:hover {
2075+
transform: translateY(-3px) scale(1.05);
2076+
border-color: rgba(124, 58, 237, 0.4);
2077+
background:
2078+
radial-gradient(circle at 15% 25%, rgba(124, 58, 237, 0.15) 0%, transparent 50%),
2079+
radial-gradient(circle at 85% 75%, rgba(59, 130, 246, 0.12) 0%, transparent 50%),
2080+
rgba(255, 255, 255, 0.95);
2081+
2082+
box-shadow:
2083+
0 16px 32px rgba(124, 58, 237, 0.2),
2084+
0 8px 16px rgba(59, 130, 246, 0.15),
2085+
0 4px 8px rgba(0, 0, 0, 0.1),
2086+
inset 0 1px 0 rgba(255, 255, 255, 0.7);
2087+
2088+
.badge-icon {
2089+
transform: scale(1.15) rotate(5deg);
2090+
box-shadow: 0 6px 18px rgba(124, 58, 237, 0.4);
2091+
}
2092+
2093+
.badge-count {
2094+
transform: scale(1.08);
2095+
}
2096+
}
2097+
}
2098+
2099+
.badge-icon {
2100+
font-size: 20px;
2101+
display: flex;
2102+
align-items: center;
2103+
justify-content: center;
2104+
width: 36px;
2105+
height: 36px;
2106+
background: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%);
2107+
border-radius: 12px;
2108+
box-shadow:
2109+
0 4px 12px rgba(124, 58, 237, 0.3),
2110+
inset 0 1px 0 rgba(255, 255, 255, 0.3);
2111+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
2112+
flex-shrink: 0;
2113+
}
2114+
2115+
.badge-info {
2116+
display: flex;
2117+
flex-direction: column;
2118+
gap: 2px;
2119+
min-width: 0;
2120+
}
2121+
2122+
.badge-label {
2123+
font-size: 11px;
2124+
font-weight: 600;
2125+
color: rgba(100, 116, 139, 0.85);
2126+
text-transform: uppercase;
2127+
letter-spacing: 0.8px;
2128+
line-height: 1;
2129+
transition: color 0.3s ease;
2130+
}
2131+
2132+
.badge-count {
2133+
font-size: 20px;
2134+
font-weight: 800;
2135+
background: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%);
2136+
-webkit-background-clip: text;
2137+
-webkit-text-fill-color: transparent;
2138+
background-clip: text;
2139+
letter-spacing: -0.5px;
2140+
line-height: 1;
2141+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
2142+
}
2143+
}
2144+
2145+
// Responsive design for badge
2146+
@media (max-width: 768px) {
2147+
.weekly-users-badge {
2148+
margin-left: 0;
2149+
margin-top: 10px;
2150+
2151+
.badge-content {
2152+
padding: 8px 14px;
2153+
min-height: 42px;
2154+
2155+
.badge-icon {
2156+
width: 32px;
2157+
height: 32px;
2158+
font-size: 18px;
2159+
}
2160+
2161+
.badge-label {
2162+
font-size: 10px;
2163+
}
2164+
2165+
.badge-count {
2166+
font-size: 18px;
2167+
}
2168+
}
2169+
}
2170+
}

0 commit comments

Comments
 (0)