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()
|