149 lines
5.5 KiB
Python
149 lines
5.5 KiB
Python
|
|
"""Журнал изменений.
|
|||
|
|
|
|||
|
|
История, которую git по зашифрованному файлу дать не может: коммиты хранят
|
|||
|
|
непрозрачные снимки, а внутри суток они ещё и схлопываются в один. Здесь видно,
|
|||
|
|
что именно менялось, когда и с какой машины.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from PySide6.QtWidgets import QComboBox, QLineEdit, QVBoxLayout, QWidget
|
|||
|
|
|
|||
|
|
from ..models import JournalEntry
|
|||
|
|
from . import theme
|
|||
|
|
from . import widgets as w
|
|||
|
|
|
|||
|
|
ENTITY_LABELS = {
|
|||
|
|
"product": "Товары",
|
|||
|
|
"batch": "Закупки",
|
|||
|
|
"sale": "Продажи",
|
|||
|
|
"counterparty": "Контрагенты",
|
|||
|
|
"bakery_payment": "Платежи пекарне",
|
|||
|
|
"settings": "Настройки",
|
|||
|
|
"journal": "Журнал",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ACTION_LABELS = {
|
|||
|
|
"product.create": "товар создан",
|
|||
|
|
"product.update": "товар изменён",
|
|||
|
|
"product.price_change": "цены изменены",
|
|||
|
|
"product.archive": "товар в архив",
|
|||
|
|
"product.delete": "товар удалён",
|
|||
|
|
"counterparty.create": "контрагент создан",
|
|||
|
|
"counterparty.update": "контрагент изменён",
|
|||
|
|
"counterparty.delete": "контрагент удалён",
|
|||
|
|
"batch.create": "закупка создана",
|
|||
|
|
"batch.update": "закупка изменена",
|
|||
|
|
"batch.close": "партия закрыта",
|
|||
|
|
"batch.reopen": "партия открыта",
|
|||
|
|
"batch.delete": "закупка удалена",
|
|||
|
|
"sale.create": "продажа создана",
|
|||
|
|
"sale.update": "продажа изменена",
|
|||
|
|
"sale.delete": "продажа удалена",
|
|||
|
|
"sale.payment_add": "принята оплата",
|
|||
|
|
"sale.payment_delete": "оплата удалена",
|
|||
|
|
"bakery_payment.add": "платёж пекарне",
|
|||
|
|
"bakery_payment.delete": "платёж пекарне удалён",
|
|||
|
|
"settings.update": "настройки изменены",
|
|||
|
|
"journal.prune": "журнал обрезан",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ACTION_COLORS = {
|
|||
|
|
"delete": theme.DANGER,
|
|||
|
|
"create": theme.OK,
|
|||
|
|
"add": theme.OK,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def describe(entry: JournalEntry) -> str:
|
|||
|
|
"""Свернуть изменения в одну строку таблицы."""
|
|||
|
|
parts = []
|
|||
|
|
for change in entry.changes:
|
|||
|
|
if change.old and change.new:
|
|||
|
|
parts.append(f"{change.field}: {change.old} → {change.new}")
|
|||
|
|
elif change.new:
|
|||
|
|
parts.append(f"{change.field}: {change.new}")
|
|||
|
|
elif change.old:
|
|||
|
|
parts.append(f"{change.field}: было {change.old}")
|
|||
|
|
return "; ".join(parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _color_for(action: str) -> str:
|
|||
|
|
tail = action.rsplit(".", 1)[-1]
|
|||
|
|
for needle, color in ACTION_COLORS.items():
|
|||
|
|
if needle in tail:
|
|||
|
|
return color
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class JournalPage(QWidget):
|
|||
|
|
def __init__(self, ctx, parent=None):
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.ctx = ctx
|
|||
|
|
|
|||
|
|
layout = QVBoxLayout(self)
|
|||
|
|
layout.setContentsMargins(20, 18, 20, 18)
|
|||
|
|
layout.setSpacing(12)
|
|||
|
|
|
|||
|
|
layout.addWidget(w.heading("Журнал"))
|
|||
|
|
layout.addWidget(
|
|||
|
|
w.label(
|
|||
|
|
"Что менялось в базе. Git хранит только зашифрованные снимки — "
|
|||
|
|
"подробности есть здесь.",
|
|||
|
|
"dim",
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
self.search = QLineEdit()
|
|||
|
|
self.search.setPlaceholderText("Поиск: булка с маком, 30,00, Вася…")
|
|||
|
|
self.search.setClearButtonEnabled(True)
|
|||
|
|
self.search.textChanged.connect(self.refresh)
|
|||
|
|
|
|||
|
|
self.entity = QComboBox()
|
|||
|
|
self.entity.addItem("Все разделы", None)
|
|||
|
|
for key, title in ENTITY_LABELS.items():
|
|||
|
|
self.entity.addItem(title, key)
|
|||
|
|
self.entity.currentIndexChanged.connect(self.refresh)
|
|||
|
|
|
|||
|
|
self.count_label = w.label("", "dim")
|
|||
|
|
layout.addWidget(w.row(self.search, self.entity, None, self.count_label, stretch_at=0))
|
|||
|
|
|
|||
|
|
self.table = w.table(
|
|||
|
|
["Когда", "Машина", "Что произошло", "Объект", "Изменения"], stretch_column=4
|
|||
|
|
)
|
|||
|
|
self.table.setSortingEnabled(False) # журнал всегда в хронологии
|
|||
|
|
layout.addWidget(self.table, 1)
|
|||
|
|
|
|||
|
|
def refresh(self) -> None:
|
|||
|
|
needle = self.search.text().strip().lower()
|
|||
|
|
wanted = self.entity.currentData()
|
|||
|
|
|
|||
|
|
rows = []
|
|||
|
|
total = 0
|
|||
|
|
for entry in reversed(self.ctx.vault.doc.journal):
|
|||
|
|
total += 1
|
|||
|
|
if wanted and entry.entity != wanted:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
description = describe(entry)
|
|||
|
|
action = ACTION_LABELS.get(entry.action, entry.action)
|
|||
|
|
haystack = " ".join([action, entry.label, description, entry.host]).lower()
|
|||
|
|
if needle and needle not in haystack:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
when = entry.ts.replace("T", " ")[:16]
|
|||
|
|
rows.append(
|
|||
|
|
[
|
|||
|
|
w.text_item(when),
|
|||
|
|
w.text_item(entry.host, theme.MUTED),
|
|||
|
|
w.text_item(action, _color_for(entry.action)),
|
|||
|
|
w.text_item(entry.label),
|
|||
|
|
w.text_item(description),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
w.fill(self.table, rows)
|
|||
|
|
self.count_label.setText(
|
|||
|
|
f"показано {len(rows)} из {total}" if len(rows) != total else f"записей: {total}"
|
|||
|
|
)
|