mirror of
https://github.com/EDeev/y.calendarkin.git
synced 2026-10-08 00:39:27 +03:00
Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4055242ddf | |||
| 3db58a13ab | |||
| abafd7a55d |
19 changed files with 629 additions and 156 deletions
|
|
@ -1,2 +1,4 @@
|
|||
# Токен бота от @BotFather
|
||||
BOT_TOKEN=123456:your-token
|
||||
# База PostgreSQL (в compose.yaml задаётся автоматически)
|
||||
DATABASE_URL=postgresql://yacal:yacal@localhost:5432/yacal
|
||||
|
|
|
|||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -8,16 +8,28 @@ on:
|
|||
jobs:
|
||||
check:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- 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
|
||||
- name: Бот импортируется
|
||||
working-directory: code
|
||||
env:
|
||||
BOT_TOKEN: "123456:TEST"
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/yacal_test
|
||||
run: python -c "import bot"
|
||||
|
|
|
|||
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
|
|
@ -19,17 +19,11 @@ jobs:
|
|||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: dcr.deev.su
|
||||
username: ${{ secrets.ZOT_USERNAME }}
|
||||
password: ${{ secrets.ZOT_PASSWORD }}
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/edeev/y.calendarkin
|
||||
dcr.deev.su/edeev/y.calendarkin
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,5 +2,4 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
db/*.db
|
||||
data/icals/*.ics
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
COPY code/ code/
|
||||
COPY data/photo_edit_alarm.jpg data/
|
||||
RUN useradd --create-home --uid 1000 app \
|
||||
&& mkdir -p data/icals db \
|
||||
&& mkdir -p data/icals \
|
||||
&& chown -R app:app /app
|
||||
USER app
|
||||
VOLUME ["/app/data/icals", "/app/db"]
|
||||
VOLUME ["/app/data/icals"]
|
||||
|
||||
# пути к данным в коде — относительно папки code/ (../data, ../db)
|
||||
# пути к данным в коде — относительно папки code/ (../data)
|
||||
WORKDIR /app/code
|
||||
CMD ["python", "bot.py"]
|
||||
|
|
|
|||
22
README.en.md
22
README.en.md
|
|
@ -12,7 +12,7 @@ bot speaks Russian.
|
|||
|
||||
**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
|
||||
|
||||
|
|
@ -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 |
|
||||
|
|
@ -44,18 +46,22 @@ cp .env.example .env # BOT_TOKEN from @BotFather
|
|||
docker compose up -d
|
||||
```
|
||||
|
||||
Prebuilt image: `docker pull ghcr.io/edeev/y.calendarkin` or `docker pull dcr.deev.su/edeev/y.calendarkin`.
|
||||
The SQLite databases are created on first start.
|
||||
Prebuilt image: `docker pull ghcr.io/edeev/y.calendarkin` or `docker pull git.deev.su/edeev/y.calendarkin`.
|
||||
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
|
||||
|
||||
```
|
||||
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 (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.
|
||||
|
|
@ -68,9 +74,9 @@ pip install -r requirements-dev.txt
|
|||
ruff check --select E9,F code tests && pytest
|
||||
```
|
||||
|
||||
The tests 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
|
||||
Packages and `dcr.deev.su`.
|
||||
The tests (storage on PostgreSQL from `TEST_DATABASE_URL`) cover calendar parsing: today's events, recurrences, `UNTIL` in different formats, link
|
||||
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
|
||||
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -11,7 +11,7 @@ Telegram-бот, который присылает уведомления о с
|
|||
|
||||
**Статус:** личный проект, поддерживается · бот [@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`)
|
||||
- Два напоминания перед событием: по умолчанию за 15 и 5 минут, меняются в `/edit_alarm`
|
||||
- Сообщение в момент начала события (`/moment`)
|
||||
- Уведомление, если в календаре появилось новое событие, а предстоящее удалили или перенесли (`/changes`)
|
||||
- Календарь обновляется раз в 13 минут; если обновление не удалось, остаётся прошлая версия
|
||||
|
||||
Пример уведомления:
|
||||
|
|
@ -42,6 +43,7 @@ Telegram-бот, который присылает уведомления о с
|
|||
| `/list` | события на сегодня |
|
||||
| `/daily` | включить или выключить утреннюю сводку |
|
||||
| `/moment` | включить или выключить сообщение в момент начала |
|
||||
| `/changes` | включить или выключить уведомления о новых, удалённых и перенесённых событиях |
|
||||
| `/get_alarm`, `/edit_alarm` | посмотреть и изменить время напоминаний |
|
||||
| `/stop_alarm` | включить или выключить второе напоминание |
|
||||
| `/notif` | приостановить или возобновить все уведомления |
|
||||
|
|
@ -55,18 +57,22 @@ cp .env.example .env # BOT_TOKEN от @BotFather
|
|||
docker compose up -d
|
||||
```
|
||||
|
||||
Готовый образ: `docker pull ghcr.io/edeev/y.calendarkin` или `docker pull dcr.deev.su/edeev/y.calendarkin`.
|
||||
Базы SQLite создаются при первом запуске.
|
||||
Готовый образ: `docker pull ghcr.io/edeev/y.calendarkin` или `docker pull git.deev.su/edeev/y.calendarkin`.
|
||||
Таблицы в 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/changes.py сравнение старой и новой выгрузки: новые, удалённые и перенесённые события
|
||||
code/handlers.py команды и приём ссылки
|
||||
code/script.py проверка ссылки, скачивание, разбор iCal и повторений, текст уведомления
|
||||
code/sql.py пользователи, подписки и настройки напоминаний (SQLite)
|
||||
code/sql.py пользователи, подписки и настройки напоминаний (PostgreSQL)
|
||||
scripts/ перенос данных из SQLite
|
||||
```
|
||||
|
||||
Принимаются только ссылки `https://calendar.yandex.*`: бот не скачивает файлы по произвольным адресам.
|
||||
|
|
@ -80,9 +86,9 @@ pip install -r requirements-dev.txt
|
|||
ruff check --select E9,F code tests && pytest
|
||||
```
|
||||
|
||||
Тесты проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку
|
||||
ссылки и текст уведомления. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и
|
||||
`dcr.deev.su`.
|
||||
Тесты (хранилище — на PostgreSQL из `TEST_DATABASE_URL`) проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку
|
||||
ссылки, текст уведомления и поиск изменений между выгрузками. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и
|
||||
`git.deev.su`.
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
|
|
|||
18
code/bot.py
18
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:
|
||||
|
|
|
|||
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>/notif</b> - команда, отключающая рассылку уведомлений, даже при наличии событий в календаре\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>/edit_alarm</b> - изменение времени оповещений\n'
|
||||
'<b>/stop_alarm</b> - команда, отключающая второе оповещение о событии\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)
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ from aiogram.fsm.storage.memory import MemoryStorage
|
|||
from aiogram.client.bot import DefaultBotProperties
|
||||
|
||||
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))
|
||||
dp = Dispatcher(storage=MemoryStorage())
|
||||
|
||||
os.makedirs("../db", exist_ok=True)
|
||||
du = Users('../db/users.db')
|
||||
dc = Clock('../db/clock.db')
|
||||
pool = connect(os.getenv("DATABASE_URL", "postgresql://yacal:yacal@localhost:5432/yacal"))
|
||||
du = Users(pool)
|
||||
dc = Clock(pool)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
213
code/sql.py
213
code/sql.py
|
|
@ -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
|
||||
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 __init__(self, database):
|
||||
"""Подключаемся к БД и сохраняем курсор соединения"""
|
||||
self.connection = sqlite3.connect(database)
|
||||
self.cursor = self.connection.cursor()
|
||||
def connect(dsn):
|
||||
"""Пул соединений сам переподключается, если PostgreSQL перезапускали"""
|
||||
pool = ConnectionPool(dsn, min_size=1, max_size=4, kwargs={"autocommit": True}, open=True)
|
||||
with pool.connection() as conn:
|
||||
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
|
||||
def user_exists(self, user_id):
|
||||
"""Проверяем, есть ли уже пользователь в базе"""
|
||||
with self.connection:
|
||||
result = self.cursor.execute('SELECT * FROM `user` WHERE `user_id` = ?', (user_id,)).fetchall()
|
||||
return bool(len(result))
|
||||
"""Проверяем, есть ли уже пользователь в базе (по id Telegram)"""
|
||||
return bool(self._one("SELECT 1 FROM users WHERE tg_id = %s", (user_id,)))
|
||||
|
||||
def all_users(self):
|
||||
"""Список айди"""
|
||||
with self.connection:
|
||||
return self.cursor.execute('SELECT `id` FROM `user`').fetchall()
|
||||
"""Список внутренних номеров"""
|
||||
return self._all("SELECT id FROM users ORDER BY id")
|
||||
|
||||
def add_user(self, user_id):
|
||||
"""Добавляем нового пользователя"""
|
||||
with self.connection:
|
||||
return self.cursor.execute("INSERT INTO `user` (`user_id`) VALUES(?)", (user_id,))
|
||||
self._run("INSERT INTO users (tg_id) VALUES (%s) ON CONFLICT DO NOTHING", (user_id,))
|
||||
|
||||
def get_user_id(self, user_id):
|
||||
"""Получаем короткое айди юзера"""
|
||||
with self.connection:
|
||||
return self.cursor.execute('SELECT `id` FROM `user` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
|
||||
"""Внутренний номер по id Telegram"""
|
||||
return self._one("SELECT id FROM users WHERE tg_id = %s", (user_id,))
|
||||
|
||||
def get_first_user_id(self, user_id):
|
||||
"""Получаем длинное айди юзера"""
|
||||
with self.connection:
|
||||
return self.cursor.execute('SELECT `user_id` FROM `user` WHERE `id` = ?', (user_id,)).fetchone()[0]
|
||||
"""id Telegram по внутреннему номеру"""
|
||||
return self._one("SELECT tg_id FROM users WHERE id = %s", (user_id,))
|
||||
|
||||
# КОМАНДЫ URL
|
||||
def url_exists(self, user_id):
|
||||
"""Проверяем, есть ли данные уже в базе"""
|
||||
with self.connection:
|
||||
result = self.cursor.execute('SELECT * FROM `url` WHERE `user_id` = ?', (user_id,)).fetchall()
|
||||
return bool(len(result))
|
||||
return bool(self._one("SELECT 1 FROM urls WHERE user_id = %s", (user_id,)))
|
||||
|
||||
def add_url(self, user_id, url_ical, time_zone):
|
||||
"""Добавляем ссылку на календарь"""
|
||||
with self.connection:
|
||||
return self.cursor.execute("INSERT INTO `url` (`user_id`, `url_ical`, `time_zone`) VALUES(?, ?, ?)",
|
||||
(user_id, url_ical, time_zone))
|
||||
self._run("INSERT INTO urls (user_id, url_ical, time_zone) VALUES (%s, %s, %s)", (user_id, url_ical, time_zone))
|
||||
|
||||
def update_status(self, user_id):
|
||||
"""Обновляем статус рассылки уведомлений"""
|
||||
with self.connection:
|
||||
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))
|
||||
"""Переключаем рассылку уведомлений"""
|
||||
self._run("UPDATE urls SET status = NOT status WHERE user_id = %s", (user_id,))
|
||||
|
||||
def update_url(self, user_id, url_ical, time_zone):
|
||||
"""Обновляем ссылку и часовой пояс в базе"""
|
||||
with self.connection:
|
||||
return self.cursor.execute("UPDATE `url` SET `url_ical` = ?, `time_zone` = ? WHERE `user_id` = ?",
|
||||
(url_ical, time_zone, user_id))
|
||||
"""Обновляем ссылку и часовой пояс"""
|
||||
self._run("UPDATE urls SET url_ical = %s, time_zone = %s WHERE user_id = %s", (url_ical, time_zone, user_id))
|
||||
|
||||
def get_status(self, user_id):
|
||||
"""Получаем статус работы"""
|
||||
with self.connection:
|
||||
return self.cursor.execute('SELECT `status` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
|
||||
return self._one("SELECT status FROM urls WHERE user_id = %s", (user_id,))
|
||||
|
||||
def get_url(self, user_id):
|
||||
"""Получаем ссылку на календарь"""
|
||||
with self.connection:
|
||||
return self.cursor.execute('SELECT `url_ical` FROM `url` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
|
||||
return self._one("SELECT url_ical FROM urls WHERE user_id = %s", (user_id,))
|
||||
|
||||
def delete_url(self, user_id):
|
||||
"""Удаляем подписку на календарь"""
|
||||
with self.connection:
|
||||
return self.cursor.execute("DELETE FROM `url` WHERE `user_id` = ?", (user_id,))
|
||||
self._run("DELETE FROM urls WHERE user_id = %s", (user_id,))
|
||||
|
||||
def get_tz(self, user_id):
|
||||
"""Получаем указанный в календаре часовой пояс"""
|
||||
with self.connection:
|
||||
return timezone(self.cursor.execute('SELECT `time_zone` FROM `url` WHERE `user_id` = ?',
|
||||
(user_id,)).fetchone()[0])
|
||||
|
||||
# ЗАКРЫТИЕ ВЫЗОВА
|
||||
def close(self):
|
||||
"""Закрываем соединение с БД"""
|
||||
self.connection.close()
|
||||
"""Часовой пояс календаря"""
|
||||
return timezone(self._one("SELECT time_zone FROM urls WHERE user_id = %s", (user_id,)))
|
||||
|
||||
|
||||
class Clock:
|
||||
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))")
|
||||
|
||||
class Clock(_Base):
|
||||
# КОМАНДЫ ALARM
|
||||
def clock_exists(self, user_id):
|
||||
"""Проверяем, есть ли данные уже в базе"""
|
||||
with self.connection:
|
||||
result = self.cursor.execute('SELECT * FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchall()
|
||||
return bool(len(result))
|
||||
return bool(self._one("SELECT 1 FROM alarms WHERE user_id = %s", (user_id,)))
|
||||
|
||||
def add_clock(self, user_id):
|
||||
"""Добавляем параметры уведомления"""
|
||||
with self.connection:
|
||||
return self.cursor.execute("INSERT INTO `alarm` (`user_id`, `alarm_1`, `alarm_2`) VALUES(?, ?, ?)",
|
||||
(user_id, 15, 5))
|
||||
"""Добавляем параметры уведомления: напоминания за 15 и 5 минут"""
|
||||
self._run("INSERT INTO alarms (user_id, alarm_1, alarm_2) VALUES (%s, 15, 5) ON CONFLICT DO NOTHING", (user_id,))
|
||||
|
||||
def delete_clock(self, user_id):
|
||||
"""Удаляем настройки оповещений"""
|
||||
with self.connection:
|
||||
return self.cursor.execute("DELETE FROM `alarm` WHERE `user_id` = ?", (user_id,))
|
||||
self._run("DELETE FROM alarms WHERE user_id = %s", (user_id,))
|
||||
|
||||
# КОМАНДЫ ПОЛУЧЕНИЯ ССЫЛОК
|
||||
def get_alarm(self, user_id):
|
||||
"""Получаем задержки таймера"""
|
||||
with self.connection:
|
||||
return self.cursor.execute('SELECT `alarm_1`, `alarm_2` FROM `alarm` WHERE `user_id` = ?',
|
||||
(user_id,)).fetchone()
|
||||
"""Задержки напоминаний (первое, второе)"""
|
||||
rows = self._all("SELECT alarm_1, alarm_2 FROM alarms WHERE user_id = %s", (user_id,))
|
||||
return tuple(rows[0]) if rows else None
|
||||
|
||||
def get_start(self, user_id):
|
||||
"""Получаем статус уведомления в момент исполнения"""
|
||||
with self.connection:
|
||||
return self.cursor.execute('SELECT `start` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
|
||||
return self._one("SELECT start FROM alarms WHERE user_id = %s", (user_id,))
|
||||
|
||||
def get_daily(self, user_id):
|
||||
"""Получаем статус ежедневного уведомления"""
|
||||
with self.connection:
|
||||
return self.cursor.execute('SELECT `daily` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
|
||||
return self._one("SELECT daily FROM alarms WHERE user_id = %s", (user_id,))
|
||||
|
||||
def get_status2(self, user_id):
|
||||
"""Получаем статус отправки второго уведомления"""
|
||||
with self.connection:
|
||||
return self.cursor.execute('SELECT `status_2` FROM `alarm` WHERE `user_id` = ?', (user_id,)).fetchone()[0]
|
||||
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):
|
||||
"""Обновляем статус ежедневного уведомления"""
|
||||
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))
|
||||
self._run("UPDATE alarms SET daily = NOT daily WHERE user_id = %s", (user_id,))
|
||||
|
||||
def update_start(self, 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))
|
||||
self._run("UPDATE alarms SET start = NOT start WHERE user_id = %s", (user_id,))
|
||||
|
||||
# КОМАНДЫ ОБНОВЛЕНИЯ ВРЕМЕННОГО ДИАПАЗОНА
|
||||
def update_alarm1(self, user_id, alarm_1):
|
||||
"""Обновляем время первого оповещения"""
|
||||
with self.connection:
|
||||
return self.cursor.execute("UPDATE `alarm` SET `alarm_1` = ? WHERE `user_id` = ?", (alarm_1, user_id))
|
||||
self._run("UPDATE alarms SET alarm_1 = %s WHERE user_id = %s", (alarm_1, user_id))
|
||||
|
||||
def update_alarm2(self, user_id, alarm_2):
|
||||
"""Обновляем время второго оповещения"""
|
||||
with self.connection:
|
||||
return self.cursor.execute("UPDATE `alarm` SET `alarm_2` = ? WHERE `user_id` = ?", (alarm_2, user_id))
|
||||
self._run("UPDATE alarms SET alarm_2 = %s WHERE user_id = %s", (alarm_2, user_id))
|
||||
|
||||
def update_status2(self, 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))
|
||||
self._run("UPDATE alarms SET status_2 = NOT status_2 WHERE user_id = %s", (user_id,))
|
||||
|
||||
# ЗАКРЫТИЕ ВЫЗОВА
|
||||
def close(self):
|
||||
"""Закрываем соединение с БД"""
|
||||
self.connection.close()
|
||||
def update_changes(self, user_id):
|
||||
self._run("UPDATE alarms SET changes = NOT changes WHERE user_id = %s", (user_id,))
|
||||
|
|
|
|||
21
compose.yaml
21
compose.yaml
|
|
@ -5,11 +5,28 @@ services:
|
|||
env_file: .env
|
||||
environment:
|
||||
TZ: Europe/Moscow
|
||||
DATABASE_URL: postgresql://yacal:yacal@db:5432/yacal
|
||||
volumes:
|
||||
- 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
|
||||
|
||||
volumes:
|
||||
icals:
|
||||
db:
|
||||
pgdata:
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ aiohttp==3.13.2
|
|||
icalendar==6.3.2
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2025.2
|
||||
psycopg[binary,pool]==3.3.6
|
||||
|
|
|
|||
60
scripts/migrate_sqlite.py
Normal file
60
scripts/migrate_sqlite.py
Normal 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
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])
|
||||
73
tests/test_sql.py
Normal file
73
tests/test_sql.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue