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

Хранилище — PostgreSQL вместо двух файлов SQLite

code/sql.py на psycopg 3 с пулом соединений (переподключается сам); таблицы users, urls,
alarms — в PostgreSQL user и url зарезервированы. Внутренние номера пользователей сохраняются:
на них ссылаются файлы календарей data/icals/<номер>.ics. scripts/migrate_sqlite.py переносит
старые базы со сверкой (проверено на копии рабочих: 25 пользователей, 9 подписок).
Тесты хранилища и переноса — на PostgreSQL в CI; compose поднимает бота вместе с базой.
This commit is contained in:
Деев Егор Викторович 2026-10-06 11:46:39 +00:00
parent 5fdf39e0b5
commit abafd7a55d
13 changed files with 275 additions and 140 deletions

View file

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

View file

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

1
.gitignore vendored
View file

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

View file

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

View file

@ -12,7 +12,7 @@ bot speaks Russian.
**Status:** personal project, maintained · bot [@calendarkin_ybot](https://t.me/calendarkin_ybot) **Status:** personal project, maintained · bot [@calendarkin_ybot](https://t.me/calendarkin_ybot)
**Stack:** Python 3.12 · aiogram 3 · aiohttp · icalendar · python-dateutil · pytz · SQLite · Docker **Stack:** Python 3.12 · aiogram 3 · aiohttp · icalendar · python-dateutil · pytz · PostgreSQL · Docker
## Features ## Features
@ -45,9 +45,11 @@ docker compose up -d
``` ```
Prebuilt image: `docker pull ghcr.io/edeev/y.calendarkin` or `docker pull dcr.deev.su/edeev/y.calendarkin`. Prebuilt image: `docker pull ghcr.io/edeev/y.calendarkin` or `docker pull dcr.deev.su/edeev/y.calendarkin`.
The SQLite databases are created on first start. PostgreSQL tables are created on first start. Data from the old version (SQLite `users.db` and `clock.db`)
is moved by `python scripts/migrate_sqlite.py --sqlite-dir path/to/db --dsn postgresql://…`.
Without Docker: Python 3.12, `pip install -r requirements.txt`, then `cd code && BOT_TOKEN=… python bot.py`. Without Docker: Python 3.10+, PostgreSQL, `pip install -r requirements.txt`, then
`cd code && BOT_TOKEN=… DATABASE_URL=postgresql://… python bot.py`.
## How it works ## How it works
@ -55,7 +57,8 @@ Without Docker: Python 3.12, `pip install -r requirements.txt`, then `cd code &&
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/handlers.py commands and receiving the link code/handlers.py commands and receiving the link
code/script.py link validation, download, iCal and recurrence parsing, notification text code/script.py link validation, download, iCal and recurrence parsing, notification text
code/sql.py users, subscriptions and reminder settings (SQLite) code/sql.py users, subscriptions and reminder settings (PostgreSQL)
scripts/ migration from SQLite
``` ```
Only `https://calendar.yandex.*` links are accepted, so the bot never downloads from arbitrary addresses. Only `https://calendar.yandex.*` links are accepted, so the bot never downloads from arbitrary addresses.
@ -68,7 +71,7 @@ pip install -r requirements-dev.txt
ruff check --select E9,F code tests && pytest ruff check --select E9,F code tests && pytest
``` ```
The tests cover calendar parsing: today's events, recurrences, `UNTIL` in different formats, link The tests (storage on PostgreSQL from `TEST_DATABASE_URL`) cover calendar parsing: today's events, recurrences, `UNTIL` in different formats, link
validation and the notification text. The Docker image is built on `v*` tags and published to GitHub validation and the notification text. The Docker image is built on `v*` tags and published to GitHub
Packages and `dcr.deev.su`. Packages and `dcr.deev.su`.

View file

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

View file

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

View file

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

View file

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

View file

View file

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

60
scripts/migrate_sqlite.py Normal file
View file

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

69
tests/test_sql.py Normal file
View file

@ -0,0 +1,69 @@
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
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
du.add_user(777)
assert du.get_user_id(777) == 6