1
0
Fork 0
mirror of https://github.com/EDeev/y.calendarkin.git synced 2026-10-08 00:39:27 +03:00

Уведомления о новых, удалённых и перенесённых событиях (#5)

При обновлении календаря (раз в 13 минут) старая выгрузка сравнивается с новой по UID события.
Бот присылает одно сообщение со списком изменений: новое событие, удалено, перенесено (было/стало).
Учитываются и отдельные вхождения повторяющихся событий — перенос (RECURRENCE-ID) и удаление
(EXDATE). Прошедшие события пропускаются: Яндекс выгружает всю историю календаря. В сообщении —
не больше 10 изменений, остальные одной строкой «…и ещё N».

Команда /changes включает и выключает эти уведомления; по умолчанию включены у всех, колонка
alarms.changes добавляется при запуске. При подписке и замене ссылки сравнения нет.

Closes #2
This commit is contained in:
Деев Егор Викторович 2026-10-07 16:27:58 +03:00 • committed by GitHub
parent 3db58a13ab
commit 4055242ddf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 351 additions and 7 deletions

View file

@ -21,6 +21,7 @@ bot speaks Russian.
- A day summary at 8:00 in the calendar's time zone (`/daily`) - A day summary at 8:00 in the calendar's time zone (`/daily`)
- Two reminders before an event: 15 and 5 minutes by default, changed with `/edit_alarm` - Two reminders before an event: 15 and 5 minutes by default, changed with `/edit_alarm`
- A message when an event starts (`/moment`) - A message when an event starts (`/moment`)
- A notification when an event is added, or an upcoming one is deleted or moved (`/changes`)
- The calendar is refreshed every 13 minutes; if a refresh fails, the previous version stays - The calendar is refreshed every 13 minutes; if a refresh fails, the previous version stays
## Commands ## Commands
@ -31,6 +32,7 @@ bot speaks Russian.
| `/list` | today's events | | `/list` | today's events |
| `/daily` | toggle the morning summary | | `/daily` | toggle the morning summary |
| `/moment` | toggle the message at event start | | `/moment` | toggle the message at event start |
| `/changes` | toggle notifications about added, deleted and moved events |
| `/get_alarm`, `/edit_alarm` | view and change reminder times | | `/get_alarm`, `/edit_alarm` | view and change reminder times |
| `/stop_alarm` | toggle the second reminder | | `/stop_alarm` | toggle the second reminder |
| `/notif` | pause or resume all notifications | | `/notif` | pause or resume all notifications |
@ -55,6 +57,7 @@ Without Docker: Python 3.10+, PostgreSQL, `pip install -r requirements.txt`, the
``` ```
code/bot.py entry point and two background loops: event checks every minute, calendar refresh code/bot.py entry point and two background loops: event checks every minute, calendar refresh
code/changes.py compares the old and new download: added, deleted and moved events
code/handlers.py commands and receiving the link code/handlers.py commands and receiving the link
code/script.py link validation, download, iCal and recurrence parsing, notification text code/script.py link validation, download, iCal and recurrence parsing, notification text
code/sql.py users, subscriptions and reminder settings (PostgreSQL) code/sql.py users, subscriptions and reminder settings (PostgreSQL)
@ -72,7 +75,7 @@ ruff check --select E9,F code tests && pytest
``` ```
The tests (storage on PostgreSQL from `TEST_DATABASE_URL`) cover calendar parsing: today's events, recurrences, `UNTIL` in different formats, link The tests (storage on PostgreSQL from `TEST_DATABASE_URL`) cover calendar parsing: today's events, recurrences, `UNTIL` in different formats, link
validation and the notification text. The Docker image is built on `v*` tags and published to GitHub validation, the notification text and change detection between downloads. The Docker image is built on `v*` tags and published to GitHub
Packages and `git.deev.su`. Packages and `git.deev.su`.
## License ## License

View file

@ -20,6 +20,7 @@ Telegram-бот, который присылает уведомления о с
- Сводка на день в 8:00 по часовому поясу календаря (`/daily`) - Сводка на день в 8:00 по часовому поясу календаря (`/daily`)
- Два напоминания перед событием: по умолчанию за 15 и 5 минут, меняются в `/edit_alarm` - Два напоминания перед событием: по умолчанию за 15 и 5 минут, меняются в `/edit_alarm`
- Сообщение в момент начала события (`/moment`) - Сообщение в момент начала события (`/moment`)
- Уведомление, если в календаре появилось новое событие, а предстоящее удалили или перенесли (`/changes`)
- Календарь обновляется раз в 13 минут; если обновление не удалось, остаётся прошлая версия - Календарь обновляется раз в 13 минут; если обновление не удалось, остаётся прошлая версия
Пример уведомления: Пример уведомления:
@ -42,6 +43,7 @@ Telegram-бот, который присылает уведомления о с
| `/list` | события на сегодня | | `/list` | события на сегодня |
| `/daily` | включить или выключить утреннюю сводку | | `/daily` | включить или выключить утреннюю сводку |
| `/moment` | включить или выключить сообщение в момент начала | | `/moment` | включить или выключить сообщение в момент начала |
| `/changes` | включить или выключить уведомления о новых, удалённых и перенесённых событиях |
| `/get_alarm`, `/edit_alarm` | посмотреть и изменить время напоминаний | | `/get_alarm`, `/edit_alarm` | посмотреть и изменить время напоминаний |
| `/stop_alarm` | включить или выключить второе напоминание | | `/stop_alarm` | включить или выключить второе напоминание |
| `/notif` | приостановить или возобновить все уведомления | | `/notif` | приостановить или возобновить все уведомления |
@ -66,6 +68,7 @@ docker compose up -d
``` ```
code/bot.py запуск и два фоновых цикла: проверка событий раз в минуту, обновление календарей code/bot.py запуск и два фоновых цикла: проверка событий раз в минуту, обновление календарей
code/changes.py сравнение старой и новой выгрузки: новые, удалённые и перенесённые события
code/handlers.py команды и приём ссылки code/handlers.py команды и приём ссылки
code/script.py проверка ссылки, скачивание, разбор iCal и повторений, текст уведомления code/script.py проверка ссылки, скачивание, разбор iCal и повторений, текст уведомления
code/sql.py пользователи, подписки и настройки напоминаний (PostgreSQL) code/sql.py пользователи, подписки и настройки напоминаний (PostgreSQL)
@ -84,7 +87,7 @@ ruff check --select E9,F code tests && pytest
``` ```
Тесты (хранилище — на PostgreSQL из `TEST_DATABASE_URL`) проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку Тесты (хранилище — на PostgreSQL из `TEST_DATABASE_URL`) проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку
ссылки и текст уведомления. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и ссылки, текст уведомления и поиск изменений между выгрузками. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и
`git.deev.su`. `git.deev.su`.
## Лицензия ## Лицензия

View file

@ -3,7 +3,8 @@ import logging
from datetime import datetime, time from datetime import datetime, time
from init import bot, dp, du, dc from init import bot, dp, du, dc
from script import delta_time, download_ical, dt_now, message_form, text_ical import changes
from script import delta_time, download_ical, dt_now, message_form, read_ical, text_ical
from handlers import router from handlers import router
@ -87,11 +88,22 @@ async def update(wait_for):
for (user_id,) in du.all_users(): for (user_id,) in du.all_users():
try: try:
if du.url_exists(user_id) and du.get_status(user_id): if not (du.url_exists(user_id) and du.get_status(user_id)):
await download_ical(du.get_url(user_id), user_id) continue
old = read_ical(user_id)
new = await download_ical(du.get_url(user_id), user_id)
except Exception as err: except Exception as err:
# при сбое остаётся прошлая версия календаря # при сбое остаётся прошлая версия календаря
logging.warning("Не удалось обновить календарь пользователя %s: %s", user_id, err) logging.warning("Не удалось обновить календарь пользователя %s: %s", user_id, err)
continue
try:
if old and old != new and dc.clock_exists(user_id) and dc.get_changes(user_id):
text = changes.report(old, new, du.get_tz(user_id))
if text:
await send(str(du.get_first_user_id(user_id)), text)
except Exception:
logging.exception("Ошибка уведомления об изменениях пользователя %s", user_id)
async def main() -> None: async def main() -> None:

165
code/changes.py Normal file
View file

@ -0,0 +1,165 @@
"""Уведомления об изменениях в календаре: новые, удалённые и перенесённые события.
При каждом обновлении календаря старая и новая выгрузки сравниваются по UID события.
Отдельное вхождение повторяющегося события Яндекс выгружает так: перенос — отдельным VEVENT
с тем же UID и RECURRENCE-ID (исходное время), удаление — датой в EXDATE основного события.
Яндекс отдаёт всю историю календаря, поэтому прошедшие события не учитываются."""
from datetime import date, datetime, timedelta
from html import escape
import icalendar
import pytz
MAX_ITEMS = 10
WEEKDAYS = ("пн", "вт", "ср", "чт", "пт", "сб", "вс")
def _norm(value, tz):
"""Дата остаётся датой, время приводится к часовому поясу календаря"""
if isinstance(value, datetime):
if value.tzinfo is None: # «плавающее» время — считаем временем календаря
value = tz.localize(value)
return value.astimezone(tz)
return value
def _exdates(component, tz):
raw = component.get("exdate")
if raw is None:
return set()
lists = raw if isinstance(raw, list) else [raw]
return {_norm(item.dt, tz) for lst in lists for item in lst.dts}
def snapshot(data, tz):
"""События выгрузки: {(UID, исходное время вхождения или None): описание}"""
events = {}
for component in icalendar.Calendar.from_ical(data).walk("VEVENT"):
if component.get("dtstart") is None:
continue
start = _norm(component.decoded("dtstart"), tz)
if component.get("dtend") is not None:
end = _norm(component.decoded("dtend"), tz)
elif component.get("duration") is not None:
end = start + component.decoded("duration")
else:
end = start + timedelta(days=1) if not isinstance(start, datetime) else start
rrule = component.get("rrule")
until = rrule.get("UNTIL") if rrule else None
rid = _norm(component.decoded("recurrence-id"), tz) if component.get("recurrence-id") is not None else None
events[(str(component.get("uid", "")), rid)] = {
"name": str(component.get("summary", "")).strip() or "Без названия",
"start": start, "end": end, "all_day": not isinstance(start, datetime),
"recurring": rrule is not None, "until": _norm(until[0], tz) if until else None,
"exdates": _exdates(component, tz),
}
return events
def _after(value, now):
"""Момент value ещё не наступил (для дат — день не закончился)"""
if isinstance(value, datetime):
return value > now
return value >= now.date()
def _upcoming(event, now):
if event["recurring"]:
return event["until"] is None or _after(event["until"], now)
end = event["end"]
if event["all_day"] and isinstance(end, date) and not isinstance(end, datetime):
end -= timedelta(days=1) # DTEND у событий на весь день — следующий день
return _after(end, now)
def diff(old, new, now):
"""Список изменений: (вид, событие, прежнее событие или None); вид — added, deleted, moved"""
changes = []
for key, event in new.items():
uid, rid = key
before = old.get(key)
if before is not None:
moved = (before["start"], before["end"]) != (event["start"], event["end"])
if moved and (_upcoming(before, now) or _upcoming(event, now)):
changes.append(("moved", event, before))
elif event["recurring"]:
# удалённые вхождения повторяющегося события
for day in sorted(event["exdates"] - before["exdates"], key=str):
if (uid, day) not in new and (uid, day) not in old and _after(day, now):
changes.append(("deleted", _occurrence(event, day), None))
continue
if rid is not None and (uid, None) in old:
# вхождение повторяющегося события изменили отдельно — важно, только если сдвинули время
if event["start"] != rid and (_after(rid, now) or _after(event["start"], now)):
changes.append(("moved", event, _occurrence(old[(uid, None)], rid)))
elif _upcoming(event, now):
changes.append(("added", event, None))
for key, event in old.items():
if key in new:
continue
uid, rid = key
if rid is not None and (uid, None) in new:
master = new[(uid, None)]
if rid in master["exdates"]:
if _after(event["start"], now):
changes.append(("deleted", event, None))
elif event["start"] != rid and (_after(rid, now) or _after(event["start"], now)):
changes.append(("moved", _occurrence(master, rid), event)) # вернули на исходное время
elif _upcoming(event, now):
changes.append(("deleted", event, None))
changes.sort(key=lambda c: _sort_key(c[1]["start"]))
return changes
def _occurrence(master, start):
"""Отдельное вхождение повторяющегося события, начинающееся в start"""
return {**master, "start": start, "end": start + (master["end"] - master["start"]),
"recurring": False, "exdates": set()}
def _sort_key(value):
if isinstance(value, datetime):
return value.astimezone(pytz.utc).replace(tzinfo=None)
return datetime.combine(value, datetime.min.time())
def when(event):
"""Когда событие: «чт 08.10 10:00 — 11:00», «чт 08.10, весь день»"""
start, end = event["start"], event["end"]
day = f"{WEEKDAYS[start.weekday()]} {start:%d.%m}"
if event["all_day"]:
last = end - timedelta(days=1) if end > start else start
text = f"{day}, весь день" if last == start else f"{day} — {WEEKDAYS[last.weekday()]} {last:%d.%m}, весь день"
elif end.date() != start.date():
text = f"{day} {start:%H:%M} — {WEEKDAYS[end.weekday()]} {end:%d.%m %H:%M}"
else:
text = f"{day} {start:%H:%M} — {end:%H:%M}"
return text + (", повторяется" if event["recurring"] else "")
def report(old_data, new_data, tz, now=None):
"""Текст уведомления об изменениях между двумя выгрузками или None, если важных изменений нет"""
now = now or datetime.now(tz)
changes = diff(snapshot(old_data, tz), snapshot(new_data, tz), now)
if not changes:
return None
parts = []
for kind, event, before in changes[:MAX_ITEMS]:
name = escape(event["name"])
if kind == "added":
parts.append(f"<b>Новое событие:</b> {name}\n{when(event)}")
elif kind == "deleted":
parts.append(f"<b>Удалено:</b> {name}\n<s>{when(event)}</s>")
else:
parts.append(f"<b>Перенесено:</b> {name}\nбыло: {when(before)}\nстало: {when(event)}")
if len(changes) > MAX_ITEMS:
parts.append(f"…и ещё {len(changes) - MAX_ITEMS}")
return "<b>Изменения в календаре</b>\n\n" + "\n\n".join(parts)

View file

@ -58,7 +58,8 @@ async def commands(call: CallbackQuery) -> None:
'<b>/list</b> - список событий календаря, запланированных на сегодняшний день\n' '<b>/list</b> - список событий календаря, запланированных на сегодняшний день\n'
'<b>/notif</b> - команда, отключающая рассылку уведомлений, даже при наличии событий в календаре\n' '<b>/notif</b> - команда, отключающая рассылку уведомлений, даже при наличии событий в календаре\n'
'<b>/daily</b> - оповещение в 8 утра по вашему часовому поясу со списком событий на день\n' '<b>/daily</b> - оповещение в 8 утра по вашему часовому поясу со списком событий на день\n'
'<b>/moment</b> - напоминание, приходящее в момент начала события\n\n' '<b>/moment</b> - напоминание, приходящее в момент начала события\n'
'<b>/changes</b> - уведомления о новых, удалённых и перенесённых событиях\n\n'
'<b>/get_alarm</b> - информация о времени на которое настроены оповещения\n' '<b>/get_alarm</b> - информация о времени на которое настроены оповещения\n'
'<b>/edit_alarm</b> - изменение времени оповещений\n' '<b>/edit_alarm</b> - изменение времени оповещений\n'
'<b>/stop_alarm</b> - команда, отключающая второе оповещение о событии\n\n' '<b>/stop_alarm</b> - команда, отключающая второе оповещение о событии\n\n'
@ -147,6 +148,18 @@ async def moment_up(msg: Message) -> None:
else: await msg.answer(NO_LINK) else: await msg.answer(NO_LINK)
@router.message(Command('changes'))
async def changes_up(msg: Message) -> None:
user_id = user_id_of(msg)
if dc.clock_exists(user_id):
if dc.get_changes(user_id): await msg.answer("Уведомления об изменениях в календаре выключены!")
else: await msg.answer("Уведомления об изменениях в календаре включены!")
dc.update_changes(user_id)
else: await msg.answer(NO_LINK)
@router.message(Command('get_alarm')) @router.message(Command('get_alarm'))
async def alarm_get(msg: Message) -> None: async def alarm_get(msg: Message) -> None:
user_id = user_id_of(msg) user_id = user_id_of(msg)

View file

@ -28,7 +28,7 @@ def parse_ical_url(url):
async def download_ical(url, user_id): async def download_ical(url, user_id):
"""Скачивает календарь, проверяет, что это iCal, и только потом заменяет старый файл""" """Скачивает календарь, проверяет, что это iCal, и только потом заменяет старый файл; возвращает содержимое"""
timeout = aiohttp.ClientTimeout(total=30) timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, allow_redirects=False) as response: async with session.get(url, allow_redirects=False) as response:
@ -42,6 +42,16 @@ async def download_ical(url, user_id):
with open(path_new, "wb") as f: with open(path_new, "wb") as f:
f.write(data) f.write(data)
os.replace(path_new, f"{ICAL_DIR}/{user_id}.ics") os.replace(path_new, f"{ICAL_DIR}/{user_id}.ics")
return data
def read_ical(user_id):
"""Сохранённая выгрузка календаря или None"""
try:
with open(f"{ICAL_DIR}/{user_id}.ics", "rb") as f:
return f.read()
except FileNotFoundError:
return None
def text_ical(user_id, tz): def text_ical(user_id, tz):

