matplobbot-shared 0.1.332__tar.gz → 0.1.334__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.
Files changed (71) hide show
  1. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/PKG-INFO +1 -1
  2. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/README.md +1 -1
  3. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/PKG-INFO +1 -1
  4. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/setup.py +1 -1
  5. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/database.py +100 -1
  6. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/models.py +6 -0
  7. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/redis_client.py +39 -0
  8. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/schemas.py +5 -0
  9. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/calendar_sync_state.py +18 -0
  10. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/schedule_service.py +8 -0
  11. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/semantic_search.py +7 -0
  12. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/university_api.py +1 -3
  13. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_keyboards.py +40 -0
  14. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_schedule_search_api.py +2 -6
  15. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_studio_router_api.py +1 -3
  16. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/LICENSE +0 -0
  17. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/SOURCES.txt +0 -0
  18. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/dependency_links.txt +0 -0
  19. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/requires.txt +0 -0
  20. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/top_level.txt +0 -0
  21. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/pyproject.toml +0 -0
  22. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/setup.cfg +0 -0
  23. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/__init__.py +0 -0
  24. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/celery_app.py +0 -0
  25. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/constants.py +0 -0
  26. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/egress.py +0 -0
  27. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/html_tools.py +0 -0
  28. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/i18n.py +0 -0
  29. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/locales/en.json +0 -0
  30. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/locales/ru.json +0 -0
  31. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/mail_bridge.py +0 -0
  32. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/request_context.py +0 -0
  33. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/__init__.py +0 -0
  34. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/broadcast_service.py +0 -0
  35. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/tasks.py +0 -0
  36. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/telegram_bot_session.py +0 -0
  37. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/telegram_http.py +0 -0
  38. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/telegram_polling.py +0 -0
  39. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/telemetry.py +0 -0
  40. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_auth_flow.py +0 -0
  41. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_authorization_guards.py +0 -0
  42. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_broadcast_service.py +0 -0
  43. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_build_audit_requirements.py +0 -0
  44. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_calendar_api.py +0 -0
  45. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_correlation_middleware.py +0 -0
  46. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_dashboard_text_encoding.py +0 -0
  47. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_egress.py +0 -0
  48. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_fastapi_config.py +0 -0
  49. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_latex_task_security.py +0 -0
  50. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_localization_completeness.py +0 -0
  51. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_mail_bridge.py +0 -0
  52. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_myschedule_filters_settings.py +0 -0
  53. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_openapi_docs.py +0 -0
  54. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_proxy_cleaner.py +0 -0
  55. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_proxy_config.py +0 -0
  56. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_rate_limit.py +0 -0
  57. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_schedule_service_calendar.py +0 -0
  58. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_scheduler_html_splitter.py +0 -0
  59. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_scheduler_service.py +0 -0
  60. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_search_center_helpers.py +0 -0
  61. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_action_users_api.py +0 -0
  62. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_avatar_security.py +0 -0
  63. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_export_api.py +0 -0
  64. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_leaderboard_api.py +0 -0
  65. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_profile_api.py +0 -0
  66. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_proxy_diagnostics_api.py +0 -0
  67. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_send_message_api.py +0 -0
  68. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_telegram_bot_session.py +0 -0
  69. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_telegram_polling.py +0 -0
  70. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_telemetry.py +0 -0
  71. {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_version_bumper.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matplobbot-shared
3
- Version: 0.1.332
3
+ Version: 0.1.334
4
4
  Summary: Shared library for the Matplobbot ecosystem.
5
5
  Author: Ackrome
6
6
  Author-email: ivansergeyevich@gmail.com
@@ -285,7 +285,7 @@ The repository uses GitHub Actions for CI and image publishing, with Jenkins han
285
285
  ## Roadmap
286
286
 
287
287
  - [x] Integrate SQLAlchemy Core instead of raw SQL
288
- - [x] Add semantic search for project materials
288
+ - [x] Add PostgreSQL text search for project materials
289
289
  - [ ] Add plugin-based support for multiple universities
290
290
  - [ ] Support voice-driven schedule requests
291
291
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matplobbot-shared
3
- Version: 0.1.332
3
+ Version: 0.1.334
4
4
  Summary: Shared library for the Matplobbot ecosystem.
5
5
  Author: Ackrome
6
6
  Author-email: ivansergeyevich@gmail.com
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="matplobbot-shared",
5
- version="0.1.332", # Bump version
5
+ version="0.1.334", # Bump version
6
6
  packages=find_packages(include=["shared_lib", "shared_lib.*"]),
7
7
  description="Shared library for the Matplobbot ecosystem.",
8
8
  author="Ackrome",
@@ -3,6 +3,7 @@ import json
3
3
  import logging
4
4
  import os
5
5
  import uuid
6
+ from zoneinfo import ZoneInfo
6
7
 
7
8
  from sqlalchemy import and_, delete, func, insert, or_, select, text, update
8
9
  from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -625,6 +626,13 @@ async def add_schedule_subscription(
625
626
  entity_id: str,
626
627
  entity_name: str,
627
628
  notification_time: datetime.time,
629
+ *,
630
+ profile_id: str | None = None,
631
+ timezone: str = "Europe/Moscow",
632
+ delivery_mode: str = "telegram",
633
+ lesson_mode: str = "all",
634
+ calendar_enabled: bool = True,
635
+ selected_modules: list[str] | None = None,
628
636
  ) -> int | None:
629
637
  async with get_session() as session:
630
638
  stmt = (
@@ -637,10 +645,26 @@ async def add_schedule_subscription(
637
645
  entity_id=entity_id,
638
646
  entity_name=entity_name,
639
647
  notification_time=notification_time,
648
+ profile_id=profile_id,
649
+ timezone=timezone or "Europe/Moscow",
650
+ delivery_mode=delivery_mode or "telegram",
651
+ lesson_mode=lesson_mode or "all",
652
+ calendar_enabled=calendar_enabled,
653
+ selected_modules=selected_modules or [],
640
654
  )
641
655
  .on_conflict_do_update(
642
656
  constraint="uq_schedule_subs",
643
- set_=dict(entity_name=entity_name, is_active=True, user_id=user_id),
657
+ set_=dict(
658
+ entity_name=entity_name,
659
+ is_active=True,
660
+ user_id=user_id,
661
+ profile_id=profile_id,
662
+ timezone=timezone or "Europe/Moscow",
663
+ delivery_mode=delivery_mode or "telegram",
664
+ lesson_mode=lesson_mode or "all",
665
+ calendar_enabled=calendar_enabled,
666
+ selected_modules=selected_modules or [],
667
+ ),
644
668
  )
645
669
  .returning(UserScheduleSubscription.id)
646
670
  )
@@ -688,6 +712,12 @@ async def get_user_subscriptions(
688
712
  "entity_name": s.entity_name,
689
713
  "notification_time": s.notification_time.strftime("%H:%M"),
690
714
  "is_active": s.is_active,
715
+ "profile_id": s.profile_id or f"telegram-{s.id}",
716
+ "timezone": s.timezone or "Europe/Moscow",
717
+ "delivery_mode": s.delivery_mode or "telegram",
718
+ "lesson_mode": s.lesson_mode or "all",
719
+ "calendar_enabled": s.calendar_enabled is not False,
720
+ "selected_modules": list(s.selected_modules or []),
691
721
  }
692
722
  for s in subs
693
723
  ], total_count
