From abafd7a55dcf7130968dc0f75b136bd6eac755e9 Mon Sep 17 00:00:00 2001 From: Egor Deev Date: Tue, 6 Oct 2026 11:46:39 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A5=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D1=89=D0=B5=20=E2=80=94=20PostgreSQL=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20=D0=B4=D0=B2=D1=83=D1=85=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D0=BE=D0=B2=20SQLite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code/sql.py на psycopg 3 с пулом соединений (переподключается сам); таблицы users, urls, alarms — в PostgreSQL user и url зарезервированы. Внутренние номера пользователей сохраняются: на них ссылаются файлы календарей data/icals/<номер>.ics. scripts/migrate_sqlite.py переносит старые базы со сверкой (проверено на копии рабочих: 25 пользователей, 9 подписок). Тесты хранилища и переноса — на PostgreSQL в CI; compose поднимает бота вместе с базой. --- .env.example | 2 + .github/workflows/ci.yml | 14 ++- .gitignore | 1 - Dockerfile | 6 +- README.en.md | 13 ++- README.md | 13 ++- code/init.py | 8 +- code/sql.py | 207 ++++++++++++++++---------------------- compose.yaml | 21 +++- db/.gitkeep | 0 requirements.txt | 1 + scripts/migrate_sqlite.py | 60 +++++++++++ tests/test_sql.py | 69 +++++++++++++ 13 files changed, 275 insertions(+), 140 deletions(-) delete mode 100644 db/.gitkeep create mode 100644 scripts/migrate_sqlite.py create mode 100644 tests/test_sql.py diff --git a/.env.example b/.env.example index 7fcca50..ebdda91 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ # Токен бота от @BotFather BOT_TOKEN=123456:your-token +# База PostgreSQL (в compose.yaml задаётся автоматически) +DATABASE_URL=postgresql://yacal:yacal@localhost:5432/yacal diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4fb84e..93a64a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" diff --git a/.gitignore b/.gitignore index cc4f60b..f949b8d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,4 @@ __pycache__/ *.pyc .pytest_cache/ -db/*.db data/icals/*.ics diff --git a/Dockerfile b/Dockerfile index 57fcd7c..4d75710 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.en.md b/README.en.md index b0041c1..0d6d1f1 100644 --- a/README.en.md +++ b/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 @@ -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`. -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 @@ -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/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,7 +71,7 @@ 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 +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 Packages and `dcr.deev.su`. diff --git a/README.md b/README.md index 8ae19ef..272624e 100644 --- a/README.md +++ b/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 ## Возможности @@ -56,9 +56,11 @@ docker compose up -d ``` Готовый образ: `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/handlers.py команды и приём ссылки code/script.py проверка ссылки, скачивание, разбор iCal и повторений, текст уведомления -code/sql.py пользователи, подписки и настройки напоминаний (SQLite) +code/sql.py пользователи, подписки и настройки напоминаний (PostgreSQL) +scripts/ перенос данных из SQLite ``` Принимаются только ссылки `https://calendar.yandex.*`: бот не скачивает файлы по произвольным адресам. @@ -80,7 +83,7 @@ pip install -r requirements-dev.txt ruff check --select E9,F code tests && pytest ``` -Тесты проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку +Тесты (хранилище — на PostgreSQL из `TEST_DATABASE_URL`) проверяют разбор календаря: события на сегодня, повторения, `UNTIL` в разных форматах, проверку ссылки и текст уведомления. Docker-образ собирается по тегу `v*` и публикуется в GitHub Packages и `dcr.deev.su`. diff --git a/code/init.py b/code/init.py index 5f5beeb..40a20e2 100644 --- a/code/init.py +++ b/code/init.py @@ -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) diff --git a/code/sql.py b/code/sql.py index 7a19882..4cf208c 100644 --- a/code/sql.py +++ b/code/sql.py @@ -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 -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 __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 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)) - - # ЗАКРЫТИЕ ВЫЗОВА - def close(self): - """Закрываем соединение с БД""" - self.connection.close() + self._run("UPDATE alarms SET status_2 = NOT status_2 WHERE user_id = %s", (user_id,)) diff --git a/compose.yaml b/compose.yaml index 2bf2177..c383414 100644 --- a/compose.yaml +++ b/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: diff --git a/db/.gitkeep b/db/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/requirements.txt b/requirements.txt index a7e0587..b10ef81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/migrate_sqlite.py b/scripts/migrate_sqlite.py new file mode 100644 index 0000000..2c7d8ff --- /dev/null +++ b/scripts/migrate_sqlite.py @@ -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() diff --git a/tests/test_sql.py b/tests/test_sql.py new file mode 100644 index 0000000..7aeafc4 --- /dev/null +++ b/tests/test_sql.py @@ -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