View file

@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS alarms (
alarm_2 INTEGER, alarm_2 INTEGER,
status_2 BOOLEAN NOT NULL DEFAULT TRUE status_2 BOOLEAN NOT NULL DEFAULT TRUE
); );
ALTER TABLE alarms ADD COLUMN IF NOT EXISTS changes BOOLEAN NOT NULL DEFAULT TRUE;
""" """
@ -132,6 +133,10 @@ class Clock(_Base):
def get_status2(self, user_id): def get_status2(self, user_id):
return self._one("SELECT status_2 FROM alarms WHERE user_id = %s", (user_id,)) return self._one("SELECT status_2 FROM alarms WHERE user_id = %s", (user_id,))
def get_changes(self, user_id):
"""Присылать ли уведомления о новых, удалённых и перенесённых событиях"""
return self._one("SELECT changes FROM alarms WHERE user_id = %s", (user_id,))
# ПЕРЕКЛЮЧАТЕЛИ # ПЕРЕКЛЮЧАТЕЛИ
def update_daily(self, user_id): def update_daily(self, user_id):
self._run("UPDATE alarms SET daily = NOT daily WHERE user_id = %s", (user_id,)) self._run("UPDATE alarms SET daily = NOT daily WHERE user_id = %s", (user_id,))
@ -147,3 +152,6 @@ class Clock(_Base):
def update_status2(self, user_id): def update_status2(self, user_id):
self._run("UPDATE alarms SET status_2 = NOT status_2 WHERE user_id = %s", (user_id,)) self._run("UPDATE alarms SET status_2 = NOT status_2 WHERE user_id = %s", (user_id,))
def update_changes(self, user_id):
self._run("UPDATE alarms SET changes = NOT changes WHERE user_id = %s", (user_id,))

