food-market/app/ui/page_settings.py

363 lines
14 KiB
Python
Raw Normal View History

"""Настройки: 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()