/
broot
/
flowee
Обзор
Документация
Войти
/
broot
/
flowee
Код
Пакеты
0
Релизы
2
Аналитика
Безопасность
dev
core/database.py
450 строк
16 KB
broot
Add minimal PySide2 main window with config, DB, and timer integration
29 июн 2026, 22:40
29 июн 2026, 22:40
5b18ca1
Код
Авторство
О чём код?
""" database.py - Модуль для работы с базой данных SQLite (Flowee). Обеспечивает: - инициализацию БД (таблицы, индексы, системные задачи) - CRUD-операции для задач и сессий - восстановление после аварийного завершения - обработку перехода через полночь """ import sqlite3 from datetime import datetime, date from typing import Optional, List, Dict # Версия схемы БД. Увеличивается при изменении структуры таблиц. LATEST_SCHEMA_VERSION = 1 def get_db_path(config: dict) -> str: """Возвращает путь к файлу базы данных из конфига или значение по умолчанию.""" return config.get("db_path", "worklog.db") def connect(db_path: str) -> sqlite3.Connection: """Создаёт подключение к БД с поддержкой внешних ключей.""" conn = sqlite3.connect(db_path) conn.execute("PRAGMA foreign_keys = ON") return conn def init_db(conn: sqlite3.Connection): """ Инициализирует базу данных: - создаёт таблицы, если их нет - добавляет системные задачи - выполняет миграции (пока заглушка, т.к. версия 1) """ _create_tables(conn) _insert_system_tasks(conn) _run_migrations(conn) conn.commit() def _create_tables(conn: sqlite3.Connection): """Создаёт все необходимые таблицы и индексы.""" conn.execute(""" CREATE TABLE IF NOT EXISTS metadata ( key TEXT PRIMARY KEY, value TEXT ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_date TEXT NOT NULL, description TEXT DEFAULT '', UNIQUE(name, created_date) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL REFERENCES tasks(id), start_time TEXT NOT NULL, end_time TEXT DEFAULT NULL, type TEXT DEFAULT 'work', active INTEGER DEFAULT 0, checked INTEGER DEFAULT 0, related_task_id INTEGER REFERENCES tasks(id) DEFAULT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id), FOREIGN KEY (related_task_id) REFERENCES tasks(id) ) """) conn.execute(""" CREATE INDEX IF NOT EXISTS idx_sessions_task ON sessions(task_id, start_time) """) conn.execute(""" CREATE INDEX IF NOT EXISTS idx_sessions_related ON sessions(related_task_id) """) def _insert_system_tasks(conn: sqlite3.Connection): """ Добавляет системные задачи, если они ещё не созданы. ID 1: Перерыв (система) ID 2: Рабочее время (система) ID 3..10: резервные (reserved_3 ... reserved_10) """ system_tasks = [ (1, "Перерыв (система)", "system", "Системная задача для перерывов"), (2, "Рабочее время (система)", "system", "Системная задача для времени без конкретной задачи"), ] for task_id in range(3, 11): system_tasks.append( (task_id, f"reserved_{task_id}", "system", "Зарезервировано для будущих системных нужд") ) for task_id, name, created_date, description in system_tasks: conn.execute( """ INSERT OR IGNORE INTO tasks (id, name, created_date, description) VALUES (?, ?, ?, ?) """, (task_id, name, created_date, description), ) def _run_migrations(conn: sqlite3.Connection): """ Проверяет текущую версию схемы и последовательно применяет миграции. Пока схема версии 1, миграций нет. """ # Убедимся, что запись о версии существует cur = conn.execute("SELECT value FROM metadata WHERE key = 'schema_version'") row = cur.fetchone() current = int(row[0]) if row else 0 if current == 0: # Первая инициализация - ставим версию 1 conn.execute( "INSERT INTO metadata (key, value) VALUES ('schema_version', '1')" ) # current = 1 # задел для будущих миграций # Здесь в будущем будем добавлять миграции: # if current < 2: # conn.execute("ALTER TABLE ...") # conn.execute("UPDATE metadata SET value = '2' WHERE key = 'schema_version'") # current = 2 # --------------------------------------------------------------------------- # CRUD: Задачи # --------------------------------------------------------------------------- def get_or_create_task(conn: sqlite3.Connection, name: str, today: str = None) -> int: """ Возвращает ID задачи с указанным именем на сегодняшнюю дату. Если такой задачи нет - создаёт новую. Если имя пустое - возвращает ID системной задачи «Рабочее время» (2). """ if not name or not name.strip(): return 2 # Системная задача «Рабочее время» if today is None: today = date.today().isoformat() cur = conn.execute( "SELECT id FROM tasks WHERE name = ? AND created_date = ?", (name, today), ) row = cur.fetchone() if row: return row[0] # Создаём новую задачу на сегодня cur = conn.execute( "INSERT INTO tasks (name, created_date) VALUES (?, ?)", (name, today), ) return cur.lastrowid def task_exists_today(conn: sqlite3.Connection, name: str) -> bool: """Проверяет, существует ли задача с таким именем сегодня.""" today = date.today().isoformat() cur = conn.execute( "SELECT 1 FROM tasks WHERE name = ? AND created_date = ?", (name, today), ) return cur.fetchone() is not None def get_tasks_for_date(conn: sqlite3.Connection, target_date: str = None) -> List[Dict]: """ Возвращает список задач (кроме системных) за указанную дату. По умолчанию - сегодня. """ if target_date is None: target_date = date.today().isoformat() cur = conn.execute( """ SELECT id, name, created_date, description FROM tasks WHERE created_date = ? AND created_date != 'system' ORDER BY id """, (target_date,), ) rows = cur.fetchall() return [ { "id": row[0], "name": row[1], "created_date": row[2], "description": row[3], } for row in rows ] # --------------------------------------------------------------------------- # CRUD: Сессии # --------------------------------------------------------------------------- def _now_iso() -> str: """Текущее локальное время в формате ISO 8601 (с 'T').""" return datetime.now().isoformat(sep="T", timespec="seconds") def start_session(conn: sqlite3.Connection, task_id: int) -> int: """ Начинает новую рабочую сессию для указанной задачи. Возвращает ID созданной сессии. """ now = _now_iso() cur = conn.execute( """ INSERT INTO sessions (task_id, start_time, type, active) VALUES (?, ?, 'work', 1) """, (task_id, now), ) conn.commit() return cur.lastrowid def stop_session(conn: sqlite3.Connection, session_id: int): """Завершает указанную сессию (проставляет end_time, снимает active).""" now = _now_iso() conn.execute( """ UPDATE sessions SET end_time = ?, active = 0 WHERE id = ? AND active = 1 """, (now, session_id), ) conn.commit() def start_break(conn: sqlite3.Connection, previous_work_task_id: int) -> int: """ Начинает перерыв. Закрывает текущую активную рабочую сессию, создаёт сессию перерыва с task_id=1 (системная задача «Перерыв»). previous_work_task_id - ID задачи, которая была активна до перерыва. Возвращает ID сессии перерыва. """ # Завершаем активную рабочую сессию (если есть) _close_active_session(conn, "work") now = _now_iso() cur = conn.execute( """ INSERT INTO sessions (task_id, start_time, type, active, related_task_id) VALUES (1, ?, 'break', 1, ?) """, (now, previous_work_task_id), ) conn.commit() return cur.lastrowid def resume_work(conn: sqlite3.Connection, task_id: int, previous_break_task_id: int = None) -> int: """ Возобновляет работу после перерыва. - Закрывает активную сессию перерыва. - Если задача та же, что была до перерыва (previous_break_task_id == task_id), проставляет related_task_id у перерыва на эту задачу. - Создаёт новую рабочую сессию для task_id. Возвращает ID новой рабочей сессии. """ now = _now_iso() # Находим активный перерыв cur = conn.execute( "SELECT id, related_task_id FROM sessions WHERE active = 1 AND type = 'break'" ) row = cur.fetchone() if row: break_id, related = row # Если возвращаемся к той же задаче, привязываем перерыв к ней if previous_break_task_id is not None and previous_break_task_id == task_id: conn.execute( "UPDATE sessions SET end_time = ?, active = 0, related_task_id = ? WHERE id = ?", (now, task_id, break_id), ) else: # Перерыв остаётся без привязки к задаче (related_task_id уже установлен в start_break) conn.execute( "UPDATE sessions SET end_time = ?, active = 0 WHERE id = ?", (now, break_id), ) # Создаём новую рабочую сессию cur = conn.execute( """ INSERT INTO sessions (task_id, start_time, type, active) VALUES (?, ?, 'work', 1) """, (task_id, now), ) conn.commit() return cur.lastrowid def _close_active_session(conn: sqlite3.Connection, session_type: str): """Закрывает активную сессию указанного типа (work или break).""" now = _now_iso() conn.execute( """ UPDATE sessions SET end_time = ?, active = 0 WHERE active = 1 AND type = ? """, (now, session_type), ) # --------------------------------------------------------------------------- # Автосохранение # --------------------------------------------------------------------------- def periodic_save(conn: sqlite3.Connection): """ Продлевает end_time у активной сессии (work или break). Вызывается каждые 60 секунд таймером. """ now = _now_iso() conn.execute( """ UPDATE sessions SET end_time = ? WHERE active = 1 """, (now,), ) conn.commit() # --------------------------------------------------------------------------- # Восстановление после сбоя # --------------------------------------------------------------------------- def get_active_session(conn: sqlite3.Connection) -> Optional[dict]: """ Возвращает активную сессию (если есть). Структура: id, task_id, type, start_time, related_task_id. """ cur = conn.execute( """ SELECT id, task_id, type, start_time, related_task_id FROM sessions WHERE active = 1 """ ) row = cur.fetchone() if row: return { "id": row[0], "task_id": row[1], "type": row[2], "start_time": row[3], "related_task_id": row[4], } return None def resolve_orphaned_session(conn: sqlite3.Connection, session: dict, action: str): """ Обрабатывает незавершённую сессию после сбоя. action: 'continue' - продолжить (оставляем active=1), 'discard' - завершить (проставляем end_time=now, active=0). """ if action == "discard": now = _now_iso() conn.execute( "UPDATE sessions SET end_time = ?, active = 0 WHERE id = ?", (now, session["id"]), ) # Если 'continue' - ничего не делаем, сессия остаётся активной conn.commit() # --------------------------------------------------------------------------- # Переход через полночь # --------------------------------------------------------------------------- def handle_midnight_transition(conn: sqlite3.Connection): """ Проверяет, не перешла ли активная сессия за полночь. Если да - завершает её и создаёт новую с началом в полночь. Работает как для рабочих сессий, так и для перерывов. """ active = get_active_session(conn) if not active: return start_dt = datetime.fromisoformat(active["start_time"]) today = date.today() if start_dt.date() >= today: return # Сессия началась сегодня или позже - ничего не делаем # Перешли через полночь: закрываем старую сессию вчерашним днём в 23:59:59 yesterday_end = datetime.combine( start_dt.date(), datetime.max.time() ).isoformat(sep="T", timespec="seconds") conn.execute( "UPDATE sessions SET end_time = ?, active = 0 WHERE id = ?", (yesterday_end, active["id"]), ) # Создаём новую сессию с началом в полночь сегодняшнего дня midnight_start = datetime.combine( today, datetime.min.time() ).isoformat(sep="T", timespec="seconds") if active["type"] == "work": # Получаем имя задачи и создаём/получаем задачу на сегодня cur = conn.execute( "SELECT name FROM tasks WHERE id = ?", (active["task_id"],) ) task_name = cur.fetchone()[0] new_task_id = get_or_create_task(conn, task_name, today.isoformat()) conn.execute( "INSERT INTO sessions (task_id, start_time, type, active) " "VALUES (?, ?, 'work', 1)", (new_task_id, midnight_start), ) else: # break # Отвязываем старый перерыв от задачи conn.execute( "UPDATE sessions SET related_task_id = NULL WHERE id = ?", (active["id"],) ) # Новый перерыв также не привязан ни к какой задаче conn.execute( "INSERT INTO sessions (task_id, start_time, type, active, related_task_id) " "VALUES (1, ?, 'break', 1, NULL)", (midnight_start,), ) conn.commit()