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

Compare commits

..

3 commits
v2.0.0 ... main

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

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

Closes #2
2026-10-07 16:27:58 +03:00
3db58a13ab Образы на git.deev.su вместо dcr.deev.su: реестр zot выведен из работы, копию из GHCR делает синхронизатор 2026-10-06 17:04:19 +03:00
abafd7a55d Хранилище — PostgreSQL вместо двух файлов SQLite
code/sql.py на psycopg 3 с пулом соединений (переподключается сам); таблицы users, urls,
alarms — в PostgreSQL user и url зарезервированы. Внутренние номера пользователей сохраняются:
на них ссылаются файлы календарей data/icals/<номер>.ics. scripts/migrate_sqlite.py переносит
старые базы со сверкой (проверено на копии рабочих: 25 пользователей, 9 подписок).
Тесты хранилища и переноса — на PostgreSQL в CI; compose поднимает бота вместе с базой.
2026-10-06 11:46:39 +00:00
19 changed files with 629 additions and 156 deletions

View file

@ -1,2 +1,4 @@
# Токен бота от @BotFather # Токен бота от @BotFather
BOT_TOKEN=123456:your-token BOT_TOKEN=123456:your-token
# База PostgreSQL (в compose.yaml задаётся автоматически)
DATABASE_URL=postgresql://yacal:yacal@localhost:5432/yacal

View file

@ -8,16 +8,28 @@ on:
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: yacal_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-retries 10
env:
TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/yacal_test
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
- run: pip install -r requirements-dev.txt - run: pip install -r requirements-dev.txt
- run: ruff check --select E9,F code tests - run: ruff check --select E9,F code tests scripts
- run: pytest -q - run: pytest -q
- name: Бот импортируется - name: Бот импортируется
working-directory: code working-directory: code
env: env:
BOT_TOKEN: "123456:TEST" BOT_TOKEN: "123456:TEST"
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/yacal_test
run: python -c "import bot" run: python -c "import bot"

View file

@ -19,17 +19,11 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@v3
with:
registry: dcr.deev.su
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
- id: meta - id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: |
ghcr.io/edeev/y.calendarkin ghcr.io/edeev/y.calendarkin
dcr.deev.su/edeev/y.calendarkin
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}

1
.gitignore vendored
View file

