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:
parent
3db58a13ab
commit
4055242ddf
9 changed files with 351 additions and 7 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
|
||||||
18
code/bot.py
18
code/bot.py
|
|
@ -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
165
code/changes.py
Normal 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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
126
tests/test_changes.py
Normal 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> Созвон <важный> & срочный" 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])
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue