Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c917df515 | ||
|
|
7835d3ca75 | ||
|
|
46eeaa1f2e |
44 changed files with 2088 additions and 110 deletions
|
|
@ -20,6 +20,7 @@ STORAGE_PROVIDER=local
|
|||
STORAGE_BASE_PATH=/data/storage
|
||||
STORAGE_MAX_UPLOAD_MB=50
|
||||
STORAGE_HOST_PATH=./storage
|
||||
# Backups liegen unter ${STORAGE_BASE_PATH}/backups im gleichen persistenten Volume.
|
||||
|
||||
# Legacy-Fallback fuer bestehende Knowledge-Installationen.
|
||||
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -43,6 +43,8 @@ out/
|
|||
# Runtime storage
|
||||
# ===========================
|
||||
storage/
|
||||
/backups/
|
||||
*.dump
|
||||
|
||||
# ===========================
|
||||
# Coverage
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ Komponenten:
|
|||
- Env-Fallbacks sind `LEXWARE_ENABLED`, `LEXWARE_API_BASE_URL` und `LEXWARE_API_KEY`.
|
||||
- Hermes testet die Verbindung serverseitig ueber `GET /v1/profile` an der Lexware Public API unter `https://api.lexware.io`.
|
||||
- Freigegebene KVs koennen manuell fuer eine spaetere Lexware-Rechnung vorbereitet werden.
|
||||
- Die eigentliche Rechnung wird weiterhin in externer Buchhaltungssoftware wie Lexware Office oder sevdesk erstellt.
|
||||
|
||||
Datenfluss:
|
||||
|
||||
|
|
@ -193,6 +194,15 @@ Browser -> Athena /api/repairs/.../lexware/prepare-invoice -> Hermes -> PostgreS
|
|||
|
||||
Der Browser ruft Lexware nie direkt auf. Vorbereitete Exporte werden in `lexware_sync_records` dokumentiert.
|
||||
|
||||
Buchhaltungsworkflow:
|
||||
|
||||
- `prepared`: Rechnungsvorbereitung wurde in Olympus erstellt.
|
||||
- `transferred`: Daten wurden manuell in die externe Buchhaltung uebernommen.
|
||||
- `booked`: Rechnung ist in der Buchhaltung gebucht, fuer spaetere Ausbaustufen vorbereitet.
|
||||
- `cancelled`: Vorbereitung wurde verworfen, fuer spaetere Ausbaustufen vorbereitet.
|
||||
|
||||
Athena zeigt bei freigegebenen Kostenvoranschlaegen die Aktion `In Buchhaltung übernehmen`. Diese oeffnet eine Kopierhilfe fuer Kundendaten und Positionen. Nach dem Speichern der Rechnung in der externen Buchhaltungssoftware kann der Benutzer die Vorbereitung mit Buchhaltungsnotiz als `transferred` markieren.
|
||||
|
||||
Neue zentrale Endpunkte:
|
||||
|
||||
- `GET /lexware/settings`
|
||||
|
|
@ -1050,9 +1060,29 @@ Im Projektroot liegen robuste Bash-Skripte fuer Betrieb und Deployment:
|
|||
|
||||
`deploy.sh` baut Images, startet Docker Compose, fuehrt Migrationen aus und startet den Healthcheck. Es erzwingt kein `git pull`.
|
||||
|
||||
`backup.sh` sichert PostgreSQL, wenn `POSTGRES_CONTAINER` oder `DATABASE_URL` mit lokalem `pg_dump` verfuegbar ist, und archiviert den Storage-Host-Pfad. `.env` wird bewusst nicht automatisch ins Backup kopiert und muss sicher separat verwaltet werden.
|
||||
`backup.sh` erzeugt ein ZIP-Backup mit `manifest.json`, `database.dump` und `storage/`. Die Datei landet standardmaessig unter `${STORAGE_HOST_PATH}/backups`.
|
||||
|
||||
`restore.sh` ist bewusst bestaetigungspflichtig und startet erst nach Eingabe von `RESTORE`.
|
||||
`restore.sh` validiert ein Backup-ZIP, fordert den bestaetigten Risikotext an, erstellt vor dem Storage-Restore einen Snapshot und spielt Datenbank sowie Storage ueber CLI zurueck.
|
||||
|
||||
### Backup-Modul ab v0.9.1
|
||||
|
||||
Olympus nutzt fuer operative Backups jetzt ein dediziertes Modul:
|
||||
|
||||
- Hermes-Service: `backend/hermes/app/services/backup_service.py`
|
||||
- Hermes-API: `backend/hermes/app/api/backups.py`
|
||||
- Hermes-Schemas: `backend/hermes/app/schemas/backup.py`
|
||||
- Athena-Seite: `frontend/athena/app/backups/page.tsx`
|
||||
- Athena-BFF: `/api/backups/...`
|
||||
|
||||
Sicherheitsregeln:
|
||||
|
||||
- Backup-Dateien liegen ausschliesslich unter `${STORAGE_BASE_PATH}/backups`.
|
||||
- Der Backup-Ordner ist Teil des persistenten Storage-Volumes.
|
||||
- Hermes anonymisiert den Datenbank-Host im Manifest.
|
||||
- Passwoerter, komplette `DATABASE_URL`-Werte und Dateiinhalte werden nicht geloggt.
|
||||
- Restore bleibt in der Weboberflaeche bewusst deaktiviert und liefert vorbereitetes, aber sicheres `501`.
|
||||
|
||||
Hermes erzeugt Datenbank-Dumps ueber `pg_dump` im Custom-Format. Deshalb enthaelt das Hermes-Image ab v0.9.1 den `postgresql-client`.
|
||||
|
||||
### Knowledge-RBAC
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,46 @@ STORAGE_BASE_PATH=/data/storage
|
|||
|
||||
Bestehende Knowledge-Dateien aus alten Setups unter `/data/knowledge` werden nicht automatisch verschoben. Vor einer manuellen Migration immer Backup erstellen.
|
||||
|
||||
## Backup und Restore
|
||||
|
||||
Ab v0.9.1 nutzt Olympus ein serverseitiges Backup-Modul.
|
||||
|
||||
Ablage:
|
||||
|
||||
- Hermes schreibt Backups nach `${STORAGE_BASE_PATH}/backups`
|
||||
- Im lokalen Standard entspricht das `${STORAGE_HOST_PATH}/backups`
|
||||
- Backup-Dateien gehoeren nie ins Git
|
||||
|
||||
Inhalt eines Backups:
|
||||
|
||||
- `manifest.json`
|
||||
- `database.dump`
|
||||
- `storage/`
|
||||
|
||||
Athena stellt dafuer ausschliesslich Same-Origin-BFF-Routen bereit:
|
||||
|
||||
- `GET /api/backups`
|
||||
- `POST /api/backups/create`
|
||||
- `GET /api/backups/[filename]/download`
|
||||
- `POST /api/backups/[filename]/validate`
|
||||
- `POST /api/backups/[filename]/restore`
|
||||
- `DELETE /api/backups/[filename]/delete`
|
||||
|
||||
Hermes nutzt intern `pg_dump` im Custom-Format. Deshalb muss im Hermes-Container `postgresql-client` verfuegbar sein.
|
||||
|
||||
Automatischer Restore ist in v0.9.1 absichtlich deaktiviert. Vor jedem produktiven Restore gilt:
|
||||
|
||||
1. Backup validieren.
|
||||
2. Sicherheitsbestaetigung pruefen.
|
||||
3. Restore ueber `scripts/restore.sh <backup-zip>` ausfuehren.
|
||||
4. Ergebnis und Audit Logs kontrollieren.
|
||||
|
||||
Empfehlung fuer den Betrieb:
|
||||
|
||||
- Backups regelmaessig extern von `${STORAGE_HOST_PATH}/backups` sichern.
|
||||
- Backup-Dateien vor Offsite-Kopie verschluesseln.
|
||||
- Restore nur in Wartungsfenstern ausfuehren.
|
||||
|
||||
## Knowledge Workflow
|
||||
|
||||
Die Wissensdatenbank folgt lokal und produktiv diesem Ablauf:
|
||||
|
|
@ -210,6 +250,15 @@ POST /api/repairs/[id]/estimates/[estimateId]/lexware/prepare-invoice
|
|||
|
||||
v0.8.9 erstellt noch keine echte Rechnung automatisch. Die Aktion erzeugt eine validierte Payload-Zusammenfassung, Mapping-Informationen und einen `lexware_sync_records`-Eintrag.
|
||||
|
||||
Ab dem Buchhaltungsworkflow wird die UI-Aktion neutral als `In Buchhaltung übernehmen` gefuehrt. Der Benutzer kopiert Kundendaten und Positionen in Lexware Office, sevdesk oder eine andere Buchhaltungssoftware und markiert die Vorbereitung danach als `transferred`. Optional kann eine Buchhaltungsnotiz wie `Lexware RG-2026-154` gespeichert werden.
|
||||
|
||||
Exportstatus:
|
||||
|
||||
- `prepared`
|
||||
- `transferred`
|
||||
- `booked`
|
||||
- `cancelled`
|
||||
|
||||
Benachrichtigungen:
|
||||
|
||||
- Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`.
|
||||
|
|
|
|||
18
ROADMAP.md
18
ROADMAP.md
|
|
@ -190,6 +190,9 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
|||
- Manuelle Aktion "Lexware-Rechnung vorbereiten" fuer freigegebene KVs
|
||||
- Keine automatische Rechnungserstellung und kein automatischer Export bei KV-Freigabe
|
||||
- RBAC-Permissions `lexware.read`, `lexware.manage`, `lexware.export`
|
||||
- Neutraler Buchhaltungsworkflow mit `prepared`, `transferred`, `booked`, `cancelled`
|
||||
- UI-Aktion "In Buchhaltung übernehmen" mit Kopierhilfe fuer externe Buchhaltungssoftware
|
||||
- Buchhaltungsnotiz und Audit fuer manuell uebertragene Rechnungen
|
||||
|
||||
## v0.9.0 - Lexware Rechnungserstellung, geplant
|
||||
|
||||
|
|
@ -198,13 +201,24 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
|||
- Exportstatus und Fehlerbehebung im Olympus UI erweitern
|
||||
- Optionaler Download/Link zur Lexware-Rechnung
|
||||
|
||||
## v0.9.1 - Kundenportal, geplant
|
||||
## v0.9.1 - Backup und Restore
|
||||
|
||||
- Backup-Modul mit Athena-BFF und Hermes-Service-Layer
|
||||
- ZIP-Backups mit `manifest.json`, `database.dump` und `storage/`
|
||||
- Persistente Ablage unter `${STORAGE_BASE_PATH}/backups`
|
||||
- RBAC-Permissions `backup.read`, `backup.create`, `backup.download`, `backup.delete`, `backup.restore`
|
||||
- Audit- und Activity-Eintraege fuer Backup-Lebenszyklus
|
||||
- Backup-Seite in Athena mit Validierung, Download, Loeschen und Restore-Vorbereitung
|
||||
- CLI-Skripte `scripts/backup.sh` und `scripts/restore.sh`
|
||||
- Automatischer Restore bewusst deaktiviert; CLI-Restore bleibt der sichere Pfad
|
||||
|
||||
## v0.9.2 - Kundenportal, geplant
|
||||
|
||||
- `/portal/login` fuer spaeteren Kundenlogin
|
||||
- Separates Authentifizierungsmodell fuer Kunden
|
||||
- Keine Vermischung mit internen Olympus-Benutzern
|
||||
|
||||
## v0.9.2 - Tickets, geplant
|
||||
## v0.9.3 - Tickets, geplant
|
||||
|
||||
- Ticketverwaltung
|
||||
- Status- und Prioritaetsmodell
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
"""add accounting export status
|
||||
|
||||
Revision ID: d2e3f4a5b6c7
|
||||
Revises: c9d4e5f6a7b8
|
||||
Create Date: 2026-07-05 17:20:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "d2e3f4a5b6c7"
|
||||
down_revision: Union[str, Sequence[str], None] = "c9d4e5f6a7b8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("lexware_sync_records", sa.Column("export_status", sa.String(length=40), server_default="prepared", nullable=False))
|
||||
op.add_column("lexware_sync_records", sa.Column("accounting_note", sa.Text(), nullable=True))
|
||||
op.add_column("lexware_sync_records", sa.Column("transferred_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("lexware_sync_records", sa.Column("transferred_by_user_id", sa.Integer(), nullable=True))
|
||||
op.create_index(op.f("ix_lexware_sync_records_export_status"), "lexware_sync_records", ["export_status"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), "lexware_sync_records", ["transferred_by_user_id"], unique=False)
|
||||
|
||||
op.add_column("repair_estimates", sa.Column("accounting_export_status", sa.String(length=40), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_note", sa.Text(), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_transferred_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_transferred_by_user_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_repair_estimates_accounting_transferred_by_user_id",
|
||||
"repair_estimates",
|
||||
"users",
|
||||
["accounting_transferred_by_user_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_repair_estimates_accounting_transferred_by_user_id", "repair_estimates", type_="foreignkey")
|
||||
op.drop_column("repair_estimates", "accounting_transferred_by_user_id")
|
||||
op.drop_column("repair_estimates", "accounting_transferred_at")
|
||||
op.drop_column("repair_estimates", "accounting_note")
|
||||
op.drop_column("repair_estimates", "accounting_export_status")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_export_status"), table_name="lexware_sync_records")
|
||||
op.drop_column("lexware_sync_records", "transferred_by_user_id")
|
||||
op.drop_column("lexware_sync_records", "transferred_at")
|
||||
op.drop_column("lexware_sync_records", "accounting_note")
|
||||
op.drop_column("lexware_sync_records", "export_status")
|
||||
|
|
@ -64,6 +64,10 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
|
|||
return "inventory.read" in permissions
|
||||
if action.startswith("lexware."):
|
||||
return "lexware.read" in permissions
|
||||
if action.startswith("accounting."):
|
||||
return "lexware.read" in permissions
|
||||
if action.startswith("backups."):
|
||||
return "backup.read" in permissions
|
||||
if action.startswith("audit_logs."):
|
||||
return "audit_logs.read" in permissions
|
||||
if action.startswith("auth."):
|
||||
|
|
|
|||
150
backend/hermes/app/api/backups.py
Normal file
150
backend/hermes/app/api/backups.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.rbac import require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.api_response import ApiSuccess
|
||||
from app.schemas.backup import BackupRestoreRequest
|
||||
from app.services.audit_service import write_audit_log
|
||||
from app.services.backup_service import BackupService
|
||||
|
||||
router = APIRouter(prefix="/backups", tags=["Backups"])
|
||||
|
||||
|
||||
@router.get("", response_model=ApiSuccess)
|
||||
def list_backups(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.read")),
|
||||
):
|
||||
return ApiSuccess(data=BackupService.list_backups(), message="Backups geladen")
|
||||
|
||||
|
||||
@router.post("/create", response_model=ApiSuccess, status_code=status.HTTP_201_CREATED)
|
||||
def create_backup(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.create")),
|
||||
):
|
||||
backup = BackupService.create_backup(actor=current_user)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.create",
|
||||
entity_type="backup",
|
||||
entity_label=backup.filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={
|
||||
"filename": backup.filename,
|
||||
"size_bytes": backup.size_bytes,
|
||||
"app_version": backup.app_version,
|
||||
},
|
||||
)
|
||||
return ApiSuccess(data=backup, message="Backup erstellt")
|
||||
|
||||
|
||||
@router.get("/{filename}/download")
|
||||
def download_backup(
|
||||
filename: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.download")),
|
||||
):
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.download",
|
||||
entity_type="backup",
|
||||
entity_label=path.name,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": path.name, "size_bytes": path.stat().st_size},
|
||||
)
|
||||
return FileResponse(path=path, media_type="application/zip", filename=path.name)
|
||||
|
||||
|
||||
@router.delete("/{filename}", response_model=ApiSuccess)
|
||||
def delete_backup(
|
||||
filename: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.delete")),
|
||||
):
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
size_bytes = path.stat().st_size
|
||||
BackupService.delete_backup(filename)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.delete",
|
||||
entity_type="backup",
|
||||
entity_label=path.name,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": path.name, "size_bytes": size_bytes},
|
||||
)
|
||||
return ApiSuccess(message="Backup geloescht")
|
||||
|
||||
|
||||
@router.post("/{filename}/restore/validate", response_model=ApiSuccess)
|
||||
def validate_backup_restore(
|
||||
filename: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.restore")),
|
||||
):
|
||||
validation = BackupService.validate_backup(filename)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.validate",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename, "valid": validation.valid, "issues": validation.issues},
|
||||
)
|
||||
return ApiSuccess(data=validation, message=validation.message)
|
||||
|
||||
|
||||
@router.post("/{filename}/restore", response_model=ApiSuccess)
|
||||
def restore_backup(
|
||||
filename: str,
|
||||
payload: BackupRestoreRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("backup.restore")),
|
||||
):
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.restore_started",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename},
|
||||
)
|
||||
try:
|
||||
validation = BackupService.restore_backup(filename, confirm_text=payload.confirm_text)
|
||||
except Exception:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.restore_failed",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename},
|
||||
)
|
||||
raise
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="backups.restore_completed",
|
||||
entity_type="backup",
|
||||
entity_label=filename,
|
||||
actor=current_user,
|
||||
request=request,
|
||||
metadata={"filename": filename},
|
||||
)
|
||||
return ApiSuccess(data=validation, message="Restore abgeschlossen")
|
||||
|
|
@ -4,10 +4,12 @@ from fastapi import APIRouter, Depends
|
|||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.rbac import get_user_permission_names, require_permission
|
||||
from app.db.database import get_db
|
||||
from app.models.rbac import Role
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.lexware import LexwareSyncRecord
|
||||
from app.models.user import User
|
||||
from app.repositories.customer_repository import CustomerRepository
|
||||
from app.repositories.inventory_repository import InventoryRepository
|
||||
|
|
@ -15,6 +17,7 @@ from app.repositories.repair_repository import RepairRepository
|
|||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
|
||||
from app.services.backup_service import BackupService
|
||||
from app.services.system_settings_service import SystemSettingsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -85,6 +88,34 @@ def get_dashboard_summary(
|
|||
MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)),
|
||||
])
|
||||
|
||||
if "lexware.read" in permissions:
|
||||
repairs.extend([
|
||||
MetricCard(
|
||||
label="Vorbereitete Rechnungen",
|
||||
value=db.scalar(
|
||||
select(func.count(LexwareSyncRecord.id))
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.where(LexwareSyncRecord.export_status == "prepared")
|
||||
) or 0,
|
||||
),
|
||||
MetricCard(
|
||||
label="An Buchhaltung übergeben",
|
||||
value=db.scalar(
|
||||
select(func.count(LexwareSyncRecord.id))
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.where(LexwareSyncRecord.export_status == "transferred")
|
||||
) or 0,
|
||||
),
|
||||
MetricCard(
|
||||
label="Noch nicht übertragen",
|
||||
value=db.scalar(
|
||||
select(func.count(LexwareSyncRecord.id))
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.where(LexwareSyncRecord.export_status == "prepared")
|
||||
) or 0,
|
||||
),
|
||||
])
|
||||
|
||||
if "inventory.read" in permissions:
|
||||
inventory = [
|
||||
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),
|
||||
|
|
@ -133,6 +164,27 @@ def get_dashboard_summary(
|
|||
),
|
||||
]
|
||||
|
||||
if "backup.read" in permissions:
|
||||
backup_stats = BackupService.get_backup_stats()
|
||||
repairs.append(MetricCard(label="Backups", value=backup_stats.total_count))
|
||||
system_status.extend([
|
||||
SystemStatusItem(
|
||||
label="Letztes Backup",
|
||||
value=backup_stats.latest_backup_at.isoformat() if backup_stats.latest_backup_at else "Noch kein Backup",
|
||||
status="ok" if backup_stats.latest_backup_at else "warning",
|
||||
),
|
||||
SystemStatusItem(
|
||||
label="Backup-Speicher",
|
||||
value=f"{backup_stats.total_count} Backup(s), {backup_stats.total_size_bytes} Bytes",
|
||||
status="ok" if backup_stats.total_count else "warning",
|
||||
),
|
||||
SystemStatusItem(
|
||||
label="Hermes-Version",
|
||||
value=settings.app_version,
|
||||
status="info",
|
||||
),
|
||||
])
|
||||
|
||||
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
|
||||
|
||||
return DashboardSummary(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ from app.models.repair_estimate import RepairEstimate
|
|||
from app.models.user import User
|
||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.lexware import LexwareInvoicePreparationResponse, LexwareSettingsResponse, LexwareSettingsUpdate, LexwareTestConnectionResponse
|
||||
from app.schemas.lexware import (
|
||||
AccountingTransferUpdate,
|
||||
LexwareInvoicePreparationResponse,
|
||||
LexwareSettingsResponse,
|
||||
LexwareSettingsUpdate,
|
||||
LexwareTestConnectionResponse,
|
||||
)
|
||||
from app.services.lexware_service import LexwareService
|
||||
|
||||
router = APIRouter(tags=["Lexware"])
|
||||
|
|
@ -67,3 +73,17 @@ def prepare_lexware_invoice(
|
|||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return LexwareService.prepare_invoice(db, repair, estimate, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/accounting/mark-transferred", response_model=LexwareInvoicePreparationResponse)
|
||||
def mark_accounting_transferred(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
payload: AccountingTransferUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.export")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return LexwareService.mark_transferred(db, repair, estimate, payload, actor=current_user, request=request)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class Settings(BaseSettings):
|
|||
secret_key: str
|
||||
|
||||
app_name: str = "Hermes API"
|
||||
app_version: str = "0.1.0"
|
||||
app_version: str = "0.9.1"
|
||||
access_token_expire_minutes: int = 60
|
||||
jwt_issuer: str = "hermes"
|
||||
log_level: str = "INFO"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import app.models.customer
|
|||
import app.models.knowledge
|
||||
import app.models.audit
|
||||
import app.models.user
|
||||
import app.models.inventory
|
||||
import app.models.repair
|
||||
import app.models.repair_estimate
|
||||
import app.models.system_setting
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from starlette.requests import Request
|
|||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.audit import router as audit_router
|
||||
from app.api.backups import router as backups_router
|
||||
from app.api.customers import router as customers_router
|
||||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.inventory import router as inventory_router
|
||||
|
|
@ -33,12 +34,13 @@ configure_logging()
|
|||
|
||||
app = FastAPI(
|
||||
title="Hermes API",
|
||||
version="0.1.0",
|
||||
version="0.9.1",
|
||||
description="Backend von Olympus",
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(backups_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(roles_router)
|
||||
app.include_router(permissions_router)
|
||||
|
|
|
|||
|
|
@ -103,7 +103,11 @@ class InventoryStockMovement(Base):
|
|||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
item: Mapped[InventoryItem] = relationship(back_populates="movements")
|
||||
actor = relationship("User", lazy="joined")
|
||||
actor = relationship(
|
||||
"User",
|
||||
foreign_keys="InventoryStockMovement.actor_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
@property
|
||||
def actor_username(self) -> str:
|
||||
|
|
|
|||
|
|
@ -16,8 +16,12 @@ class LexwareSyncRecord(Base):
|
|||
lexware_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="pending", server_default="pending", index=True)
|
||||
direction: Mapped[str] = mapped_column(String(40), default="push", server_default="push", index=True)
|
||||
export_status: Mapped[str] = mapped_column(String(40), default="prepared", server_default="prepared", index=True)
|
||||
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
transferred_by_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,11 @@ class RepairStatusHistory(Base):
|
|||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
repair: Mapped[Repair] = relationship(back_populates="history")
|
||||
actor = relationship("User", lazy="joined")
|
||||
actor = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairStatusHistory.actor_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
@property
|
||||
def actor_username(self) -> str:
|
||||
|
|
@ -105,7 +109,11 @@ class RepairDocument(Base):
|
|||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
repair: Mapped[Repair] = relationship(back_populates="documents")
|
||||
uploaded_by = relationship("User", lazy="joined")
|
||||
uploaded_by = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairDocument.uploaded_by_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
|
||||
@property
|
||||
def uploaded_by_username(self) -> str:
|
||||
|
|
|
|||
|
|
@ -31,12 +31,25 @@ class RepairEstimate(Base):
|
|||
lexware_invoice_number: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
lexware_invoice_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
lexware_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
accounting_export_status: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
accounting_transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
accounting_transferred_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
repair = relationship("Repair", lazy="joined")
|
||||
created_by = relationship("User", lazy="joined")
|
||||
created_by = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairEstimate.created_by_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
accounting_transferred_by = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairEstimate.accounting_transferred_by_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
items: Mapped[list["RepairEstimateItem"]] = relationship(
|
||||
back_populates="estimate",
|
||||
cascade="all, delete-orphan",
|
||||
|
|
@ -88,4 +101,8 @@ class RepairEstimateEvent(Base):
|
|||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
estimate: Mapped[RepairEstimate] = relationship(back_populates="events")
|
||||
actor = relationship("User", lazy="joined")
|
||||
actor = relationship(
|
||||
"User",
|
||||
foreign_keys="RepairEstimateEvent.actor_user_id",
|
||||
lazy="joined",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -102,6 +102,11 @@ STANDARD_PERMISSIONS = [
|
|||
("lexware.read", "Lexware lesen", "Lexware-Integration anzeigen", "lexware"),
|
||||
("lexware.manage", "Lexware verwalten", "Lexware-Konfiguration verwalten", "lexware"),
|
||||
("lexware.export", "Lexware exportieren", "Rechnungen für Lexware vorbereiten und exportieren", "lexware"),
|
||||
("backup.read", "Backups lesen", "Backups und Backup-Status anzeigen", "backup"),
|
||||
("backup.create", "Backups erstellen", "Neue Backups erzeugen", "backup"),
|
||||
("backup.download", "Backups herunterladen", "Backup-Dateien herunterladen", "backup"),
|
||||
("backup.delete", "Backups loeschen", "Backup-Dateien loeschen", "backup"),
|
||||
("backup.restore", "Backups wiederherstellen", "Backup-Validierung und Restore vorbereiten", "backup"),
|
||||
]
|
||||
|
||||
ROLE_PERMISSION_NAMES = {
|
||||
|
|
@ -134,6 +139,9 @@ ROLE_PERMISSION_NAMES = {
|
|||
"lexware.read",
|
||||
"lexware.manage",
|
||||
"lexware.export",
|
||||
"backup.read",
|
||||
"backup.create",
|
||||
"backup.download",
|
||||
},
|
||||
"sales": {
|
||||
"dashboard.read",
|
||||
|
|
|
|||
57
backend/hermes/app/schemas/backup.py
Normal file
57
backend/hermes/app/schemas/backup.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BackupManifest(BaseModel):
|
||||
backup_id: str
|
||||
created_at: datetime
|
||||
app_version: str
|
||||
backup_type: str = "full"
|
||||
database_url_host_anonymized: str
|
||||
database_name: str
|
||||
storage_base_path: str
|
||||
included_sections: list[str] = Field(default_factory=list)
|
||||
file_count: int = 0
|
||||
total_size_bytes: int = 0
|
||||
checksum_sha256: str
|
||||
created_by_user_id: int | None = None
|
||||
created_by_username: str = ""
|
||||
|
||||
|
||||
class BackupSummary(BaseModel):
|
||||
filename: str
|
||||
size_bytes: int
|
||||
created_at: datetime | None = None
|
||||
app_version: str = ""
|
||||
backup_type: str = "full"
|
||||
database_name: str = ""
|
||||
storage_base_path: str = ""
|
||||
file_count: int = 0
|
||||
total_size_bytes: int = 0
|
||||
created_by_user_id: int | None = None
|
||||
created_by_username: str = ""
|
||||
validation_status: str = "valid"
|
||||
validation_message: str = ""
|
||||
|
||||
|
||||
class BackupListResponse(BaseModel):
|
||||
items: list[BackupSummary] = Field(default_factory=list)
|
||||
total_count: int = 0
|
||||
total_size_bytes: int = 0
|
||||
latest_backup_at: datetime | None = None
|
||||
|
||||
|
||||
class BackupValidationResponse(BaseModel):
|
||||
filename: str
|
||||
valid: bool
|
||||
message: str
|
||||
issues: list[str] = Field(default_factory=list)
|
||||
checksum_valid: bool = False
|
||||
restore_supported: bool = False
|
||||
requires_cli_restore: bool = True
|
||||
manifest: BackupManifest | None = None
|
||||
|
||||
|
||||
class BackupRestoreRequest(BaseModel):
|
||||
confirm_text: str
|
||||
|
|
@ -8,6 +8,7 @@ from app.schemas.system_setting import SettingsSource, normalize_text
|
|||
|
||||
LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"]
|
||||
LexwareSyncDirection = Literal["push", "pull"]
|
||||
AccountingExportStatus = Literal["prepared", "transferred", "booked", "cancelled"]
|
||||
|
||||
|
||||
class LexwareSettingsResponse(BaseModel):
|
||||
|
|
@ -73,9 +74,23 @@ class LexwareLineItemMapping(BaseModel):
|
|||
|
||||
class LexwareInvoicePreparationResponse(BaseModel):
|
||||
ready_for_export: bool
|
||||
export_status: AccountingExportStatus
|
||||
payload_summary: dict
|
||||
customer_mapping: LexwareCustomerMapping
|
||||
line_item_mapping: list[LexwareLineItemMapping]
|
||||
tax_mapping: dict
|
||||
warnings: list[str]
|
||||
sync_record_id: int
|
||||
accounting_note: str = ""
|
||||
transferred_at: str | None = None
|
||||
transferred_by_user_id: int | None = None
|
||||
|
||||
|
||||
class AccountingTransferUpdate(BaseModel):
|
||||
accounting_note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
@field_validator("accounting_note", mode="before")
|
||||
@classmethod
|
||||
def normalize_note(cls, value: object) -> str | None:
|
||||
text = normalize_text(value)
|
||||
return text or None
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@ class RepairEstimateResponse(BaseModel):
|
|||
lexware_invoice_number: str | None
|
||||
lexware_invoice_status: str | None
|
||||
lexware_synced_at: datetime | None
|
||||
accounting_export_status: str | None
|
||||
accounting_note: str | None
|
||||
accounting_transferred_at: datetime | None
|
||||
accounting_transferred_by_user_id: int | None
|
||||
created_by_user_id: int | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
|
|
|||
|
|
@ -212,6 +212,16 @@ def action_title(action: str) -> str:
|
|||
"lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen",
|
||||
"lexware.invoice.prepare": "Lexware Rechnung vorbereitet",
|
||||
"lexware.invoice.export_failed": "Lexware Export fehlgeschlagen",
|
||||
"accounting.invoice.handoff": "Rechnung an Buchhaltung übergeben",
|
||||
"accounting.invoice.mark_transferred": "Rechnung als übertragen markiert",
|
||||
"accounting.invoice.note_update": "Buchhaltungsnotiz geändert",
|
||||
"backups.create": "Backup erstellt",
|
||||
"backups.download": "Backup heruntergeladen",
|
||||
"backups.delete": "Backup gelöscht",
|
||||
"backups.validate": "Backup validiert",
|
||||
"backups.restore_started": "Restore gestartet",
|
||||
"backups.restore_failed": "Restore fehlgeschlagen",
|
||||
"backups.restore_completed": "Restore abgeschlossen",
|
||||
}
|
||||
return labels.get(action, action)
|
||||
|
||||
|
|
|
|||
373
backend/hermes/app/services/backup_service.py
Normal file
373
backend/hermes/app/services/backup_service.py
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.backup import (
|
||||
BackupListResponse,
|
||||
BackupManifest,
|
||||
BackupSummary,
|
||||
BackupValidationResponse,
|
||||
)
|
||||
|
||||
BACKUP_FILENAME_PREFIX = "olympus-backup-"
|
||||
BACKUP_FILENAME_SUFFIX = ".zip"
|
||||
DATABASE_DUMP_NAME = "database.dump"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
STORAGE_DIR_NAME = "storage"
|
||||
BACKUP_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO"
|
||||
RESTORE_DISABLED_MESSAGE = (
|
||||
"Automatischer Restore ist vorbereitet, aber in v0.9.1 deaktiviert. "
|
||||
"Bitte Restore ueber CLI-Script ausfuehren."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackupStats:
|
||||
total_count: int
|
||||
total_size_bytes: int
|
||||
latest_backup_at: datetime | None
|
||||
|
||||
|
||||
class BackupService:
|
||||
@staticmethod
|
||||
def get_backup_dir() -> Path:
|
||||
backup_dir = (Path(settings.storage_base_path) / "backups").resolve()
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
return backup_dir
|
||||
|
||||
@staticmethod
|
||||
def list_backups() -> BackupListResponse:
|
||||
items = [
|
||||
BackupService._read_summary(path)
|
||||
for path in sorted(
|
||||
BackupService.get_backup_dir().glob(f"{BACKUP_FILENAME_PREFIX}*{BACKUP_FILENAME_SUFFIX}"),
|
||||
key=lambda item: item.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
]
|
||||
latest_backup_at = next((item.created_at for item in items if item.created_at is not None), None)
|
||||
return BackupListResponse(
|
||||
items=items,
|
||||
total_count=len(items),
|
||||
total_size_bytes=sum(item.size_bytes for item in items),
|
||||
latest_backup_at=latest_backup_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_backup_stats() -> BackupStats:
|
||||
backups = BackupService.list_backups()
|
||||
return BackupStats(
|
||||
total_count=backups.total_count,
|
||||
total_size_bytes=backups.total_size_bytes,
|
||||
latest_backup_at=backups.latest_backup_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_backup(*, actor: User) -> BackupSummary:
|
||||
backup_dir = BackupService.get_backup_dir()
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||
filename = f"{BACKUP_FILENAME_PREFIX}{timestamp}{BACKUP_FILENAME_SUFFIX}"
|
||||
target_path = backup_dir / filename
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="backup-", dir=backup_dir) as temp_dir_name:
|
||||
temp_dir = Path(temp_dir_name)
|
||||
dump_path = temp_dir / DATABASE_DUMP_NAME
|
||||
storage_temp_dir = temp_dir / STORAGE_DIR_NAME
|
||||
manifest_path = temp_dir / MANIFEST_NAME
|
||||
archive_path = temp_dir / filename
|
||||
|
||||
BackupService._run_pg_dump(dump_path)
|
||||
file_count, total_size_bytes = BackupService._copy_storage_snapshot(storage_temp_dir)
|
||||
dump_size = dump_path.stat().st_size
|
||||
checksum_sha256 = BackupService._calculate_archive_checksum(
|
||||
dump_path=dump_path,
|
||||
storage_dir=storage_temp_dir,
|
||||
)
|
||||
|
||||
manifest = BackupManifest(
|
||||
backup_id=hashlib.sha256(f"{filename}:{actor.id}:{timestamp}".encode("utf-8")).hexdigest()[:24],
|
||||
created_at=datetime.now(UTC),
|
||||
app_version=settings.app_version,
|
||||
backup_type="full",
|
||||
database_url_host_anonymized=BackupService._anonymized_database_host(),
|
||||
database_name=BackupService._database_name(),
|
||||
storage_base_path=settings.storage_base_path,
|
||||
included_sections=["database", "storage"],
|
||||
file_count=file_count + 1,
|
||||
total_size_bytes=total_size_bytes + dump_size,
|
||||
checksum_sha256=checksum_sha256,
|
||||
created_by_user_id=actor.id,
|
||||
created_by_username=actor.username,
|
||||
)
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest.model_dump(mode="json"), indent=2, ensure_ascii=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
BackupService._write_archive(
|
||||
archive_path=archive_path,
|
||||
manifest_path=manifest_path,
|
||||
dump_path=dump_path,
|
||||
storage_dir=storage_temp_dir,
|
||||
)
|
||||
shutil.move(str(archive_path), target_path)
|
||||
|
||||
return BackupService._read_summary(target_path)
|
||||
|
||||
@staticmethod
|
||||
def validate_backup(filename: str) -> BackupValidationResponse:
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
issues: list[str] = []
|
||||
manifest: BackupManifest | None = None
|
||||
checksum_valid = False
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = set(archive.namelist())
|
||||
if MANIFEST_NAME not in names:
|
||||
issues.append("manifest.json fehlt")
|
||||
if DATABASE_DUMP_NAME not in names:
|
||||
issues.append("database.dump fehlt")
|
||||
if not any(name == f"{STORAGE_DIR_NAME}/" or name.startswith(f"{STORAGE_DIR_NAME}/") for name in names):
|
||||
issues.append("storage/ fehlt")
|
||||
|
||||
if MANIFEST_NAME in names:
|
||||
try:
|
||||
with archive.open(MANIFEST_NAME) as manifest_file:
|
||||
manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
|
||||
except Exception:
|
||||
issues.append("manifest.json ist ungueltig")
|
||||
|
||||
if manifest is not None:
|
||||
checksum_valid = BackupService._validate_archive_checksum(archive, manifest.checksum_sha256)
|
||||
if not checksum_valid:
|
||||
issues.append("Checksumme ist ungueltig")
|
||||
except zipfile.BadZipFile:
|
||||
issues.append("ZIP-Datei ist ungueltig")
|
||||
|
||||
valid = len(issues) == 0
|
||||
return BackupValidationResponse(
|
||||
filename=path.name,
|
||||
valid=valid,
|
||||
message="Backup ist gueltig" if valid else "Backup-Pruefung fehlgeschlagen",
|
||||
issues=issues,
|
||||
checksum_valid=checksum_valid,
|
||||
restore_supported=False,
|
||||
requires_cli_restore=True,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def restore_backup(filename: str, *, confirm_text: str) -> BackupValidationResponse:
|
||||
if confirm_text != BACKUP_CONFIRM_TEXT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Bestaetigungstext stimmt nicht ueberein",
|
||||
)
|
||||
|
||||
validation = BackupService.validate_backup(filename)
|
||||
if not validation.valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Backup ist ungueltig und kann nicht wiederhergestellt werden",
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail=RESTORE_DISABLED_MESSAGE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_backup(filename: str) -> None:
|
||||
path = BackupService.resolve_backup_path(filename)
|
||||
path.unlink(missing_ok=False)
|
||||
|
||||
@staticmethod
|
||||
def resolve_backup_path(filename: str) -> Path:
|
||||
if not filename.endswith(BACKUP_FILENAME_SUFFIX):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||
if Path(filename).name != filename or ".." in Path(filename).parts:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||
|
||||
path = (BackupService.get_backup_dir() / filename).resolve()
|
||||
backup_dir = BackupService.get_backup_dir()
|
||||
if backup_dir != path.parent or not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup nicht gefunden")
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _run_pg_dump(dump_path: Path) -> None:
|
||||
pg_dump_url = BackupService._pg_dump_database_url()
|
||||
command = [
|
||||
"pg_dump",
|
||||
"--format=custom",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
f"--file={dump_path}",
|
||||
f"--dbname={pg_dump_url}",
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="pg_dump ist im Hermes-Container nicht verfuegbar",
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="PostgreSQL-Dump konnte nicht erstellt werden",
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _copy_storage_snapshot(target_dir: Path) -> tuple[int, int]:
|
||||
source_dir = Path(settings.storage_base_path).resolve()
|
||||
backup_dir = BackupService.get_backup_dir()
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_count = 0
|
||||
total_size_bytes = 0
|
||||
|
||||
for source_path in sorted(source_dir.rglob("*")):
|
||||
if source_path == backup_dir or backup_dir in source_path.parents:
|
||||
continue
|
||||
relative_path = source_path.relative_to(source_dir)
|
||||
destination_path = target_dir / relative_path
|
||||
if source_path.is_dir():
|
||||
destination_path.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if not source_path.is_file():
|
||||
continue
|
||||
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_path, destination_path)
|
||||
file_count += 1
|
||||
total_size_bytes += source_path.stat().st_size
|
||||
|
||||
return file_count, total_size_bytes
|
||||
|
||||
@staticmethod
|
||||
def _write_archive(
|
||||
*,
|
||||
archive_path: Path,
|
||||
manifest_path: Path,
|
||||
dump_path: Path,
|
||||
storage_dir: Path,
|
||||
) -> None:
|
||||
with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr(f"{STORAGE_DIR_NAME}/", "")
|
||||
archive.write(manifest_path, MANIFEST_NAME)
|
||||
archive.write(dump_path, DATABASE_DUMP_NAME)
|
||||
for file_path in sorted(storage_dir.rglob("*")):
|
||||
if file_path.is_dir():
|
||||
continue
|
||||
archive.write(file_path, file_path.relative_to(storage_dir.parent).as_posix())
|
||||
|
||||
@staticmethod
|
||||
def _read_summary(path: Path) -> BackupSummary:
|
||||
default_summary = BackupSummary(
|
||||
filename=path.name,
|
||||
size_bytes=path.stat().st_size,
|
||||
validation_status="warning",
|
||||
validation_message="Manifest konnte nicht gelesen werden",
|
||||
)
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
with archive.open(MANIFEST_NAME) as manifest_file:
|
||||
manifest = BackupManifest.model_validate_json(manifest_file.read().decode("utf-8"))
|
||||
return BackupSummary(
|
||||
filename=path.name,
|
||||
size_bytes=path.stat().st_size,
|
||||
created_at=manifest.created_at,
|
||||
app_version=manifest.app_version,
|
||||
backup_type=manifest.backup_type,
|
||||
database_name=manifest.database_name,
|
||||
storage_base_path=manifest.storage_base_path,
|
||||
file_count=manifest.file_count,
|
||||
total_size_bytes=manifest.total_size_bytes,
|
||||
created_by_user_id=manifest.created_by_user_id,
|
||||
created_by_username=manifest.created_by_username,
|
||||
validation_status="valid",
|
||||
validation_message="Backup ist lesbar",
|
||||
)
|
||||
except Exception:
|
||||
return default_summary
|
||||
|
||||
@staticmethod
|
||||
def _calculate_archive_checksum(*, dump_path: Path, storage_dir: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
|
||||
BackupService._update_digest_from_file(digest, dump_path)
|
||||
|
||||
for file_path in sorted(storage_dir.rglob("*")):
|
||||
if file_path.is_dir():
|
||||
continue
|
||||
digest.update(file_path.relative_to(storage_dir.parent).as_posix().encode("utf-8"))
|
||||
BackupService._update_digest_from_file(digest, file_path)
|
||||
|
||||
return digest.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _validate_archive_checksum(archive: zipfile.ZipFile, expected_checksum: str) -> bool:
|
||||
digest = hashlib.sha256()
|
||||
if DATABASE_DUMP_NAME not in archive.namelist():
|
||||
return False
|
||||
|
||||
digest.update(DATABASE_DUMP_NAME.encode("utf-8"))
|
||||
with archive.open(DATABASE_DUMP_NAME) as dump_file:
|
||||
BackupService._update_digest_from_stream(digest, dump_file)
|
||||
|
||||
for name in sorted(item for item in archive.namelist() if item.startswith(f"{STORAGE_DIR_NAME}/") and not item.endswith("/")):
|
||||
digest.update(name.encode("utf-8"))
|
||||
with archive.open(name) as storage_file:
|
||||
BackupService._update_digest_from_stream(digest, storage_file)
|
||||
|
||||
return digest.hexdigest() == expected_checksum
|
||||
|
||||
@staticmethod
|
||||
def _anonymized_database_host() -> str:
|
||||
parsed = make_url(settings.database_url)
|
||||
host = parsed.host or "unknown"
|
||||
digest = hashlib.sha256(host.encode("utf-8")).hexdigest()[:12]
|
||||
return f"sha256:{digest}"
|
||||
|
||||
@staticmethod
|
||||
def _database_name() -> str:
|
||||
parsed = make_url(settings.database_url)
|
||||
return parsed.database or "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _pg_dump_database_url() -> str:
|
||||
parsed = make_url(settings.database_url)
|
||||
normalized = parsed.set(drivername="postgresql")
|
||||
return normalized.render_as_string(hide_password=False)
|
||||
|
||||
@staticmethod
|
||||
def _update_digest_from_file(digest, file_path: Path) -> None:
|
||||
with file_path.open("rb") as file_handle:
|
||||
BackupService._update_digest_from_stream(digest, file_handle)
|
||||
|
||||
@staticmethod
|
||||
def _update_digest_from_stream(digest, stream) -> None:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request as UrlRequest
|
||||
|
|
@ -16,6 +17,7 @@ from app.models.repair_estimate import RepairEstimate
|
|||
from app.models.user import User
|
||||
from app.repositories.system_settings_repository import SystemSettingsRepository
|
||||
from app.schemas.lexware import (
|
||||
AccountingTransferUpdate,
|
||||
LexwareCustomerMapping,
|
||||
LexwareInvoicePreparationResponse,
|
||||
LexwareLineItemMapping,
|
||||
|
|
@ -276,7 +278,160 @@ class LexwareService:
|
|||
)
|
||||
for item in estimate.items
|
||||
]
|
||||
payload_summary = {
|
||||
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
|
||||
record = LexwareSyncRecord(
|
||||
entity_type="repair_estimate",
|
||||
entity_id=estimate.id,
|
||||
lexware_resource_type="invoice",
|
||||
status="pending" if not warnings else "skipped",
|
||||
direction="push",
|
||||
export_status="prepared",
|
||||
accounting_note=estimate.accounting_note,
|
||||
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
|
||||
error_message="; ".join(warnings) if warnings else None,
|
||||
)
|
||||
estimate.accounting_export_status = "prepared"
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
db.refresh(estimate)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.handoff",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"ready_for_export": not warnings,
|
||||
"sync_record_id": record.id,
|
||||
"export_status": record.export_status,
|
||||
},
|
||||
)
|
||||
return LexwareService._invoice_preparation_response(
|
||||
record=record,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_items=line_items,
|
||||
estimate=estimate,
|
||||
config=config,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def mark_transferred(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
payload: AccountingTransferUpdate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> LexwareInvoicePreparationResponse:
|
||||
record = LexwareService._latest_invoice_record(db, estimate.id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Rechnungsvorbereitung gefunden")
|
||||
if record.export_status not in {"prepared", "transferred"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Diese Rechnungsvorbereitung kann nicht als übertragen markiert werden")
|
||||
|
||||
note_changed = payload.accounting_note is not None and payload.accounting_note != (estimate.accounting_note or "")
|
||||
now = datetime.now(UTC)
|
||||
record.export_status = "transferred"
|
||||
record.status = "success"
|
||||
record.accounting_note = payload.accounting_note if payload.accounting_note is not None else record.accounting_note
|
||||
record.transferred_at = now
|
||||
record.transferred_by_user_id = actor.id
|
||||
record.synced_at = now
|
||||
estimate.accounting_export_status = "transferred"
|
||||
estimate.accounting_note = record.accounting_note
|
||||
estimate.accounting_transferred_at = now
|
||||
estimate.accounting_transferred_by_user_id = actor.id
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
db.refresh(estimate)
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.mark_transferred",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"sync_record_id": record.id,
|
||||
"export_status": record.export_status,
|
||||
"transferred_at": record.transferred_at,
|
||||
},
|
||||
)
|
||||
if note_changed:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.note_update",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "sync_record_id": record.id},
|
||||
)
|
||||
|
||||
config = LexwareService.get_runtime_config(db)
|
||||
customer_mapping, line_items = LexwareService._invoice_mapping(repair, estimate)
|
||||
warnings: list[str] = []
|
||||
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
|
||||
return LexwareService._invoice_preparation_response(
|
||||
record=record,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_items=line_items,
|
||||
estimate=estimate,
|
||||
config=config,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _invoice_mapping(repair: Repair, estimate: RepairEstimate) -> tuple[LexwareCustomerMapping, list[LexwareLineItemMapping]]:
|
||||
customer_payload = {
|
||||
"roles": {"customer": {}},
|
||||
"company": {"name": repair.customer_name},
|
||||
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
|
||||
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
|
||||
}
|
||||
customer_mapping = LexwareCustomerMapping(
|
||||
name=repair.customer_name,
|
||||
email=repair.customer_email,
|
||||
phone=repair.customer_phone,
|
||||
search_strategy="email" if repair.customer_email else "name",
|
||||
create_payload=customer_payload,
|
||||
)
|
||||
line_items = [
|
||||
LexwareLineItemMapping(
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
quantity=item.quantity,
|
||||
unit=item.unit,
|
||||
unit_price=_euros(item.unit_price_cents),
|
||||
tax_rate=estimate.tax_rate_percent,
|
||||
total=_euros(item.total_cents),
|
||||
)
|
||||
for item in estimate.items
|
||||
]
|
||||
return customer_mapping, line_items
|
||||
|
||||
@staticmethod
|
||||
def _invoice_payload_summary(
|
||||
config: LexwareRuntimeConfig,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
line_items: list[LexwareLineItemMapping],
|
||||
) -> dict:
|
||||
return {
|
||||
"type": "invoice",
|
||||
"title": f"Rechnung zu Reparatur {repair.repair_number}",
|
||||
"introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_number}.",
|
||||
|
|
@ -289,35 +444,21 @@ class LexwareService:
|
|||
"total": str(_euros(estimate.total_cents)),
|
||||
"line_item_count": len(line_items),
|
||||
}
|
||||
record = LexwareSyncRecord(
|
||||
entity_type="repair_estimate",
|
||||
entity_id=estimate.id,
|
||||
lexware_resource_type="invoice",
|
||||
status="pending" if not warnings else "skipped",
|
||||
direction="push",
|
||||
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
|
||||
error_message="; ".join(warnings) if warnings else None,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="lexware.invoice.prepare",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"ready_for_export": not warnings,
|
||||
"sync_record_id": record.id,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _invoice_preparation_response(
|
||||
*,
|
||||
record: LexwareSyncRecord,
|
||||
payload_summary: dict,
|
||||
customer_mapping: LexwareCustomerMapping,
|
||||
line_items: list[LexwareLineItemMapping],
|
||||
estimate: RepairEstimate,
|
||||
config: LexwareRuntimeConfig,
|
||||
warnings: list[str],
|
||||
) -> LexwareInvoicePreparationResponse:
|
||||
return LexwareInvoicePreparationResponse(
|
||||
ready_for_export=not warnings,
|
||||
export_status=record.export_status,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_item_mapping=line_items,
|
||||
|
|
@ -329,6 +470,22 @@ class LexwareService:
|
|||
},
|
||||
warnings=warnings,
|
||||
sync_record_id=record.id,
|
||||
accounting_note=record.accounting_note or "",
|
||||
transferred_at=record.transferred_at.isoformat() if record.transferred_at else None,
|
||||
transferred_by_user_id=record.transferred_by_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _latest_invoice_record(db: Session, estimate_id: int) -> LexwareSyncRecord | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
return db.scalar(
|
||||
select(LexwareSyncRecord)
|
||||
.where(LexwareSyncRecord.entity_type == "repair_estimate")
|
||||
.where(LexwareSyncRecord.entity_id == estimate_id)
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.order_by(LexwareSyncRecord.created_at.desc(), LexwareSyncRecord.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ services:
|
|||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
APP_NAME: Hermes API
|
||||
APP_VERSION: 0.1.0
|
||||
APP_VERSION: 0.9.1
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ FROM python:3.13-slim
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pip install uv
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ services:
|
|||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
APP_NAME: Hermes API
|
||||
APP_VERSION: 0.1.0
|
||||
APP_VERSION: 0.9.1
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
||||
|
|
|
|||
19
frontend/athena/app/api/backups/[filename]/delete/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/delete/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function DELETE(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
const { filename } = await context.params;
|
||||
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}`);
|
||||
}
|
||||
14
frontend/athena/app/api/backups/[filename]/download/route.ts
Normal file
14
frontend/athena/app/api/backups/[filename]/download/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest, context: Params) {
|
||||
const { filename } = await context.params;
|
||||
return proxyHermesStreamRequest(request, `/backups/${encodeURIComponent(filename)}/download`);
|
||||
}
|
||||
19
frontend/athena/app/api/backups/[filename]/restore/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/restore/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
const { filename } = await context.params;
|
||||
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore`);
|
||||
}
|
||||
19
frontend/athena/app/api/backups/[filename]/validate/route.ts
Normal file
19
frontend/athena/app/api/backups/[filename]/validate/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, context: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
const { filename } = await context.params;
|
||||
return proxyHermesRequest(request, `/backups/${encodeURIComponent(filename)}/restore/validate`);
|
||||
}
|
||||
12
frontend/athena/app/api/backups/create/route.ts
Normal file
12
frontend/athena/app/api/backups/create/route.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
return proxyHermesRequest(request, "/backups/create");
|
||||
}
|
||||
7
frontend/athena/app/api/backups/route.ts
Normal file
7
frontend/athena/app/api/backups/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, "/backups");
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string; estimateId: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
const originError = assertSameOrigin(request);
|
||||
|
||||
if (originError) {
|
||||
return originError;
|
||||
}
|
||||
|
||||
const { id, estimateId } = await params;
|
||||
return proxyHermesRequest(request, `/repairs/${id}/estimates/${estimateId}/accounting/mark-transferred`);
|
||||
}
|
||||
415
frontend/athena/app/backups/page.tsx
Normal file
415
frontend/athena/app/backups/page.tsx
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AlertTriangle, Archive, Download, RefreshCcw, RotateCcw, ShieldAlert, Trash2 } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import SummaryCard from "@/components/common/SummaryCard";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/api";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import type { ApiSuccess } from "@/types/audit";
|
||||
import type { BackupListResponse, BackupSummary, BackupValidationResponse } from "@/types/backup";
|
||||
import type { CurrentUser } from "@/types/rbac";
|
||||
|
||||
const RESTORE_CONFIRM_TEXT = "ICH VERSTEHE DAS RISIKO";
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (typeof error === "object" && error !== null && "response" in error) {
|
||||
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
||||
return response?.data?.detail ?? response?.data?.message ?? fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) {
|
||||
return "Unbekannt";
|
||||
}
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export default function BackupsPage() {
|
||||
const { showToast } = useToast();
|
||||
const [data, setData] = useState<BackupListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<BackupSummary | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [validationByFile, setValidationByFile] = useState<Record<string, BackupValidationResponse>>({});
|
||||
const [validatingFile, setValidatingFile] = useState("");
|
||||
const [restoreTarget, setRestoreTarget] = useState<BackupSummary | null>(null);
|
||||
const [restoreConfirm, setRestoreConfirm] = useState("");
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
|
||||
const loadBackups = useCallback(async () => {
|
||||
setError("");
|
||||
try {
|
||||
const response = await api.get<ApiSuccess<BackupListResponse>>("/backups");
|
||||
setData(response.data.data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, "Backups konnten nicht geladen werden."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadBackups();
|
||||
});
|
||||
}, [loadBackups]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(async () => {
|
||||
try {
|
||||
const response = await api.get<CurrentUser>("/me");
|
||||
setCurrentUser(response.data);
|
||||
} catch {
|
||||
setCurrentUser(null);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cards = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ label: "Backups", value: data.total_count },
|
||||
{ label: "Gesamtgroesse", value: formatBytes(data.total_size_bytes) },
|
||||
{ label: "Letztes Backup", value: data.latest_backup_at ? formatDate(data.latest_backup_at) : "Noch keines" },
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
const canCreate = hasPermission(currentUser, "backup.create");
|
||||
const canDownload = hasPermission(currentUser, "backup.download");
|
||||
const canDelete = hasPermission(currentUser, "backup.delete");
|
||||
const canRestore = hasPermission(currentUser, "backup.restore");
|
||||
|
||||
async function createBackup() {
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post<ApiSuccess<BackupSummary>>("/backups/create");
|
||||
await loadBackups();
|
||||
showToast({ type: "success", title: "Backup erstellt" });
|
||||
} catch (err) {
|
||||
showToast({
|
||||
type: "error",
|
||||
title: "Backup konnte nicht erstellt werden",
|
||||
description: getErrorMessage(err, "Bitte pruefe die Hermes- und Datenbank-Konfiguration."),
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateBackup(filename: string) {
|
||||
setValidatingFile(filename);
|
||||
try {
|
||||
const response = await api.post<ApiSuccess<BackupValidationResponse>>(`/backups/${encodeURIComponent(filename)}/validate`);
|
||||
setValidationByFile((current) => ({ ...current, [filename]: response.data.data }));
|
||||
showToast({
|
||||
type: response.data.data.valid ? "success" : "error",
|
||||
title: response.data.data.valid ? "Backup ist gueltig" : "Backup-Pruefung fehlgeschlagen",
|
||||
description: response.data.data.valid ? response.data.data.message : response.data.data.issues.join(" | "),
|
||||
});
|
||||
} catch (err) {
|
||||
showToast({
|
||||
type: "error",
|
||||
title: "Backup konnte nicht validiert werden",
|
||||
description: getErrorMessage(err, "Bitte pruefe die Backup-Datei."),
|
||||
});
|
||||
} finally {
|
||||
setValidatingFile("");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackup() {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/backups/${encodeURIComponent(deleteTarget.filename)}/delete`);
|
||||
setDeleteTarget(null);
|
||||
await loadBackups();
|
||||
showToast({ type: "success", title: "Backup geloescht" });
|
||||
} catch (err) {
|
||||
showToast({
|
||||
type: "error",
|
||||
title: "Backup konnte nicht geloescht werden",
|
||||
description: getErrorMessage(err, "Bitte versuche es erneut."),
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup() {
|
||||
if (!restoreTarget) {
|
||||
return;
|
||||
}
|
||||
setRestoring(true);
|
||||
try {
|
||||
await api.post(`/backups/${encodeURIComponent(restoreTarget.filename)}/restore`, {
|
||||
confirm_text: restoreConfirm,
|
||||
});
|
||||
showToast({ type: "success", title: "Restore abgeschlossen" });
|
||||
} catch (err) {
|
||||
showToast({
|
||||
type: "info",
|
||||
title: "CLI-Restore erforderlich",
|
||||
description: getErrorMessage(err, "Automatischer Restore ist derzeit deaktiviert."),
|
||||
});
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border bg-white p-8 text-slate-500">Backups werden geladen...</div>;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Backups verfuegbar"}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-950">Backups</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">PostgreSQL-Dumps, Storage-Dateien und Restore-Validierung zentral verwalten.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => void loadBackups()}>
|
||||
<RefreshCcw />
|
||||
Aktualisieren
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button type="button" onClick={() => void createBackup()} disabled={creating}>
|
||||
<Archive />
|
||||
{creating ? "Backup wird erstellt..." : "Backup erstellen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<SummaryCard key={card.label} label={card.label} value={card.value} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border bg-white">
|
||||
<div className="border-b px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-slate-950">Verfuegbare Backup-Dateien</h2>
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<div className="px-6 py-10 text-sm text-slate-500">Noch keine Backup-Dateien vorhanden.</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{data.items.map((backup) => {
|
||||
const validation = validationByFile[backup.filename];
|
||||
return (
|
||||
<div key={backup.filename} className="px-6 py-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="truncate font-semibold text-slate-950">{backup.filename}</p>
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${
|
||||
backup.validation_status === "valid"
|
||||
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-600/20"
|
||||
: "bg-amber-50 text-amber-700 ring-1 ring-amber-600/20"
|
||||
}`}>
|
||||
{backup.validation_status === "valid" ? "Manifest lesbar" : "Pruefung empfohlen"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Erstellt</p>
|
||||
<p>{formatDate(backup.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Groesse</p>
|
||||
<p>{formatBytes(backup.size_bytes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Version</p>
|
||||
<p>{backup.app_version || "Unbekannt"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Ersteller</p>
|
||||
<p>{backup.created_by_username || "System"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Datenbank</p>
|
||||
<p>{backup.database_name || "Unbekannt"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Dateien</p>
|
||||
<p>{backup.file_count}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Storage-Pfad</p>
|
||||
<p className="break-all">{backup.storage_base_path || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{validation && (
|
||||
<div className={`mt-4 rounded-lg border p-4 text-sm ${
|
||||
validation.valid
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-950"
|
||||
: "border-amber-200 bg-amber-50 text-amber-950"
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{validation.valid ? <RotateCcw className="mt-0.5 h-4 w-4" /> : <AlertTriangle className="mt-0.5 h-4 w-4" />}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{validation.message}</p>
|
||||
<p>Checksumme: {validation.checksum_valid ? "gueltig" : "ungueltig"}</p>
|
||||
{!validation.valid && validation.issues.length > 0 && (
|
||||
<p>{validation.issues.join(" | ")}</p>
|
||||
)}
|
||||
{validation.requires_cli_restore && (
|
||||
<p>Restore ist vorbereitet, muss aktuell aber ueber das CLI-Script ausgefuehrt werden.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{canDownload && (
|
||||
<a
|
||||
href={`/api/backups/${encodeURIComponent(backup.filename)}/download`}
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
<Download />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
{canRestore && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void validateBackup(backup.filename)}
|
||||
disabled={validatingFile === backup.filename}
|
||||
>
|
||||
<ShieldAlert />
|
||||
{validatingFile === backup.filename ? "Prueft..." : "Validieren"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setRestoreTarget(backup);
|
||||
setRestoreConfirm("");
|
||||
}}
|
||||
>
|
||||
<RotateCcw />
|
||||
Restore vorbereiten
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button type="button" variant="destructive" onClick={() => setDeleteTarget(backup)}>
|
||||
<Trash2 />
|
||||
Loeschen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
title="Backup loeschen"
|
||||
description="Die Backup-Datei wird dauerhaft entfernt. Diese Aktion kann nicht rueckgaengig gemacht werden."
|
||||
confirmLabel="Backup loeschen"
|
||||
pending={deleting}
|
||||
pendingLabel="Backup wird geloescht..."
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={() => void deleteBackup()}
|
||||
>
|
||||
{deleteTarget && (
|
||||
<div className="space-y-1 text-sm text-slate-700">
|
||||
<p className="font-medium">{deleteTarget.filename}</p>
|
||||
<p>{formatBytes(deleteTarget.size_bytes)}</p>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={restoreTarget !== null}
|
||||
title="Restore vorbereiten"
|
||||
description="Restore ist ein Hochrisiko-Vorgang. Bitte bestaetige den Text exakt, bevor Olympus den CLI-Restore vorbereitet."
|
||||
confirmLabel="Restore starten"
|
||||
pending={restoring}
|
||||
pendingLabel="Restore wird vorbereitet..."
|
||||
confirmDisabled={restoreConfirm !== RESTORE_CONFIRM_TEXT}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRestoreTarget(null);
|
||||
setRestoreConfirm("");
|
||||
}
|
||||
}}
|
||||
onConfirm={() => void restoreBackup()}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950">
|
||||
<div className="flex gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>Automatischer Restore ist aus Sicherheitsgruenden deaktiviert. Die Validierung und Auditierung laufen trotzdem ueber Olympus.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-slate-700">Bestaetigungstext</p>
|
||||
<Input
|
||||
value={restoreConfirm}
|
||||
onChange={(event) => setRestoreConfirm(event.target.value)}
|
||||
placeholder={RESTORE_CONFIRM_TEXT}
|
||||
/>
|
||||
</div>
|
||||
{restoreTarget && (
|
||||
<p className="text-sm text-slate-600">
|
||||
Ziel-Backup: <span className="font-medium text-slate-900">{restoreTarget.filename}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
BookOpen,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
HardDriveDownload,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Settings,
|
||||
|
|
@ -78,6 +79,12 @@ const menu = [
|
|||
name: "Dokumente",
|
||||
href: "/documents",
|
||||
},
|
||||
{
|
||||
icon: HardDriveDownload,
|
||||
name: "Backups",
|
||||
href: "/backups",
|
||||
permission: "backup.read",
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
name: "Einstellungen",
|
||||
|
|
@ -135,7 +142,7 @@ export default function Sidebar() {
|
|||
</nav>
|
||||
|
||||
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
|
||||
Olympus CRM v0.1
|
||||
Olympus CRM v0.9.1
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ type Props = {
|
|||
description: string;
|
||||
confirmLabel?: string;
|
||||
pending?: boolean;
|
||||
pendingLabel?: string;
|
||||
confirmDisabled?: boolean;
|
||||
children?: ReactNode;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
|
|
@ -29,6 +31,8 @@ export default function ConfirmDialog({
|
|||
description,
|
||||
confirmLabel = "Löschen",
|
||||
pending = false,
|
||||
pendingLabel = "Wird gelöscht...",
|
||||
confirmDisabled = false,
|
||||
children,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
|
|
@ -56,9 +60,9 @@ export default function ConfirmDialog({
|
|||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={pending}
|
||||
disabled={pending || confirmDisabled}
|
||||
>
|
||||
{pending ? "Wird gelöscht..." : confirmLabel}
|
||||
{pending ? pendingLabel : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertTriangle, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, Copy, FileCheck2, PackageSearch, Plus, ReceiptText, Send, Trash2, Undo2, XCircle } from "lucide-react";
|
||||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
|
|
@ -46,6 +46,20 @@ const statusLabels: Record<string, string> = {
|
|||
revoked: "Zurückgenommen",
|
||||
};
|
||||
|
||||
const accountingStatusLabels: Record<string, string> = {
|
||||
prepared: "Vorbereitet",
|
||||
transferred: "Übertragen",
|
||||
booked: "Gebucht",
|
||||
cancelled: "Storniert",
|
||||
};
|
||||
|
||||
const accountingStatusClasses: Record<string, string> = {
|
||||
prepared: "bg-blue-50 text-blue-700 ring-blue-600/20",
|
||||
transferred: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
|
||||
booked: "bg-slate-900 text-white ring-slate-900/20",
|
||||
cancelled: "bg-red-50 text-red-700 ring-red-600/20",
|
||||
};
|
||||
|
||||
function humanizeValidationDetail(detail: unknown): string | null {
|
||||
if (!Array.isArray(detail)) {
|
||||
return null;
|
||||
|
|
@ -193,6 +207,8 @@ export default function RepairEstimatesSection({
|
|||
const [revokeTarget, setRevokeTarget] = useState<RepairEstimate | null>(null);
|
||||
const [lexwareResult, setLexwareResult] = useState<LexwareInvoicePreparation | null>(null);
|
||||
const [lexwareDialogOpen, setLexwareDialogOpen] = useState(false);
|
||||
const [accountingTarget, setAccountingTarget] = useState<RepairEstimate | null>(null);
|
||||
const [accountingNote, setAccountingNote] = useState("");
|
||||
const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false);
|
||||
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
|
||||
const [inventoryCategories, setInventoryCategories] = useState<InventoryCategory[]>([]);
|
||||
|
|
@ -417,19 +433,48 @@ export default function RepairEstimatesSection({
|
|||
try {
|
||||
const response = await api.post<LexwareInvoicePreparation>(`/repairs/${repairId}/estimates/${estimate.id}/lexware/prepare-invoice`);
|
||||
setLexwareResult(response.data);
|
||||
setAccountingTarget(estimate);
|
||||
setAccountingNote(response.data.accounting_note || estimate.accounting_note || "");
|
||||
setLexwareDialogOpen(true);
|
||||
showToast({
|
||||
type: response.data.ready_for_export ? "success" : "error",
|
||||
title: response.data.ready_for_export ? "Lexware-Rechnung vorbereitet" : "Lexware-Vorbereitung mit Hinweisen",
|
||||
description: response.data.ready_for_export ? "Die Daten wurden geprüft und für den späteren Export vorgemerkt." : "Bitte prüfe die Hinweise vor dem Export.",
|
||||
title: response.data.ready_for_export ? "Rechnungsvorbereitung erstellt" : "Rechnungsvorbereitung mit Hinweisen",
|
||||
description: response.data.ready_for_export ? "Die Daten stehen für die Übernahme in die Buchhaltung bereit." : "Bitte prüfe die Hinweise vor der Übernahme.",
|
||||
});
|
||||
} catch (err) {
|
||||
showToast({ type: "error", title: "Lexware-Rechnung konnte nicht vorbereitet werden", description: getErrorMessage(err) });
|
||||
showToast({ type: "error", title: "Rechnungsvorbereitung fehlgeschlagen", description: getErrorMessage(err) });
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function markAccountingTransferred() {
|
||||
if (!accountingTarget || !lexwareResult) return;
|
||||
setPendingId(accountingTarget.id);
|
||||
try {
|
||||
const response = await api.post<LexwareInvoicePreparation>(
|
||||
`/repairs/${repairId}/estimates/${accountingTarget.id}/accounting/mark-transferred`,
|
||||
{ accounting_note: accountingNote },
|
||||
);
|
||||
setLexwareResult(response.data);
|
||||
await loadEstimates();
|
||||
showToast({ type: "success", title: "Als übertragen markiert", description: "Die Übergabe an die Buchhaltung wurde dokumentiert." });
|
||||
} catch (err) {
|
||||
showToast({ type: "error", title: "Status konnte nicht aktualisiert werden", description: getErrorMessage(err) });
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(label: string, text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast({ type: "success", title: `${label} kopiert` });
|
||||
} catch {
|
||||
showToast({ type: "error", title: "Kopieren fehlgeschlagen", description: "Bitte markiere den Text manuell." });
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEstimate() {
|
||||
if (!deleteTarget) return;
|
||||
setPendingId(deleteTarget.id);
|
||||
|
|
@ -494,6 +539,16 @@ export default function RepairEstimatesSection({
|
|||
<Meta label="Antwort" value={estimate.customer_response_message || "-"} />
|
||||
</dl>
|
||||
|
||||
{estimate.accounting_export_status && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg border bg-slate-50 p-3 text-sm">
|
||||
<ReceiptText className="h-4 w-4 text-slate-500" />
|
||||
<span className="font-medium text-slate-700">Buchhaltung</span>
|
||||
<AccountingBadge status={estimate.accounting_export_status} />
|
||||
{estimate.accounting_transferred_at && <span className="text-xs text-slate-500">{dateTime(estimate.accounting_transferred_at)}</span>}
|
||||
{estimate.accounting_note && <span className="break-words text-xs text-slate-500">{estimate.accounting_note}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 divide-y rounded-lg border">
|
||||
{estimate.items.map((item) => (
|
||||
<div key={item.id} className="flex items-start justify-between gap-3 p-3 text-sm">
|
||||
|
|
@ -519,7 +574,7 @@ export default function RepairEstimatesSection({
|
|||
{canSend && ["draft", "sent"].includes(estimate.status) && <Button type="button" size="sm" onClick={() => void sendEstimate(estimate)} disabled={pendingId === estimate.id || !customerEmail}><Send />Senden</Button>}
|
||||
{(canUpdate || canSend) && ["draft", "sent"].includes(estimate.status) && <Button type="button" variant="outline" size="sm" onClick={() => setCancelTarget(estimate)} disabled={pendingId === estimate.id}><XCircle />Stornieren</Button>}
|
||||
{canRevoke && estimate.status === "approved" && <Button type="button" variant="destructive" size="sm" onClick={() => setRevokeTarget(estimate)} disabled={pendingId === estimate.id}><Undo2 />Freigabe zurücknehmen</Button>}
|
||||
{canLexwareExport && estimate.status === "approved" && <Button type="button" variant="outline" size="sm" onClick={() => void prepareLexwareInvoice(estimate)} disabled={pendingId === estimate.id}><ReceiptText />Lexware-Rechnung vorbereiten</Button>}
|
||||
{canLexwareExport && estimate.status === "approved" && <Button type="button" variant="outline" size="sm" onClick={() => void prepareLexwareInvoice(estimate)} disabled={pendingId === estimate.id}><ReceiptText />In Buchhaltung übernehmen</Button>}
|
||||
{canDelete && ["draft", "cancelled"].includes(estimate.status) && <Button type="button" variant="destructive" size="sm" onClick={() => setDeleteTarget(estimate)} disabled={pendingId === estimate.id}><Trash2 />Löschen</Button>}
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -656,14 +711,22 @@ export default function RepairEstimatesSection({
|
|||
<Dialog open={lexwareDialogOpen} onOpenChange={setLexwareDialogOpen}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lexware-Rechnung vorbereiten</DialogTitle>
|
||||
<DialogDescription>Die Rechnung wird noch nicht automatisch in Lexware erstellt.</DialogDescription>
|
||||
<DialogTitle>In Buchhaltung übernehmen</DialogTitle>
|
||||
<DialogDescription>Die Rechnung wird in der externen Buchhaltungssoftware erstellt. Olympus dokumentiert die Übergabe.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{lexwareResult && (
|
||||
<div className="grid max-h-[70vh] gap-4 overflow-y-auto pr-1 text-sm">
|
||||
<div className={`rounded-lg border p-4 ${lexwareResult.ready_for_export ? "bg-emerald-50 text-emerald-950" : "bg-amber-50 text-amber-950"}`}>
|
||||
<p className="font-semibold">{lexwareResult.ready_for_export ? "Bereit für späteren Export" : "Vorbereitung mit Hinweisen"}</p>
|
||||
<p className="mt-1">Sync-Record #{lexwareResult.sync_record_id}</p>
|
||||
<div className="rounded-lg border bg-slate-50 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-slate-950">Exportstatus</span>
|
||||
<AccountingBadge status={lexwareResult.export_status} />
|
||||
<span className="text-slate-400">→</span>
|
||||
<AccountingBadge status="transferred" muted={lexwareResult.export_status !== "transferred" && lexwareResult.export_status !== "booked"} />
|
||||
<span className="text-slate-400">→</span>
|
||||
<AccountingBadge status="booked" muted={lexwareResult.export_status !== "booked"} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">Rechnungsvorbereitung #{lexwareResult.sync_record_id}</p>
|
||||
{lexwareResult.transferred_at && <p className="mt-1 text-xs text-slate-500">Übertragen am {dateTime(lexwareResult.transferred_at)}</p>}
|
||||
</div>
|
||||
|
||||
{lexwareResult.warnings.length > 0 && (
|
||||
|
|
@ -675,14 +738,21 @@ export default function RepairEstimatesSection({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-2">
|
||||
<Meta label="Kunde" value={lexwareResult.customer_mapping.name} />
|
||||
<Meta label="E-Mail" value={lexwareResult.customer_mapping.email || "-"} />
|
||||
<Meta label="Telefon" value={lexwareResult.customer_mapping.phone || "-"} />
|
||||
<Meta label="Kontaktabgleich" value={lexwareResult.customer_mapping.search_strategy} />
|
||||
<div className="rounded-lg border">
|
||||
<StepHeader step="1" title="Kundendaten kopieren" />
|
||||
<CopyBlock
|
||||
text={[
|
||||
`Kunde: ${lexwareResult.customer_mapping.name}`,
|
||||
`E-Mail: ${lexwareResult.customer_mapping.email || "-"}`,
|
||||
`Telefon: ${lexwareResult.customer_mapping.phone || "-"}`,
|
||||
].join("\n")}
|
||||
onCopy={(text) => void copyText("Kundendaten", text)}
|
||||
/>
|
||||
<StepHeader step="2" title="In Buchhaltungssoftware einfügen" muted />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<StepHeader step="3" title="Positionen kopieren" />
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-3 border-b bg-slate-50 p-3 text-xs font-semibold uppercase text-slate-500">
|
||||
<span>Position</span>
|
||||
<span>Steuer</span>
|
||||
|
|
@ -698,6 +768,13 @@ export default function RepairEstimatesSection({
|
|||
<span className="font-medium text-slate-950">{item.total} €</span>
|
||||
</div>
|
||||
))}
|
||||
<CopyBlock
|
||||
text={lexwareResult.line_item_mapping.map((item) => (
|
||||
`${item.title}; ${item.description || "-"}; ${item.quantity} ${item.unit}; ${item.unit_price} EUR; ${item.tax_rate}%; ${item.total} EUR`
|
||||
)).join("\n")}
|
||||
onCopy={(text) => void copyText("Positionen", text)}
|
||||
/>
|
||||
<StepHeader step="4" title="In Buchhaltungssoftware einfügen" muted />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border bg-slate-50 p-4 md:grid-cols-3">
|
||||
|
|
@ -705,6 +782,33 @@ export default function RepairEstimatesSection({
|
|||
<Meta label="MwSt." value={`${String(lexwareResult.payload_summary.tax ?? "-")} €`} />
|
||||
<Meta label="Gesamt" value={`${String(lexwareResult.payload_summary.total ?? "-")} €`} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-white">
|
||||
<StepHeader step="5" title="Rechnung speichern" muted />
|
||||
<StepHeader step="6" title="Zurück zu Olympus" muted />
|
||||
<div className="border-t p-4">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">Buchhaltungsnotiz</span>
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none"
|
||||
placeholder="z. B. Lexware RG-2026-154 oder Rechnung in sevdesk erstellt"
|
||||
value={accountingNote}
|
||||
onChange={(event) => setAccountingNote(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void markAccountingTransferred()}
|
||||
disabled={!accountingTarget || pendingId === accountingTarget.id || lexwareResult.export_status === "transferred"}
|
||||
>
|
||||
<CheckCircle2 />
|
||||
Als übertragen markieren
|
||||
</Button>
|
||||
{lexwareResult.export_status === "transferred" && <span className="text-sm font-medium text-emerald-700">Bereits übertragen</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
|
|
@ -760,6 +864,37 @@ function Meta({ label, value }: { label: string; value: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function AccountingBadge({ status, muted = false }: { status: string; muted?: boolean }) {
|
||||
const label = accountingStatusLabels[status] ?? status;
|
||||
const activeClass = accountingStatusClasses[status] ?? "bg-slate-100 text-slate-700 ring-slate-600/20";
|
||||
return (
|
||||
<span className={`w-fit rounded-full px-2.5 py-1 text-xs font-medium ring-1 ${muted ? "bg-slate-100 text-slate-400 ring-slate-200" : activeClass}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StepHeader({ step, title, muted = false }: { step: string; title: string; muted?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 border-b p-3 ${muted ? "bg-slate-50 text-slate-500" : "bg-white text-slate-950"}`}>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{step}</span>
|
||||
<p className="font-medium">{title}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyBlock({ text, onCopy }: { text: string; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
<div className="grid gap-3 p-3">
|
||||
<pre className="max-h-40 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-white whitespace-pre-wrap">{text}</pre>
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={() => onCopy(text)}>
|
||||
<Copy />
|
||||
Kopieren
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const config = {
|
|||
"/roles/:path*",
|
||||
"/customers/:path*",
|
||||
"/repairs/:path*",
|
||||
"/backups/:path*",
|
||||
"/settings/:path*",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
49
frontend/athena/types/backup.ts
Normal file
49
frontend/athena/types/backup.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export interface BackupManifest {
|
||||
backup_id: string;
|
||||
created_at: string;
|
||||
app_version: string;
|
||||
backup_type: string;
|
||||
database_url_host_anonymized: string;
|
||||
database_name: string;
|
||||
storage_base_path: string;
|
||||
included_sections: string[];
|
||||
file_count: number;
|
||||
total_size_bytes: number;
|
||||
checksum_sha256: string;
|
||||
created_by_user_id: number | null;
|
||||
created_by_username: string;
|
||||
}
|
||||
|
||||
export interface BackupSummary {
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
created_at: string | null;
|
||||
app_version: string;
|
||||
backup_type: string;
|
||||
database_name: string;
|
||||
storage_base_path: string;
|
||||
file_count: number;
|
||||
total_size_bytes: number;
|
||||
created_by_user_id: number | null;
|
||||
created_by_username: string;
|
||||
validation_status: string;
|
||||
validation_message: string;
|
||||
}
|
||||
|
||||
export interface BackupListResponse {
|
||||
items: BackupSummary[];
|
||||
total_count: number;
|
||||
total_size_bytes: number;
|
||||
latest_backup_at: string | null;
|
||||
}
|
||||
|
||||
export interface BackupValidationResponse {
|
||||
filename: string;
|
||||
valid: boolean;
|
||||
message: string;
|
||||
issues: string[];
|
||||
checksum_valid: boolean;
|
||||
restore_supported: boolean;
|
||||
requires_cli_restore: boolean;
|
||||
manifest: BackupManifest | null;
|
||||
}
|
||||
|
|
@ -47,10 +47,14 @@ export interface LexwareLineItemMapping {
|
|||
|
||||
export interface LexwareInvoicePreparation {
|
||||
ready_for_export: boolean;
|
||||
export_status: "prepared" | "transferred" | "booked" | "cancelled";
|
||||
payload_summary: Record<string, unknown>;
|
||||
customer_mapping: LexwareCustomerMapping;
|
||||
line_item_mapping: LexwareLineItemMapping[];
|
||||
tax_mapping: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
sync_record_id: number;
|
||||
accounting_note: string;
|
||||
transferred_at: string | null;
|
||||
transferred_by_user_id: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,6 +220,10 @@ export interface RepairEstimate {
|
|||
lexware_invoice_number: string | null;
|
||||
lexware_invoice_status: string | null;
|
||||
lexware_synced_at: string | null;
|
||||
accounting_export_status: "prepared" | "transferred" | "booked" | "cancelled" | null;
|
||||
accounting_note: string | null;
|
||||
accounting_transferred_at: string | null;
|
||||
accounting_transferred_by_user_id: number | null;
|
||||
created_by_user_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
|
|||
150
scripts/backup.sh
Executable file → Normal file
150
scripts/backup.sh
Executable file → Normal file
|
|
@ -1,33 +1,139 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-./backups}"
|
||||
STORAGE_HOST_PATH="${STORAGE_HOST_PATH:-./storage}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-${STORAGE_HOST_PATH%/}/backups}"
|
||||
APP_VERSION="${APP_VERSION:-0.9.1}"
|
||||
DATABASE_URL="${DATABASE_URL:-}"
|
||||
|
||||
if [[ -z "$DATABASE_URL" ]]; then
|
||||
echo "DATABASE_URL ist erforderlich." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v pg_dump >/dev/null 2>&1; then
|
||||
echo "pg_dump ist nicht verfuegbar." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR" "$STORAGE_HOST_PATH"
|
||||
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
TARGET_DIR="${BACKUP_DIR}/${TIMESTAMP}"
|
||||
FILENAME="olympus-backup-${TIMESTAMP}.zip"
|
||||
WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.backup.XXXXXX")"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
mkdir -p "${TARGET_DIR}"
|
||||
export STORAGE_HOST_PATH BACKUP_DIR WORK_DIR APP_VERSION DATABASE_URL FILENAME
|
||||
|
||||
echo "==> Creating backup in ${TARGET_DIR}"
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
|
||||
if [[ -n "${POSTGRES_CONTAINER:-}" ]]; then
|
||||
echo "==> Creating PostgreSQL dump from container ${POSTGRES_CONTAINER}"
|
||||
docker exec "${POSTGRES_CONTAINER}" pg_dump -U "${POSTGRES_USER:-olympus}" "${POSTGRES_DB:-olympus}" > "${TARGET_DIR}/postgres.sql"
|
||||
elif command -v pg_dump >/dev/null 2>&1 && [[ -n "${DATABASE_URL:-}" ]]; then
|
||||
echo "==> Creating PostgreSQL dump from DATABASE_URL"
|
||||
pg_dump "${DATABASE_URL}" > "${TARGET_DIR}/postgres.sql"
|
||||
else
|
||||
echo "WARN: PostgreSQL dump skipped. Set POSTGRES_CONTAINER or install pg_dump with DATABASE_URL."
|
||||
fi
|
||||
source = Path(os.environ["STORAGE_HOST_PATH"]).resolve()
|
||||
backup_dir = Path(os.environ["BACKUP_DIR"]).resolve()
|
||||
target = Path(os.environ["WORK_DIR"]).resolve() / "storage"
|
||||
source.mkdir(parents=True, exist_ok=True)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if [[ -d "${STORAGE_HOST_PATH}" ]]; then
|
||||
echo "==> Archiving storage directory"
|
||||
tar -czf "${TARGET_DIR}/storage.tar.gz" -C "${STORAGE_HOST_PATH}" .
|
||||
else
|
||||
echo "WARN: Storage directory ${STORAGE_HOST_PATH} not found; storage backup skipped."
|
||||
fi
|
||||
for source_path in sorted(source.rglob("*")):
|
||||
if source_path == backup_dir or backup_dir in source_path.parents:
|
||||
continue
|
||||
relative = source_path.relative_to(source)
|
||||
destination = target / relative
|
||||
if source_path.is_dir():
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if source_path.is_file():
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_path, destination)
|
||||
PY
|
||||
|
||||
echo "INFO: .env is not copied automatically. Store production secrets separately and securely."
|
||||
echo "==> Backup completed"
|
||||
PG_DUMP_URL="$(python3 - <<'PY'
|
||||
import os
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
database_url = os.environ["DATABASE_URL"]
|
||||
parts = urlsplit(database_url)
|
||||
scheme = parts.scheme.split("+", 1)[0]
|
||||
print(urlunsplit((scheme, parts.netloc, parts.path, parts.query, parts.fragment)))
|
||||
PY
|
||||
)"
|
||||
|
||||
pg_dump \
|
||||
--format=custom \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
--file="$WORK_DIR/database.dump" \
|
||||
--dbname="$PG_DUMP_URL"
|
||||
|
||||
python3 - <<'PY'
|
||||
from datetime import datetime, UTC
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
import zipfile
|
||||
|
||||
work_dir = Path(os.environ["WORK_DIR"]).resolve()
|
||||
storage_dir = work_dir / "storage"
|
||||
dump_path = work_dir / "database.dump"
|
||||
backup_dir = Path(os.environ["BACKUP_DIR"]).resolve()
|
||||
filename = os.environ["FILENAME"]
|
||||
archive_path = backup_dir / filename
|
||||
database_url = urlsplit(os.environ["DATABASE_URL"])
|
||||
|
||||
def update_digest_from_file(digest, file_path: Path) -> None:
|
||||
with file_path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
|
||||
checksum = sha256()
|
||||
checksum.update(b"database.dump")
|
||||
update_digest_from_file(checksum, dump_path)
|
||||
|
||||
file_count = 1
|
||||
total_size = dump_path.stat().st_size
|
||||
for file_path in sorted(storage_dir.rglob("*")):
|
||||
if file_path.is_dir():
|
||||
continue
|
||||
checksum.update(file_path.relative_to(work_dir).as_posix().encode("utf-8"))
|
||||
update_digest_from_file(checksum, file_path)
|
||||
file_count += 1
|
||||
total_size += file_path.stat().st_size
|
||||
|
||||
host = database_url.hostname or "unknown"
|
||||
manifest = {
|
||||
"backup_id": sha256(f"{filename}:{datetime.now(UTC).isoformat()}".encode("utf-8")).hexdigest()[:24],
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"app_version": os.environ["APP_VERSION"],
|
||||
"backup_type": "full",
|
||||
"database_url_host_anonymized": f"sha256:{sha256(host.encode('utf-8')).hexdigest()[:12]}",
|
||||
"database_name": database_url.path.rsplit("/", 1)[-1] or "unknown",
|
||||
"storage_base_path": os.environ["STORAGE_HOST_PATH"],
|
||||
"included_sections": ["database", "storage"],
|
||||
"file_count": file_count,
|
||||
"total_size_bytes": total_size,
|
||||
"checksum_sha256": checksum.hexdigest(),
|
||||
"created_by_user_id": None,
|
||||
"created_by_username": "cli",
|
||||
}
|
||||
|
||||
manifest_path = work_dir / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=True), encoding="utf-8")
|
||||
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("storage/", "")
|
||||
archive.write(manifest_path, "manifest.json")
|
||||
archive.write(dump_path, "database.dump")
|
||||
for file_path in sorted(storage_dir.rglob("*")):
|
||||
if file_path.is_dir():
|
||||
continue
|
||||
archive.write(file_path, file_path.relative_to(work_dir).as_posix())
|
||||
PY
|
||||
|
||||
echo "Backup erstellt: ${BACKUP_DIR%/}/${FILENAME}"
|
||||
|
|
|
|||
161
scripts/restore.sh
Executable file → Normal file
161
scripts/restore.sh
Executable file → Normal file
|
|
@ -1,46 +1,151 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
BACKUP_SOURCE="${1:-}"
|
||||
STORAGE_HOST_PATH="${STORAGE_HOST_PATH:-./storage}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-${STORAGE_HOST_PATH%/}/backups}"
|
||||
DATABASE_URL="${DATABASE_URL:-}"
|
||||
STOPPED_SERVICES=0
|
||||
|
||||
if [[ -z "${BACKUP_SOURCE}" || ! -d "${BACKUP_SOURCE}" ]]; then
|
||||
echo "Usage: scripts/restore.sh <backup-directory>" >&2
|
||||
if [[ -z "$BACKUP_SOURCE" || ! -f "$BACKUP_SOURCE" ]]; then
|
||||
echo "Usage: scripts/restore.sh <backup-zip>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "This restore can overwrite database and storage state."
|
||||
echo "Backup source: ${BACKUP_SOURCE}"
|
||||
echo "Storage target: ${STORAGE_HOST_PATH}"
|
||||
read -r -p "Type RESTORE to continue: " confirmation
|
||||
if [[ -z "$DATABASE_URL" ]]; then
|
||||
echo "DATABASE_URL ist erforderlich." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${confirmation}" != "RESTORE" ]]; then
|
||||
echo "Restore cancelled"
|
||||
if ! command -v pg_restore >/dev/null 2>&1; then
|
||||
echo "pg_restore ist nicht verfuegbar." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$STORAGE_HOST_PATH" "$BACKUP_DIR"
|
||||
|
||||
python3 - "$BACKUP_SOURCE" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
path = Path(sys.argv[1]).resolve()
|
||||
required = {"manifest.json", "database.dump"}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = set(archive.namelist())
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise SystemExit(f"Ungueltige ZIP-Datei: {exc}") from exc
|
||||
|
||||
missing = [name for name in required if name not in names]
|
||||
if not any(name == "storage/" or name.startswith("storage/") for name in names):
|
||||
missing.append("storage/")
|
||||
if missing:
|
||||
raise SystemExit(f"Backup unvollstaendig: {', '.join(missing)}")
|
||||
PY
|
||||
|
||||
echo "WARNUNG: Dieser Restore kann Datenbank- und Storage-Daten dauerhaft ueberschreiben."
|
||||
echo "Backup: $BACKUP_SOURCE"
|
||||
read -r -p "Bitte exakt 'ICH VERSTEHE DAS RISIKO' eingeben: " confirmation
|
||||
|
||||
if [[ "$confirmation" != "ICH VERSTEHE DAS RISIKO" ]]; then
|
||||
echo "Restore abgebrochen."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -f "${BACKUP_SOURCE}/postgres.sql" ]]; then
|
||||
if [[ -n "${POSTGRES_CONTAINER:-}" ]]; then
|
||||
echo "==> Restoring PostgreSQL dump into container ${POSTGRES_CONTAINER}"
|
||||
docker exec -i "${POSTGRES_CONTAINER}" psql -U "${POSTGRES_USER:-olympus}" "${POSTGRES_DB:-olympus}" < "${BACKUP_SOURCE}/postgres.sql"
|
||||
elif command -v psql >/dev/null 2>&1 && [[ -n "${DATABASE_URL:-}" ]]; then
|
||||
echo "==> Restoring PostgreSQL dump from DATABASE_URL"
|
||||
psql "${DATABASE_URL}" < "${BACKUP_SOURCE}/postgres.sql"
|
||||
else
|
||||
echo "WARN: PostgreSQL restore skipped. Set POSTGRES_CONTAINER or install psql with DATABASE_URL."
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
read -r -p "Olympus-Dienste jetzt per docker compose stoppen? [y/N] " stop_reply
|
||||
if [[ "$stop_reply" =~ ^[Yy]$ ]]; then
|
||||
docker compose stop athena hermes
|
||||
STOPPED_SERVICES=1
|
||||
fi
|
||||
else
|
||||
echo "WARN: postgres.sql not found; database restore skipped."
|
||||
fi
|
||||
|
||||
if [[ -f "${BACKUP_SOURCE}/storage.tar.gz" ]]; then
|
||||
mkdir -p "${STORAGE_HOST_PATH}"
|
||||
echo "==> Restoring storage archive"
|
||||
tar -xzf "${BACKUP_SOURCE}/storage.tar.gz" -C "${STORAGE_HOST_PATH}"
|
||||
else
|
||||
echo "WARN: storage.tar.gz not found; storage restore skipped."
|
||||
fi
|
||||
WORK_DIR="$(mktemp -d "${BACKUP_DIR%/}/tmp.restore.XXXXXX")"
|
||||
trap 'rm -rf "$WORK_DIR"; if [[ "$STOPPED_SERVICES" -eq 1 ]]; then docker compose start hermes athena; fi' EXIT
|
||||
|
||||
echo "==> Restore completed"
|
||||
python3 - "$BACKUP_SOURCE" "$WORK_DIR" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
archive_path = Path(sys.argv[1]).resolve()
|
||||
target_dir = Path(sys.argv[2]).resolve()
|
||||
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
archive.extractall(target_dir)
|
||||
PY
|
||||
|
||||
SNAPSHOT_PATH="${BACKUP_DIR%/}/pre-restore-storage-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
export STORAGE_HOST_PATH SNAPSHOT_PATH
|
||||
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
storage = Path(os.environ["STORAGE_HOST_PATH"]).resolve()
|
||||
snapshot = Path(os.environ["SNAPSHOT_PATH"]).resolve()
|
||||
storage.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(snapshot, "w:gz") as archive:
|
||||
for path in sorted(storage.rglob("*")):
|
||||
if path.name == "backups" and path.is_dir():
|
||||
continue
|
||||
if "backups" in path.parts:
|
||||
continue
|
||||
archive.add(path, arcname=path.relative_to(storage))
|
||||
PY
|
||||
|
||||
PG_RESTORE_URL="$(python3 - <<'PY'
|
||||
import os
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
database_url = os.environ["DATABASE_URL"]
|
||||
parts = urlsplit(database_url)
|
||||
scheme = parts.scheme.split("+", 1)[0]
|
||||
print(urlunsplit((scheme, parts.netloc, parts.path, parts.query, parts.fragment)))
|
||||
PY
|
||||
)"
|
||||
|
||||
pg_restore \
|
||||
--clean \
|
||||
--if-exists \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
--dbname="$PG_RESTORE_URL" \
|
||||
"$WORK_DIR/database.dump"
|
||||
|
||||
export WORK_DIR
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
|
||||
storage_target = Path(os.environ["STORAGE_HOST_PATH"]).resolve()
|
||||
storage_source = (Path(os.environ["WORK_DIR"]).resolve() / "storage")
|
||||
storage_target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for path in storage_target.iterdir():
|
||||
if path.name == "backups":
|
||||
continue
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
|
||||
for source_path in sorted(storage_source.rglob("*")):
|
||||
relative = source_path.relative_to(storage_source)
|
||||
destination = storage_target / relative
|
||||
if source_path.is_dir():
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_path, destination)
|
||||
PY
|
||||
|
||||
echo "Restore abgeschlossen. Storage-Snapshot: ${SNAPSHOT_PATH}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue