Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
5.6 KiB
Python
160 lines
5.6 KiB
Python
"""Разблокировка существующей базы или создание новой."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtCore import Qt
|
||
from PySide6.QtWidgets import (
|
||
QCheckBox,
|
||
QDialog,
|
||
QFormLayout,
|
||
QLabel,
|
||
QLineEdit,
|
||
QMessageBox,
|
||
QVBoxLayout,
|
||
)
|
||
|
||
from .. import crypto, journal
|
||
from ..storage import Vault
|
||
from . import widgets as w
|
||
|
||
MIN_PASSWORD_LENGTH = 4
|
||
|
||
|
||
class LoginDialog(QDialog):
|
||
"""Диалог входа. После accept() в self.vault лежит открытая база."""
|
||
|
||
def __init__(self, vault_path: Path, parent=None):
|
||
super().__init__(parent)
|
||
self.vault_path = Path(vault_path)
|
||
self.is_new = not self.vault_path.exists()
|
||
self.vault: Vault | None = None
|
||
|
||
self.setWindowTitle("Булочная")
|
||
self.setMinimumWidth(440)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(24, 20, 24, 20)
|
||
layout.setSpacing(12)
|
||
|
||
layout.addWidget(w.heading("Новая база" if self.is_new else "Булочная"))
|
||
layout.addWidget(
|
||
w.label(
|
||
"Придумай пароль. Им шифруется весь учёт — восстановить его "
|
||
"невозможно, так что запиши в надёжном месте."
|
||
if self.is_new
|
||
else str(self.vault_path),
|
||
"dim",
|
||
)
|
||
)
|
||
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
|
||
|
||
form = QFormLayout()
|
||
form.setSpacing(8)
|
||
|
||
self.password = QLineEdit()
|
||
self.password.setEchoMode(QLineEdit.Password)
|
||
self.password.returnPressed.connect(self.accept)
|
||
form.addRow("Пароль", self.password)
|
||
|
||
if self.is_new:
|
||
self.confirm = QLineEdit()
|
||
self.confirm.setEchoMode(QLineEdit.Password)
|
||
self.confirm.returnPressed.connect(self.accept)
|
||
form.addRow("Ещё раз", self.confirm)
|
||
|
||
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("Токен GitLab", self.token)
|
||
|
||
self.demo = QCheckBox("Завести три булки для примера")
|
||
self.demo.setChecked(True)
|
||
form.addRow("", self.demo)
|
||
|
||
layout.addLayout(form)
|
||
|
||
show = QCheckBox("Показать пароль")
|
||
show.toggled.connect(self._toggle_echo)
|
||
layout.addWidget(show)
|
||
|
||
self.error = QLabel("")
|
||
self.error.setWordWrap(True)
|
||
self.error.setStyleSheet("color: #e0605e;")
|
||
layout.addWidget(self.error)
|
||
|
||
layout.addWidget(
|
||
w.row(
|
||
None,
|
||
w.button("Отмена", on_click=self.reject),
|
||
w.button("Создать" if self.is_new else "Открыть", "primary", self.accept),
|
||
)
|
||
)
|
||
self.password.setFocus()
|
||
|
||
def _toggle_echo(self, visible: bool) -> None:
|
||
mode = QLineEdit.Normal if visible else QLineEdit.Password
|
||
self.password.setEchoMode(mode)
|
||
if self.is_new:
|
||
self.confirm.setEchoMode(mode)
|
||
|
||
def accept(self) -> None:
|
||
self.error.setText("")
|
||
password = self.password.text()
|
||
|
||
if self.is_new:
|
||
if len(password) < MIN_PASSWORD_LENGTH:
|
||
self.error.setText(f"Пароль короче {MIN_PASSWORD_LENGTH} символов.")
|
||
return
|
||
if password != self.confirm.text():
|
||
self.error.setText("Пароли не совпадают.")
|
||
return
|
||
self._create(password)
|
||
else:
|
||
self._open(password)
|
||
|
||
def _create(self, password: str) -> None:
|
||
try:
|
||
vault = Vault.create(self.vault_path, password)
|
||
except OSError as exc:
|
||
self.error.setText(f"Не удалось создать файл: {exc}")
|
||
return
|
||
|
||
git_settings = {}
|
||
if self.remote.text().strip():
|
||
git_settings["remote_url"] = self.remote.text().strip()
|
||
if self.token.text():
|
||
git_settings["token"] = self.token.text()
|
||
if git_settings:
|
||
journal.update_settings(vault, git=git_settings)
|
||
|
||
if self.demo.isChecked():
|
||
journal.create_product(vault, "Булка с повидлом", cost_price=30, retail_price=50)
|
||
journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35)
|
||
journal.create_product(vault, "Булка с корицей", cost_price=25, retail_price=40)
|
||
|
||
vault.save()
|
||
self.vault = vault
|
||
super().accept()
|
||
|
||
def _open(self, password: str) -> None:
|
||
try:
|
||
self.vault = Vault.open(self.vault_path, password)
|
||
except crypto.WrongPassword:
|
||
self.error.setText("Неверный пароль.")
|
||
self.password.selectAll()
|
||
self.password.setFocus()
|
||
return
|
||
except crypto.VaultError as exc:
|
||
QMessageBox.critical(self, "Не удалось открыть базу", str(exc))
|
||
return
|
||
except OSError as exc:
|
||
QMessageBox.critical(self, "Не удалось прочитать файл", str(exc))
|
||
return
|
||
super().accept()
|