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.
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/PKG-INFO +1 -1
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/README.md +1 -1
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/PKG-INFO +1 -1
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/setup.py +1 -1
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/database.py +100 -1
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/models.py +6 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/redis_client.py +39 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/schemas.py +5 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/calendar_sync_state.py +18 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/schedule_service.py +8 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/semantic_search.py +7 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/university_api.py +1 -3
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_keyboards.py +40 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_schedule_search_api.py +2 -6
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_studio_router_api.py +1 -3
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/LICENSE +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/SOURCES.txt +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/dependency_links.txt +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/requires.txt +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/top_level.txt +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/pyproject.toml +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/setup.cfg +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/__init__.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/celery_app.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/constants.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/egress.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/html_tools.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/i18n.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/locales/en.json +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/locales/ru.json +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/mail_bridge.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/request_context.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/__init__.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/broadcast_service.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/tasks.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/telegram_bot_session.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/telegram_http.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/telegram_polling.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/telemetry.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_auth_flow.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_authorization_guards.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_broadcast_service.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_build_audit_requirements.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_calendar_api.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_correlation_middleware.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_dashboard_text_encoding.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_egress.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_fastapi_config.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_latex_task_security.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_localization_completeness.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_mail_bridge.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_myschedule_filters_settings.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_openapi_docs.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_proxy_cleaner.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_proxy_config.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_rate_limit.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_schedule_service_calendar.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_scheduler_html_splitter.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_scheduler_service.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_search_center_helpers.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_action_users_api.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_avatar_security.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_export_api.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_leaderboard_api.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_profile_api.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_proxy_diagnostics_api.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_send_message_api.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_telegram_bot_session.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_telegram_polling.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_telemetry.py +0 -0
- {matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_version_bumper.py +0 -0
|
@@ -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
|
|
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
|
|
|
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="matplobbot-shared",
|
|
5
|
-
version="0.1.
|
|
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(
|
|
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):
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/calendar_sync_state.py
RENAMED
|
@@ -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"]
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/schedule_service.py
RENAMED
|
@@ -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)
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/semantic_search.py
RENAMED
|
@@ -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
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/university_api.py
RENAMED
|
@@ -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)
|
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/requires.txt
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/matplobbot_shared.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/shared_lib/services/broadcast_service.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_build_audit_requirements.py
RENAMED
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_correlation_middleware.py
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_dashboard_text_encoding.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_localization_completeness.py
RENAMED
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_myschedule_filters_settings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_schedule_service_calendar.py
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_scheduler_html_splitter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_action_users_api.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_proxy_diagnostics_api.py
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.332 → matplobbot_shared-0.1.334}/tests/test_stats_send_message_api.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|