@@ -728,6 +758,12 @@ async def get_chat_subscriptions(
728
758
  "entity_name": s.entity_name,
729
759
  "notification_time": s.notification_time.strftime("%H:%M"),
730
760
  "is_active": s.is_active,
761
+ "profile_id": s.profile_id or f"telegram-{s.id}",
762
+ "timezone": s.timezone or "Europe/Moscow",
763
+ "delivery_mode": s.delivery_mode or "telegram",
764
+ "lesson_mode": s.lesson_mode or "all",
765
+ "calendar_enabled": s.calendar_enabled is not False,
766
+ "selected_modules": list(s.selected_modules or []),
731
767
  }
732
768
  for s in subs
733
769
  ], total_count
@@ -841,6 +877,11 @@ async def get_subscriptions_for_notification(notification_time: str) -> list:
841
877
  "entity_id": s.entity_id,
842
878
  "entity_name": s.entity_name,
843
879
  "last_schedule_hash": s.last_schedule_hash,
880
+ "profile_id": s.profile_id or f"telegram-{s.id}",
881
+ "timezone": s.timezone or "Europe/Moscow",
882
+ "delivery_mode": s.delivery_mode or "telegram",
883
+ "lesson_mode": s.lesson_mode or "all",
884
+ "selected_modules": list(s.selected_modules or []),
844
885
  }
