From 4055242ddf5d838ca3fc17685cf70e28305b1c2f Mon Sep 17 00:00:00 2001 From: Egor Deev Date: Wed, 7 Oct 2026 16:27:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=20=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D1=85,=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D1=8F=D1=85=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При обновлении календаря (раз в 13 минут) старая выгрузка сравнивается с новой по UID события. Бот присылает одно сообщение со списком изменений: новое событие, удалено, перенесено (было/стало). Учитываются и отдельные вхождения повторяющихся событий — перенос (RECURRENCE-ID) и удаление (EXDATE). Прошедшие события пропускаются: Яндекс выгружает всю историю календаря. В сообщении — не больше 10 изменений, остальные одной строкой «…и ещё N». Команда /changes включает и выключает эти уведомления; по умолчанию включены у всех, колонка alarms.changes добавляется при запуске. При подписке и замене ссылки сравнения нет. Closes #2 --- README.en.md | 5 +- README.md | 5 +- code/bot.py | 18 ++++- code/changes.py | 165 ++++++++++++++++++++++++++++++++++++++++++ code/handlers.py | 15 +++- code/script.py | 12 ++- code/sql.py | 8 ++ tests/test_changes.py | 126 ++++++++++++++++++++++++++++++++ tests/test_sql.py | 4 + 9 files changed, 351 insertions(+), 7 deletions(-) create mode 100644 code/changes.py create mode 100644 tests/test_changes.py diff --git a/README.en.md b/README.en.md index e2b089e..2940791 100644 --- a/README.en.md +++ b/README.en.md @@ -21,6 +21,7 @@ bot speaks Russian. - 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` - 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 ## Commands @@ -31,6 +32,7 @@ bot speaks Russian. | `/list` | today's events | | `/daily` | toggle the morning summary | | `/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 | | `/stop_alarm` | toggle the second reminder | | `/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/changes.py compares the old and new download: added, deleted and moved events code/handlers.py commands and receiving the link code/script.py link validation, download, iCal and recurrence parsing, notification text 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 -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`. ## License diff --git a/README.md b/README.md index 11882b2..ae0f125 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Telegram-бот, который присылает уведомления о с - Сводка на день в 8:00 по часовому поясу календаря (`/daily`) - Два напоминания перед событием: по умолчанию за 15 и 5 минут, меняются в `/edit_alarm` - Сообщение в момент начала события (`/moment`) +- Уведомление, если в календаре появилось новое событие, а предстоящее удалили или перенесли (`/changes`) - Календарь обновляется раз в 13 минут; если обновление не удалось, остаётся прошлая версия Пример уведомления: @@ -42,6 +43,7 @@ Telegram-бот, который присылает уведомления о с | `/list` | события на сегодня | | `/daily` | включить или выключить утреннюю сводку | | `/moment` | включить или выключить сообщение в момент начала | +| `/changes` | включить или выключить уведомления о новых, удалённых и перенесённых событиях | | `/get_alarm`, `/edit_alarm` | посмотреть и изменить время напоминаний | | `/stop_alarm` | включить или выключить второе напоминание | | `/notif` | приостановить или возобновить все уведомления | @@ -66,6 +68,7 @@ docker compose up -d ``` code/bot.py запуск и два фоновых цикла: проверка событий раз в минуту, обновление календарей +code/changes.py сравнение старой и новой выгрузки: новые, удалённые и перенесённые события code/handlers.py команды и приём ссылки code/script.py проверка ссылки, скачивание, разбор iCal и повторений, текст уведомления code/sql.py пользователи, подписки и настройки напоминаний (PostgreSQL) @@ -84,7 +87,7 @@ ruff check --select E9,F code tests && pytest ``` Тесты (хранилище — на PostgreSQL из `TEST_DATABASE_URL`) проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку -ссылки и текст уведомления. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и +ссылки, текст уведомления и поиск изменений между выгрузками. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и `git.deev.su`. ## Лицензия diff --git a/code/bot.py b/code/bot.py index 31a328c..ba270c5 100644 --- a/code/bot.py +++ b/code/bot.py @@ -3,7 +3,8 @@ import logging from datetime import datetime, time 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 @@ -87,11 +88,22 @@ async def update(wait_for): for (user_id,) in du.all_users(): try: - if du.url_exists(user_id) and du.get_status(user_id): - await download_ical(du.get_url(user_id), user_id) + if not (du.url_exists(user_id) and du.get_status(user_id)): + continue + old = read_ical(user_id) + new = await download_ical(du.get_url(user_id), user_id) except Exception as 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: diff --git a/code/changes.py b/code/changes.py new file mode 100644 index 0000000..bae6dbe --- /dev/null +++ b/code/changes.py @@ -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"Новое событие: {name}\n{when(event)}") + elif kind == "deleted": + parts.append(f"Удалено: {name}\n{when(event)}") + else: + parts.append(f"Перенесено: {name}\nбыло: {when(before)}\nстало: {when(event)}") + if len(changes) > MAX_ITEMS: + parts.append(f"…и ещё {len(changes) - MAX_ITEMS}") + + return "Изменения в календаре\n\n" + "\n\n".join(parts) diff --git a/code/handlers.py b/code/handlers.py index d5abe4a..60fb73e 100644 --- a/code/handlers.py +++ b/code/handlers.py @@ -58,7 +58,8 @@ async def commands(call: CallbackQuery) -> None: '/list - список событий календаря, запланированных на сегодняшний день\n' '/notif - команда, отключающая рассылку уведомлений, даже при наличии событий в календаре\n' '/daily - оповещение в 8 утра по вашему часовому поясу со списком событий на день\n' - '/moment - напоминание, приходящее в момент начала события\n\n' + '/moment - напоминание, приходящее в момент начала события\n' + '/changes - уведомления о новых, удалённых и перенесённых событиях\n\n' '/get_alarm - информация о времени на которое настроены оповещения\n' '/edit_alarm - изменение времени оповещений\n' '/stop_alarm - команда, отключающая второе оповещение о событии\n\n' @@ -147,6 +148,18 @@ async def moment_up(msg: Message) -> None: 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')) async def alarm_get(msg: Message) -> None: user_id = user_id_of(msg) diff --git a/code/script.py b/code/script.py index 8b5111b..4c19132 100644 --- a/code/script.py +++ b/code/script.py @@ -28,7 +28,7 @@ def parse_ical_url(url): async def download_ical(url, user_id): - """Скачивает календарь, проверяет, что это iCal, и только потом заменяет старый файл""" + """Скачивает календарь, проверяет, что это iCal, и только потом заменяет старый файл; возвращает содержимое""" timeout = aiohttp.ClientTimeout(total=30) async with aiohttp.ClientSession(timeout=timeout) as session: 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: f.write(data) 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): diff --git a/code/sql.py b/code/sql.py index 4cf208c..eed0f1c 100644 --- a/code/sql.py +++ b/code/sql.py @@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS alarms ( alarm_2 INTEGER, 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): 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): 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): 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,)) diff --git a/tests/test_changes.py b/tests/test_changes.py new file mode 100644 index 0000000..af5e5bf --- /dev/null +++ b/tests/test_changes.py @@ -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("Изменения в календаре") + assert "Новое событие: Созвон <важный> & срочный" 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 "Удалено: Планёрка\nпт 09.10 15:00 — 16:00" in text + + +def test_moved_event(): + text = report([vevent("a", at(8, 10), summary="Планёрка")], [vevent("a", at(9, 14, 30), summary="Планёрка")]) + assert "Перенесено: Планёрка\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 "Удалено: Стендап\nчт 15.10 09:00 — 10:00" 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]) diff --git a/tests/test_sql.py b/tests/test_sql.py index 7aeafc4..9fd4dda 100644 --- a/tests/test_sql.py +++ b/tests/test_sql.py @@ -37,6 +37,9 @@ def test_users_and_alarms(pool): dc.update_alarm1(uid, 30) dc.update_daily(uid) 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) assert not du.url_exists(uid) assert du.all_users() == [(uid,)] @@ -65,5 +68,6 @@ def test_migration(pool, tmp_path): du, dc = Users(pool), Clock(pool) 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_changes(5) is True # новая настройка включена и у перенесённых пользователей du.add_user(777) assert du.get_user_id(777) == 6