Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
363 lines
14 KiB
Python
363 lines
14 KiB
Python
"""Настройки: 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()
|