845
886
  for s in subs
846
887
  ]
@@ -861,11 +902,63 @@ async def get_all_active_subscriptions() -> list:
861
902
  "entity_id": s.entity_id,
862
903
  "entity_name": s.entity_name,
863
904
  "last_schedule_hash": s.last_schedule_hash,
905
+ "profile_id": s.profile_id or f"telegram-{s.id}",
906
+ "timezone": s.timezone or "Europe/Moscow",
907
+ "delivery_mode": s.delivery_mode or "telegram",
908
+ "lesson_mode": s.lesson_mode or "all",
909
+ "calendar_enabled": s.calendar_enabled is not False,
910
+ "selected_modules": list(s.selected_modules or []),
864
911
  }
865
912
  for s in subs
866
913
  ]
867
914
 
868
915
 
916
+ async def get_subscriptions_due_for_notification(now_utc: datetime.datetime | None = None) -> list:
917
+ """Return Telegram-delivery subscriptions whose local time is now.
918
+
919
+ ``notification_time`` is a wall-clock value in each profile's timezone;
920
+ legacy rows without a timezone use Moscow by design.
921
+ """
922
+ now_utc = now_utc or datetime.datetime.now(datetime.UTC)
923
+ async with get_session() as session:
924
+ result = await session.execute(
925
+ select(UserScheduleSubscription).where(
926
+ and_(
927
+ UserScheduleSubscription.is_active.is_(True),
928
+ UserScheduleSubscription.delivery_mode == "telegram",
929
+ )
930
+ )
931
+ )
932
+ due = []
933
+ for s in result.scalars().all():
934
+ timezone_name = s.timezone or "Europe/Moscow"
935
+ try:
936
+ local_now = now_utc.astimezone(ZoneInfo(timezone_name))
937
+ except Exception:
938
+ local_now = now_utc.astimezone(ZoneInfo("Europe/Moscow"))
939
+ if s.notification_time and s.notification_time.strftime("%H:%M") != local_now.strftime(
940
+ "%H:%M"
941
+ ):
942
+ continue
943
+ due.append(
944
+ {
945
+ "id": s.id,
946
+ "user_id": s.user_id,
947
+ "chat_id": s.chat_id,
948
+ "message_thread_id": s.message_thread_id,
949
+ "entity_type": s.entity_type,
950
+ "entity_id": s.entity_id,
951
+ "entity_name": s.entity_name,
952
+ "last_schedule_hash": s.last_schedule_hash,
953
+ "profile_id": s.profile_id or f"telegram-{s.id}",
954
+ "timezone": timezone_name,
955
+ "lesson_mode": s.lesson_mode or "all",
956
+ "selected_modules": list(s.selected_modules or []),
957
+ }
958
+ )
959
+ return due
960
+
961
+
869
962
  async def get_unique_active_subscription_entities() -> list:
870
963
  async with get_session() as session:
871
964
  stmt = text(
@@ -1694,6 +1787,12 @@ async def get_subscription_by_id(subscription_id: int) -> dict | None:
1694
1787
  "entity_name": sub.entity_name,
1695
1788
  "is_active": sub.is_active,
1696
1789
  "notification_time": sub.notification_time,
1790
+ "profile_id": sub.profile_id or f"telegram-{sub.id}",
1791
+ "timezone": sub.timezone or "Europe/Moscow",
1792
+ "delivery_mode": sub.delivery_mode or "telegram",
1793
+ "lesson_mode": sub.lesson_mode or "all",
1794
+ "calendar_enabled": sub.calendar_enabled is not False,
1795
+ "selected_modules": list(sub.selected_modules or []),
1697
1796
  }
1698
1797
  return None
1699
1798
 
@@ -97,6 +97,12 @@ class UserScheduleSubscription(Base):
97
97
  message_thread_id = Column(BigInteger, nullable=True)
98
98
  added_at = Column(DateTime(timezone=True), server_default=func.now())
99
99
  selected_modules = Column(JSON, server_default="[]", nullable=False)
100
+ # Canonical profile fields shared by Telegram notifications and WebCal.
101
+ profile_id = Column(String(128), nullable=True, index=True)
102
+ timezone = Column(String(64), nullable=False, server_default="Europe/Moscow")
103
+ delivery_mode = Column(String(16), nullable=False, server_default="telegram")
104
+ lesson_mode = Column(String(16), nullable=False, server_default="all")
105
+ calendar_enabled = Column(Boolean, nullable=False, server_default="true")
100
106
 
