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

Compare commits

..

No commits in common. "main" and "v2.0.0" have entirely different histories.
main ... v2.0.0

19 changed files with 156 additions and 629 deletions

View file

@ -1,4 +1,2 @@
# Токен бота от @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,28 +8,16 @@ 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 scripts - run: ruff check --select E9,F code tests
- 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,11 +19,17 @@ 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,4 +2,5 @@
__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 \ && mkdir -p data/icals db \
&& chown -R app:app /app && chown -R app:app /app
USER app USER app
VOLUME ["/app/data/icals"] VOLUME ["/app/data/icals", "/app/db"]
# пути к данным в коде — относительно папки code/ (../data) # пути к данным в коде — относительно папки code/ (../data, ../db)
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 · PostgreSQL · Docker **Stack:** Python 3.12 · aiogram 3 · aiohttp · icalendar · python-dateutil · pytz · SQLite · Docker
## Features ## Features
@ -21,7 +21,6 @@ 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
@ -32,7 +31,6 @@ 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 |
@ -46,22 +44,18 @@ 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 git.deev.su/edeev/y.calendarkin`. Prebuilt image: `docker pull ghcr.io/edeev/y.calendarkin` or `docker pull dcr.deev.su/edeev/y.calendarkin`.
PostgreSQL tables are created on first start. Data from the old version (SQLite `users.db` and `clock.db`) The SQLite databases are created on first start.
is moved by `python scripts/migrate_sqlite.py --sqlite-dir path/to/db --dsn postgresql://…`.
Without Docker: Python 3.10+, PostgreSQL, `pip install -r requirements.txt`, then Without Docker: Python 3.12, `pip install -r requirements.txt`, then `cd code && BOT_TOKEN=… python bot.py`.
`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 (PostgreSQL) code/sql.py users, subscriptions and reminder settings (SQLite)
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.
@ -74,9 +68,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 (storage on PostgreSQL from `TEST_DATABASE_URL`) cover calendar parsing: today's events, recurrences, `UNTIL` in different formats, link The tests 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 validation and the notification text. The Docker image is built on `v*` tags and published to GitHub
Packages and `git.deev.su`. Packages and `dcr.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 · PostgreSQL · Docker **Стек:** Python 3.12 · aiogram 3 · aiohttp · icalendar · python-dateutil · pytz · SQLite · Docker
## Возможности ## Возможности
@ -20,7 +20,6 @@ Telegram-бот, который присылает уведомления о с
- Сводка на день в 8:00 по часовому поясу календаря (`/daily`) - Сводка на день в 8:00 по часовому поясу календаря (`/daily`)
- Два напоминания перед событием: по умолчанию за 15 и 5 минут, меняются в `/edit_alarm` - Два напоминания перед событием: по умолчанию за 15 и 5 минут, меняются в `/edit_alarm`
- Сообщение в момент начала события (`/moment`) - Сообщение в момент начала события (`/moment`)
- Уведомление, если в календаре появилось новое событие, а предстоящее удалили или перенесли (`/changes`)
- Календарь обновляется раз в 13 минут; если обновление не удалось, остаётся прошлая версия - Календарь обновляется раз в 13 минут; если обновление не удалось, остаётся прошлая версия
Пример уведомления: Пример уведомления:
@ -43,7 +42,6 @@ Telegram-бот, который присылает уведомления о с
| `/list` | события на сегодня | | `/list` | события на сегодня |
| `/daily` | включить или выключить утреннюю сводку | | `/daily` | включить или выключить утреннюю сводку |
| `/moment` | включить или выключить сообщение в момент начала | | `/moment` | включить или выключить сообщение в момент начала |
| `/changes` | включить или выключить уведомления о новых, удалённых и перенесённых событиях |
| `/get_alarm`, `/edit_alarm` | посмотреть и изменить время напоминаний | | `/get_alarm`, `/edit_alarm` | посмотреть и изменить время напоминаний |
| `/stop_alarm` | включить или выключить второе напоминание | | `/stop_alarm` | включить или выключить второе напоминание |
| `/notif` | приостановить или возобновить все уведомления | | `/notif` | приостановить или возобновить все уведомления |
@ -57,22 +55,18 @@ cp .env.example .env # BOT_TOKEN от @BotFather
docker compose up -d docker compose up -d
``` ```
Готовый образ: `docker pull ghcr.io/edeev/y.calendarkin` или `docker pull git.deev.su/edeev/y.calendarkin`. Готовый образ: `docker pull ghcr.io/edeev/y.calendarkin` или `docker pull dcr.deev.su/edeev/y.calendarkin`.
Таблицы в PostgreSQL создаются при первом запуске. Данные старой версии (SQLite `users.db` и `clock.db`) Базы SQLite создаются при первом запуске.
переносит `python scripts/migrate_sqlite.py --sqlite-dir путь/к/db --dsn postgresql://…`.
Без Docker: Python 3.10+, PostgreSQL, `pip install -r requirements.txt`, затем Без Docker: Python 3.12, `pip install -r requirements.txt`, затем `cd code && BOT_TOKEN=… python bot.py`.
`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 пользователи, подписки и настройки напоминаний (PostgreSQL) code/sql.py пользователи, подписки и настройки напоминаний (SQLite)
scripts/ перенос данных из SQLite
``` ```
Принимаются только ссылки `https://calendar.yandex.*`: бот не скачивает файлы по произвольным адресам. Принимаются только ссылки `https://calendar.yandex.*`: бот не скачивает файлы по произвольным адресам.
@ -86,9 +80,9 @@ pip install -r requirements-dev.txt
ruff check --select E9,F code tests && pytest ruff check --select E9,F code tests && pytest
``` ```
Тесты (хранилище — на PostgreSQL из `TEST_DATABASE_URL`) проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку Тесты проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку
ссылки, текст уведомления и поиск изменений между выгрузками. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и ссылки и текст уведомления. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и
`git.deev.su`. `dcr.deev.su`.
## Лицензия ## Лицензия

