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