matplobbot-shared 0.1.26__tar.gz → 0.1.29__tar.gz

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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matplobbot-shared
3
- Version: 0.1.26
3
+ Version: 0.1.29
4
4
  Summary: Shared library for the Matplobbot ecosystem (database, services, i18n).
5
5
  Author: Ackrome
6
6
  Author-email: ivansergeyevich@gmail.com
@@ -1,5 +1,5 @@
1
1
  <div align="center" style="border: none; padding: 0; margin: 0;">
2
- <img src="image/notes/thelogo.png" alt="Matplobbot Logo" width="400" style="border: none; outline: none;">
2
+ <img src="image/logo/thelogo.png" alt="Matplobbot Logo" width="400" style="border: none; outline: none;">
3
3
  <h1>Matplobbot & Stats Dashboard</h1>
4
4
  <strong>A comprehensive solution: An Aiogram 3 Telegram bot for advanced code interaction and a FastAPI dashboard for real-time analytics.</strong>
5
5
  <br>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matplobbot-shared
3
- Version: 0.1.26
3
+ Version: 0.1.29
4
4
  Summary: Shared library for the Matplobbot ecosystem (database, services, i18n).
5
5
  Author: Ackrome
6
6
  Author-email: ivansergeyevich@gmail.com
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="matplobbot-shared",
5
- version="0.1.26", # Let's use the version from your requirements.txt
5
+ version="0.1.29", # Let's use the version from your requirements.txt
6
6
  packages=find_packages(include=['shared_lib', 'shared_lib.*']),
7
7
  description="Shared library for the Matplobbot ecosystem (database, services, i18n).",
8
8
  author="Ackrome",
@@ -7,13 +7,10 @@ import os
7
7
  logger = logging.getLogger(__name__)
8
8
 
9
9
  # --- PostgreSQL Database Configuration ---
10
- POSTGRES_USER = os.getenv("POSTGRES_USER", "user")
11
- POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "password")
12
- POSTGRES_HOST = os.getenv("POSTGRES_HOST", "localhost")
13
- POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
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 u.user_id, u.full_name, COALESCE(u.username, 'N/A') AS username, u.avatar_pic_url, COUNT(ua.id)::int AS actions_count
230
- FROM users u JOIN user_actions ua ON u.user_id = ua.user_id
231
- GROUP BY u.user_id ORDER BY actions_count DESC LIMIT 100;
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
- query = f"SELECT TO_CHAR(timestamp, '{date_format}') as period_start, COUNT(id) as actions_count FROM user_actions GROUP BY period_start ORDER BY period_start ASC;"
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
- async def get_user_profile_data_from_db(db_conn, user_id: int, page: int = 1, page_size: int = 50):
266
- offset = (page - 1) * page_size
267
- query = """
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 id, action_type, action_details, TO_CHAR(timestamp, 'YYYY-MM-DD HH24:MI:SS') AS timestamp
270
- FROM user_actions WHERE user_id = $1
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 u.user_id, u.full_name, COALESCE(u.username, 'N/A') AS username, u.avatar_pic_url,
273
- (SELECT COUNT(*) FROM UserActions) as total_actions,
274
- ua.id as action_id, ua.action_type, ua.action_details, ua.timestamp
275
- FROM users u LEFT JOIN UserActions ua ON 1=1
276
- WHERE u.user_id = $2 ORDER BY ua.timestamp DESC LIMIT $3 OFFSET $4;
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: return None
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 = {k: first_row[k] for k in ["user_id", "full_name", "username", "avatar_pic_url"]}
283
- actions = [dict(r) for r in rows if r["action_id"] is not None]
284
-
285
- return {"user_details": user_details, "actions": actions, "total_actions": first_row["total_actions"]}
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;
@@ -0,0 +1,40 @@
1
+ # bot/services/schedule_service.py
2
+
3
+ from typing import List, Dict, Any
4
+ from datetime import datetime, date
5
+
6
+ def format_schedule(schedule_data: List[Dict[str, Any]], lang: str, entity_name: str, start_date: date) -> str:
7
+ """Formats a list of lessons into a readable daily schedule."""
8
+ if not schedule_data:
9
+ return f"🗓 *Расписание для \"{entity_name}\"*\n\nНа запрошенный день занятий нет."
10
+
11
+ # Group lessons by date
12
+ days = {}
13
+ for lesson in schedule_data:
14
+ date_str = lesson['date']
15
+ if date_str not in days:
16
+ days[date_str] = []
17
+ days[date_str].append(lesson)
18
+
19
+ # Find the first day with lessons that is on or after the start_date
20
+ for date_str, lessons in sorted(days.items()):
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
+
39
+ # If no lessons were found in the entire fetched range
40
+ return f"🗓 *Расписание для \"{entity_name}\"*\n\nВ ближайшую неделю занятий не найдено."
@@ -1,39 +0,0 @@
1
- # bot/services/schedule_service.py
2
-
3
- from typing import List, Dict, Any
4
- from datetime import datetime
5
-
6
- def format_schedule(schedule_data: List[Dict[str, Any]], lang: str, entity_name: str) -> str:
7
- """Formats a list of lessons into a readable daily schedule."""
8
- if not schedule_data:
9
- # This part is now handled in the handler to provide more context.
10
- return f"🗓 *Расписание на {datetime.now().strftime('%d.%m.%Y')} для \"{entity_name}\"*\n\nНа этот день занятий нет."
11
- # Group lessons by date
12
- days = {}
13
- for lesson in schedule_data:
14
- date_str = lesson['date']
15
- if date_str not in days:
16
- days[date_str] = []
17
- days[date_str].append(lesson)
18
-
19
- # Format each day's schedule
20
- formatted_days = []
21
- for date_str, lessons in sorted(days.items()):
22
- date_obj = datetime.strptime(date_str, "%Y-%m-%d")
23
- # Example format, we'll use i18n later
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']}\n"
34
- f" *Почта преподавателя:* {lesson['lecturerEmail']}\n"
35
- )
36
-
37
- formatted_days.append(f"{day_header}\n" + "\n\n".join(formatted_lessons))
38
-
39
- return "\n\n---\n\n".join(formatted_days)