@ -2,5 +2,4 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
db/*.db
data/icals/*.ics data/icals/*.ics

View file

@ -10,11 +10,11 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY code/ code/ COPY code/ code/
COPY data/photo_edit_alarm.jpg data/ COPY data/photo_edit_alarm.jpg data/
RUN useradd --create-home --uid 1000 app \ RUN useradd --create-home --uid 1000 app \
&& mkdir -p data/icals db \ && mkdir -p data/icals \
&& chown -R app:app /app && chown -R app:app /app
USER app USER app
VOLUME ["/app/data/icals", "/app/db"] VOLUME ["/app/data/icals"]
# пути к данным в коде — относительно папки code/ (../data, ../db) # пути к данным в коде — относительно папки code/ (../data)
WORKDIR /app/code WORKDIR /app/code
CMD ["python", "bot.py"] CMD ["python", "bot.py"]

View file

@ -12,7 +12,7 @@ bot speaks Russian.
**Status:** personal project, maintained · bot [@calendarkin_ybot](https://t.me/calendarkin_ybot) **Status:** personal project, maintained · bot [@calendarkin_ybot](https://t.me/calendarkin_ybot)
**Stack:** Python 3.12 · aiogram 3 · aiohttp · icalendar · python-dateutil · pytz · SQLite · Docker **Stack:** Python 3.12 · aiogram 3 · aiohttp · icalendar · python-dateutil · pytz · PostgreSQL · Docker
## Features ## Features
@ -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 |
@ -44,18 +46,22 @@ cp .env.example .env # BOT_TOKEN from @BotFather
docker compose up -d docker compose up -d
``` ```
Prebuilt image: `docker pull ghcr.io/edeev/y.calendarkin` or `docker pull dcr.deev.su/edeev/y.calendarkin`. Prebuilt image: `docker pull ghcr.io/edeev/y.calendarkin` or `docker pull git.deev.su/edeev/y.calendarkin`.
The SQLite databases are created on first start. PostgreSQL tables are created on first start. Data from the old version (SQLite `users.db` and `clock.db`)
is moved by `python scripts/migrate_sqlite.py --sqlite-dir path/to/db --dsn postgresql://…`.
Without Docker: Python 3.12, `pip install -r requirements.txt`, then `cd code && BOT_TOKEN=… python bot.py`. Without Docker: Python 3.10+, PostgreSQL, `pip install -r requirements.txt`, then
`cd code && BOT_TOKEN=… DATABASE_URL=postgresql://… python bot.py`.
## How it works ## How it works
``` ```
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 (SQLite) code/sql.py users, subscriptions and reminder settings (PostgreSQL)
scripts/ migration from SQLite
``` ```
Only `https://calendar.yandex.*` links are accepted, so the bot never downloads from arbitrary addresses. Only `https://calendar.yandex.*` links are accepted, so the bot never downloads from arbitrary addresses.
@ -68,9 +74,9 @@ pip install -r requirements-dev.txt
ruff check --select E9,F code tests && pytest ruff check --select E9,F code tests && pytest
``` ```
The tests 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 `dcr.deev.su`. Packages and `git.deev.su`.
## License ## License

View file

@ -11,7 +11,7 @@ Telegram-бот, который присылает уведомления о с
**Статус:** личный проект, поддерживается · бот [@calendarkin_ybot](https://t.me/calendarkin_ybot) **Статус:** личный проект, поддерживается · бот [@calendarkin_ybot](https://t.me/calendarkin_ybot)
**Стек:** Python 3.12 · aiogram 3 · aiohttp · icalendar · python-dateutil · pytz · SQLite · Docker **Стек:** Python 3.12 · aiogram 3 · aiohttp · icalendar · python-dateutil · pytz · PostgreSQL · Docker
## Возможности ## Возможности
@ -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` | приостановить или возобновить все уведомления |
@ -55,18 +57,22 @@ cp .env.example .env # BOT_TOKEN от @BotFather
docker compose up -d docker compose up -d
``` ```
Готовый образ: `docker pull ghcr.io/edeev/y.calendarkin` или `docker pull dcr.deev.su/edeev/y.calendarkin`. Готовый образ: `docker pull ghcr.io/edeev/y.calendarkin` или `docker pull git.deev.su/edeev/y.calendarkin`.
Базы SQLite создаются при первом запуске. Таблицы в PostgreSQL создаются при первом запуске. Данные старой версии (SQLite `users.db` и `clock.db`)
переносит `python scripts/migrate_sqlite.py --sqlite-dir путь/к/db --dsn postgresql://…`.
Без Docker: Python 3.12, `pip install -r requirements.txt`, затем `cd code && BOT_TOKEN=… python bot.py`. Без Docker: Python 3.10+, PostgreSQL, `pip install -r requirements.txt`, затем
`cd code && BOT_TOKEN=… DATABASE_URL=postgresql://… python bot.py`.
## Как устроено ## Как устроено
``` ```
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 пользователи, подписки и настройки напоминаний (SQLite) code/sql.py пользователи, подписки и настройки напоминаний (PostgreSQL)
scripts/ перенос данных из SQLite
``` ```
Принимаются только ссылки `https://calendar.yandex.*`: бот не скачивает файлы по произвольным адресам. Принимаются только ссылки `https://calendar.yandex.*`: бот не скачивает файлы по произвольным адресам.
@ -80,9 +86,9 @@ pip install -r requirements-dev.txt
ruff check --select E9,F code tests && pytest ruff check --select E9,F code tests && pytest
``` ```
Тесты проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку Тесты (хранилище — на PostgreSQL из `TEST_DATABASE_URL`) проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку
ссылки и текст уведомления. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и ссылки, текст уведомления и поиск изменений между выгрузками. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и
`dcr.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

@ -6,11 +6,11 @@ from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.client.bot import DefaultBotProperties from aiogram.client.bot import DefaultBotProperties
from config import botToken from config import botToken
from sql import Users, Clock from sql import Users, Clock, connect
bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) bot = Bot(token=botToken, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher(storage=MemoryStorage()) dp = Dispatcher(storage=MemoryStorage())
os.makedirs("../db", exist_ok=True) pool = connect(os.getenv("DATABASE_URL", "postgresql://yacal:yacal@localhost:5432/yacal"))
du = Users('../db/users.db') du = Users(pool)
dc = Clock('../db/clock.db') dc = Clock(pool)

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

@ -1,180 +1,157 @@
"""Хранилище бота — PostgreSQL (до 2026-10 — два файла SQLite users.db и clock.db).
Таблица users хранит короткий внутренний номер пользователя (id) и его id в Telegram (tg_id);
остальные таблицы ссылаются на внутренний номер, как и раньше."""
from psycopg_pool import ConnectionPool
from pytz import timezone from pytz import timezone
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
tg_id BIGINT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS urls (
user_id INTEGER PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
status BOOLEAN NOT NULL DEFAULT TRUE,
url_ical TEXT,
time_zone TEXT
);
CREATE TABLE IF NOT EXISTS alarms (
user_id INTEGER PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
daily BOOLEAN NOT NULL DEFAULT FALSE,
start BOOLEAN NOT NULL DEFAULT TRUE,
alarm_1 INTEGER,
alarm_2 INTEGER,
status_2 BOOLEAN NOT NULL DEFAULT TRUE
);
ALTER TABLE alarms ADD COLUMN IF NOT EXISTS changes BOOLEAN NOT NULL DEFAULT TRUE;
"""
class Users: def connect(dsn):
def __init__(self, database): """Пул соединений сам переподключается, если PostgreSQL перезапускали"""
"""Подключаемся к БД и сохраняем курсор соединения""" pool = ConnectionPool(dsn, min_size=1, max_size=4, kwargs={"autocommit": True}, open=True)
self.connection = sqlite3.connect(database) with pool.connection() as conn:
self.cursor = self.connection.cursor() conn.execute(SCHEMA)
return pool
# на новой установке (например, в Docker с пустым томом) таблиц ещё нет
with self.connection:
self.cursor.execute("CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL)")
self.cursor.execute("CREATE TABLE IF NOT EXISTS url (user_id INTEGER NOT NULL, "
"status BOOLEAN NOT NULL DEFAULT (True), url_ical STRING, time_zone STRING)")
class _Base:
def __init__(self, pool):
self.pool = pool
def _one(self, query, args=()):
with self.pool.connection() as conn:
row = conn.execute(query, args).fetchone()
return row[0] if row else None
def _all(self, query, args=()):
with self.pool.connection() as conn:
return conn.execute(query, args).fetchall()
def _run(self, query, args=()):
with self.pool.connection() as conn:
conn.execute(query, args)
class Users(_Base):
# КОМАНДЫ USER # КОМАНДЫ USER
def user_exists(self, user_id): def user_exists(self, user_id):
"""Проверяем, есть ли уже пользователь в базе""" """Проверяем, есть ли уже пользователь в базе (по id Telegram)"""
with self.connection: return bool(self._one("SELECT 1 FROM users WHERE tg_id = %s", (user_id,)))
result = self.cursor.execute('SELECT * FROM `user` WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result))
def all_users(self): def all_users(self):
"""Список айди""" """Список внутренних номеров"""
with self.connection: return self._all("SELECT id FROM users ORDER BY id")
return self.cursor.execute('SELECT `id` FROM `user`').fetchall()
def add_user(self, user_id): def add_user(self, user_id):
"""Добавляем нового пользователя""" """Добавляем нового пользователя"""
with self.connection: self._run("INSERT INTO users (tg_id) VALUES (%s) ON CONFLICT DO NOTHING", (user_id,))
return self.cursor.execute("INSERT INTO `user` (`user_id`) VALUES(?)", (user_id,))
def get_user_id(self, user_id): def get_user_id(self, user_id):
"""Получаем короткое айди юзера""" """Внутренний номер по id Telegram"""
with self.connection: return self._one("SELECT id FROM users WHERE tg_id = %s", (user_id,))
return self.cursor.execute('SELECT `id` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def get_first_user_id(self, user_id): def get_first_user_id(self, user_id):
"""Получаем длинное айди юзера""" """id Telegram по внутреннему номеру"""
with self.connection: return self._one("SELECT tg_id FROM users WHERE id = %s", (user_id,))
return self.cursor.execute('SELECT `user_id` FROM `user` WHERE `id` = ?', (user_id,)).fetchone()[0]
# КОМАНДЫ URL # КОМАНДЫ URL
def url_exists(self, user_id): def url_exists(self, user_id):
"""Проверяем, есть ли данные уже в базе""" return bool(self._one("SELECT 1 FROM urls WHERE user_id = %s", (user_id,)))
with self.connection:
result = self.cursor.execute('SELECT * FROM `url` WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result))
def add_url(self, user_id, url_ical, time_zone): def add_url(self, user_id, url_ical, time_zone):
"""Добавляем ссылку на календарь""" """Добавляем ссылку на календарь"""
with self.connection: self._run("INSERT INTO urls (user_id, url_ical, time_zone) VALUES (%s, %s, %s)", (user_id, url_ical, time_zone))
return self.cursor.execute("INSERT INTO `url` (`user_id`, `url_ical`, `time_zone`) VALUES(?, ?, ?)",
(user_id, url_ical, time_zone))
def update_status(self, user_id): def update_status(self, user_id):
"""Обновляем статус рассылки уведомлений""" """Переключаем рассылку уведомлений"""
with self.connection: self._run("UPDATE urls SET status = NOT status WHERE user_id = %s", (user_id,))
status = self.cursor.execute('SELECT `status` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `url` SET `status` = ? WHERE `user_id` = ?", (not status, user_id))
def update_url(self, user_id, url_ical, time_zone): def update_url(self, user_id, url_ical, time_zone):
"""Обновляем ссылку и часовой пояс в базе""" """Обновляем ссылку и часовой пояс"""
with self.connection: self._run("UPDATE urls SET url_ical = %s, time_zone = %s WHERE user_id = %s", (url_ical, time_zone, user_id))
return self.cursor.execute("UPDATE `url` SET `url_ical` = ?, `time_zone` = ? WHERE `user_id` = ?",
(url_ical, time_zone, user_id))
def get_status(self, user_id): def get_status(self, user_id):
"""Получаем статус работы""" return self._one("SELECT status FROM urls WHERE user_id = %s", (user_id,))
with self.connection:
return self.cursor.execute('SELECT `status` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def get_url(self, user_id): def get_url(self, user_id):
"""Получаем ссылку на календарь""" return self._one("SELECT url_ical FROM urls WHERE user_id = %s", (user_id,))
with self.connection:
return self.cursor.execute('SELECT `url_ical` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def delete_url(self, user_id): def delete_url(self, user_id):
"""Удаляем подписку на календарь""" """Удаляем подписку на календарь"""
with self.connection: self._run("DELETE FROM urls WHERE user_id = %s", (user_id,))
return self.cursor.execute("DELETE FROM `url` WHERE `user_id` = ?", (user_id,))
def get_tz(self, user_id): def get_tz(self, user_id):
"""Получаем указанный в календаре часовой пояс""" """Часовой пояс календаря"""
with self.connection: return timezone(self._one("SELECT time_zone FROM urls WHERE user_id = %s", (user_id,)))
return timezone(self.cursor.execute('SELECT `time_zone` FROM `url` WHERE `user_id` = ?',
(user_id,)).fetchone()[0])
# ЗАКРЫТИЕ ВЫЗОВА
def close(self):
"""Закрываем соединение с БД"""
self.connection.close()
class Clock: class Clock(_Base):
def __init__(self, database):
"""Подключаемся к БД и сохраняем курсор соединения"""
self.connection = sqlite3.connect(database)
self.cursor = self.connection.cursor()
with self.connection:
self.cursor.execute("CREATE TABLE IF NOT EXISTS alarm (user_id INTEGER NOT NULL, "
"daily BOOLEAN NOT NULL DEFAULT (False), start BOOLEAN NOT NULL DEFAULT (True), "
"alarm_1 INTEGER, alarm_2 INTEGER, status_2 BOOLEAN NOT NULL DEFAULT (True))")
# КОМАНДЫ ALARM # КОМАНДЫ ALARM
def clock_exists(self, user_id): def clock_exists(self, user_id):
"""Проверяем, есть ли данные уже в базе""" return bool(self._one("SELECT 1 FROM alarms WHERE user_id = %s", (user_id,)))
with self.connection:
result = self.cursor.execute('SELECT * FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchall()
return bool(len(result))
def add_clock(self, user_id): def add_clock(self, user_id):
"""Добавляем параметры уведомления""" """Добавляем параметры уведомления: напоминания за 15 и 5 минут"""
with self.connection: self._run("INSERT INTO alarms (user_id, alarm_1, alarm_2) VALUES (%s, 15, 5) ON CONFLICT DO NOTHING", (user_id,))
return self.cursor.execute("INSERT INTO `alarm` (`user_id`, `alarm_1`, `alarm_2`) VALUES(?, ?, ?)",
(user_id, 15, 5))
def delete_clock(self, user_id): def delete_clock(self, user_id):
"""Удаляем настройки оповещений""" self._run("DELETE FROM alarms WHERE user_id = %s", (user_id,))
with self.connection:
return self.cursor.execute("DELETE FROM `alarm` WHERE `user_id` = ?", (user_id,))
# КОМАНДЫ ПОЛУЧЕНИЯ ССЫЛОК
def get_alarm(self, user_id): def get_alarm(self, user_id):
"""Получаем задержки таймера""" """Задержки напоминаний (первое, второе)"""
with self.connection: rows = self._all("SELECT alarm_1, alarm_2 FROM alarms WHERE user_id = %s", (user_id,))
return self.cursor.execute('SELECT `alarm_1`, `alarm_2` FROM `alarm` WHERE `user_id` = ?', return tuple(rows[0]) if rows else None
(user_id,)).fetchone()
def get_start(self, user_id): def get_start(self, user_id):
"""Получаем статус уведомления в момент исполнения""" return self._one("SELECT start FROM alarms WHERE user_id = %s", (user_id,))
with self.connection:
return self.cursor.execute('SELECT `start` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
def get_daily(self, user_id): def get_daily(self, user_id):
"""Получаем статус ежедневного уведомления""" return self._one("SELECT daily FROM alarms WHERE user_id = %s", (user_id,))
with self.connection:
return self.cursor.execute('SELECT `daily` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
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,))
with self.connection:
return self.cursor.execute('SELECT `status_2` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
# КОМАНДЫ ОБНОВЛЕНИЯ БУЛЕВЫХ СТАТУСОВ 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,))
with self.connection:
daily = self.cursor.execute('SELECT `daily` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `alarm` SET `daily` = ? WHERE `user_id` = ?", (not daily, user_id))
def update_start(self, user_id): def update_start(self, user_id):
"""Обновляем статус уведомления в момент исполнения""" self._run("UPDATE alarms SET start = NOT start WHERE user_id = %s", (user_id,))
with self.connection:
start = self.cursor.execute('SELECT `start` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `alarm` SET `start` = ? WHERE `user_id` = ?", (not start, user_id))
# КОМАНДЫ ОБНОВЛЕНИЯ ВРЕМЕННОГО ДИАПАЗОНА
def update_alarm1(self, user_id, alarm_1): def update_alarm1(self, user_id, alarm_1):
"""Обновляем время первого оповещения""" self._run("UPDATE alarms SET alarm_1 = %s WHERE user_id = %s", (alarm_1, user_id))
with self.connection:
return self.cursor.execute("UPDATE `alarm` SET `alarm_1` = ? WHERE `user_id` = ?", (alarm_1, user_id))
def update_alarm2(self, user_id, alarm_2): def update_alarm2(self, user_id, alarm_2):
"""Обновляем время второго оповещения""" self._run("UPDATE alarms SET alarm_2 = %s WHERE user_id = %s", (alarm_2, user_id))
with self.connection:
return self.cursor.execute("UPDATE `alarm` SET `alarm_2` = ? WHERE `user_id` = ?", (alarm_2, user_id))
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,))
with self.connection:
status_2 = self.cursor.execute('SELECT `status_2` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
return self.cursor.execute("UPDATE `alarm` SET `status_2` = ? WHERE `user_id` = ?", (not status_2, user_id))
# ЗАКРЫТИЕ ВЫЗОВА def update_changes(self, user_id):
def close(self): self._run("UPDATE alarms SET changes = NOT changes WHERE user_id = %s", (user_id,))
"""Закрываем соединение с БД"""
self.connection.close()

View file

@ -5,11 +5,28 @@ services:
env_file: .env env_file: .env
environment: environment:
TZ: Europe/Moscow TZ: Europe/Moscow
DATABASE_URL: postgresql://yacal:yacal@db:5432/yacal
volumes: volumes:
- icals:/app/data/icals - icals:/app/data/icals
- db:/app/db depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: yacal
POSTGRES_PASSWORD: yacal
POSTGRES_DB: yacal
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U yacal"]
interval: 5s
retries: 10
restart: unless-stopped restart: unless-stopped
volumes: volumes:
icals: icals:
db: pgdata:

View file

View file

@ -3,3 +3,4 @@ aiohttp==3.13.2
icalendar==6.3.2 icalendar==6.3.2
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
pytz==2025.2 pytz==2025.2
psycopg[binary,pool]==3.3.6

60
scripts/migrate_sqlite.py Normal file
View file

@ -0,0 +1,60 @@
"""Перенос данных Я.Календаркина из SQLite (users.db, clock.db) в PostgreSQL.
python scripts/migrate_sqlite.py --sqlite-dir /path/to/db --dsn postgresql://…
Внутренние номера пользователей сохраняются: на них ссылаются файлы календарей data/icals/<номер>.ics.
"""
import argparse
import os
import sqlite3
import sys
import psycopg
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "code"))
from sql import SCHEMA # noqa: E402
def migrate(sqlite_dir, dsn, force=False):
users_db = sqlite3.connect(os.path.join(sqlite_dir, "users.db"))
clock_db = sqlite3.connect(os.path.join(sqlite_dir, "clock.db"))
users = users_db.execute("SELECT id, user_id FROM user ORDER BY id").fetchall()
urls = users_db.execute("SELECT user_id, status, url_ical, time_zone FROM url").fetchall()
alarms = clock_db.execute("SELECT user_id, daily, start, alarm_1, alarm_2, status_2 FROM alarm").fetchall()
known = {u[0] for u in users}
with psycopg.connect(dsn) as conn:
conn.execute(SCHEMA)
if conn.execute("SELECT count(*) FROM users").fetchone()[0]:
if not force:
raise SystemExit("В PostgreSQL уже есть данные — перенос остановлен (--force очистит таблицы)")
conn.execute("TRUNCATE users, urls, alarms RESTART IDENTITY CASCADE")
with conn.cursor() as cur:
cur.executemany("INSERT INTO users (id, tg_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", users)
cur.executemany("INSERT INTO urls (user_id, status, url_ical, time_zone) VALUES (%s, %s, %s, %s) "
"ON CONFLICT DO NOTHING",
[(u, bool(s), url, tz) for u, s, url, tz in urls if u in known])
cur.executemany("INSERT INTO alarms (user_id, daily, start, alarm_1, alarm_2, status_2) "
"VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT DO NOTHING",
[(u, bool(d), bool(s), a1, a2, bool(s2)) for u, d, s, a1, a2, s2 in alarms if u in known])
cur.execute("SELECT setval(pg_get_serial_sequence('users', 'id'), GREATEST((SELECT max(id) FROM users), 1))")
report = {name: (src, conn.execute(f"SELECT count(*) FROM {name}").fetchone()[0])
for name, src in (("users", len(users)), ("urls", len(urls)), ("alarms", len(alarms)))}
for name, (src, dst) in report.items():
print(f"{name:7} SQLite {src:>5} PostgreSQL {dst:>5} {'ok' if src == dst else 'MISMATCH'}")
return all(a == b for a, b in report.values())
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--sqlite-dir", required=True)
parser.add_argument("--dsn", default=os.getenv("DATABASE_URL"))
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
sys.exit(0 if migrate(args.sqlite_dir, args.dsn, args.force) else 1)
if __name__ == "__main__":
main()

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])

73
tests/test_sql.py Normal file
View file

@ -0,0 +1,73 @@
import os
import sqlite3
import sys
import psycopg
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
DSN = os.environ.get("TEST_DATABASE_URL")
pytestmark = pytest.mark.skipif(not DSN, reason="нужен TEST_DATABASE_URL (PostgreSQL)")
@pytest.fixture
def pool():
with psycopg.connect(DSN, autocommit=True) as conn:
conn.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public;")
import sql
p = sql.connect(DSN)
yield p
p.close()
def test_users_and_alarms(pool):
from sql import Clock, Users
du, dc = Users(pool), Clock(pool)
du.add_user(555)
du.add_user(555) # повторно — без ошибки
uid = du.get_user_id(555)
assert du.user_exists(555) and du.get_first_user_id(uid) == 555
du.add_url(uid, "https://calendar.yandex.ru/x", "Asia/Krasnoyarsk")
dc.add_clock(uid)
assert du.get_tz(uid).zone == "Asia/Krasnoyarsk" and du.get_status(uid) is True
du.update_status(uid)
assert du.get_status(uid) is False
assert dc.get_alarm(uid) == (15, 5) and dc.get_start(uid) is True and dc.get_daily(uid) is False
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,)]
def test_migration(pool, tmp_path):
import migrate_sqlite
users = sqlite3.connect(tmp_path / "users.db")
users.executescript("""
CREATE TABLE user (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL);
CREATE TABLE url (user_id INTEGER NOT NULL, status BOOLEAN NOT NULL DEFAULT (True), url_ical STRING, time_zone STRING);
INSERT INTO user VALUES (1, 111), (5, 555);
INSERT INTO url VALUES (5, 1, 'https://calendar.yandex.ru/a', 'Europe/Moscow');
""")
users.commit()
clock = sqlite3.connect(tmp_path / "clock.db")
clock.executescript("""
CREATE TABLE alarm (user_id INTEGER NOT NULL, daily BOOLEAN NOT NULL DEFAULT (False), start BOOLEAN NOT NULL DEFAULT (True),
alarm_1 INTEGER, alarm_2 INTEGER, status_2 BOOLEAN NOT NULL DEFAULT (True));
INSERT INTO alarm VALUES (5, 1, 0, 20, 10, 1);
""")
clock.commit()
assert migrate_sqlite.migrate(str(tmp_path), DSN, force=True)
from sql import Clock, Users
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