126
tests/test_changes.py Normal file
View file

@ -0,0 +1,126 @@
from datetime import datetime, timedelta
import pytz
import changes
TZ = pytz.timezone("Europe/Moscow")
NOW = TZ.localize(datetime(2026, 10, 7, 12, 0)) # среда
def ics(*events):
body = "\r\n".join(events)
return ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" + body + "\r\nEND:VCALENDAR\r\n").encode()
def vevent(uid, start, minutes=60, summary="Встреча", extra=""):
end = start + timedelta(minutes=minutes)
return (f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261007T090000Z\r\n"
f"DTSTART;TZID=Europe/Moscow:{start:%Y%m%dT%H%M%S}\r\nDTEND;TZID=Europe/Moscow:{end:%Y%m%dT%H%M%S}\r\n"
f"SUMMARY:{summary}\r\n{extra}END:VEVENT")
def at(day, hour, minute=0):
return datetime(2026, 10, day, hour, minute)
def report(old, new):
return changes.report(ics(*old), ics(*new), TZ, now=NOW)
def test_no_changes_and_dtstamp_ignored():
event = vevent("a", at(8, 10))
assert report([event], [event.replace("DTSTAMP:20261007T090000Z", "DTSTAMP:20261007T091300Z")]) is None
def test_renamed_event_is_not_reported():
assert report([vevent("a", at(8, 10), summary="Старое")], [vevent("a", at(8, 10), summary="Новое")]) is None
def test_added_event():
text = report([], [vevent("a", at(8, 10), summary="Созвон <важный> & срочный")])
assert text.startswith("<b>Изменения в календаре</b>")
assert "<b>Новое событие:</b> Созвон &lt;важный&gt; &amp; срочный" in text
assert "чт 08.10 10:00 — 11:00" in text
def test_past_events_are_ignored():
# Яндекс выгружает всю историю: правка старого события не должна приходить уведомлением
assert report([], [vevent("old", at(1, 10))]) is None
assert report([vevent("old", at(1, 10))], []) is None
assert report([vevent("old", at(1, 10))], [vevent("old", at(2, 10))]) is None
def test_event_in_progress_counts_as_upcoming():
assert "Удалено" in report([vevent("now", at(7, 11, 30))], [])
def test_deleted_event():
text = report([vevent("a", at(9, 15), summary="Планёрка")], [])
assert "<b>Удалено:</b> Планёрка\n<s>пт 09.10 15:00 — 16:00</s>" in text
def test_moved_event():
text = report([vevent("a", at(8, 10), summary="Планёрка")], [vevent("a", at(9, 14, 30), summary="Планёрка")])
assert "<b>Перенесено:</b> Планёрка\nбыло: чт 08.10 10:00 — 11:00\nстало: пт 09.10 14:30 — 15:30" in text
def test_moved_from_past_to_future():
assert "Перенесено" in report([vevent("a", at(6, 10))], [vevent("a", at(12, 10))])
def test_all_day_event():
event = ("BEGIN:VEVENT\r\nUID:day\r\nDTSTART;VALUE=DATE:20261010\r\nDTEND;VALUE=DATE:20261011\r\n"
"SUMMARY:Выходной\r\nEND:VEVENT")
assert "сб 10.10, весь день" in report([], [event])
today = event.replace("20261010", "20261007").replace("20261011", "20261008")
assert "ср 07.10, весь день" in report([today], []) # день ещё не закончился
def test_recurring_occurrence_deleted():
weekly = vevent("w", at(1, 9), summary="Стендап", extra="RRULE:FREQ=WEEKLY\r\n")
without = weekly.replace("RRULE:FREQ=WEEKLY\r\n", "RRULE:FREQ=WEEKLY\r\nEXDATE;TZID=Europe/Moscow:20261015T090000\r\n")
text = report([weekly], [without])
assert "<b>Удалено:</b> Стендап\n<s>чт 15.10 09:00 — 10:00</s>" in text
assert "повторяется" not in text
def test_recurring_occurrence_moved_and_moved_back():
weekly = vevent("w", at(1, 9), summary="Стендап", extra="RRULE:FREQ=WEEKLY\r\n")
override = vevent("w", at(15, 11), summary="Стендап", extra="RECURRENCE-ID;TZID=Europe/Moscow:20261015T090000\r\n")
text = report([weekly], [weekly, override])
assert "было: чт 15.10 09:00 — 10:00\nстало: чт 15.10 11:00 — 12:00" in text
text = report([weekly, override], [weekly])
assert "было: чт 15.10 11:00 — 12:00\nстало: чт 15.10 09:00 — 10:00" in text
def test_recurring_override_deleted():
override = vevent("w", at(15, 11), extra="RECURRENCE-ID;TZID=Europe/Moscow:20261015T090000\r\n")
weekly = vevent("w", at(1, 9), extra="RRULE:FREQ=WEEKLY\r\n")
without = weekly.replace("RRULE:FREQ=WEEKLY\r\n", "RRULE:FREQ=WEEKLY\r\nEXDATE;TZID=Europe/Moscow:20261015T090000\r\n")
text = report([weekly, override], [without])
assert text.count("Удалено") == 1 and "чт 15.10 11:00 — 12:00" in text
def test_recurring_series():
weekly = vevent("w", at(1, 9), summary="Стендап", extra="RRULE:FREQ=WEEKLY\r\n")
assert "чт 01.10 09:00 — 10:00, повторяется" in report([], [weekly])
assert "Удалено" in report([weekly], [])
ended = vevent("e", at(1, 9), extra="RRULE:FREQ=WEEKLY;UNTIL=20261005T060000Z\r\n")
assert report([ended], []) is None
def test_many_changes_are_capped():
text = report([], [vevent(f"e{i}", at(10, 8) + timedelta(hours=i)) for i in range(13)])
assert text.count("Новое событие") == changes.MAX_ITEMS
assert text.endswith("…и ещё 3")
assert text.index("08:00") < text.index("09:00") # по времени события
def test_floating_time_uses_calendar_zone():
event = "BEGIN:VEVENT\r\nUID:f\r\nDTSTART:20261008T100000\r\nDTEND:20261008T110000\r\nSUMMARY:X\r\nEND:VEVENT"
assert "чт 08.10 10:00 — 11:00" in report([], [event])

View file

@ -37,6 +37,9 @@ def test_users_and_alarms(pool):
dc.update_alarm1(uid, 30) dc.update_alarm1(uid, 30)
dc.update_daily(uid) dc.update_daily(uid)
assert dc.get_alarm(uid) == (30, 5) and dc.get_daily(uid) is True assert dc.get_alarm(uid) == (30, 5) and dc.get_daily(uid) is True
assert dc.get_changes(uid) is True
dc.update_changes(uid)
assert dc.get_changes(uid) is False
du.delete_url(uid) du.delete_url(uid)
assert not du.url_exists(uid) assert not du.url_exists(uid)
assert du.all_users() == [(uid,)] assert du.all_users() == [(uid,)]
@ -65,5 +68,6 @@ def test_migration(pool, tmp_path):
du, dc = Users(pool), Clock(pool) du, dc = Users(pool), Clock(pool)
assert du.get_user_id(555) == 5 # внутренний номер сохранён — на него ссылаются файлы календарей assert du.get_user_id(555) == 5 # внутренний номер сохранён — на него ссылаются файлы календарей
assert dc.get_alarm(5) == (20, 10) and dc.get_daily(5) is True and dc.get_start(5) is False assert dc.get_alarm(5) == (20, 10) and dc.get_daily(5) is True and dc.get_start(5) is False
assert dc.get_changes(5) is True # новая настройка включена и у перенесённых пользователей
du.add_user(777) du.add_user(777)
assert du.get_user_id(777) == 6 assert du.get_user_id(777) == 6