101
107
  __table_args__ = (
102
108
  UniqueConstraint(
@@ -8,6 +8,11 @@ logger = logging.getLogger(__name__)
8
8
 
9
9
  # TTL для кэша в секундах (например, 1 час)
10
10
  CACHE_TTL = 3600
11
+ CALLBACK_PATH_KEY_PREFIX = "callback_path:"
12
+
13
+
14
+ def _is_callback_path_hash(value: str) -> bool:
15
+ return len(value) == 16 and all(char in "0123456789abcdef" for char in value)
11
16
 
12
17
 
13
18
  class RedisClient:
@@ -66,6 +71,40 @@ class RedisClient:
66
71
  except Exception as e:
67
72
  logger.error(f"Ошибка при очистке кэша Redis: {e}")
68
73
 
74
+ async def set_callback_path(self, path_hash: str, path: str, ttl: int):
75
+ """Persist a Telegram callback hash mapping without serializing secrets."""
76
+ if not _is_callback_path_hash(path_hash):
77
+ raise ValueError("invalid callback path hash")
78
+ if ttl <= 0:
79
+ raise ValueError("callback path TTL must be positive")
80
+ try:
81
+ await self.client.set(
82
+ f"{CALLBACK_PATH_KEY_PREFIX}{path_hash}",
83
+ path,
84
+ ex=ttl,
85
+ )
86
+ except Exception as e:
87
+ logger.error("Ошибка при записи callback path в Redis для hash=%s: %s", path_hash, e)
88
+
89
+ async def get_callback_path(self, path_hash: str) -> str | None:
90
+ """Load a callback mapping created by this application."""
91
+ if not _is_callback_path_hash(path_hash):
92
+ return None
93
+ try:
94
+ return await self.client.get(f"{CALLBACK_PATH_KEY_PREFIX}{path_hash}")
95
+ except Exception as e:
96
+ logger.error("Ошибка при чтении callback path из Redis для hash=%s: %s", path_hash, e)
97
+ return None
98
+
99
+ async def clear_callback_paths(self):
100
+ """Remove persistent callback mappings during an explicit admin cache clear."""
101
+ try:
102
+ async for key in self.client.scan_iter(f"{CALLBACK_PATH_KEY_PREFIX}*"):
103
+ await self.client.delete(key)
104
+ logger.info("Все callback path mappings в Redis очищены.")
105
+ except Exception as e:
106
+ logger.error("Ошибка при очистке callback path mappings в Redis: %s", e)
107
+
69
108
 
70
109
  # Создаем единственный экземпляр клиента
71
110
  redis_client = RedisClient(
@@ -313,6 +313,8 @@ class CalendarSubscriptionProfile(BaseModel):
313
313
  name: str
314
314
  kind: Literal["built_in", "custom"] = "built_in"
315
315
  lesson_mode: Literal["all", "exams_only"] = "all"
316
+ timezone: str = "Europe/Moscow"
317
+ timezone_label: str = "GMT+3"
316
318
  selected: bool = False
317
319
  can_delete: bool = False
318
320
  entity_type: str | None = None
@@ -333,6 +335,7 @@ class CalendarSubscriptionResponse(BaseModel):
333
335
  sync_enabled: bool = True
334
336
  selected_profile_id: str | None = None
335
337
  profile_limit: int = 0
338
+ timezone_options: list[dict[str, str]] = Field(default_factory=list)
336
339
  http_url: str | None = None
337
340
  webcal_url: str | None = None
338
341
  download_url: str | None = None
@@ -359,12 +362,14 @@ class CalendarSubscriptionProfileCreateRequest(BaseModel):
359
362
  entity_name: str = Field(..., min_length=1, max_length=255)
360
363
  lesson_mode: Literal["all", "exams_only"] = "all"
361
364
  modules: list[str] = Field(default_factory=list)
365
+ timezone: str = Field(default="Europe/Moscow", max_length=64)
362
366
 
363
367
 
364
368
  class CalendarSubscriptionProfileUpdateRequest(BaseModel):
365
369
  name: str | None = Field(default=None, min_length=1, max_length=255)
366
370
  lesson_mode: Literal["all", "exams_only"] | None = None
367
371
  modules: list[str] | None = None
372
+ timezone: str | None = Field(default=None, max_length=64)
368
373
 
369
374
 
370
375
  class CalendarSubscriptionProfileSelectRequest(BaseModel):
@@ -8,6 +8,7 @@ from urllib.parse import quote
8
8
  CALENDAR_SYNC_KEY = "calendar_sync"
9
9
  CALENDAR_PROFILE_LIMIT = 6
10
10
  DEFAULT_PROFILE_ID = "all"
11
+ DEFAULT_TIMEZONE = "Europe/Moscow"
11
12
 
12
13
  BUILT_IN_PROFILES = (
13
14
  {
@@ -36,6 +37,19 @@ class CalendarProfilePayload:
36
37
  entity_name: str
37
38
  lesson_mode: str = "all"
38
39
  modules: tuple[str, ...] = ()
40
+ timezone: str = DEFAULT_TIMEZONE
41
+
42
+
43
+ def normalize_timezone(value: str | None) -> str:
44
+ candidate = str(value or DEFAULT_TIMEZONE).strip()
45
+ if candidate in {DEFAULT_TIMEZONE, "UTC"} or re.fullmatch(
46
+ r"Etc/GMT[+-](?:0|[1-9]|1[0-2]|1[3-4])", candidate
47
+ ):
48
+ return candidate
49
+ match = re.fullmatch(r"GMT\s*([+-])\s*(\d{1,2})", candidate, re.IGNORECASE)
50
+ if match and int(match.group(2)) <= 14:
51
+ return f"Etc/GMT-{'%d' % int(match.group(2))}" if match.group(1) == "+" else f"Etc/GMT+{int(match.group(2))}"
52
+ return DEFAULT_TIMEZONE
39
53
 
40
54
 
41
55
  def default_calendar_sync_state() -> dict:
@@ -92,6 +106,7 @@ def normalize_custom_profile(raw_profile: object) -> dict | None:
92
106
  "entity_name": entity_name,
93
107
  "modules": modules,
94
108
  "can_delete": True,
109
+ "timezone": normalize_timezone(raw_profile.get("timezone")),
95
110
  }
96
111
 
97
112
 
@@ -137,6 +152,7 @@ def serialize_calendar_sync_state(state: dict) -> dict:
137
152
  "entity_name": profile["entity_name"],
138
153
  "lesson_mode": profile["lesson_mode"],
139
154
  "modules": list(profile.get("modules", [])),
155
+ "timezone": normalize_timezone(profile.get("timezone")),
140
156
  }
141
157
  for profile in state.get("custom_profiles", [])
142
158
  if profile.get("id")
@@ -239,6 +255,7 @@ def upsert_custom_profile(
239
255
  entity_name=payload.entity_name,
240
256
  lesson_mode=payload.lesson_mode,
241
257
  modules=tuple(normalized_modules),
258
+ timezone=payload.timezone,
242
259
  )
243
260
  ),
244
261
  "kind": "custom",
@@ -248,6 +265,7 @@ def upsert_custom_profile(
248
265
  "entity_name": payload.entity_name,
249
266
  "modules": normalized_modules,
250
267
  "can_delete": True,
268
+ "timezone": normalize_timezone(payload.timezone),
251
269
  }
252
270
  next_state["custom_profiles"].append(profile)
253
271
  next_state["selected_profile_id"] = profile["id"]
@@ -478,6 +478,7 @@ async def format_schedule(
478
478
  start_date: date,
479
479
  is_week_view: bool = False,
480
480
  subscription_id: int | None = None,
481
+ lesson_mode: str = "all",
481
482
  ) -> str:
482
483
  """Formats a list of lessons into a readable daily schedule using Variant B (Subgroup Hierarchy)."""
483
484
  if not schedule_data:
@@ -532,6 +533,13 @@ async def format_schedule(
532
533
 
533
534
  schedule_data = filtered_data
534
535
 
536
+ if lesson_mode == "exams_only":
537
+ schedule_data = [
538
+ lesson
539
+ for lesson in schedule_data
540
+ if _get_simple_lesson_type(lesson.get("kindOfWork", "")) in {"Exam", "Consultation"}
541
+ ]
542
+
535
543
  # --- 1. Fetch Settings ---
536
544
  user_settings = await get_user_settings(user_id)
537
545
  use_short_names = user_settings.get("use_short_names", True)
@@ -1,3 +1,10 @@
1
+ """PostgreSQL full-text search implementation.
2
+
3
+ The historical module name is kept for import compatibility. The product
4
+ feature is called *text search*: it uses PostgreSQL FTS and intentionally does
5
+ not create embeddings or depend on a vector store.
6
+ """
7
+
1
8
  import json
2
9
  import logging
3
10
  import re
@@ -78,9 +78,7 @@ class RuzAPIClient:
78
78
 
79
79
  async def search(self, term: str, search_type: str) -> list[dict[str, Any]]:
80
80
  """Generic search function."""
81
- return await self._request(
82
- "/api/search", params={"term": term, "type": search_type}
83
- )
81
+ return await self._request("/api/search", params={"term": term, "type": search_type})
84
82
 
85
83
  async def get_schedule(
86
84
  self, entity_type: str, entity_id: str, start: str, finish: str
@@ -1,7 +1,9 @@
1
+ import asyncio
1
2
  import importlib
2
3
  import sys
3
4
  import types
4
5
  import unittest
6
+ from unittest.mock import AsyncMock, patch
5
7
 
6
8
 
7
9
  def _install_matplobblib_stub() -> None:
@@ -90,3 +92,41 @@ class TestTelegramWebAppKeyboards(unittest.IsolatedAsyncioTestCase):
90
92
  buttons = [button for row in help_markup.inline_keyboard for button in row]
91
93
  self.assertFalse(any(button.web_app for button in buttons))
92
94
  self.assertIn("help_btn_matp_all", [button.text for button in buttons])
95
+
96
+
97
+ @unittest.skipUnless(KEYBOARDS_AVAILABLE, "bot keyboard dependencies are not installed")
98
+ class TestCallbackPathPersistence(unittest.IsolatedAsyncioTestCase):
99
+ async def asyncSetUp(self):
100
+ kb.code_path_cache.clear()
101
+
102
+ async def asyncTearDown(self):
103
+ kb.code_path_cache.clear()
104
+
105
+ async def test_resolve_code_path_restores_mapping_from_redis(self):
106
+ path_hash = "a" * 16
107
+ with patch.object(
108
+ kb.redis_client,
109
+ "get_callback_path",
110
+ new=AsyncMock(return_value="owner/repo/README.md"),
111
+ ) as get_callback_path:
112
+ value = await kb.resolve_code_path(path_hash)
113
+
114
+ self.assertEqual(value, "owner/repo/README.md")
115
+ self.assertEqual(kb.code_path_cache.get(path_hash), "owner/repo/README.md")
116
+ get_callback_path.assert_awaited_once_with(path_hash)
117
+
118
+ async def test_cache_write_persists_mapping_to_redis(self):
119
+ path_hash = "b" * 16
120
+ with patch.object(
121
+ kb.redis_client,
122
+ "set_callback_path",
123
+ new=AsyncMock(),
124
+ ) as set_callback_path:
125
+ kb.code_path_cache[path_hash] = "owner/repo/src/main.py"
126
+ await asyncio.sleep(0)
127
+
128
+ set_callback_path.assert_awaited_once_with(
129
+ path_hash,
130
+ "owner/repo/src/main.py",
131
+ ttl=kb.CALLBACK_PATH_TTL_SECONDS,
132
+ )
@@ -566,9 +566,7 @@ class TestRuzAPIClient(unittest.IsolatedAsyncioTestCase):
566
566
  await client.get_schedule("group", "M80/101", "2026-04-01", "2026-04-30")
567
567
 
568
568
  self.assertEqual(session.calls[0][0], "https://ruz.fa.ru/api/search")
569
- self.assertEqual(
570
- session.calls[0][1]["params"], {"term": "М80 & 101", "type": "group"}
571
- )
569
+ self.assertEqual(session.calls[0][1]["params"], {"term": "М80 & 101", "type": "group"})
572
570
  self.assertEqual(session.calls[1][0], "https://ruz.fa.ru/api/schedule/group/M80/101")
573
571
  self.assertEqual(
574
572
  session.calls[1][1]["params"],
@@ -578,9 +576,7 @@ class TestRuzAPIClient(unittest.IsolatedAsyncioTestCase):
578
576
 
579
577
  class TestCachedScheduleLabels(unittest.TestCase):
580
578
  def test_cached_entity_name_uses_type_specific_display_field(self):
581
- self.assertEqual(
582
- _cached_entity_name("group", [{"group": "M80-101"}], "group-1"), "M80-101"
583
- )
579
+ self.assertEqual(_cached_entity_name("group", [{"group": "M80-101"}], "group-1"), "M80-101")
584
580
  self.assertEqual(
585
581
  _cached_entity_name("person", [{"lecturer_title": "Ivan Petrov"}], "person-1"),
586
582
  "Ivan Petrov",
@@ -82,9 +82,7 @@ class TestStudioRouterAPI(unittest.TestCase):
82
82
  self.app.dependency_overrides.clear()
83
83
 
84
84
  def test_project_filename_rejects_paths_and_control_characters(self):
85
- self.assertEqual(
86
- studio_router._sanitize_project_filename("diagram.png"), "diagram.png"
87
- )
85
+ self.assertEqual(studio_router._sanitize_project_filename("diagram.png"), "diagram.png")
88
86
  for filename in ("../secret.txt", "nested/image.png", r"nested\image.png", "bad\x00.txt"):
89
87
  with self.subTest(filename=filename), self.assertRaises(HTTPException) as raised:
90
88
  studio_router._sanitize_project_filename(filename)