"""Главное окно: боковое меню, экраны, автосохранение и фоновая синхронизация. Окно заодно служит контекстом для страниц: они берут отсюда базу, пересчитанный отчёт и валюту, а после любой правки зовут ctx.changed(). """ from __future__ import annotations from datetime import datetime from pathlib import Path from PySide6.QtCore import QThread, QTimer, Qt, Signal from PySide6.QtWidgets import ( QLabel, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QStackedWidget, QWidget, QHBoxLayout, ) from .. import gitsync, ledger, paths from ..gitsync import Diverged, GitError, GitSync from ..storage import Vault from . import theme from . import widgets as w from .page_batches import BatchesPage from .page_dashboard import DashboardPage from .page_debts import DebtsPage from .page_journal import JournalPage from .page_products import ProductsPage from .page_sales import SalesPage from .page_settings import SettingsPage from .page_stats import StatsPage AUTOSAVE_MS = 2000 CLOSE_SYNC_WAIT_MS = 20_000 class SyncTask(QThread): """Один прогон синхронизации в фоне. Git блокирует надолго (сеть, scrypt тут ни при чём), а замерзший интерфейс на каждом часовом тике — худшее, что может сделать фоновая задача. """ done = Signal(object, str, bool) # результат, текст ошибки, был ли конфликт def __init__(self, sync: GitSync, remote_copy: Path, parent=None): super().__init__(parent) self.sync = sync self.remote_copy = remote_copy def run(self) -> None: try: self.done.emit(self.sync.sync(self.remote_copy), "", False) except Diverged as exc: self.done.emit(None, str(exc), True) except GitError as exc: self.done.emit(None, str(exc), False) except Exception as exc: # noqa: BLE001 — фон не должен ронять приложение self.done.emit(None, f"Непредвиденная ошибка синхронизации: {exc}", False) class MainWindow(QMainWindow): def __init__(self, vault: Vault): super().__init__() self.vault = vault self.report = ledger.build(vault.doc) self.conflict_message: str | None = None self._sync_task: SyncTask | None = None self._syncing = False self._sync_text = "не синхронизировалось" self._saved_revision = vault.revision self.setWindowTitle("Булочная") self.resize(1180, 760) self._build_ui() self._build_status_bar() self._start_timers() self.refresh_all() # Первый заход в сеть — сразу: если с другой машины что-то прилетело, # лучше узнать об этом до того, как начнёшь вводить данные. QTimer.singleShot(600, lambda: self.sync_now(quiet=True)) # --- сборка --- def _build_ui(self) -> None: central = QWidget() layout = QHBoxLayout(central) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.sidebar = QListWidget() self.sidebar.setObjectName("sidebar") self.sidebar.setFixedWidth(196) self.sidebar.setFocusPolicy(Qt.NoFocus) layout.addWidget(self.sidebar) self.stack = QStackedWidget() layout.addWidget(self.stack, 1) self.pages = [ ("Сводка", DashboardPage(self)), ("Закупки", BatchesPage(self)), ("Продажи", SalesPage(self)), ("Долги", DebtsPage(self)), ("Товары", ProductsPage(self)), ("Статистика", StatsPage(self)), ("Журнал", JournalPage(self)), ("Настройки", SettingsPage(self)), ] for title, page in self.pages: self.sidebar.addItem(QListWidgetItem(title)) self.stack.addWidget(page) self.sidebar.currentRowChanged.connect(self._on_page_changed) self.sidebar.setCurrentRow(0) self.setCentralWidget(central) def _build_status_bar(self) -> None: self.status_label = QLabel("") self.status_label.setStyleSheet(f"color: {theme.TEXT_DIM};") bar = self.statusBar() bar.addWidget(self.status_label, 1) bar.addPermanentWidget(w.button("Синхронизировать", on_click=self.sync_now)) def _start_timers(self) -> None: self.autosave_timer = QTimer(self) self.autosave_timer.timeout.connect(self._autosave) self.autosave_timer.start(AUTOSAVE_MS) self.sync_timer = QTimer(self) self.sync_timer.timeout.connect(lambda: self.sync_now(quiet=True)) self.restart_sync_timer() def restart_sync_timer(self) -> None: git = self.vault.doc.settings.git self.sync_timer.stop() if git.enabled and git.remote_url: self.sync_timer.start(max(1, git.auto_push_minutes) * 60_000) # --- контекст для страниц --- @property def currency(self) -> str: return self.vault.doc.settings.currency def changed(self) -> None: """Позвать после любой правки базы.""" self.report = ledger.build(self.vault.doc) self.refresh_all() def refresh_all(self) -> None: for _, page in self.pages: page.refresh() self._update_status() def _on_page_changed(self, index: int) -> None: self.stack.setCurrentIndex(index) # Настройки и сводка показывают состояние синхронизации, которое могло # измениться в фоне, пока смотрели другой экран. self.pages[index][1].refresh() def sync_status_text(self) -> str: return self._sync_text def _git(self) -> GitSync: """Git всегда работает над папкой данных, а не над корнем приложения. Единственная точка создания GitSync: если exe лежит внутри клона с исходниками, ошибка в этом месте заставила бы приложение распоряжаться репозиторием кода — переписать ему origin и запушить исходники в репозиторий данных. """ return GitSync(paths.data_dir(), paths.vault_rel_posix(), self.vault.doc.settings.git) # --- сохранение --- def _autosave(self) -> None: if self._syncing or self.vault.revision == self._saved_revision: return self.save_now() def save_now(self) -> bool: try: written = self.vault.save() except OSError as exc: self.status_label.setText(f"Не удалось сохранить базу: {exc}") return False self._saved_revision = self.vault.revision return written # --- синхронизация --- def sync_now(self, quiet: bool = False) -> None: if self._syncing: return git = self.vault.doc.settings.git if not git.remote_url: if not quiet: QMessageBox.information( self, "Не настроено", "Укажи адрес репозитория в настройках." ) return if gitsync.git_executable() is None: self._sync_text = "git не установлен — синхронизация недоступна" self._update_status() if not quiet: QMessageBox.warning(self, "Нет git", self._sync_text) return if self.conflict_message and quiet: # Пока конфликт не разрешён, автопуш стоит: иначе мы бы либо # затирали чужое, либо каждый час показывали одну и ту же ошибку. return self.save_now() self._syncing = True self._sync_text = "синхронизация…" self._update_status() sync = self._git() self._sync_task = SyncTask(sync, paths.remote_copy_path(), self) self._sync_task.done.connect(lambda *args: self._on_sync_done(*args, quiet=quiet)) self._sync_task.start() def _on_sync_done(self, result, error: str, conflict: bool, quiet: bool = True) -> None: self._syncing = False stamp = datetime.now().strftime("%H:%M") if conflict: self.conflict_message = error self._sync_text = f"{stamp} — расхождение версий, автопуш остановлен" self._update_status() self.refresh_all() QMessageBox.warning(self, "Версии разошлись", error) return if error: self._sync_text = f"{stamp} — ошибка: {error.splitlines()[0][:120]}" self._update_status() if not quiet: QMessageBox.warning(self, "Синхронизация не удалась", error) return self.conflict_message = None if result is not None and result.pulled: try: self.vault.reload() self._saved_revision = self.vault.revision except Exception as exc: # noqa: BLE001 QMessageBox.critical( self, "Не удалось прочитать версию с сервера", f"Файл подтянут, но открыть его не вышло: {exc}\n\n" "Скорее всего, база с другой машины шифровалась другим паролем.", ) self._sync_text = f"{stamp} — {result.message if result else 'готово'}" self.changed() def _update_status(self) -> None: git = self.vault.doc.settings.git mode = ( f"каждые {git.auto_push_minutes} мин" if git.enabled and git.remote_url else "автопуш выключен" ) dirty = " • есть несохранённые изменения" if self.vault.revision != self._saved_revision else "" self.status_label.setText(f"{self._sync_text} • {mode}{dirty}") # --- разрешение конфликта --- def adopt_remote_version(self, _copy_path: Path) -> None: sync = self._git() try: sync.reset_to_remote() self.vault.reload() except (GitError, OSError) as exc: QMessageBox.critical(self, "Не получилось", str(exc)) return except Exception as exc: # noqa: BLE001 QMessageBox.critical( self, "Версия с сервера не открывается", f"{exc}\n\nВероятно, у неё другой пароль.", ) return self._saved_revision = self.vault.revision self.conflict_message = None self._sync_text = "принята версия с сервера" self.changed() def force_push_local(self) -> None: self.save_now() sync = self._git() try: sync.force_push() except GitError as exc: QMessageBox.critical(self, "Не получилось", str(exc)) return self.conflict_message = None self._sync_text = "сервер перезаписан локальной версией" self.changed() # --- закрытие --- def closeEvent(self, event) -> None: self.autosave_timer.stop() self.sync_timer.stop() self.save_now() git = self.vault.doc.settings.git if not (git.enabled and git.remote_url) or self.conflict_message: event.accept() return if gitsync.git_executable() is None: event.accept() return if self._sync_task is not None and self._sync_task.isRunning(): self._sync_task.wait(CLOSE_SYNC_WAIT_MS) event.accept() return # Последний пуш на выходе, с потолком по времени: висеть на закрытии # из-за пропавшей сети приложение не должно. self.status_label.setText("Отправляю базу на сервер…") sync = self._git() task = SyncTask(sync, paths.remote_copy_path(), self) task.start() task.wait(CLOSE_SYNC_WAIT_MS) event.accept()