matplobbot-shared 0.1.331__tar.gz → 0.1.332__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.331 → matplobbot_shared-0.1.332}/PKG-INFO +1 -1
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/README.md +2 -1
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/PKG-INFO +1 -1
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/setup.py +1 -1
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/database.py +41 -6
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/html_tools.py +1 -1
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/locales/en.json +1 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/locales/ru.json +1 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/mail_bridge.py +3 -20
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/models.py +40 -2
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/redis_client.py +5 -1
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/university_api.py +13 -5
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/tasks.py +3 -1
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_authorization_guards.py +8 -2
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_keyboards.py +2 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_latex_task_security.py +5 -2
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_mail_bridge.py +1 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_schedule_search_api.py +58 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_scheduler_html_splitter.py +8 -4
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_stats_avatar_security.py +1 -1
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_studio_router_api.py +10 -1
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/LICENSE +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/SOURCES.txt +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/dependency_links.txt +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/requires.txt +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/top_level.txt +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/pyproject.toml +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/setup.cfg +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/__init__.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/celery_app.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/constants.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/egress.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/i18n.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/request_context.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/schemas.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/__init__.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/broadcast_service.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/calendar_sync_state.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/schedule_service.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/semantic_search.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/telegram_bot_session.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/telegram_http.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/telegram_polling.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/telemetry.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_auth_flow.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_broadcast_service.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_build_audit_requirements.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_calendar_api.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_correlation_middleware.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_dashboard_text_encoding.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_egress.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_fastapi_config.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_localization_completeness.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_myschedule_filters_settings.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_openapi_docs.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_proxy_cleaner.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_proxy_config.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_rate_limit.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_schedule_service_calendar.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_scheduler_service.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_search_center_helpers.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_stats_action_users_api.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_stats_export_api.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_stats_leaderboard_api.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_stats_profile_api.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_stats_proxy_diagnostics_api.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_stats_send_message_api.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_telegram_bot_session.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_telegram_polling.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_telemetry.py +0 -0
- {matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_version_bumper.py +0 -0
|
@@ -59,7 +59,8 @@ The project currently includes:
|
|
|
59
59
|
- View live usage stats over WebSockets.
|
|
60
60
|
- See popular commands, text activity, action-type breakdowns, and activity trends.
|
|
61
61
|
- Inspect user-specific pages with paginated history, filtering, and CSV export.
|
|
62
|
-
-
|
|
62
|
+
- View bot logs with `docker compose logs -f mpb-telegram-bot`; the legacy log WebSocket is intentionally disabled.
|
|
63
|
+
- Configure private mailbox forwarding with `/mail` (requires `MAIL_CREDENTIAL_KEY`).
|
|
63
64
|
|
|
64
65
|
## Screenshots
|
|
65
66
|
|
|
@@ -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.332", # 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",
|
|
@@ -815,14 +815,17 @@ async def delete_old_inactive_subscriptions(days_inactive: int = 30):
|
|
|
815
815
|
|
|
816
816
|
|
|
817
817
|
async def get_subscriptions_for_notification(notification_time: str) -> list:
|
|
818
|
+
try:
|
|
819
|
+
target_time = datetime.time.fromisoformat(notification_time)
|
|
820
|
+
except (TypeError, ValueError):
|
|
821
|
+
logger.warning("Ignoring invalid notification time: %r", notification_time)
|
|
822
|
+
return []
|
|
823
|
+
|
|
818
824
|
async with get_session() as session:
|
|
819
|
-
# notification_time comes as "HH:MM"
|
|
820
|
-
# We use PostgreSQL TO_CHAR on the Time column
|
|
821
825
|
stmt = select(UserScheduleSubscription).where(
|
|
822
826
|
and_(
|
|
823
827
|
UserScheduleSubscription.is_active.is_(True),
|
|
824
|
-
|
|
825
|
-
== notification_time,
|
|
828
|
+
UserScheduleSubscription.notification_time == target_time,
|
|
826
829
|
)
|
|
827
830
|
)
|
|
828
831
|
result = await session.execute(stmt)
|
|
@@ -1004,20 +1007,46 @@ async def batch_update_subscription_hashes(entity_type: str, entity_id: str, new
|
|
|
1004
1007
|
|
|
1005
1008
|
|
|
1006
1009
|
# --- Cached Schedules ---
|
|
1010
|
+
def _cached_entity_name(entity_type: str, data: list | dict, fallback: str) -> str:
|
|
1011
|
+
"""Extract a stable display name without querying/expanding JSON later."""
|
|
1012
|
+
if not isinstance(data, list):
|
|
1013
|
+
return fallback
|
|
1014
|
+
field_by_type = {
|
|
1015
|
+
"group": "group",
|
|
1016
|
+
"person": "lecturer_title",
|
|
1017
|
+
"auditorium": "auditorium",
|
|
1018
|
+
}
|
|
1019
|
+
field = field_by_type.get(entity_type)
|
|
1020
|
+
if not field:
|
|
1021
|
+
return fallback
|
|
1022
|
+
for lesson in data:
|
|
1023
|
+
if isinstance(lesson, dict):
|
|
1024
|
+
value = str(lesson.get(field) or "").strip()
|
|
1025
|
+
if value:
|
|
1026
|
+
return value[:255]
|
|
1027
|
+
return fallback
|
|
1028
|
+
|
|
1029
|
+
|
|
1007
1030
|
async def upsert_cached_schedule(entity_type: str, entity_id: str, data: list | dict):
|
|
1008
1031
|
json_data = json.loads(json.dumps(data, default=str)) # Ensure serializable
|
|
1032
|
+
entity_name = _cached_entity_name(entity_type, json_data, str(entity_id))
|
|
1009
1033
|
async with get_session() as session:
|
|
1010
1034
|
stmt = (
|
|
1011
1035
|
pg_insert(CachedSchedule)
|
|
1012
1036
|
.values(
|
|
1013
1037
|
entity_type=entity_type,
|
|
1014
1038
|
entity_id=str(entity_id),
|
|
1039
|
+
entity_name=entity_name,
|
|
1015
1040
|
schedule_data=json_data,
|
|
1016
1041
|
updated_at=datetime.datetime.now(),
|
|
1017
1042
|
)
|
|
1018
1043
|
.on_conflict_do_update(
|
|
1019
1044
|
constraint="uq_cached_schedule_entity",
|
|
1020
|
-
set_=dict(
|
|
1045
|
+
set_=dict(
|
|
1046
|
+
entity_name=entity_name,
|
|
1047
|
+
schedule_data=json_data,
|
|
1048
|
+
updated_at=datetime.datetime.now(),
|
|
1049
|
+
),
|
|
1021
1050
|
)
|
|
1022
1051
|
)
|
|
1023
1052
|
await session.execute(stmt)
|
|
@@ -1598,6 +1627,7 @@ async def merge_cached_schedule(
|
|
|
1598
1627
|
# merged_data.sort(key=lambda x: (x.get('date', ''), x.get('beginLesson', '')))
|
|
1599
1628
|
|
|
1600
1629
|
json_data = json.loads(json.dumps(merged_data, default=str))
|
|
1630
|
+
entity_name = _cached_entity_name(entity_type, json_data, str(entity_id))
|
|
1601
1631
|
|
|
1602
1632
|
# 4. Upsert (вставка или обновление)
|
|
1603
1633
|
stmt_insert = (
|
|
@@ -1605,12 +1635,17 @@ async def merge_cached_schedule(
|
|
|
1605
1635
|
.values(
|
|
1606
1636
|
entity_type=entity_type,
|
|
1607
1637
|
entity_id=str(entity_id),
|
|
1638
|
+
entity_name=entity_name,
|
|
1608
1639
|
schedule_data=json_data,
|
|
1609
1640
|
updated_at=datetime.datetime.now(),
|
|
1610
1641
|
)
|
|
1611
1642
|
.on_conflict_do_update(
|
|
1612
1643
|
constraint="uq_cached_schedule_entity",
|
|
1613
|
-
set_=dict(
|
|
1644
|
+
set_=dict(
|
|
1645
|
+
entity_name=entity_name,
|
|
1646
|
+
schedule_data=json_data,
|
|
1647
|
+
updated_at=datetime.datetime.now(),
|
|
1648
|
+
),
|
|
1614
1649
|
)
|
|
1615
1650
|
)
|
|
1616
1651
|
|
|
@@ -157,6 +157,7 @@
|
|
|
157
157
|
"help_btn_lec_search": "📚 /lec_search - Search notes",
|
|
158
158
|
"help_btn_lec_all": "📂 /lec_all - Browse notes",
|
|
159
159
|
"help_btn_favorites": "⭐ /favorites - Favorites",
|
|
160
|
+
"help_btn_mail": "✉️ /mail - Mailboxes",
|
|
160
161
|
"help_btn_settings": "⚙️ /settings - Settings",
|
|
161
162
|
"help_btn_latex": "🧮 /latex - Render LaTeX",
|
|
162
163
|
"help_btn_mermaid": "🎨 /mermaid - Render Mermaid",
|
|
@@ -136,6 +136,7 @@
|
|
|
136
136
|
"help_btn_lec_search": "📚 /lec_search - Поиск по конспектам",
|
|
137
137
|
"help_btn_lec_all": "📂 /lec_all - Просмотр конспектов",
|
|
138
138
|
"help_btn_favorites": "⭐ /favorites - Избранное",
|
|
139
|
+
"help_btn_mail": "✉️ /mail - Почтовые ящики",
|
|
139
140
|
"help_btn_settings": "⚙️ /settings - Настройки",
|
|
140
141
|
"help_btn_latex": "🧮 /latex - Рендер LaTeX",
|
|
141
142
|
"help_btn_mermaid": "🎨 /mermaid - Рендер Mermaid",
|
|
@@ -13,9 +13,10 @@ from html import escape
|
|
|
13
13
|
|
|
14
14
|
from bs4 import BeautifulSoup
|
|
15
15
|
from cryptography.fernet import Fernet
|
|
16
|
-
from sqlalchemy import BigInteger, Boolean, Column, Integer, LargeBinary, String, UniqueConstraint
|
|
17
16
|
|
|
18
|
-
from shared_lib.models import
|
|
17
|
+
from shared_lib.models import MailAccount
|
|
18
|
+
|
|
19
|
+
__all__ = ["MailAccount", "cipher", "seal", "unseal", "validate_host", "poll_mail", "parse_mail"]
|
|
19
20
|
|
|
20
21
|
MAX_MAIL_BYTES = 35 * 1024 * 1024
|
|
21
22
|
PASSWORD_PROMPT = "Пароль приложения для почты"
|
|
@@ -41,24 +42,6 @@ def sensitive_mail_update(event, data):
|
|
|
41
42
|
)
|
|
42
43
|
|
|
43
44
|
|
|
44
|
-
class MailAccount(Base):
|
|
45
|
-
__tablename__ = "mail_accounts"
|
|
46
|
-
__table_args__ = (
|
|
47
|
-
UniqueConstraint("user_id", "address", "host", name="uq_mail_owner_address_host"),
|
|
48
|
-
)
|
|
49
|
-
id = Column(Integer, primary_key=True)
|
|
50
|
-
user_id = Column(BigInteger, nullable=False, index=True)
|
|
51
|
-
address = Column(String(320), nullable=False)
|
|
52
|
-
host = Column(String(253), nullable=False)
|
|
53
|
-
protocol = Column(String(8), nullable=False)
|
|
54
|
-
port = Column(Integer, nullable=False, default=993)
|
|
55
|
-
credential = Column(LargeBinary, nullable=False)
|
|
56
|
-
enabled = Column(Boolean, nullable=False, default=True)
|
|
57
|
-
checkpoint = Column(LargeBinary, nullable=False)
|
|
58
|
-
pending = Column(LargeBinary, nullable=True)
|
|
59
|
-
status = Column(String(80), nullable=False, default="ready")
|
|
60
|
-
|
|
61
|
-
|
|
62
45
|
def cipher():
|
|
63
46
|
return Fernet(os.environ["MAIL_CREDENTIAL_KEY"].encode("ascii"))
|
|
64
47
|
|
|
@@ -36,7 +36,9 @@ class UserAction(Base):
|
|
|
36
36
|
__tablename__ = "user_actions"
|
|
37
37
|
|
|
38
38
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
39
|
-
user_id = Column(
|
|
39
|
+
user_id = Column(
|
|
40
|
+
BigInteger, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True
|
|
41
|
+
)
|
|
40
42
|
action_type = Column(String, nullable=False, index=True)
|
|
41
43
|
action_details = Column(String, nullable=True)
|
|
42
44
|
timestamp = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
|
@@ -78,7 +80,12 @@ class UserScheduleSubscription(Base):
|
|
|
78
80
|
__tablename__ = "user_schedule_subscriptions"
|
|
79
81
|
|
|
80
82
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
81
|
-
user_id = Column(
|
|
83
|
+
user_id = Column(
|
|
84
|
+
BigInteger,
|
|
85
|
+
ForeignKey("users.user_id", ondelete="CASCADE"),
|
|
86
|
+
nullable=False,
|
|
87
|
+
index=True,
|
|
88
|
+
)
|
|
82
89
|
chat_id = Column(BigInteger, nullable=False)
|
|
83
90
|
entity_type = Column(String, nullable=False)
|
|
84
91
|
entity_id = Column(String, nullable=False)
|
|
@@ -95,6 +102,11 @@ class UserScheduleSubscription(Base):
|
|
|
95
102
|
UniqueConstraint(
|
|
96
103
|
"chat_id", "entity_type", "entity_id", "notification_time", name="uq_schedule_subs"
|
|
97
104
|
),
|
|
105
|
+
Index(
|
|
106
|
+
"ix_user_subscriptions_notification_time_active",
|
|
107
|
+
"notification_time",
|
|
108
|
+
"is_active",
|
|
109
|
+
),
|
|
98
110
|
)
|
|
99
111
|
|
|
100
112
|
|
|
@@ -132,6 +144,7 @@ class CachedSchedule(Base):
|
|
|
132
144
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
133
145
|
entity_type = Column(String, nullable=False)
|
|
134
146
|
entity_id = Column(String, nullable=False)
|
|
147
|
+
entity_name = Column(String(255), nullable=True)
|
|
135
148
|
schedule_data = Column(JSONB, nullable=False)
|
|
136
149
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
137
150
|
|
|
@@ -140,6 +153,31 @@ class CachedSchedule(Base):
|
|
|
140
153
|
)
|
|
141
154
|
|
|
142
155
|
|
|
156
|
+
class MailAccount(Base):
|
|
157
|
+
"""Encrypted mailbox connection owned by a Telegram user."""
|
|
158
|
+
|
|
159
|
+
__tablename__ = "mail_accounts"
|
|
160
|
+
__table_args__ = (
|
|
161
|
+
UniqueConstraint("user_id", "address", "host", name="uq_mail_owner_address_host"),
|
|
162
|
+
)
|
|
163
|
+
id = Column(Integer, primary_key=True)
|
|
164
|
+
user_id = Column(
|
|
165
|
+
BigInteger,
|
|
166
|
+
ForeignKey("users.user_id", ondelete="CASCADE"),
|
|
167
|
+
nullable=False,
|
|
168
|
+
index=True,
|
|
169
|
+
)
|
|
170
|
+
address = Column(String(320), nullable=False)
|
|
171
|
+
host = Column(String(253), nullable=False)
|
|
172
|
+
protocol = Column(String(8), nullable=False)
|
|
173
|
+
port = Column(Integer, nullable=False, default=993)
|
|
174
|
+
credential = Column(LargeBinary, nullable=False)
|
|
175
|
+
enabled = Column(Boolean, nullable=False, default=True)
|
|
176
|
+
checkpoint = Column(LargeBinary, nullable=False)
|
|
177
|
+
pending = Column(LargeBinary, nullable=True)
|
|
178
|
+
status = Column(String(80), nullable=False, default="ready")
|
|
179
|
+
|
|
180
|
+
|
|
143
181
|
class SearchDocument(Base):
|
|
144
182
|
__tablename__ = "search_documents"
|
|
145
183
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
|
+
import os
|
|
3
4
|
|
|
4
5
|
import redis.asyncio as redis
|
|
5
6
|
|
|
@@ -67,4 +68,7 @@ class RedisClient:
|
|
|
67
68
|
|
|
68
69
|
|
|
69
70
|
# Создаем единственный экземпляр клиента
|
|
70
|
-
redis_client = RedisClient(
|
|
71
|
+
redis_client = RedisClient(
|
|
72
|
+
host=os.getenv("REDIS_HOST", "redis"),
|
|
73
|
+
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
74
|
+
)
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/university_api.py
RENAMED
|
@@ -31,8 +31,10 @@ class RuzAPIClient:
|
|
|
31
31
|
self.initial_delay = initial_delay
|
|
32
32
|
self.backoff_factor = backoff_factor
|
|
33
33
|
|
|
34
|
-
async def _request(
|
|
35
|
-
|
|
34
|
+
async def _request(
|
|
35
|
+
self, path: str, params: dict[str, str] | None = None
|
|
36
|
+
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
37
|
+
full_url = self.HOST + path
|
|
36
38
|
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
|
37
39
|
last_exception = None
|
|
38
40
|
|
|
@@ -40,7 +42,10 @@ class RuzAPIClient:
|
|
|
40
42
|
try:
|
|
41
43
|
# Добавляем явный таймаут на запрос, чтобы не висеть бесконечно
|
|
42
44
|
async with self.session.get(
|
|
43
|
-
full_url,
|
|
45
|
+
full_url,
|
|
46
|
+
params=params,
|
|
47
|
+
ssl=ssl_context,
|
|
48
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
44
49
|
) as response:
|
|
45
50
|
if response.status == 200:
|
|
46
51
|
try:
|
|
@@ -73,14 +78,17 @@ class RuzAPIClient:
|
|
|
73
78
|
|
|
74
79
|
async def search(self, term: str, search_type: str) -> list[dict[str, Any]]:
|
|
75
80
|
"""Generic search function."""
|
|
76
|
-
return await self._request(
|
|
81
|
+
return await self._request(
|
|
82
|
+
"/api/search", params={"term": term, "type": search_type}
|
|
83
|
+
)
|
|
77
84
|
|
|
78
85
|
async def get_schedule(
|
|
79
86
|
self, entity_type: str, entity_id: str, start: str, finish: str
|
|
80
87
|
) -> list[dict[str, Any]]:
|
|
81
88
|
"""Generic function to get a schedule."""
|
|
82
89
|
return await self._request(
|
|
83
|
-
f"/api/schedule/{entity_type}/{entity_id}
|
|
90
|
+
f"/api/schedule/{entity_type}/{entity_id}",
|
|
91
|
+
params={"start": start, "finish": finish, "lng": "1"},
|
|
84
92
|
)
|
|
85
93
|
|
|
86
94
|
|
|
@@ -623,7 +623,9 @@ def compile_project_task(
|
|
|
623
623
|
return {
|
|
624
624
|
"status": "error",
|
|
625
625
|
"message": f"Security restriction in {path}: LaTeX source must be UTF-8 text",
|
|
626
|
-
"errors": [
|
|
626
|
+
"errors": [
|
|
627
|
+
{"line": 1, "message": "LaTeX source must be UTF-8 text"}
|
|
628
|
+
],
|
|
627
629
|
}
|
|
628
630
|
is_safe, reason = _validate_latex_source(file_text)
|
|
629
631
|
if not is_safe:
|
|
@@ -61,11 +61,17 @@ class TestAuthorizationGuards(unittest.IsolatedAsyncioTestCase):
|
|
|
61
61
|
self.assertFalse(can_subscribe_user_updates({"role": "user", "telegram_id": 1}, 2))
|
|
62
62
|
|
|
63
63
|
def test_get_jwt_secret_key_raises_if_missing_in_production(self):
|
|
64
|
-
with
|
|
64
|
+
with (
|
|
65
|
+
patch.dict(os.environ, {"ENVIRONMENT": "production"}, clear=True),
|
|
66
|
+
self.assertRaises(RuntimeError),
|
|
67
|
+
):
|
|
65
68
|
_get_jwt_secret_key()
|
|
66
69
|
|
|
67
70
|
def test_get_jwt_secret_key_raises_for_prod_alias(self):
|
|
68
|
-
with
|
|
71
|
+
with (
|
|
72
|
+
patch.dict(os.environ, {"ENVIRONMENT": "prod"}, clear=True),
|
|
73
|
+
self.assertRaises(RuntimeError),
|
|
74
|
+
):
|
|
69
75
|
_get_jwt_secret_key()
|
|
70
76
|
|
|
71
77
|
def test_get_jwt_secret_key_fallback_in_development(self):
|
|
@@ -78,6 +78,8 @@ class TestTelegramWebAppKeyboards(unittest.IsolatedAsyncioTestCase):
|
|
|
78
78
|
buttons = [button for row in markup.keyboard for button in row]
|
|
79
79
|
self.assertFalse(any(button.web_app for button in buttons))
|
|
80
80
|
self.assertIn("/search", [button.text for button in buttons])
|
|
81
|
+
self.assertIn("/mail", [button.text for button in buttons])
|
|
82
|
+
self.assertLessEqual(max(len(row) for row in markup.keyboard), 2)
|
|
81
83
|
|
|
82
84
|
async def test_invalid_public_site_url_omits_inline_web_app_buttons(self):
|
|
83
85
|
kb.PUBLIC_SITE_URL = "http://localhost:8080"
|
|
@@ -60,7 +60,7 @@ class TestLatexTaskSecurity(unittest.TestCase):
|
|
|
60
60
|
self.assertIn("input", reason)
|
|
61
61
|
|
|
62
62
|
def test_binary_latex_is_validated_before_compilation(self):
|
|
63
|
-
binary_tex = base64.b64encode(
|
|
63
|
+
binary_tex = base64.b64encode(rb"\input{/etc/passwd}").decode()
|
|
64
64
|
project_files = [{"path": "main.tex", "binary": binary_tex}]
|
|
65
65
|
with patch("subprocess.run") as mock_run:
|
|
66
66
|
result = compile_project_task.run(project_files, "main.tex")
|
|
@@ -71,7 +71,10 @@ class TestLatexTaskSecurity(unittest.TestCase):
|
|
|
71
71
|
@patch("subprocess.run")
|
|
72
72
|
def test_compile_project_task_rejects_evil_file_before_compilation(self, mock_run):
|
|
73
73
|
project_files = [
|
|
74
|
-
{
|
|
74
|
+
{
|
|
75
|
+
"path": "main.tex",
|
|
76
|
+
"text": r"\documentclass{article}\begin{document}\write18{rm -rf /}\end{document}",
|
|
77
|
+
}
|
|
75
78
|
]
|
|
76
79
|
result = compile_project_task.run(project_files, "main.tex")
|
|
77
80
|
self.assertEqual(result["status"], "error")
|
|
@@ -106,6 +106,7 @@ class MailTests(unittest.TestCase):
|
|
|
106
106
|
def test_sensitive_inputs_and_router(self):
|
|
107
107
|
module = handlers()
|
|
108
108
|
self.assertEqual(module.MailManager().router.name, "mail")
|
|
109
|
+
self.assertEqual(module.MAX_MAILBOXES_PER_USER, 10)
|
|
109
110
|
event = SimpleNamespace(message=SimpleNamespace(reply_to_message=None), edited_message=None)
|
|
110
111
|
self.assertTrue(sensitive_mail_update(event, {"raw_state": "MailSetup:password"}))
|
|
111
112
|
event.message.reply_to_message = SimpleNamespace(text=module.PASSWORD_PROMPT)
|
|
@@ -31,6 +31,9 @@ try:
|
|
|
31
31
|
except ModuleNotFoundError:
|
|
32
32
|
FASTAPI_AVAILABLE = False
|
|
33
33
|
|
|
34
|
+
from shared_lib.database import _cached_entity_name
|
|
35
|
+
from shared_lib.services.university_api import RuzAPIClient
|
|
36
|
+
|
|
34
37
|
|
|
35
38
|
@unittest.skipUnless(FASTAPI_AVAILABLE, "fastapi is not installed in this environment")
|
|
36
39
|
class TestScheduleSearchAPI(unittest.TestCase):
|
|
@@ -532,3 +535,58 @@ class TestScheduleSearchAPI(unittest.TestCase):
|
|
|
532
535
|
refresh_schema = schema["components"]["schemas"]["ScheduleCacheBulkRefreshResponse"]
|
|
533
536
|
self.assertIn("remapped", refresh_schema["properties"])
|
|
534
537
|
self.assertIn("items", refresh_schema["properties"])
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class TestRuzAPIClient(unittest.IsolatedAsyncioTestCase):
|
|
541
|
+
async def test_search_and_schedule_send_query_parameters_separately(self):
|
|
542
|
+
class FakeResponse:
|
|
543
|
+
status = 200
|
|
544
|
+
|
|
545
|
+
async def __aenter__(self):
|
|
546
|
+
return self
|
|
547
|
+
|
|
548
|
+
async def __aexit__(self, *_args):
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
async def json(self):
|
|
552
|
+
return []
|
|
553
|
+
|
|
554
|
+
class FakeSession:
|
|
555
|
+
def __init__(self):
|
|
556
|
+
self.calls = []
|
|
557
|
+
|
|
558
|
+
def get(self, url, **kwargs):
|
|
559
|
+
self.calls.append((url, kwargs))
|
|
560
|
+
return FakeResponse()
|
|
561
|
+
|
|
562
|
+
session = FakeSession()
|
|
563
|
+
client = RuzAPIClient(session, max_retries=1)
|
|
564
|
+
|
|
565
|
+
await client.search("М80 & 101", "group")
|
|
566
|
+
await client.get_schedule("group", "M80/101", "2026-04-01", "2026-04-30")
|
|
567
|
+
|
|
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
|
+
)
|
|
572
|
+
self.assertEqual(session.calls[1][0], "https://ruz.fa.ru/api/schedule/group/M80/101")
|
|
573
|
+
self.assertEqual(
|
|
574
|
+
session.calls[1][1]["params"],
|
|
575
|
+
{"start": "2026-04-01", "finish": "2026-04-30", "lng": "1"},
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class TestCachedScheduleLabels(unittest.TestCase):
|
|
580
|
+
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
|
+
)
|
|
584
|
+
self.assertEqual(
|
|
585
|
+
_cached_entity_name("person", [{"lecturer_title": "Ivan Petrov"}], "person-1"),
|
|
586
|
+
"Ivan Petrov",
|
|
587
|
+
)
|
|
588
|
+
self.assertEqual(
|
|
589
|
+
_cached_entity_name("auditorium", [{"auditorium": "A-101"}], "room-1"),
|
|
590
|
+
"A-101",
|
|
591
|
+
)
|
|
592
|
+
self.assertEqual(_cached_entity_name("group", [], "group-1"), "group-1")
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_scheduler_html_splitter.py
RENAMED
|
@@ -31,7 +31,7 @@ class TestSchedulerHTMLSplitter(unittest.TestCase):
|
|
|
31
31
|
|
|
32
32
|
def test_split_nested_tags(self):
|
|
33
33
|
inner = "Click here to see schedule details.\n" * 15
|
|
34
|
-
msg = f
|
|
34
|
+
msg = f'<b>Notice: <a href="https://example.com/sched">{inner}</a></b>'
|
|
35
35
|
chunks = split_telegram_html_message(msg, max_chars=200)
|
|
36
36
|
|
|
37
37
|
self.assertGreater(len(chunks), 1)
|
|
@@ -42,13 +42,17 @@ class TestSchedulerHTMLSplitter(unittest.TestCase):
|
|
|
42
42
|
|
|
43
43
|
def test_split_preserves_code_and_pre(self):
|
|
44
44
|
inner = "const x = 123;\nconsole.log(x);\n" * 15
|
|
45
|
-
msg = f
|
|
45
|
+
msg = f'<pre><code class="language-js">{inner}</code></pre>'
|
|
46
46
|
chunks = split_telegram_html_message(msg, max_chars=200)
|
|
47
47
|
|
|
48
48
|
self.assertGreater(len(chunks), 1)
|
|
49
49
|
for i, chunk in enumerate(chunks):
|
|
50
|
-
self.assertEqual(
|
|
51
|
-
|
|
50
|
+
self.assertEqual(
|
|
51
|
+
chunk.count("<pre>"), chunk.count("</pre>"), f"Chunk {i} unbalanced <pre>"
|
|
52
|
+
)
|
|
53
|
+
self.assertEqual(
|
|
54
|
+
chunk.count("<code"), chunk.count("</code>"), f"Chunk {i} unbalanced <code>"
|
|
55
|
+
)
|
|
52
56
|
|
|
53
57
|
|
|
54
58
|
if __name__ == "__main__":
|
|
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
|
|
6
6
|
|
|
7
7
|
FASTAPI_AVAILABLE = True
|
|
8
8
|
try:
|
|
9
|
-
from fastapi import FastAPI
|
|
9
|
+
from fastapi import FastAPI, HTTPException
|
|
10
10
|
from fastapi.testclient import TestClient
|
|
11
11
|
|
|
12
12
|
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-for-unit-tests")
|
|
@@ -81,6 +81,15 @@ class TestStudioRouterAPI(unittest.TestCase):
|
|
|
81
81
|
def tearDown(self):
|
|
82
82
|
self.app.dependency_overrides.clear()
|
|
83
83
|
|
|
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
|
+
)
|
|
88
|
+
for filename in ("../secret.txt", "nested/image.png", r"nested\image.png", "bad\x00.txt"):
|
|
89
|
+
with self.subTest(filename=filename), self.assertRaises(HTTPException) as raised:
|
|
90
|
+
studio_router._sanitize_project_filename(filename)
|
|
91
|
+
self.assertEqual(raised.exception.status_code, 400)
|
|
92
|
+
|
|
84
93
|
def test_project_ownership_guard_returns_404(self):
|
|
85
94
|
self.db.execute.return_value = _mock_scalar_result(None)
|
|
86
95
|
|
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/matplobbot_shared.egg-info/requires.txt
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/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
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/broadcast_service.py
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/calendar_sync_state.py
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/schedule_service.py
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/shared_lib/services/semantic_search.py
RENAMED
|
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.331 → matplobbot_shared-0.1.332}/tests/test_build_audit_requirements.py
RENAMED
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_correlation_middleware.py
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_dashboard_text_encoding.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_localization_completeness.py
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/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.331 → matplobbot_shared-0.1.332}/tests/test_schedule_service_calendar.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_stats_action_users_api.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_stats_proxy_diagnostics_api.py
RENAMED
|
File without changes
|
{matplobbot_shared-0.1.331 → matplobbot_shared-0.1.332}/tests/test_stats_send_message_api.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|