"""Настройки: git, пароль, обслуживание базы и разрешение конфликтов.""" from __future__ import annotations from datetime import date, timedelta from PySide6.QtWidgets import ( QCheckBox, QDialog, QFileDialog, QFormLayout, QGroupBox, QInputDialog, QLabel, QLineEdit, QMessageBox, QScrollArea, QSpinBox, QVBoxLayout, QWidget, ) from .. import gitsync, journal, paths from ..storage import Vault from . import theme from . import widgets as w class ChangePasswordDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Смена пароля") self.setMinimumWidth(380) layout = QVBoxLayout(self) layout.setContentsMargins(20, 16, 20, 16) layout.setSpacing(10) form = QFormLayout() self.current = QLineEdit() self.current.setEchoMode(QLineEdit.Password) form.addRow("Текущий", self.current) self.new = QLineEdit() self.new.setEchoMode(QLineEdit.Password) form.addRow("Новый", self.new) self.confirm = QLineEdit() self.confirm.setEchoMode(QLineEdit.Password) form.addRow("Ещё раз", self.confirm) layout.addLayout(form) self.error = QLabel("") self.error.setStyleSheet(f"color: {theme.DANGER};") self.error.setWordWrap(True) layout.addWidget(self.error) layout.addWidget( w.label( "Файл будет перешифрован целиком. Старые копии в истории git " "останутся зашифрованными прежним паролем.", "dim", ) ) layout.itemAt(layout.count() - 1).widget().setWordWrap(True) layout.addWidget( w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сменить", "primary", self.accept)) ) class SettingsPage(QWidget): def __init__(self, ctx, parent=None): super().__init__(parent) self.ctx = ctx outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QScrollArea.NoFrame) outer.addWidget(scroll) body = QWidget() layout = QVBoxLayout(body) layout.setContentsMargins(20, 18, 20, 18) layout.setSpacing(14) scroll.setWidget(body) layout.addWidget(w.heading("Настройки")) layout.addWidget(self._git_group()) layout.addWidget(self._conflict_group()) layout.addWidget(self._general_group()) layout.addWidget(self._maintenance_group()) layout.addStretch(1) # --- группы --- def _git_group(self) -> QGroupBox: box = QGroupBox("Синхронизация с GitLab") layout = QVBoxLayout(box) layout.setSpacing(8) form = QFormLayout() form.setSpacing(8) self.remote = QLineEdit() self.remote.setPlaceholderText("https://gt.ser.gay/kizya/food-market.git") form.addRow("Репозиторий", self.remote) self.token = QLineEdit() self.token.setEchoMode(QLineEdit.Password) self.token.setPlaceholderText("оставь пустым, чтобы не менять") form.addRow("Токен", self.token) self.branch = QLineEdit() form.addRow("Ветка", self.branch) self.interval = QSpinBox() self.interval.setRange(5, 24 * 60) self.interval.setSuffix(" мин") form.addRow("Пушить каждые", self.interval) self.enabled = QCheckBox("Синхронизировать автоматически") form.addRow("", self.enabled) self.squash = QCheckBox("Один коммит на день") self.squash.setToolTip( "Дельта-сжатие на зашифрованном файле не работает: каждый коммит — " "это полная копия базы. Схлопывание сбивает рост репозитория " "примерно с 60 до 13 МБ в год. Взамен откат через git возможен " "на границу суток, а не на любой час — внутри дня историю показывает журнал." ) form.addRow("", self.squash) layout.addLayout(form) self.git_status = w.label("", "dim") self.git_status.setWordWrap(True) layout.addWidget(self.git_status) layout.addWidget( w.row( w.button("Сохранить", "primary", self.save_git), w.button("Синхронизировать сейчас", on_click=self.ctx.sync_now), None, ) ) layout.addWidget( w.label( "Токен хранится внутри зашифрованной базы и не попадает в .git/config. " "В журнал его значение тоже не пишется.", "dim", ) ) layout.itemAt(layout.count() - 1).widget().setWordWrap(True) return box def _conflict_group(self) -> QGroupBox: self.conflict_box = QGroupBox("Расхождение версий") layout = QVBoxLayout(self.conflict_box) layout.setSpacing(8) self.conflict_text = w.label("", "dim") self.conflict_text.setWordWrap(True) layout.addWidget(self.conflict_text) layout.addWidget( w.row( w.button("Взять версию с сервера", on_click=self.take_remote), w.button("Оставить мою и перезаписать сервер", "danger", self.force_local), None, ) ) return self.conflict_box def _general_group(self) -> QGroupBox: box = QGroupBox("Общее") form = QFormLayout(box) form.setSpacing(8) self.currency = QLineEdit() self.currency.setMaximumWidth(80) form.addRow("Валюта", self.currency) self.period_days = QSpinBox() self.period_days.setRange(1, 365) self.period_days.setSuffix(" дн.") form.addRow("Период по умолчанию", self.period_days) form.addRow("", w.button("Сохранить", "primary", self.save_general)) return box def _maintenance_group(self) -> QGroupBox: box = QGroupBox("База") layout = QVBoxLayout(box) layout.setSpacing(8) self.db_info = w.label("", "dim") self.db_info.setWordWrap(True) layout.addWidget(self.db_info) layout.addWidget( w.row( w.button("Сменить пароль", on_click=self.change_password), w.button("Выгрузить открытый JSON", on_click=self.export_json), w.button("Обрезать журнал", on_click=self.prune_journal), None, ) ) layout.addWidget( w.label( "Забытый пароль восстановить нельзя — это единственная страховка. " "В выгрузке лежит и токен, так что храни её не на общем диске.", "dim", ) ) layout.itemAt(layout.count() - 1).widget().setWordWrap(True) return box # --- отрисовка --- def refresh(self) -> None: settings = self.ctx.vault.doc.settings git = settings.git self.remote.setText(git.remote_url) self.branch.setText(git.branch) self.interval.setValue(git.auto_push_minutes) self.enabled.setChecked(git.enabled) self.squash.setChecked(git.daily_squash) self.token.setPlaceholderText( "токен задан, оставь пустым чтобы не менять" if git.token else "токен не задан" ) self.currency.setText(settings.currency) self.period_days.setValue(settings.default_period_days) if gitsync.git_executable() is None: self.git_status.setText( "git не найден в системе — синхронизация выключена. " "Установи Git для Windows, учёт при этом работает как обычно." ) else: self.git_status.setText(self.ctx.sync_status_text()) self.conflict_box.setVisible(self.ctx.conflict_message is not None) if self.ctx.conflict_message: self.conflict_text.setText( f"{self.ctx.conflict_message}\n\n" f"Версия с сервера сохранена в {paths.remote_copy_path().name} " "рядом с базой. Выбери, какая версия верная." ) doc = self.ctx.vault.doc size = self.ctx.vault.path.stat().st_size if self.ctx.vault.path.exists() else 0 self.db_info.setText( f"{self.ctx.vault.path}\n" f"{size / 1024:.1f} КБ • товаров {len(doc.products)} • " f"закупок {len(doc.batches)} • продаж {len(doc.sales)} • " f"записей в журнале {len(doc.journal)}" ) # --- действия --- def save_git(self) -> None: changes = { "remote_url": self.remote.text().strip(), "branch": self.branch.text().strip() or "main", "auto_push_minutes": self.interval.value(), "enabled": self.enabled.isChecked(), "daily_squash": self.squash.isChecked(), } # Пустое поле означает «не трогать», иначе токен стирался бы при # каждом сохранении настроек. if self.token.text(): changes["token"] = self.token.text() journal.update_settings(self.ctx.vault, git=changes) self.token.clear() self.ctx.restart_sync_timer() self.ctx.changed() def save_general(self) -> None: journal.update_settings( self.ctx.vault, currency=self.currency.text().strip(), default_period_days=self.period_days.value(), ) self.ctx.changed() def change_password(self) -> None: dialog = ChangePasswordDialog(self) while dialog.exec() == QDialog.Accepted: if not self.ctx.vault.check_password(dialog.current.text()): dialog.error.setText("Текущий пароль неверный.") continue if len(dialog.new.text()) < 4: dialog.error.setText("Новый пароль короче 4 символов.") continue if dialog.new.text() != dialog.confirm.text(): dialog.error.setText("Новые пароли не совпадают.") continue self.ctx.vault.change_password(dialog.new.text()) QMessageBox.information(self, "Готово", "Пароль изменён, база перешифрована.") self.ctx.changed() return def export_json(self) -> None: default = paths.data_dir() / f"export-{date.today().isoformat()}.json" target, _ = QFileDialog.getSaveFileName( self, "Выгрузить базу в открытый JSON", str(default), "JSON (*.json)" ) if not target: return self.ctx.vault.export_plain_json(target) QMessageBox.warning( self, "Выгружено", f"Открытая копия сохранена:\n{target}\n\n" "Файл не зашифрован и содержит токен GitLab. Держи его отдельно.", ) def prune_journal(self) -> None: years, ok = QInputDialog.getInt( self, "Обрезать журнал", "Удалить записи старше, лет:", 3, 1, 50 ) if not ok: return cutoff = date.today() - timedelta(days=365 * years) removed = journal.prune_journal(self.ctx.vault, cutoff) QMessageBox.information( self, "Готово", f"Удалено записей: {removed}." if removed else "Удалять нечего." ) self.ctx.changed() # --- разрешение конфликта --- def take_remote(self) -> None: copy = paths.remote_copy_path() if not copy.exists(): QMessageBox.warning(self, "Нет файла", "Версия с сервера не найдена.") return confirm = QMessageBox.question( self, "Взять версию с сервера?", "Твои локальные изменения будут потеряны. Продолжить?", ) if confirm != QMessageBox.Yes: return self.ctx.adopt_remote_version(copy) def force_local(self) -> None: confirm = QMessageBox.question( self, "Перезаписать сервер?", "Версия на сервере будет заменена твоей. Изменения, сделанные " "на другой машине, пропадут. Продолжить?", ) if confirm != QMessageBox.Yes: return self.ctx.force_push_local()