View file

@ -3,8 +3,7 @@ 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
import changes from script import delta_time, download_ical, dt_now, message_form, text_ical
from script import delta_time, download_ical, dt_now, message_form, read_ical, text_ical
from handlers import router from handlers import router
@ -88,22 +87,11 @@ async def update(wait_for):
for (user_id,) in du.all_users(): for (user_id,) in du.all_users():
try: try:
if not (du.url_exists(user_id) and du.get_status(user_id)): if du.url_exists(user_id) and du.get_status(user_id):
continue await download_ical(du.get_url(user_id), user_id)
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:

View file

@ -1,165 +0,0 @@
"""Уведомления об изменениях в календаре: новые, удалённые и перенесённые события.
При каждом обновлении календаря старая и новая выгрузки сравниваются по 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,8 +58,7 @@ 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' '<b>/moment</b> - напоминание, приходящее в момент начала события\n\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'
@ -148,18 +147,6 @@ 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, connect from sql import Users, Clock
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())
pool = connect(os.getenv("DATABASE_URL", "postgresql://yacal:yacal@localhost:5432/yacal")) os.makedirs("../db", exist_ok=True)
du = Users(pool) du = Users('../db/users.db')
dc = Clock(pool) dc = Clock('../db/clock.db')

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,16 +42,6 @@ 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,157 +1,180 @@
"""Хранилище бота — 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;
"""
def connect(dsn): class Users:
"""Пул соединений сам переподключается, если PostgreSQL перезапускали""" def __init__(self, database):
pool = ConnectionPool(dsn, min_size=1, max_size=4, kwargs={"autocommit": True}, open=True) """Подключаемся к БД и сохраняем курсор соединения"""
with pool.connection() as conn: self.connection = sqlite3.connect(database)
conn.execute(SCHEMA) self.cursor = self.connection.cursor()
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)""" """Проверяем, есть ли уже пользователь в базе"""
return bool(self._one("SELECT 1 FROM users WHERE tg_id = %s", (user_id,))) with self.connection:
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):
"""Список внутренних номеров""" """Список айди"""
return self._all("SELECT id FROM users ORDER BY id") with self.connection:
return self.cursor.execute('SELECT `id` FROM `user`').fetchall()
def add_user(self, user_id): def add_user(self, user_id):
"""Добавляем нового пользователя""" """Добавляем нового пользователя"""
self._run("INSERT INTO users (tg_id) VALUES (%s) ON CONFLICT DO NOTHING", (user_id,)) with self.connection:
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""" """Получаем короткое айди юзера"""
return self._one("SELECT id FROM users WHERE tg_id = %s", (user_id,)) with self.connection:
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 по внутреннему номеру""" """Получаем длинное айди юзера"""
return self._one("SELECT tg_id FROM users WHERE id = %s", (user_id,)) with self.connection:
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):
"""Добавляем ссылку на календарь""" """Добавляем ссылку на календарь"""
self._run("INSERT INTO urls (user_id, url_ical, time_zone) VALUES (%s, %s, %s)", (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))
def update_status(self, user_id): def update_status(self, user_id):
"""Переключаем рассылку уведомлений""" """Обновляем статус рассылки уведомлений"""
self._run("UPDATE urls SET status = NOT status WHERE user_id = %s", (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))
def update_url(self, user_id, url_ical, time_zone): def update_url(self, user_id, url_ical, time_zone):
"""Обновляем ссылку и часовой пояс""" """Обновляем ссылку и часовой пояс в базе"""
self._run("UPDATE urls SET url_ical = %s, time_zone = %s WHERE user_id = %s", (url_ical, time_zone, user_id)) with self.connection:
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):
"""Удаляем подписку на календарь""" """Удаляем подписку на календарь"""
self._run("DELETE FROM urls WHERE user_id = %s", (user_id,)) with self.connection:
return self.cursor.execute("DELETE FROM `url` WHERE `user_id` = ?", (user_id,))
def get_tz(self, user_id): def get_tz(self, user_id):
"""Часовой пояс календаря""" """Получаем указанный в календаре часовой пояс"""
return timezone(self._one("SELECT time_zone FROM urls WHERE user_id = %s", (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()
class Clock(_Base): 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))")
# КОМАНДЫ 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 минут""" """Добавляем параметры уведомления"""
self._run("INSERT INTO alarms (user_id, alarm_1, alarm_2) VALUES (%s, 15, 5) ON CONFLICT DO NOTHING", (user_id,)) with self.connection:
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):
"""Задержки напоминаний (первое, второе)""" """Получаем задержки таймера"""
rows = self._all("SELECT alarm_1, alarm_2 FROM alarms WHERE user_id = %s", (user_id,)) with self.connection:
return tuple(rows[0]) if rows else None return self.cursor.execute('SELECT `alarm_1`, `alarm_2` FROM `alarm` WHERE `user_id` = ?',
(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): # ЗАКРЫТИЕ ВЫЗОВА
self._run("UPDATE alarms SET changes = NOT changes WHERE user_id = %s", (user_id,)) def close(self):
"""Закрываем соединение с БД"""
self.connection.close()

View file

@ -5,28 +5,11 @@ 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
depends_on: - db:/app/db
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:
pgdata: db:

0
db/.gitkeep Normal file
View file

View file

@ -3,4 +3,3 @@ 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

View file

@ -1,60 +0,0 @@
"""Перенос данных Я.Календаркина из 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()

View file

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

View file

@ -1,73 +0,0 @@
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