matplobbot-shared 0.1.26__py3-none-any.whl → 0.1.29__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of matplobbot-shared might be problematic. Click here for more details.
- {matplobbot_shared-0.1.26.dist-info → matplobbot_shared-0.1.29.dist-info}/METADATA +1 -1
- matplobbot_shared-0.1.29.dist-info/RECORD +10 -0
- shared_lib/database.py +146 -29
- shared_lib/services/schedule_service.py +24 -23
- matplobbot_shared-0.1.26.dist-info/RECORD +0 -10
- {matplobbot_shared-0.1.26.dist-info → matplobbot_shared-0.1.29.dist-info}/WHEEL +0 -0
- {matplobbot_shared-0.1.26.dist-info → matplobbot_shared-0.1.29.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
shared_lib/__init__.py,sha256=Wxuw1wbvCOsKqs6WfIQ05cx_vndhPs6rH2krMatFRqA,45
|
|
2
|
+
shared_lib/database.py,sha256=vGiCxk1m_xZxaJQtjQPC_7G2Wxnu-ptWx5Z7q_XDIGs,20706
|
|
3
|
+
shared_lib/i18n.py,sha256=VBWQWVF-k_HDiidYo_RUPyUCM7oL897z5hOw9jvOoYY,1762
|
|
4
|
+
shared_lib/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
shared_lib/services/schedule_service.py,sha256=S14iRmZHYX7l4TVB95gtAWVzHLOjC8Heos9Q1T-1-d0,1911
|
|
6
|
+
shared_lib/services/university_api.py,sha256=Ui-zjfKOHCCf2Imh8CNtVOWegwuep7IB8gO9IKNUrrE,1898
|
|
7
|
+
matplobbot_shared-0.1.29.dist-info/METADATA,sha256=svsJe75U880XVPVDaqs4kR00H42ErlPta2_DaKHyow0,395
|
|
8
|
+
matplobbot_shared-0.1.29.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
matplobbot_shared-0.1.29.dist-info/top_level.txt,sha256=L8mrC50YWCe19jmh_zrUZFvXSkhsnES5K6y027G1838,11
|
|
10
|
+
matplobbot_shared-0.1.29.dist-info/RECORD,,
|
shared_lib/database.py
CHANGED
|
@@ -7,13 +7,10 @@ import os
|
|
|
7
7
|
logger = logging.getLogger(__name__)
|
|
8
8
|
|
|
9
9
|
# --- PostgreSQL Database Configuration ---
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
POSTGRES_DB = os.getenv("POSTGRES_DB", "matplobbot_db")
|
|
15
|
-
|
|
16
|
-
DATABASE_URL = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
|
|
10
|
+
# The DATABASE_URL should be a complete connection string.
|
|
11
|
+
# Example for Docker: postgresql://user:password@postgres:5432/matplobbot_db
|
|
12
|
+
# Example for local: postgresql://user:password@localhost:5432/matplobbot_db
|
|
13
|
+
DATABASE_URL = os.getenv("DATABASE_URL")
|
|
17
14
|
|
|
18
15
|
# Global connection pool
|
|
19
16
|
pool = None
|
|
@@ -22,6 +19,9 @@ async def init_db_pool():
|
|
|
22
19
|
global pool
|
|
23
20
|
if pool is None:
|
|
24
21
|
try:
|
|
22
|
+
if not DATABASE_URL:
|
|
23
|
+
logger.critical("DATABASE_URL environment variable is not set. Cannot initialize database pool.")
|
|
24
|
+
raise ValueError("DATABASE_URL is not set.")
|
|
25
25
|
pool = await asyncpg.create_pool(DATABASE_URL, min_size=5, max_size=20)
|
|
26
26
|
logger.info("Shared DB Pool: Database connection pool created successfully.")
|
|
27
27
|
except Exception as e:
|
|
@@ -80,6 +80,7 @@ async def init_db():
|
|
|
80
80
|
id SERIAL PRIMARY KEY,
|
|
81
81
|
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
|
82
82
|
code_path TEXT NOT NULL,
|
|
83
|
+
added_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
83
84
|
UNIQUE(user_id, code_path)
|
|
84
85
|
)
|
|
85
86
|
''')
|
|
@@ -95,6 +96,7 @@ async def init_db():
|
|
|
95
96
|
id SERIAL PRIMARY KEY,
|
|
96
97
|
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
|
97
98
|
repo_path TEXT NOT NULL,
|
|
99
|
+
added_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
98
100
|
UNIQUE(user_id, repo_path)
|
|
99
101
|
)
|
|
100
102
|
''')
|
|
@@ -213,6 +215,42 @@ async def add_schedule_subscription(user_id: int, entity_type: str, entity_id: s
|
|
|
213
215
|
except Exception as e:
|
|
214
216
|
logger.error(f"Failed to add schedule subscription for user {user_id}: {e}", exc_info=True)
|
|
215
217
|
return False
|
|
218
|
+
|
|
219
|
+
async def get_user_subscriptions(user_id: int, page: int = 0, page_size: int = 5) -> tuple[list, int]:
|
|
220
|
+
"""
|
|
221
|
+
Gets a paginated list of active schedule subscriptions for a specific user.
|
|
222
|
+
Returns a tuple: (list of subscriptions, total count).
|
|
223
|
+
"""
|
|
224
|
+
if not pool:
|
|
225
|
+
raise ConnectionError("Database pool is not initialized.")
|
|
226
|
+
async with pool.acquire() as connection:
|
|
227
|
+
# Query to get the paginated list
|
|
228
|
+
offset = page * page_size
|
|
229
|
+
rows = await connection.fetch("""
|
|
230
|
+
SELECT id, entity_type, entity_id, entity_name, TO_CHAR(notification_time, 'HH24:MI') as notification_time
|
|
231
|
+
FROM user_schedule_subscriptions
|
|
232
|
+
WHERE user_id = $1 AND is_active = TRUE
|
|
233
|
+
ORDER BY entity_name, notification_time
|
|
234
|
+
LIMIT $2 OFFSET $3
|
|
235
|
+
""", user_id, page_size, offset)
|
|
236
|
+
|
|
237
|
+
# Query to get the total count for pagination
|
|
238
|
+
total_count = await connection.fetchval("SELECT COUNT(*) FROM user_schedule_subscriptions WHERE user_id = $1 AND is_active = TRUE", user_id)
|
|
239
|
+
|
|
240
|
+
return [dict(row) for row in rows], total_count or 0
|
|
241
|
+
|
|
242
|
+
async def remove_schedule_subscription(subscription_id: int, user_id: int) -> str | None:
|
|
243
|
+
"""
|
|
244
|
+
Removes a specific schedule subscription for a user, ensuring ownership.
|
|
245
|
+
Returns the entity_name of the deleted subscription on success, otherwise None.
|
|
246
|
+
"""
|
|
247
|
+
if not pool:
|
|
248
|
+
raise ConnectionError("Database pool is not initialized.")
|
|
249
|
+
async with pool.acquire() as connection:
|
|
250
|
+
deleted_name = await connection.fetchval(
|
|
251
|
+
"DELETE FROM user_schedule_subscriptions WHERE id = $1 AND user_id = $2 RETURNING entity_name",
|
|
252
|
+
subscription_id, user_id)
|
|
253
|
+
return deleted_name
|
|
216
254
|
|
|
217
255
|
async def get_subscriptions_for_notification(notification_time: str) -> list:
|
|
218
256
|
async with pool.acquire() as connection:
|
|
@@ -225,10 +263,19 @@ async def get_subscriptions_for_notification(notification_time: str) -> list:
|
|
|
225
263
|
|
|
226
264
|
# --- FastAPI Specific Queries ---
|
|
227
265
|
async def get_leaderboard_data_from_db(db_conn):
|
|
266
|
+
# The timestamp is stored in UTC (TIMESTAMPTZ). We convert it to Moscow time for display.
|
|
228
267
|
query = """
|
|
229
|
-
SELECT
|
|
230
|
-
|
|
231
|
-
|
|
268
|
+
SELECT
|
|
269
|
+
u.user_id,
|
|
270
|
+
u.full_name,
|
|
271
|
+
COALESCE(u.username, 'N/A') AS username,
|
|
272
|
+
u.avatar_pic_url,
|
|
273
|
+
COUNT(ua.id)::int AS actions_count,
|
|
274
|
+
TO_CHAR(MAX(ua.timestamp AT TIME ZONE 'Europe/Moscow'), 'YYYY-MM-DD HH24:MI:SS') AS last_action_time
|
|
275
|
+
FROM users u
|
|
276
|
+
JOIN user_actions ua ON u.user_id = ua.user_id
|
|
277
|
+
GROUP BY u.user_id, u.full_name, u.username, u.avatar_pic_url
|
|
278
|
+
ORDER BY actions_count DESC LIMIT 100;
|
|
232
279
|
"""
|
|
233
280
|
rows = await db_conn.fetch(query)
|
|
234
281
|
return [dict(row) for row in rows]
|
|
@@ -258,32 +305,103 @@ async def get_action_types_distribution_from_db(db_conn):
|
|
|
258
305
|
|
|
259
306
|
async def get_activity_over_time_data_from_db(db_conn, period='day'):
|
|
260
307
|
date_format = {'day': 'YYYY-MM-DD', 'week': 'IYYY-IW', 'month': 'YYYY-MM'}.get(period, 'YYYY-MM-DD')
|
|
261
|
-
|
|
308
|
+
# Convert timestamp to Moscow time before grouping
|
|
309
|
+
query = f"""
|
|
310
|
+
SELECT TO_CHAR(timestamp AT TIME ZONE 'Europe/Moscow', '{date_format}') as period_start, COUNT(id) as actions_count
|
|
311
|
+
FROM user_actions GROUP BY period_start ORDER BY period_start ASC;
|
|
312
|
+
"""
|
|
262
313
|
rows = await db_conn.fetch(query)
|
|
263
314
|
return [{"period": row['period_start'], "count": row['actions_count']} for row in rows]
|
|
264
315
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
316
|
+
|
|
317
|
+
async def get_user_profile_data_from_db(
|
|
318
|
+
db_conn,
|
|
319
|
+
user_id: int,
|
|
320
|
+
page: int = 1,
|
|
321
|
+
page_size: int = 50,
|
|
322
|
+
sort_by: str = 'timestamp',
|
|
323
|
+
sort_order: str = 'desc'
|
|
324
|
+
):
|
|
325
|
+
"""Извлекает детали профиля пользователя и пагинированный список его действий."""
|
|
326
|
+
# --- Безопасная сортировка ---
|
|
327
|
+
allowed_sort_columns = ['id', 'action_type', 'action_details', 'timestamp'] # These are ua columns
|
|
328
|
+
if sort_by not in allowed_sort_columns:
|
|
329
|
+
sort_by = 'timestamp' # Значение по умолчанию
|
|
330
|
+
sort_order = 'ASC' if sort_order.lower() == 'asc' else 'DESC' # Безопасное определение порядка
|
|
331
|
+
|
|
332
|
+
# --- Единый запрос для получения всех данных ---
|
|
333
|
+
# Используем CTE и оконные функции для эффективности.
|
|
334
|
+
# 1. Выбираем все действия пользователя.
|
|
335
|
+
# 2. С помощью оконной функции COUNT(*) OVER () получаем общее количество действий без дополнительного запроса.
|
|
336
|
+
# 3. Присоединяем информацию о пользователе.
|
|
337
|
+
# 4. Применяем пагинацию и сортировку.
|
|
338
|
+
query = f"""
|
|
268
339
|
WITH UserActions AS (
|
|
269
|
-
SELECT
|
|
270
|
-
|
|
340
|
+
SELECT
|
|
341
|
+
id,
|
|
342
|
+
action_type,
|
|
343
|
+
action_details,
|
|
344
|
+
TO_CHAR(timestamp AT TIME ZONE 'Europe/Moscow', 'YYYY-MM-DD HH24:MI:SS') AS timestamp
|
|
345
|
+
FROM user_actions
|
|
346
|
+
WHERE user_id = $1
|
|
271
347
|
)
|
|
272
|
-
SELECT
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
348
|
+
SELECT
|
|
349
|
+
u.user_id,
|
|
350
|
+
u.full_name,
|
|
351
|
+
COALESCE(u.username, 'Нет username') AS username,
|
|
352
|
+
u.avatar_pic_url,
|
|
353
|
+
(SELECT COUNT(*) FROM UserActions) as total_actions,
|
|
354
|
+
ua.id as action_id,
|
|
355
|
+
ua.action_type,
|
|
356
|
+
ua.action_details,
|
|
357
|
+
ua.timestamp
|
|
358
|
+
FROM users u
|
|
359
|
+
LEFT JOIN UserActions ua ON 1=1
|
|
360
|
+
WHERE u.user_id = $2
|
|
361
|
+
ORDER BY ua.{sort_by} {sort_order}
|
|
362
|
+
LIMIT $3 OFFSET $4;
|
|
277
363
|
"""
|
|
364
|
+
offset = (page - 1) * page_size
|
|
278
365
|
rows = await db_conn.fetch(query, user_id, user_id, page_size, offset)
|
|
279
|
-
if not rows:
|
|
280
|
-
|
|
366
|
+
if not rows:
|
|
367
|
+
# If user exists but has no actions, we might get no rows. Check user existence separately.
|
|
368
|
+
user_exists = await db_conn.fetchrow("SELECT 1 FROM users WHERE user_id = $1", user_id)
|
|
369
|
+
if not user_exists:
|
|
370
|
+
return None # User not found
|
|
371
|
+
# User exists but has no actions, return empty actions list
|
|
372
|
+
user_details_row = await db_conn.fetchrow("SELECT user_id, full_name, COALESCE(username, 'Нет username') AS username, avatar_pic_url FROM users WHERE user_id = $1", user_id)
|
|
373
|
+
return {
|
|
374
|
+
"user_details": dict(user_details_row),
|
|
375
|
+
"actions": [],
|
|
376
|
+
"total_actions": 0
|
|
377
|
+
}
|
|
378
|
+
|
|
281
379
|
first_row = dict(rows[0])
|
|
282
|
-
user_details = {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
380
|
+
user_details = {
|
|
381
|
+
"user_id": first_row["user_id"],
|
|
382
|
+
"full_name": first_row["full_name"],
|
|
383
|
+
"username": first_row["username"],
|
|
384
|
+
"avatar_pic_url": first_row["avatar_pic_url"]
|
|
385
|
+
}
|
|
386
|
+
total_actions = first_row["total_actions"]
|
|
387
|
+
|
|
388
|
+
# Собираем действия, если они есть (может быть пользователь без действий)
|
|
389
|
+
actions = []
|
|
390
|
+
for row in rows:
|
|
391
|
+
row_dict = dict(row)
|
|
392
|
+
if row_dict["action_id"] is not None: # action_id не NULL
|
|
393
|
+
actions.append({
|
|
394
|
+
"id": row_dict["action_id"],
|
|
395
|
+
"action_type": row_dict["action_type"],
|
|
396
|
+
"action_details": row_dict["action_details"],
|
|
397
|
+
"timestamp": row_dict["timestamp"]
|
|
398
|
+
})
|
|
286
399
|
|
|
400
|
+
return {
|
|
401
|
+
"user_details": user_details,
|
|
402
|
+
"actions": actions,
|
|
403
|
+
"total_actions": total_actions
|
|
404
|
+
}
|
|
287
405
|
|
|
288
406
|
async def get_users_for_action(db_conn, action_type: str, action_details: str, page: int = 1, page_size: int = 15, sort_by: str = 'full_name', sort_order: str = 'asc'):
|
|
289
407
|
"""Извлекает пагинированный список уникальных пользователей, совершивших определенное действие."""
|
|
@@ -326,7 +444,6 @@ async def get_users_for_action(db_conn, action_type: str, action_details: str, p
|
|
|
326
444
|
"users": users,
|
|
327
445
|
"total_users": total_users
|
|
328
446
|
}
|
|
329
|
-
|
|
330
447
|
|
|
331
448
|
async def get_all_user_actions(db_conn, user_id: int):
|
|
332
449
|
"""Извлекает ВСЕ действия для указанного пользователя без пагинации."""
|
|
@@ -335,7 +452,7 @@ async def get_all_user_actions(db_conn, user_id: int):
|
|
|
335
452
|
id,
|
|
336
453
|
action_type,
|
|
337
454
|
action_details,
|
|
338
|
-
TO_CHAR(timestamp, 'YYYY-MM-DD HH24:MI:SS') AS timestamp
|
|
455
|
+
TO_CHAR(timestamp AT TIME ZONE 'Europe/Moscow', 'YYYY-MM-DD HH24:MI:SS') AS timestamp
|
|
339
456
|
FROM user_actions
|
|
340
457
|
WHERE user_id = $1
|
|
341
458
|
ORDER BY timestamp DESC;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# bot/services/schedule_service.py
|
|
2
2
|
|
|
3
3
|
from typing import List, Dict, Any
|
|
4
|
-
from datetime import datetime
|
|
4
|
+
from datetime import datetime, date
|
|
5
5
|
|
|
6
|
-
def format_schedule(schedule_data: List[Dict[str, Any]], lang: str, entity_name: str) -> str:
|
|
6
|
+
def format_schedule(schedule_data: List[Dict[str, Any]], lang: str, entity_name: str, start_date: date) -> str:
|
|
7
7
|
"""Formats a list of lessons into a readable daily schedule."""
|
|
8
8
|
if not schedule_data:
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
return f"🗓 *Расписание для \"{entity_name}\"*\n\nНа запрошенный день занятий нет."
|
|
10
|
+
|
|
11
11
|
# Group lessons by date
|
|
12
12
|
days = {}
|
|
13
13
|
for lesson in schedule_data:
|
|
@@ -16,24 +16,25 @@ def format_schedule(schedule_data: List[Dict[str, Any]], lang: str, entity_name:
|
|
|
16
16
|
days[date_str] = []
|
|
17
17
|
days[date_str].append(lesson)
|
|
18
18
|
|
|
19
|
-
#
|
|
20
|
-
formatted_days = []
|
|
19
|
+
# Find the first day with lessons that is on or after the start_date
|
|
21
20
|
for date_str, lessons in sorted(days.items()):
|
|
22
|
-
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
21
|
+
date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
|
|
22
|
+
if date_obj >= start_date:
|
|
23
|
+
# Found the first relevant day, format it and return immediately.
|
|
24
|
+
day_header = f"🗓 *Расписание на {date_obj.strftime('%d.%m.%Y')} для \"{entity_name}\"*"
|
|
25
|
+
|
|
26
|
+
formatted_lessons = []
|
|
27
|
+
for lesson in sorted(lessons, key=lambda x: x['beginLesson']):
|
|
28
|
+
formatted_lessons.append(
|
|
29
|
+
f"`{lesson['beginLesson']} - {lesson['endLesson']}`\n"
|
|
30
|
+
f"{lesson['discipline']}\n"
|
|
31
|
+
f"{lesson['kindOfWork']}\n"
|
|
32
|
+
f"{lesson['auditorium']} ({lesson['building']})\n"
|
|
33
|
+
f"{lesson['lecturer_title'].replace('_',' ')}\n"
|
|
34
|
+
f"{lesson['lecturerEmail']}\n"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return f"{day_header}\n" + "\n\n".join(formatted_lessons)
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
# If no lessons were found in the entire fetched range
|
|
40
|
+
return f"🗓 *Расписание для \"{entity_name}\"*\n\nВ ближайшую неделю занятий не найдено."
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
shared_lib/__init__.py,sha256=Wxuw1wbvCOsKqs6WfIQ05cx_vndhPs6rH2krMatFRqA,45
|
|
2
|
-
shared_lib/database.py,sha256=CjjJ9HX_70IXOJ6aIS8xoD63Srj3Y-WHUqZ0zPCJsYc,15554
|
|
3
|
-
shared_lib/i18n.py,sha256=VBWQWVF-k_HDiidYo_RUPyUCM7oL897z5hOw9jvOoYY,1762
|
|
4
|
-
shared_lib/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
shared_lib/services/schedule_service.py,sha256=tKXgwZQWeaagQeqG_tKP2gLflWo_6kxKZOhW9PFvbK4,1835
|
|
6
|
-
shared_lib/services/university_api.py,sha256=Ui-zjfKOHCCf2Imh8CNtVOWegwuep7IB8gO9IKNUrrE,1898
|
|
7
|
-
matplobbot_shared-0.1.26.dist-info/METADATA,sha256=SEK0yxxYSavWzGQSx2C9EcgYaWdNL9TB3sdI2orLF8k,395
|
|
8
|
-
matplobbot_shared-0.1.26.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
-
matplobbot_shared-0.1.26.dist-info/top_level.txt,sha256=L8mrC50YWCe19jmh_zrUZFvXSkhsnES5K6y027G1838,11
|
|
10
|
-
matplobbot_shared-0.1.26.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|