Initial commit
This commit is contained in:
146
.env.example
Normal file
146
.env.example
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Paperless-ngx - Konfigurationsvorlage fuer die ecoDMS-Migration
|
||||||
|
# Nach .env kopieren und anpassen.
|
||||||
|
#
|
||||||
|
# Geheimnisse erzeugen: openssl rand -hex 32
|
||||||
|
# Hexadezimal statt base64, dann enthaelt der Wert garantiert keine
|
||||||
|
# Zeichen, die die Env-Datei durcheinanderbringen.
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# GEHEIMNISSE
|
||||||
|
# ===========================================================================
|
||||||
|
POSTGRES_PASSWORD=BITTE_ERSETZEN
|
||||||
|
# Ausschreiben, nicht ${POSTGRES_PASSWORD}. Docker Compose ersetzt Variablen
|
||||||
|
# nur in der docker-compose.yml, nicht in einer ueber env_file eingebundenen
|
||||||
|
# Datei - dort wird der Text woertlich durchgereicht.
|
||||||
|
PAPERLESS_DBPASS=BITTE_ERSETZEN
|
||||||
|
|
||||||
|
PAPERLESS_SECRET_KEY=BITTE_ERSETZEN
|
||||||
|
PAPERLESS_ADMIN_USER=admin
|
||||||
|
PAPERLESS_ADMIN_PASSWORD=BITTE_ERSETZEN
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# BINDUNG
|
||||||
|
# ===========================================================================
|
||||||
|
# 127.0.0.1 wenn der Reverse Proxy auf demselben Host laeuft,
|
||||||
|
# sonst die LAN-Adresse. Paperless nie direkt ins Internet.
|
||||||
|
BIND_IP=127.0.0.1
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# ANBINDUNG
|
||||||
|
# ===========================================================================
|
||||||
|
PAPERLESS_REDIS=redis://broker:6379
|
||||||
|
PAPERLESS_DBHOST=db
|
||||||
|
PAPERLESS_DBNAME=paperless
|
||||||
|
PAPERLESS_DBUSER=paperless
|
||||||
|
|
||||||
|
PAPERLESS_TIKA_ENABLED=1
|
||||||
|
PAPERLESS_TIKA_ENDPOINT=http://tika:9998
|
||||||
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://gotenberg:3000
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# ERREICHBARKEIT
|
||||||
|
# ===========================================================================
|
||||||
|
# Waehrend der Migration lokal:
|
||||||
|
PAPERLESS_URL=http://localhost:8000
|
||||||
|
PAPERLESS_ALLOWED_HOSTS=localhost
|
||||||
|
|
||||||
|
# Im Produktivbetrieb hinter einem Reverse Proxy stattdessen:
|
||||||
|
#PAPERLESS_URL=https://dms.example.org
|
||||||
|
#PAPERLESS_ALLOWED_HOSTS=dms.example.org
|
||||||
|
#PAPERLESS_CSRF_TRUSTED_ORIGINS=https://dms.example.org
|
||||||
|
#PAPERLESS_CORS_ALLOWED_HOSTS=https://dms.example.org
|
||||||
|
#PAPERLESS_USE_X_FORWARD_HOST=true
|
||||||
|
#PAPERLESS_USE_X_FORWARD_PORT=true
|
||||||
|
#PAPERLESS_PROXY_SSL_HEADER=["HTTP_X_FORWARDED_PROTO","https"]
|
||||||
|
#PAPERLESS_COOKIE_PREFIX=pngx_
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# GRUNDEINSTELLUNGEN
|
||||||
|
# ===========================================================================
|
||||||
|
PAPERLESS_TIME_ZONE=Europe/Berlin
|
||||||
|
PAPERLESS_OCR_LANGUAGE=deu+eng
|
||||||
|
PAPERLESS_OCR_LANGUAGES=deu eng
|
||||||
|
|
||||||
|
# EINMAL festlegen. Eine spaetere Aenderung benennt den gesamten Bestand um.
|
||||||
|
# Seit 3.x doppelte geschweifte Klammern (Jinja-Syntax).
|
||||||
|
PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ document_type }}/{{ title }}
|
||||||
|
PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=true
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# NACHVOLLZIEHBARKEIT
|
||||||
|
# ===========================================================================
|
||||||
|
PAPERLESS_AUDIT_LOG_ENABLED=true
|
||||||
|
PAPERLESS_EMPTY_TRASH_DELAY=90
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# BERECHTIGUNGEN
|
||||||
|
# ===========================================================================
|
||||||
|
# WICHTIG: numerische Benutzer-ID, kein Benutzername. Eine falsche ID laesst
|
||||||
|
# jeden Konsumvorgang mit "Error while queuing document" scheitern.
|
||||||
|
# ID ermitteln: docker compose exec -T db psql -U paperless -d paperless \
|
||||||
|
# -c "SELECT id, username FROM auth_user ORDER BY id;"
|
||||||
|
#
|
||||||
|
# Ohne diese Einstellung haetten neu konsumierte Dokumente keinen Eigentuemer
|
||||||
|
# - und "keine Rechte" heisst in Paperless nicht gesperrt, sondern offen.
|
||||||
|
PAPERLESS_DEFAULT_PERMISSIONS_OWNER=2
|
||||||
|
#PAPERLESS_DEFAULT_PERMISSIONS_VIEW_GROUPS=1
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# CONSUME-ORDNER
|
||||||
|
# ===========================================================================
|
||||||
|
# Waehrend der Migration abgeschaltet.
|
||||||
|
PAPERLESS_CONSUMER_POLLING=0
|
||||||
|
|
||||||
|
# Im Produktivbetrieb: Polling statt inotify, wenn der Ordner ueber SMB,
|
||||||
|
# NFS oder Nextcloud befuellt wird - Dateisystem-Benachrichtigungen sind
|
||||||
|
# dort unzuverlaessig.
|
||||||
|
#PAPERLESS_CONSUMER_POLLING=60
|
||||||
|
#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=5
|
||||||
|
#PAPERLESS_CONSUMER_POLLING_DELAY=5
|
||||||
|
#PAPERLESS_CONSUMER_RECURSIVE=true
|
||||||
|
#PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=true
|
||||||
|
|
||||||
|
# PAPERLESS_CONSUMER_IGNORE_PATTERNS NICHT setzen, ausser man kennt die
|
||||||
|
# Regex-Syntax: In 3.x ist das eine Liste REGULAERER AUSDRUECKE. Ein
|
||||||
|
# Glob-Muster wie "*.part" laesst den Consumer beim Start abstuerzen
|
||||||
|
# ("nothing to repeat at position 0"). Die Vorgaben decken .DS_Store,
|
||||||
|
# Thumbs.db und Aehnliches bereits ab.
|
||||||
|
|
||||||
|
PAPERLESS_CONSUMER_DELETE_DUPLICATES=false
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# OCR UND ARCHIVDATEIEN
|
||||||
|
# ===========================================================================
|
||||||
|
# In 3.x sind OCR-Steuerung und Archivdatei-Steuerung entkoppelt. Die alten
|
||||||
|
# Werte OCR_MODE=skip und OCR_SKIP_ARCHIVE_FILE sind entfallen und werden
|
||||||
|
# mit W002/W003 angemahnt.
|
||||||
|
#
|
||||||
|
# 'auto' laesst OCR aus, wenn bereits ein Textlayer vorhanden ist - bei einem
|
||||||
|
# Bestand aus einem anderen DMS der Regelfall. Unterschied: Stunden statt Tage.
|
||||||
|
PAPERLESS_OCR_MODE=auto
|
||||||
|
PAPERLESS_ARCHIVE_FILE_GENERATION=auto
|
||||||
|
PAPERLESS_OCR_OUTPUT_TYPE=pdfa
|
||||||
|
PAPERLESS_OCR_CLEAN=clean
|
||||||
|
PAPERLESS_OCR_DESKEW=true
|
||||||
|
PAPERLESS_OCR_ROTATE_PAGES=true
|
||||||
|
PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12
|
||||||
|
|
||||||
|
# Schuetzt vor einem sehr grossen Scan, der einen Worker lange blockiert.
|
||||||
|
PAPERLESS_OCR_MAX_IMAGE_PIXELS=256000000
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# DURCHSATZ
|
||||||
|
# ===========================================================================
|
||||||
|
# Waehrend der Migration hoch, im Produktivbetrieb zurueckdrehen.
|
||||||
|
PAPERLESS_TASK_WORKERS=6
|
||||||
|
PAPERLESS_THREADS_PER_WORKER=3
|
||||||
|
PAPERLESS_WEBSERVER_WORKERS=2
|
||||||
|
PAPERLESS_CONVERT_MEMORY_LIMIT=0
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# SONSTIGES
|
||||||
|
# ===========================================================================
|
||||||
|
PAPERLESS_TRAIN_TASK_CRON=10 3 * * *
|
||||||
|
PAPERLESS_EMAIL_TASK_CRON=*/15 * * * *
|
||||||
|
PAPERLESS_ENABLE_UPDATE_CHECK=false
|
||||||
|
#PAPERLESS_APP_TITLE=Archiv
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Konfiguration mit Geheimnissen
|
||||||
|
.env
|
||||||
|
rollen.yml
|
||||||
|
|
||||||
|
# Laufzeitdaten
|
||||||
|
data/
|
||||||
|
migration.sqlite
|
||||||
|
*.json
|
||||||
|
!*.example.json
|
||||||
|
*.csv
|
||||||
|
*.dump
|
||||||
|
fix_added.py
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Paperless-ngx mit E-Rechnungs-Unterstuetzung (XRechnung + ZUGFeRD).
|
||||||
|
#
|
||||||
|
# Das Plugin nutzt das Parser-Framework aus 3.x. Es meldet sich unter der
|
||||||
|
# Entry-Point-Gruppe paperless_ngx.parsers an und wird beim Start automatisch
|
||||||
|
# gefunden. Passt eine Datei nicht, greifen wieder die eingebauten Parser.
|
||||||
|
#
|
||||||
|
# Fuer XRechnung wird Apache FOP gebraucht (XSL-FO -> PDF), das wiederum eine
|
||||||
|
# Java-Laufzeit benoetigt. ZUGFeRD kommt ohne aus, dort wird das vorhandene
|
||||||
|
# PDF/A-3 unveraendert durchgereicht.
|
||||||
|
#
|
||||||
|
# Bauen: docker compose build webserver
|
||||||
|
# Pruefen nach dem Start:
|
||||||
|
# docker compose exec webserver python -c \
|
||||||
|
# "from paperless.parsers.registry import get_parser_registry; print(get_parser_registry())"
|
||||||
|
|
||||||
|
FROM ghcr.io/paperless-ngx/paperless-ngx:3.1.0
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install --no-install-recommends -y default-jre-headless git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Version pinnen, sobald ein getagtes Release vorliegt - das Projekt ist neu.
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
"paperless-ngx-erechnung @ git+https://github.com/bitbetterde/paperless-ngx-erechnung.git"
|
||||||
|
|
||||||
|
USER paperless
|
||||||
121
docker-compose.yml
Normal file
121
docker-compose.yml
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Paperless-ngx fuer die ecoDMS-Migration
|
||||||
|
#
|
||||||
|
# Vorbereitung einmalig:
|
||||||
|
# mkdir -p data/{data,media,export,consume,pgdata,redis}
|
||||||
|
# chown -R 1500:1500 data/{data,media,export,consume,redis}
|
||||||
|
# chown 999:999 data/pgdata
|
||||||
|
# # auf Btrfs zusaetzlich, VOR dem ersten Start:
|
||||||
|
# chattr +C data/pgdata data/redis
|
||||||
|
#
|
||||||
|
# Auf Systemen mit SELinux im Enforcing-Modus brauchen Bind Mounts eine
|
||||||
|
# Kennzeichnung. Dauerhaft ueber semanage, siehe docs/01.
|
||||||
|
#
|
||||||
|
# Aufruf:
|
||||||
|
# docker compose up -d --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
broker:
|
||||||
|
image: docker.io/library/redis:7
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "1500:1500"
|
||||||
|
volumes:
|
||||||
|
- ./data/redis:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: docker.io/library/postgres:17
|
||||||
|
restart: unless-stopped
|
||||||
|
#
|
||||||
|
# KEIN "user:" setzen. Das Einstiegsskript korrigiert beim Start Besitzer
|
||||||
|
# und Rechte des Datenverzeichnisses und schaltet dann selbst auf seinen
|
||||||
|
# internen Benutzer postgres (UID 999) herunter. Ein erzwungener Benutzer
|
||||||
|
# laesst das mit "Permission denied" scheitern. Stattdessen gehoert das
|
||||||
|
# Verzeichnis auf dem Host der 999.
|
||||||
|
volumes:
|
||||||
|
- ./data/pgdata:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: paperless
|
||||||
|
POSTGRES_USER: paperless
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_INITDB_ARGS: "--data-checksums"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U paperless -d paperless"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
gotenberg:
|
||||||
|
# Wandelt Office-Dokumente in PDF und rendert HTML-Mails.
|
||||||
|
# Ohne diesen Dienst bleiben Office-Originale ohne Archivdatei.
|
||||||
|
#
|
||||||
|
# KEIN "user:" setzen. Chromium legt beim Start ein Crashpad-Verzeichnis
|
||||||
|
# im Home des internen Benutzers an. Unter fremder UID scheitert das mit
|
||||||
|
# "chrome_crashpad_handler: --database is required" (HTTP 500 beim Rendern
|
||||||
|
# von .eml). Unkritisch: keine Bind Mounts, nur internes Netz.
|
||||||
|
image: docker.io/gotenberg/gotenberg:8
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- "gotenberg"
|
||||||
|
- "--chromium-disable-javascript=true"
|
||||||
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
|
|
||||||
|
tika:
|
||||||
|
# Version GEPINNT und nicht ":latest".
|
||||||
|
# ":latest" zeigt inzwischen auf Tika 4.0.0, und Paperless 3.1 quittiert
|
||||||
|
# das mit "HTTP 406 Not Acceptable" bei JEDEM Office-Dokument und JEDER
|
||||||
|
# .eml - ohne verwertbare Fehlermeldung im Task. Diesen Stand nicht ohne
|
||||||
|
# Test anheben.
|
||||||
|
image: docker.io/apache/tika:3.2.3.0
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "1500:1500"
|
||||||
|
|
||||||
|
webserver:
|
||||||
|
# Ohne E-Rechnungs-Plugin stattdessen:
|
||||||
|
# image: ghcr.io/paperless-ngx/paperless-ngx:3.1.0
|
||||||
|
# und den build-Block entfernen.
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: paperless-ngx-local:3.1.0
|
||||||
|
restart: unless-stopped
|
||||||
|
#
|
||||||
|
# Das Image startet bereits als unprivilegierter Benutzer (1000:1000),
|
||||||
|
# nicht als root. USERMAP_UID/GID sind damit wirkungslos - sie greifen
|
||||||
|
# nur, wenn das Einstiegsskript als root beginnt. Die UID wird deshalb
|
||||||
|
# hier fest gesetzt, passend zum Dateibesitz unter ./data.
|
||||||
|
user: "1500:1500"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
broker:
|
||||||
|
condition: service_healthy
|
||||||
|
gotenberg:
|
||||||
|
condition: service_started
|
||||||
|
tika:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- "${BIND_IP:-127.0.0.1}:8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data/data:/usr/src/paperless/data
|
||||||
|
- ./data/media:/usr/src/paperless/media
|
||||||
|
- ./data/export:/usr/src/paperless/export
|
||||||
|
- ./data/consume:/usr/src/paperless/consume
|
||||||
|
# Nur waehrend der Migration: Exportbestand read-only einhaengen.
|
||||||
|
#- /pfad/zu/export-daten:/ecodms:ro
|
||||||
|
env_file: .env
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
288
docs/01-paperless-vorbereiten.md
Normal file
288
docs/01-paperless-vorbereiten.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
# 1. Paperless-Instanz vorbereiten
|
||||||
|
|
||||||
|
Ziel ist eine **leere** Paperless-Instanz, in die importiert wird. Ob sie
|
||||||
|
später produktiv läuft oder nur der Migration dient, ist zunächst
|
||||||
|
gleichgültig — die Konfiguration sollte aber von Anfang an die
|
||||||
|
Produktivfassung sein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wo migrieren?
|
||||||
|
|
||||||
|
Der Import ist CPU-gebunden, nicht I/O-gebunden. Wenn die spätere
|
||||||
|
Produktivumgebung ein schwacher Server ist, lohnt es, die Migration auf
|
||||||
|
einer stärkeren Maschine zu fahren und den Bestand danach umzuziehen.
|
||||||
|
|
||||||
|
Ein Paperless-Bestand ist portabel: Postgres-Dump plus die Verzeichnisse
|
||||||
|
`media` und `data`. Voraussetzung ist, dass Paperless- und
|
||||||
|
Postgres-**Hauptversion** auf beiden Seiten übereinstimmen.
|
||||||
|
|
||||||
|
Zweiter Vorteil: Man kann beim Skriptbau beliebig oft von vorn anfangen,
|
||||||
|
ohne die produktive Instanz anzufassen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versionen festlegen
|
||||||
|
|
||||||
|
**Nie `:latest`.** Alle fünf Images pinnen — Paperless, Postgres, Redis,
|
||||||
|
Gotenberg und Tika.
|
||||||
|
|
||||||
|
Bei **Tika** ist das keine Stilfrage: `apache/tika:latest` zeigt inzwischen
|
||||||
|
auf Tika 4.0.0, und Paperless 3.1 quittiert das mit `HTTP 406 Not
|
||||||
|
Acceptable` bei **jedem** Office-Dokument und **jeder** `.eml`. In einem
|
||||||
|
Testlauf waren das 315 Fehlschläge in einer einzigen Tranche, ohne
|
||||||
|
verwertbare Fehlermeldung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verzeichnisse
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data/{data,media,export,consume,pgdata,redis}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf **Btrfs** vor dem ersten Start das Copy-on-Write für die
|
||||||
|
Datenbankverzeichnisse abschalten, sonst fragmentieren sie stark:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chattr +C data/pgdata data/redis
|
||||||
|
lsattr -d data/pgdata # muss ---------------C zeigen
|
||||||
|
```
|
||||||
|
|
||||||
|
Wirkt nur auf leere Verzeichnisse. Nachträglich greift es bei bestehenden
|
||||||
|
Dateien nicht.
|
||||||
|
|
||||||
|
Auf **ZFS** ist das nicht nötig. Dort lohnt getrenntes Anlegen der
|
||||||
|
Datenbereiche, damit Snapshots und Sicherungen gezielt greifen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benutzer im Container
|
||||||
|
|
||||||
|
Das Paperless-Image startet seit 3.x bereits als unprivilegierter Benutzer
|
||||||
|
(1000:1000), **nicht als root**. Damit ist `USERMAP_UID`/`USERMAP_GID`
|
||||||
|
wirkungslos — dieser Mechanismus setzt voraus, dass das Einstiegsskript als
|
||||||
|
root beginnt und selbst herunterschaltet. Im Log erscheint dann:
|
||||||
|
|
||||||
|
```
|
||||||
|
[init-user] WARNING: USERMAP_UID/USERMAP_GID are set but have no effect
|
||||||
|
when the container is started as a non-root user
|
||||||
|
```
|
||||||
|
|
||||||
|
Setz die UID stattdessen fest in der Compose und richte den Dateibesitz
|
||||||
|
danach aus:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
user: "1500:1500"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zwei Container, bei denen das falsch wäre
|
||||||
|
|
||||||
|
**Postgres.** Sein Einstiegsskript korrigiert beim Start Besitzer und
|
||||||
|
Rechte des Datenverzeichnisses und schaltet dann selbst auf UID 999
|
||||||
|
herunter. Ein erzwungener Benutzer lässt das scheitern:
|
||||||
|
|
||||||
|
```
|
||||||
|
chmod: changing permissions of '/var/lib/postgresql/data': Permission denied
|
||||||
|
```
|
||||||
|
|
||||||
|
Stattdessen gehört das Verzeichnis auf dem Host der 999:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chown 999:999 data/pgdata
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotenberg.** Chromium legt beim Start ein Crashpad-Verzeichnis im Home
|
||||||
|
des internen Benutzers an. Unter fremder UID scheitert das:
|
||||||
|
|
||||||
|
```
|
||||||
|
chrome failed to start: chrome_crashpad_handler: --database is required
|
||||||
|
```
|
||||||
|
|
||||||
|
Symptom ist ein HTTP 500 beim Rendern von `.eml`. Unkritisch ohne eigene
|
||||||
|
UID, weil Gotenberg keine Bind Mounts hat und nur im internen Netz
|
||||||
|
erreichbar ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SELinux
|
||||||
|
|
||||||
|
Auf Systemen mit SELinux im Enforcing-Modus — etwa openSUSE Tumbleweed —
|
||||||
|
brauchen Bind Mounts eine Kennzeichnung, sonst bekommen die Container
|
||||||
|
`Permission denied`, obwohl Besitzer und Rechte stimmen.
|
||||||
|
|
||||||
|
Erkennbar am Punkt hinter den Rechten und am Kontext `unlabeled_t`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -ldZ data/pgdata
|
||||||
|
# drwxr-xr-x. 999 999 unconfined_u:object_r:unlabeled_t:s0
|
||||||
|
```
|
||||||
|
|
||||||
|
`unlabeled_t` tritt typischerweise bei frisch angelegten
|
||||||
|
Btrfs-Subvolumes auf. Dauerhafte Lösung:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zypper in policycoreutils-python-utils
|
||||||
|
semanage fcontext -a -t container_file_t "/pfad/zu/data(/.*)?"
|
||||||
|
restorecon -Rv /pfad/zu/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Die `:z`-Option an den Bind Mounts wirkt nur, wenn der Docker-Daemon mit
|
||||||
|
`"selinux-enabled": true` läuft. Der `semanage`-Eintrag ist unabhängig
|
||||||
|
davon und überlebt Neustarts.
|
||||||
|
|
||||||
|
**Setz das Label, bevor große Datenmengen hineinkopiert werden** — sonst
|
||||||
|
läuft `restorecon` später über den gesamten Bestand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
`.env.example` nach `.env` kopieren und anpassen. Geheimnisse erzeugen mit
|
||||||
|
`openssl rand -hex 32` — hexadezimal statt base64, dann enthält der Wert
|
||||||
|
garantiert keine Zeichen, die die Env-Datei durcheinanderbringen.
|
||||||
|
|
||||||
|
### Was besonders zu beachten ist
|
||||||
|
|
||||||
|
**`PAPERLESS_DBPASS` ausschreiben.** Docker Compose ersetzt Variablen nur
|
||||||
|
in der `docker-compose.yml`, nicht innerhalb einer über `env_file`
|
||||||
|
eingebundenen Datei. Ein `${POSTGRES_PASSWORD}` dort wird wörtlich
|
||||||
|
durchgereicht.
|
||||||
|
|
||||||
|
**`PAPERLESS_FILENAME_FORMAT` einmal festlegen.** Eine spätere Änderung
|
||||||
|
benennt den gesamten Bestand um. Seit 3.x doppelte geschweifte Klammern:
|
||||||
|
|
||||||
|
```
|
||||||
|
PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ document_type }}/{{ title }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**OCR-Einstellungen in 3.x-Syntax.** Die alten Werte `OCR_MODE=skip` und
|
||||||
|
`OCR_SKIP_ARCHIVE_FILE` sind entfallen:
|
||||||
|
|
||||||
|
```
|
||||||
|
PAPERLESS_OCR_MODE=auto
|
||||||
|
PAPERLESS_ARCHIVE_FILE_GENERATION=auto
|
||||||
|
```
|
||||||
|
|
||||||
|
`auto` überspringt OCR, wenn bereits ein Textlayer vorhanden ist — bei
|
||||||
|
einem Bestand aus einem anderen DMS der Regelfall. Der Unterschied liegt
|
||||||
|
bei Stunden gegen Tage.
|
||||||
|
|
||||||
|
**`PAPERLESS_CONSUMER_IGNORE_PATTERNS` nicht setzen.** In 3.x ist das eine
|
||||||
|
Liste von **regulären Ausdrücken**, nicht von Glob-Mustern. Ein Eintrag wie
|
||||||
|
`*.part` lässt den Consumer beim Start abstürzen:
|
||||||
|
|
||||||
|
```
|
||||||
|
re.PatternError: nothing to repeat at position 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Die eingebauten Vorgaben decken `.DS_Store`, `Thumbs.db` und Ähnliches
|
||||||
|
bereits ab.
|
||||||
|
|
||||||
|
**Consume-Ordner abschalten.** Während der Migration:
|
||||||
|
|
||||||
|
```
|
||||||
|
PAPERLESS_CONSUMER_POLLING=0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Worker hochdrehen**, aber mit Bedacht. Die Migration ist seriell je
|
||||||
|
Dokument, Parallelität nutzt vor allem den Sidecar-Diensten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f webserver
|
||||||
|
```
|
||||||
|
|
||||||
|
Das `--build` ist nötig, wenn ein eigenes Image mit Plugins verwendet
|
||||||
|
wird. Ohne baut Compose nicht neu, sobald der Tag existiert.
|
||||||
|
|
||||||
|
Erwartet wird eine saubere Startmeldung ohne Warnungen. Jede Warnung im
|
||||||
|
Log ist eine spätere Fehlerquelle — die drei häufigsten betreffen
|
||||||
|
OCR-Einstellungen, das Dateinamensformat und die Benutzerabbildung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benutzer und Gruppen anlegen
|
||||||
|
|
||||||
|
**Vor dem ersten Import**, damit die Rechteübersetzung sie zuordnen kann.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec webserver python manage.py shell
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.contrib.auth.models import User, Group
|
||||||
|
for n in ["benutzer_a", "benutzer_b"]:
|
||||||
|
User.objects.get_or_create(username=n)
|
||||||
|
for g in ["Gruppe_A", "Gruppe_B"]:
|
||||||
|
Group.objects.get_or_create(name=g)
|
||||||
|
|
||||||
|
U = lambda n: User.objects.get(username=n)
|
||||||
|
Group.objects.get(name="Gruppe_A").user_set.set([U("benutzer_a"), U("benutzer_b")])
|
||||||
|
|
||||||
|
for g in Group.objects.all():
|
||||||
|
print(g.id, g.name, [u.username for u in g.user_set.all()])
|
||||||
|
for u in User.objects.all():
|
||||||
|
print(u.id, u.username)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notier die IDs.** Sie werden in `rollen.yml` und in
|
||||||
|
`PAPERLESS_DEFAULT_PERMISSIONS_OWNER` gebraucht — und dort erwartet
|
||||||
|
Paperless eine **numerische ID**, keinen Benutzernamen. Eine falsche ID
|
||||||
|
lässt jeden Konsumvorgang mit `Error while queuing document` scheitern.
|
||||||
|
|
||||||
|
### Löschrechte
|
||||||
|
|
||||||
|
Keiner Gruppe das Recht zum endgültigen Löschen geben. Zusammen mit
|
||||||
|
Audit-Log, Versionierung und Papierkorb-Frist ist das der vierte Baustein
|
||||||
|
der Nachvollziehbarkeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API-Token
|
||||||
|
|
||||||
|
Im Benutzerprofil erzeugen, nicht in der allgemeinen Verwaltung.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PT=dein_token_40_zeichen
|
||||||
|
curl -s -H "Authorization: Token $PT" http://localhost:8000/api/documents/
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet wird `{"count":0,...}`. Kommt
|
||||||
|
`{"detail":"Authentication credentials were not provided."}`, fehlt der
|
||||||
|
Header-Name — die Variable enthält nur den Token, nicht `Authorization:`.
|
||||||
|
|
||||||
|
Alternativ per Shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec webserver python manage.py shell -c \
|
||||||
|
"from django.contrib.auth.models import User
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
t,_=Token.objects.get_or_create(user=User.objects.get(username='DEIN_BENUTZER'))
|
||||||
|
print(t.key)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Snapshot
|
||||||
|
|
||||||
|
Wenn Instanz, Benutzer, Gruppen und Token stehen: einen Snapshot des
|
||||||
|
Datenverzeichnisses anlegen. Das ist der Zustand, zu dem man beim Bau des
|
||||||
|
Migrationsskripts immer wieder zurückwill.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
btrfs subvolume snapshot -r data ../snapshots/paperless-vorbereitet
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf ZFS entsprechend `zfs snapshot`. Container vorher stoppen, sonst ist
|
||||||
|
der Datenbankstand nicht konsistent zu den Dateien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Weiter mit [Export aus ecoDMS](02-export-aus-ecodms.md).
|
||||||
234
docs/02-export-aus-ecodms.md
Normal file
234
docs/02-export-aus-ecodms.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# 2. Export aus ecoDMS
|
||||||
|
|
||||||
|
Der Export läuft über das **Datenexport-Plugin**, das zum normalen
|
||||||
|
Lizenzumfang gehört. Eine API-Lizenz wird nicht gebraucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warum nicht die Datenbank
|
||||||
|
|
||||||
|
Naheliegend wäre, die ecoDMS-PostgreSQL direkt auszulesen. Das
|
||||||
|
funktioniert nicht: ecoDMS legt die Dateien in einem eigenen
|
||||||
|
Container-Speichersystem ab, weder als BLOB in der Datenbank noch als
|
||||||
|
normale Dateien im Archivverzeichnis.
|
||||||
|
|
||||||
|
Und die Klassifizierungsdaten liegen dort ebenfalls nicht — in der
|
||||||
|
Live-Datenbank gibt es weder eine `klassifizierung`- noch eine
|
||||||
|
`docs`-Tabelle. Diese Struktur erzeugt erst der Exporter.
|
||||||
|
|
||||||
|
Der Export ist also der einzige Weg an Inhalte **und** Metadaten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorab: Bestandsaufnahme
|
||||||
|
|
||||||
|
Bevor Zeit in Exporte fließt, sollte man die Größenordnung kennen. Am
|
||||||
|
einfachsten über einen kleinen Testexport (siehe unten) und die Auswertung
|
||||||
|
der Tabellen `ecosimsversions` und `docs`.
|
||||||
|
|
||||||
|
Wichtige Zahlen:
|
||||||
|
|
||||||
|
| Frage | Warum |
|
||||||
|
|---|---|
|
||||||
|
| Wie viele Dokumente haben mehr als eine Version? | bestimmt den Aufwand des schwierigen Teils |
|
||||||
|
| Wie viele haben **keine** Version? | brauchen einen Rückfall auf `ecosimsarchive` |
|
||||||
|
| Wie viele sind mehrfach klassifiziert? | bestimmt die Zusammenführungsregel |
|
||||||
|
| Welche Dateitypen kommen vor? | nicht alle kann Paperless konsumieren |
|
||||||
|
| Welche Rollen und Rechtearten? | müssen in der Übersetzung abgebildet sein |
|
||||||
|
|
||||||
|
`ecodms_extract.py` beantwortet all das im Prüfbericht — es lohnt sich
|
||||||
|
deshalb, früh einen kleinen Export zu machen und ihn durch Stufe 1 zu
|
||||||
|
schicken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testexport zuerst
|
||||||
|
|
||||||
|
**Nicht mit dem Gesamtbestand anfangen.** Ein Export von fünf bis zehn
|
||||||
|
Dokumenten, der bewusst alle Sonderfälle enthält, ist die beste
|
||||||
|
Investition:
|
||||||
|
|
||||||
|
- ein Office-Dokument mit mehreren Versionen
|
||||||
|
- ein PDF mit mehreren Versionen
|
||||||
|
- ein Dokument ganz **ohne** Versionseinträge
|
||||||
|
- ein mehrfach klassifiziertes Dokument
|
||||||
|
- eine E-Mail (`.eml`)
|
||||||
|
- eine ZUGFeRD-Rechnung
|
||||||
|
- ein Dokument mit einer Notiz
|
||||||
|
- ein Dokument mit besonderen Rechten
|
||||||
|
|
||||||
|
Gegen diesen Satz lässt sich die gesamte Migrationslogik entwickeln, ohne
|
||||||
|
je den Produktivbestand anzufassen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallstrick: unerfüllbare Exportabfrage
|
||||||
|
|
||||||
|
Bei manueller Mehrfachauswahl kann der Exporter eine Abfrage erzeugen, die
|
||||||
|
niemals zutrifft:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
docid = '11072' AND docid = '11094' AND docid = '11143' AND …
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Ergebnis: ein Export mit vollständigen Stammdaten und **null
|
||||||
|
Dokumenten**. Ohne Fehlermeldung, ohne Warnung.
|
||||||
|
|
||||||
|
**Nach jedem Export prüfen:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3, base64
|
||||||
|
c = sqlite3.connect('file:export.data?mode=ro', uri=True)
|
||||||
|
print(base64.b64decode(c.execute('SELECT exp_query FROM ecodmsexporter').fetchone()[0]).decode())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Steht dort `AND` zwischen den IDs statt `OR`, ist der Export leer.
|
||||||
|
|
||||||
|
Vermeiden lässt es sich, indem man Exporte über ein **Suchkriterium** oder
|
||||||
|
einen **ID-Bereich** definiert statt über einzeln markierte Dokumente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tranchen bilden
|
||||||
|
|
||||||
|
Bei größeren Beständen den Export in Blöcke teilen. Bewährt haben sich
|
||||||
|
etwa 2.000 Dokumente je Tranche.
|
||||||
|
|
||||||
|
Empfohlene Struktur:
|
||||||
|
|
||||||
|
```
|
||||||
|
export-daten/
|
||||||
|
├── export1/archive/{export.data, export.xml, *.pdf, …}
|
||||||
|
├── export2/archive/…
|
||||||
|
└── …
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach ID-Bereichen teilen, nicht nach manueller Auswahl. Der Wurzelknoten
|
||||||
|
der XML trägt `startid` und `endid` — der Exporter denkt ohnehin in
|
||||||
|
Bereichen.
|
||||||
|
|
||||||
|
**Tranchen dürfen sich überschneiden.** Das Journal überspringt bereits
|
||||||
|
importierte Dokumente. Das ist nützlich, wenn Teile des Bestands nur unter
|
||||||
|
einem anderen Benutzerkonto sichtbar sind: einfach eine zusätzliche
|
||||||
|
Tranche unter diesem Konto exportieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prüfung je Tranche
|
||||||
|
|
||||||
|
### 1. Abfrage plausibel?
|
||||||
|
|
||||||
|
Siehe oben.
|
||||||
|
|
||||||
|
### 2. Dokumentanzahl?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3
|
||||||
|
c = sqlite3.connect('file:export.data?mode=ro', uri=True)
|
||||||
|
for t in ('docs','klassifizierung','ecosimsversions','ecosimsarchive',
|
||||||
|
'docrollen_hist','econotice'):
|
||||||
|
print(f'{t:20s}', c.execute(f'SELECT COUNT(*) FROM \"{t}\"').fetchone()[0])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Sind `docs` und `klassifizierung` auf null, während `documentenart` und
|
||||||
|
`status` gefüllt sind, hat die Abfrage nicht gegriffen.
|
||||||
|
|
||||||
|
### 3. Referenzen vollständig?
|
||||||
|
|
||||||
|
Die XML verweist auf Dateien im Archivordner. Jede fehlende Referenz ist
|
||||||
|
ein Dokument, das beim Import scheitert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import xml.etree.ElementTree as ET, pathlib
|
||||||
|
root = ET.parse('export.xml').getroot()
|
||||||
|
ref = {e.get('filePath') for e in root.iter() if e.get('filePath')}
|
||||||
|
have = {p.name for p in pathlib.Path('.').iterdir() if p.is_file()}
|
||||||
|
print('fehlt:', sorted(ref - have)[:20])
|
||||||
|
print('unreferenziert:', sorted(have - ref - {'export.xml','export.data'})[:20])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartete Fehlmeldungen:** Bei Nicht-PDF-Originalen referenziert die XML
|
||||||
|
eine PDF/A-Fassung je Version, die nicht exportiert wird. Das ist normal
|
||||||
|
und für Paperless unkritisch.
|
||||||
|
|
||||||
|
### 4. Dateitypen?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3, collections
|
||||||
|
c = sqlite3.connect('file:export.data?mode=ro', uri=True)
|
||||||
|
n = collections.Counter()
|
||||||
|
for (f,) in c.execute('SELECT realname FROM ecosimsversions WHERE realname IS NOT NULL'):
|
||||||
|
n['.' + f.rsplit('.',1)[-1].lower()] += 1
|
||||||
|
for k, v in n.most_common(): print(f'{v:6d} {k}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Vergleich mit dem, was die Zielinstanz kann:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec webserver python manage.py shell -c \
|
||||||
|
"from documents.parsers import get_supported_file_extensions as f; print(sorted(f()))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Typischerweise **nicht** unterstützt: `.html`, `.htm`, `.zip`, `.indd`.
|
||||||
|
`ecodms_extract.py` sortiert solche Dokumente aus und listet sie zur
|
||||||
|
Nacharbeit.
|
||||||
|
|
||||||
|
### 5. Rollen und Rechtearten?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3
|
||||||
|
c = sqlite3.connect('file:export.data?mode=ro', uri=True)
|
||||||
|
for r in c.execute('SELECT DISTINCT role, doc_right FROM docrollen_hist ORDER BY role'):
|
||||||
|
print(r)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Jede Rolle muss in `rollen.yml` abgebildet oder ausdrücklich verworfen
|
||||||
|
werden. Stufe 2 bricht sonst vor dem ersten Upload ab — bewusst, denn ein
|
||||||
|
stilles Verwerfen würde Dokumente **öffnen**, nicht schließen.
|
||||||
|
|
||||||
|
### 6. Mehrfachklassifizierung?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3, collections
|
||||||
|
c = sqlite3.connect('file:export.data?mode=ro', uri=True)
|
||||||
|
n = collections.Counter(d for (d,) in c.execute('SELECT docid FROM docs'))
|
||||||
|
h = collections.Counter(n.values())
|
||||||
|
for k in sorted(h): print(f'{h[k]:6d} Dokumente mit {k} Klassifizierung(en)')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was der Export nicht enthält
|
||||||
|
|
||||||
|
| Element | Alternative |
|
||||||
|
|---|---|
|
||||||
|
| Dokumentverknüpfungen | nicht ermittelbar, Nacharbeit von Hand |
|
||||||
|
| Klassifizierungshistorie | steht in der XML, aber Paperless kann sie nicht abbilden |
|
||||||
|
| Anhänge als eigene Dateien | reisen eingebettet im Original mit |
|
||||||
|
|
||||||
|
Der `lucene`-Ordner enthält den Suchindex des Offline-Clients und ist für
|
||||||
|
die Migration wertlos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Speicherbedarf
|
||||||
|
|
||||||
|
Der Export enthält je Version Original und teils PDF/A. Rechne mit etwa
|
||||||
|
dem Umfang der ecoDMS-Sicherung. Auf der Zielseite kommt Ähnliches
|
||||||
|
hinzu, weil Paperless Original und Archivdatei getrennt ablegt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Weiter mit [Extraktion und Prüfung](03-extraktion.md).
|
||||||
210
docs/03-extraktion.md
Normal file
210
docs/03-extraktion.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# 3. Extraktion und Prüfung
|
||||||
|
|
||||||
|
Stufe 1 liest eine Exporttranche und erzeugt ein normalisiertes
|
||||||
|
JSON-Manifest, einen Prüfbericht und eine Ausschlussliste. **Paperless wird
|
||||||
|
dabei nicht berührt.**
|
||||||
|
|
||||||
|
Die Trennung ist Absicht: Der Lauf ist offline, beliebig wiederholbar, und
|
||||||
|
das Ergebnis lässt sich ansehen, bevor irgendetwas geschrieben wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aufruf
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ecodms_extract.py /pfad/export1/archive \
|
||||||
|
-t export1 \
|
||||||
|
-o export1.json \
|
||||||
|
--skipped-csv export1-nacharbeit.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Als Pfad das Verzeichnis angeben, in dem `export.data` und `export.xml`
|
||||||
|
liegen — also `archive`, nicht das Tranchenverzeichnis darüber.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der Prüfbericht
|
||||||
|
|
||||||
|
```
|
||||||
|
Tranche export1
|
||||||
|
Dokumente 1963
|
||||||
|
Uebersprungen 34 (.html 20, .zip 11, .indd 2, .dotx 1)
|
||||||
|
Versionen 2462
|
||||||
|
Mehrfachklassifiziert 87
|
||||||
|
Notizen 81
|
||||||
|
Rollen FFN, r_benutzer_a, r_benutzer_b, ecoSIMSUSER
|
||||||
|
Rechtearten R, W
|
||||||
|
|
||||||
|
Hinweise (12):
|
||||||
|
[nicht_unterstuetzt] 34
|
||||||
|
[mehrfachklassifizierung] 87
|
||||||
|
[ohne_rechte] 1204
|
||||||
|
[nur_archiv] 143
|
||||||
|
…
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diesen Bericht lesen, bevor importiert wird.** Er beantwortet alle
|
||||||
|
Fragen, die sonst mitten im Import auftauchen.
|
||||||
|
|
||||||
|
### Was die Kategorien bedeuten
|
||||||
|
|
||||||
|
| Kategorie | Bedeutung | Handlung |
|
||||||
|
|---|---|---|
|
||||||
|
| `nicht_unterstuetzt` | Dateityp, den Paperless ablehnt | Nacharbeit, Liste in der CSV |
|
||||||
|
| `mehrfachklassifizierung` | Dokument in mehreren Ordnern | wird zusammengeführt, Konflikte protokolliert |
|
||||||
|
| `ohne_rechte` | keine Zeile in `docrollen_hist` | Regel in `rollen.yml` festlegen |
|
||||||
|
| `nur_archiv` | keine Versionszeilen, Rückfall auf `ecosimsarchive` | normal, kein Handlungsbedarf |
|
||||||
|
| `datei_fehlt` | Version verweist auf nicht vorhandene Datei | Export unvollständig, prüfen |
|
||||||
|
| `xml_referenz_ohne_datei` | XML verweist auf fehlende Datei | bei Office-Originalen normal |
|
||||||
|
| `geloeschte_stammdaten` | Dokumentart oder Status ist gelöscht markiert | meist unkritisch |
|
||||||
|
| `papierkorb` | Dokument ist im ecoDMS-Papierkorb | wird übersprungen |
|
||||||
|
| `notiz_ohne_bezug` | Notiz zeigt auf unbekannte `docs_id` | in anderer Tranche, oder verwaist |
|
||||||
|
|
||||||
|
### Rollen prüfen
|
||||||
|
|
||||||
|
Die Zeile `Rollen` ist die wichtigste für den nächsten Schritt. Jede
|
||||||
|
genannte Rolle muss in `rollen.yml` stehen — entweder mit einem Ziel oder
|
||||||
|
ausdrücklich als `type: null`.
|
||||||
|
|
||||||
|
Stufe 2 bricht sonst ab, bevor die erste Datei hochgeht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Manifest
|
||||||
|
|
||||||
|
Je Dokument entsteht ein Eintrag:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ecodms_docid": 10347,
|
||||||
|
"tranche": "export1",
|
||||||
|
"title": "Rechnung Beispiel GmbH",
|
||||||
|
"created": "2025-07-29",
|
||||||
|
"document_type": "Rechnung",
|
||||||
|
"status": "Erledigt",
|
||||||
|
"folders": ["Allgemeines / Haushalt / Kleingeräte",
|
||||||
|
"Allgemeines / Steuer / ESt / Anlage N"],
|
||||||
|
"folder_parts": [["Allgemeines","Haushalt","Kleingeräte"],
|
||||||
|
["Allgemeines","Steuer","ESt","Anlage N"]],
|
||||||
|
"custom_fields": {
|
||||||
|
"Belegnummer": {"value": "902500503118", "type": "String"},
|
||||||
|
"Wiedervorlage": {"value": "2026-02-02", "type": "Date"}
|
||||||
|
},
|
||||||
|
"versions": [
|
||||||
|
{"version": 1, "file": "ecodms_docid_0010347_revision_0001.pdf",
|
||||||
|
"saved_at": "2025-07-29 14:12:03.881", "checksum": "…",
|
||||||
|
"source": "ecosimsversions"}
|
||||||
|
],
|
||||||
|
"permissions": [{"role": "r_benutzer_a", "right": "W"}],
|
||||||
|
"notes": [{"created": "2014-07-14 00:45:00", "text": "…", "user": ""}],
|
||||||
|
"docs_ids": [10829, 10834],
|
||||||
|
"conflicts": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`folder_parts` ist maßgeblich, `folders` nur für die Anzeige.**
|
||||||
|
Ordnernamen können Schrägstriche enthalten (`Telefon / Internet`) — ein
|
||||||
|
späteres Zerlegen am Schrägstrich würde solche Namen zerschneiden.
|
||||||
|
|
||||||
|
**`conflicts`** wird bei Mehrfachklassifizierung gefüllt, wenn Titel,
|
||||||
|
Dokumentart, Status, Datum oder Wiedervorlage zwischen den Instanzen
|
||||||
|
abweichen. Dann gewinnt der jüngste `ctimestamp`, aber man sieht, dass
|
||||||
|
etwas verworfen wurde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Ausschlussliste
|
||||||
|
|
||||||
|
`--skipped-csv` schreibt alle Dokumente, deren Dateityp Paperless nicht
|
||||||
|
konsumieren kann:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
ecodms_docid,endungen,titel,erstellt,ordner,dateien
|
||||||
|
1247,.html,Webseite Beispiel,2019-03-12,Allgemeines | EDV,ecodms_docid_0001247_revision_0001.html
|
||||||
|
1533,.zip,Belege Sammlung,2020-11-04,Steuer,ecodms_docid_0001533_revision_0001.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Dokumente sind **nicht** im Manifest. Sie scheitern deshalb nicht
|
||||||
|
beim Import, sondern stehen sauber auf einer Liste.
|
||||||
|
|
||||||
|
Die Liste der akzeptierten Endungen steht als `SUPPORTED_EXTENSIONS` oben
|
||||||
|
im Skript. Sie sollte gegen die Zielinstanz geprüft werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec webserver python manage.py shell -c \
|
||||||
|
"from documents.parsers import get_supported_file_extensions as f; print(sorted(f()))"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regeln, die Stufe 1 anwendet
|
||||||
|
|
||||||
|
Alle stammen aus der Analyse eines produktiven Bestands. Ausführlich in
|
||||||
|
[referenz-ecodms-exportformat.md](referenz-ecodms-exportformat.md).
|
||||||
|
|
||||||
|
1. **Nach `docid` gruppieren, nicht nach `docs_id`.** Eine `docid` kann
|
||||||
|
mehrere Klassifizierungsinstanzen haben.
|
||||||
|
2. **Ordner vereinigen, jüngster `ctimestamp` gewinnt** bei skalaren
|
||||||
|
Feldern.
|
||||||
|
3. **Immer `realname` verwenden, nie `pdfrealname`.** Sonst gehen
|
||||||
|
eingebettete ZUGFeRD-XML und E-Mail-Anhänge verloren.
|
||||||
|
4. **Ohne Versionszeilen auf `ecosimsarchive` zurückfallen.** Sonst
|
||||||
|
verschwinden solche Dokumente stillschweigend.
|
||||||
|
5. **`docrollen_hist.docid` enthält `docs_id`.** Join über `docs.id`.
|
||||||
|
6. **Nur die höchste in `docrollen_hist` vorhandene Revision** je
|
||||||
|
`docs_id` gilt. Ältere enthalten noch das globale Importrecht.
|
||||||
|
7. **Revisionen als Zahlentupel sortieren.** `'1.10'` ist lexikografisch
|
||||||
|
kleiner als `'1.9'`.
|
||||||
|
8. **Base64 selektiv dekodieren.** `savedate` und `filename` sind
|
||||||
|
kodiert, `realname` und `pdfrealname` nicht.
|
||||||
|
9. **Wiedervorlage aus `klassifizierung.defdate`**, nicht aus
|
||||||
|
`dynattribute`.
|
||||||
|
10. **`econotice.nid` enthält `docs_id`**, der Text ist base64-kodiertes
|
||||||
|
Qt-HTML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wiederholbarkeit
|
||||||
|
|
||||||
|
Stufe 1 schreibt nichts außer den Ausgabedateien. Sie kann beliebig oft
|
||||||
|
laufen — bei jeder Änderung an den Regeln, bei jedem neuen Export.
|
||||||
|
|
||||||
|
Nach einer Änderung am Skript **alle Manifeste neu erzeugen**, damit sie
|
||||||
|
dasselbe Format haben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alle Tranchen auf einmal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for n in 1 2 3 4 5 6; do
|
||||||
|
python3 ecodms_extract.py "export-daten/export$n/archive" \
|
||||||
|
-t "export$n" -o "export$n.json" \
|
||||||
|
--skipped-csv "export$n-nacharbeit.csv"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach eine Gesamtübersicht der Dateitypen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import json, glob, collections
|
||||||
|
v = collections.Counter(); d = collections.Counter()
|
||||||
|
for f in sorted(glob.glob('export*.json')):
|
||||||
|
for doc in json.load(open(f))['documents']:
|
||||||
|
exts = set()
|
||||||
|
for ver in doc['versions']:
|
||||||
|
e = '.' + ver['file'].rsplit('.',1)[-1].lower()
|
||||||
|
v[e] += 1; exts.add(e)
|
||||||
|
for e in exts: d[e] += 1
|
||||||
|
print(f\"{'Endung':10s} {'Versionen':>10s} {'Dokumente':>10s}\")
|
||||||
|
for k, n in v.most_common(): print(f'{k:10s} {n:10d} {d[k]:10d}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist die Zahl, die vor dem Import bekannt sein sollte — sonst stolpert
|
||||||
|
man bei Tranche 4 über einen Dateityp, der in Tranche 1 nicht vorkam.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Weiter mit [Import](04-import.md).
|
||||||
296
docs/04-import.md
Normal file
296
docs/04-import.md
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# 4. Import
|
||||||
|
|
||||||
|
Stufe 2 spielt ein Manifest über die REST-API in Paperless ein und führt
|
||||||
|
dabei ein eigenes SQLite-Journal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Journal
|
||||||
|
|
||||||
|
`migration.sqlite` ist der Kern. Es hält je Dokument fest:
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `ecodms_docid` | Quell-ID |
|
||||||
|
| `paperless_id` | Ziel-ID |
|
||||||
|
| `versions_total` / `versions_done` | Fortschritt der Kette |
|
||||||
|
| `status` | `pending`, `root_created`, `done`, `failed`, `uebersprungen` |
|
||||||
|
| `last_error` | Fehlertext im Klartext |
|
||||||
|
|
||||||
|
Daraus folgen drei Eigenschaften:
|
||||||
|
|
||||||
|
- **Wiederaufsetzbar.** Ein abgebrochener Lauf setzt fort, ohne Duplikate.
|
||||||
|
- **Tranchen dürfen sich überschneiden.** Bekannte Dokumente werden
|
||||||
|
übersprungen.
|
||||||
|
- **Tranchenübergreifend auswertbar.** Nötig für Zeitstempel und
|
||||||
|
Verknüpfungen am Ende.
|
||||||
|
|
||||||
|
Das Journal **nicht** löschen, solange die Migration läuft. Und nicht
|
||||||
|
zusammen mit dem Paperless-Bestand zurücksetzen, ohne beides gemeinsam zu
|
||||||
|
tun — sonst entstehen Doppelanlagen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollenübersetzung
|
||||||
|
|
||||||
|
`rollen.yml.example` nach `rollen.yml` kopieren und anpassen.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
default_owner: benutzer_a
|
||||||
|
|
||||||
|
roles:
|
||||||
|
r_benutzer_a:
|
||||||
|
type: user
|
||||||
|
paperless: benutzer_a
|
||||||
|
owner: true # wird Eigentümer, wenn beteiligt
|
||||||
|
FFN:
|
||||||
|
type: group
|
||||||
|
paperless: Gruppe_A
|
||||||
|
ecoSIMSUSER:
|
||||||
|
type: null # globales Importrecht, entfällt bewusst
|
||||||
|
|
||||||
|
rights:
|
||||||
|
R: [view]
|
||||||
|
W: [view, change]
|
||||||
|
|
||||||
|
no_rights_policy: owner_only
|
||||||
|
```
|
||||||
|
|
||||||
|
### Warum `ecoSIMSUSER` verworfen wird
|
||||||
|
|
||||||
|
Das ist das globale Importrecht — jeder hat Zugriff. Es wird beim Import
|
||||||
|
gesetzt und bei der ersten Klassifizierung ersetzt. Wer es übernimmt,
|
||||||
|
öffnet jedes Dokument für alle.
|
||||||
|
|
||||||
|
### Warum `W` beide Rechte bekommt
|
||||||
|
|
||||||
|
In Paperless implizieren `change` und `view` einander nicht. Ein `change`
|
||||||
|
ohne `view` ergibt keinen sinnvollen Zustand.
|
||||||
|
|
||||||
|
### Warum unbekannte Rollen zum Abbruch führen
|
||||||
|
|
||||||
|
**In Paperless bedeutet „keine Rechte gesetzt" nicht gesperrt, sondern
|
||||||
|
unbeschränkt.** Ein Tippfehler im Rollennamen würde Dokumente öffnen, nicht
|
||||||
|
schließen. Deshalb: entweder abbilden oder ausdrücklich als `type: null`
|
||||||
|
eintragen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zusatzfelder vorab anlegen
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
custom_fields:
|
||||||
|
Belegnummer: string
|
||||||
|
Zeitraum: string
|
||||||
|
Eingangsdatum: date
|
||||||
|
Zahlungsdatum: date
|
||||||
|
Wiedervorlage: date
|
||||||
|
ecoDMS-ID: string
|
||||||
|
ecoDMS-Verknuepfung: documentlink
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nicht von Hand in der Oberfläche anlegen.** Weicht der Name auch nur
|
||||||
|
minimal ab, erzeugt das Skript ein zweites gleichnamiges Feld, und die
|
||||||
|
Werte verteilen sich auf beide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
### Trockenlauf
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PT=dein_api_token
|
||||||
|
python3 paperless_import.py export1.json --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Schreibt nichts, prüft aber die Rollen gegen `rollen.yml` und meldet
|
||||||
|
Unbekanntes.
|
||||||
|
|
||||||
|
### Stammdaten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 paperless_import.py export1.json --setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Legt Tags, Dokumenttypen und Zusatzfelder an. Vorhandene werden erkannt.
|
||||||
|
|
||||||
|
Danach in der Oberfläche ansehen: Passen die Tags? Bei tiefen
|
||||||
|
Ordnerbäumen entstehen schnell mehrere hundert. Das ist der Moment, es zu
|
||||||
|
ändern — später kostet es eine Umstellung des gesamten Bestands.
|
||||||
|
|
||||||
|
### Probelauf
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 paperless_import.py export1.json --limit 2 --stop-on-error
|
||||||
|
```
|
||||||
|
|
||||||
|
Zwei Dokumente, Abbruch beim ersten Fehler. Danach in der Oberfläche
|
||||||
|
prüfen:
|
||||||
|
|
||||||
|
- Tags einzeln statt als Pfad?
|
||||||
|
- Archiv-Seriennummer gleich der ecoDMS-ID?
|
||||||
|
- Zusatzfelder gefüllt?
|
||||||
|
- Eigentümer gesetzt?
|
||||||
|
- Bei einem mehrversionigen Dokument: Kette vollständig?
|
||||||
|
|
||||||
|
### Volle Tranche
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 paperless_import.py export1.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `--stop-on-error`, damit einzelne Fehlschläge den Lauf nicht
|
||||||
|
anhalten. Sie landen als `failed` im Journal.
|
||||||
|
|
||||||
|
### Prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 paperless_import.py --verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Vergleicht Journal und Instanz. Meldet abweichende Versionszahlen,
|
||||||
|
fehlende Eigentümer, abweichende Seriennummern und **Dokumente ohne
|
||||||
|
Journaleintrag** — das sind mögliche Doppelanlagen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tag-Modus
|
||||||
|
|
||||||
|
Standard ist `segments`: Aus `Beruf/Fortbildung/Steuerberater` werden drei
|
||||||
|
Tags. Das erlaubt Filtern nach einzelnen Ebenen, kostet aber den
|
||||||
|
Hierarchiekontext — `Steuerberater` allein ist mehrdeutig.
|
||||||
|
|
||||||
|
Alternative:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 paperless_import.py export1.json --tag-mode path
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Tag je vollständigem Pfad. Übersichtlicher, aber nicht nach Ebenen
|
||||||
|
filterbar.
|
||||||
|
|
||||||
|
**Die Entscheidung vor dem ersten `--setup` treffen.** Ein Wechsel später
|
||||||
|
bedeutet, alle Tags neu zu vergeben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was Stufe 2 je Dokument tut
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/documents/post_document/ → Version 1, mit Titel, created,
|
||||||
|
Dokumenttyp, Tags
|
||||||
|
Task pollen → paperless_id ins Journal
|
||||||
|
POST /api/documents/{id}/update_version/ → Version 2…n, mit version_label
|
||||||
|
Task pollen, streng seriell
|
||||||
|
PATCH /api/documents/{id}/ → Eigentümer, Rechte,
|
||||||
|
Zusatzfelder, Seriennummer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Warum seriell
|
||||||
|
|
||||||
|
`update_version` hängt an den aktuellen Head an. Innerhalb einer
|
||||||
|
Versionskette muss jede Version fertig konsumiert sein, bevor die nächste
|
||||||
|
kommt. Parallelisiert wird über Dokumente hinweg, nicht innerhalb.
|
||||||
|
|
||||||
|
### Rechte nur auf die Wurzel
|
||||||
|
|
||||||
|
Versionen sind eigene Dokumentdatensätze, erben die Rechte aber über
|
||||||
|
`root_document`. Ein Zugriffstest mit einem nicht berechtigten Benutzer
|
||||||
|
liefert bei allen Versionen 403. Es genügt also, die Rechte einmal auf das
|
||||||
|
Wurzeldokument zu setzen.
|
||||||
|
|
||||||
|
### Was die API nicht kann
|
||||||
|
|
||||||
|
`update_version` nimmt ausschließlich `document` und `version_label`
|
||||||
|
entgegen — **keinen Zeitstempel**. Das `added`-Feld je Version muss
|
||||||
|
nachträglich gesetzt werden, siehe [Nacharbeit](05-nacharbeit.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehler behandeln
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 -header -column migration.sqlite \
|
||||||
|
"SELECT tranche, status, COUNT(*) FROM mapping GROUP BY tranche, status;"
|
||||||
|
|
||||||
|
sqlite3 -header migration.sqlite \
|
||||||
|
"SELECT ecodms_docid, last_error FROM mapping WHERE status='failed' LIMIT 20;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach einer Korrektur die Fehlschläge erneut anstoßen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 migration.sqlite \
|
||||||
|
"UPDATE mapping SET status='pending', last_error=NULL WHERE status='failed';"
|
||||||
|
python3 paperless_import.py export1.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Dauerhaft nicht importierbare Dokumente auf einen eigenen Status setzen,
|
||||||
|
damit sie nicht bei jedem Lauf erneut versucht werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 migration.sqlite \
|
||||||
|
"UPDATE mapping SET status='uebersprungen' WHERE ecodms_docid IN (…);"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Häufige Fehlerbilder
|
||||||
|
|
||||||
|
| Meldung | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| `File type … not supported` | Dateityp, oder Inhalt passt nicht zur Endung |
|
||||||
|
| `HTTP 406` bei Office/`.eml` | Tika-Version zu neu |
|
||||||
|
| `Error while queuing document` | oft eine falsche `DEFAULT_PERMISSIONS_OWNER`-ID |
|
||||||
|
| `Task … nach 600s ohne Ergebnis` | großer Scan mit OCR, `TASK_TIMEOUT` erhöhen |
|
||||||
|
| `HTTP 400` beim Anlegen eines Tags | Namenskollision nach Normalisierung |
|
||||||
|
|
||||||
|
### Zwischenzustand nach Abbruch
|
||||||
|
|
||||||
|
Wird ein Lauf zwischen Upload und Journaleintrag unterbrochen, existiert
|
||||||
|
das Dokument in Paperless ohne Journaleintrag. `--verify` findet das.
|
||||||
|
|
||||||
|
Behandlung: Zeigt das Journal `failed` und das Dokument existiert, ist der
|
||||||
|
Eintrag nachzutragen statt neu hochzuladen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 migration.sqlite \
|
||||||
|
"UPDATE mapping SET paperless_id=<id>, versions_done=1,
|
||||||
|
status='root_created', last_error=NULL WHERE ecodms_docid=<docid>;"
|
||||||
|
python3 paperless_import.py exportN.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Lauf ergänzt dann Rechte, Zusatzfelder und Seriennummer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Snapshots
|
||||||
|
|
||||||
|
Vor jeder Tranche einen Snapshot des Datenverzeichnisses:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
btrfs subvolume snapshot -r data ../snapshots/paperless-vor-tranche2
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Container vorher stoppen, sonst ist der Datenbankstand nicht konsistent zu
|
||||||
|
den Dateien. Beim ersten Durchlauf wird man das brauchen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Laufzeit
|
||||||
|
|
||||||
|
Bei PDFs mit vorhandenem Textlayer und `OCR_MODE=auto` liegt ein
|
||||||
|
Konsumvorgang unter einer Sekunde. Für 12.000 Versionen bedeutet das
|
||||||
|
wenige Stunden.
|
||||||
|
|
||||||
|
Der Engpass ist nicht die CPU, sondern die serielle Arbeitsweise plus das
|
||||||
|
Abfrageintervall. Mehr Worker beschleunigen nichts, solange immer nur eine
|
||||||
|
Aufgabe gleichzeitig läuft.
|
||||||
|
|
||||||
|
Deutlich länger dauern Office-Dokumente und E-Mails, weil sie über
|
||||||
|
Gotenberg und Tika laufen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Weiter mit [Nacharbeit](05-nacharbeit.md).
|
||||||
250
docs/05-nacharbeit.md
Normal file
250
docs/05-nacharbeit.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# 5. Nacharbeit
|
||||||
|
|
||||||
|
Diese Schritte laufen **nach allen Tranchen**, nicht zwischendurch.
|
||||||
|
Zeitstempel und Verknüpfungen brauchen ein vollständiges Journal.
|
||||||
|
|
||||||
|
Vorher einen Snapshot anlegen — die ersten beiden Schritte greifen direkt
|
||||||
|
in die Datenbank ein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zeitstempel setzen
|
||||||
|
|
||||||
|
Der Endpunkt `update_version` nimmt keinen Zeitstempel entgegen. Alle
|
||||||
|
Versionen tragen deshalb zunächst das Importdatum statt des
|
||||||
|
ecoDMS-Zeitstempels.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 paperless_import.py --emit-timestamps > fix_added.py
|
||||||
|
wc -l fix_added.py
|
||||||
|
head -20 fix_added.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Kurz hineinsehen: Die Liste sollte plausible Datumswerte enthalten und
|
||||||
|
ungefähr so viele Einträge haben wie Versionen vorhanden sind.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T webserver python manage.py shell < fix_added.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Das erzeugte Skript benutzt `Document.objects.filter(pk=…).update(added=…)`
|
||||||
|
statt `save()`. Das ist wichtig: `update()` umgeht `auto_now`-Felder und
|
||||||
|
Signal-Handler, die den Wert sonst wieder überschreiben und nebenbei
|
||||||
|
Reindexierungen auslösen würden.
|
||||||
|
|
||||||
|
### Prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Token $PT" \
|
||||||
|
"http://localhost:8000/api/documents/?archive_serial_number=<docid>" \
|
||||||
|
| python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)['results'][0]['versions'], indent=2))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Die `added`-Werte müssen aus dem Quellzeitraum stammen, nicht vom
|
||||||
|
Importdatum.
|
||||||
|
|
||||||
|
> Die `versions`-Liste ist **absteigend** sortiert, neueste zuerst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Notizen übertragen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ecodms_notizen.py --exports /pfad/export-daten --tranchen 1-6 \
|
||||||
|
--dry-run --csv notizen.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Trockenlauf schreibt nichts, erzeugt aber die vollständige Liste. **In
|
||||||
|
die CSV sehen, bevor übertragen wird:** Bei jeder Zeile stehen die
|
||||||
|
aufgelöste `ecodms_docid`, die Paperless-ID, der Titel und der Notiztext.
|
||||||
|
Passen Titel und Notiz inhaltlich zusammen, stimmt die Zuordnung.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ecodms_notizen.py --exports /pfad/export-daten --tranchen 1-6 \
|
||||||
|
--csv notizen-uebertragen.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Lauf ist wiederholbar — textgleiche Notizen am selben Dokument werden
|
||||||
|
übersprungen.
|
||||||
|
|
||||||
|
### Warum über die Exporte
|
||||||
|
|
||||||
|
`econotice.nid` enthält die `docs_id`. Die Auflösung nach `docid` steht in
|
||||||
|
der `docs`-Tabelle, und die existiert nur im Export, nicht in der
|
||||||
|
Live-Datenbank.
|
||||||
|
|
||||||
|
Das Skript liest deshalb **alle** Tranchen ein und baut eine gemeinsame
|
||||||
|
Zuordnungstabelle, bevor es überträgt — eine Notiz kann zu einem Dokument
|
||||||
|
aus einer anderen Tranche gehören.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Klassifikator und Suchindex
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec webserver python manage.py document_create_classifier
|
||||||
|
docker compose exec webserver python manage.py document_index reindex
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Reindex über einen großen Bestand dauert.
|
||||||
|
|
||||||
|
Das ist der unterschätzte Nebeneffekt der Migration: Der Klassifikator hat
|
||||||
|
jetzt einen vollständig klassifizierten Bestand als Trainingsbasis. Neue
|
||||||
|
Dokumente werden dadurch von Anfang an brauchbar vorgeschlagen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Verknüpfungen
|
||||||
|
|
||||||
|
Dokumentverknüpfungen sind im Export nicht enthalten und in der
|
||||||
|
Live-Datenbank nicht auffindbar. Sie müssen von Hand nachgetragen werden.
|
||||||
|
|
||||||
|
Falls die Paare aus einer anderen Quelle vorliegen, geht es automatisch:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
src_docid,dst_docid
|
||||||
|
10346,10351
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 paperless_import.py --links verknuepfungen.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript setzt **beide Richtungen** ausdrücklich. Die automatische
|
||||||
|
Gegenrichtung von Paperless greift bei Massenbearbeitung nachweislich
|
||||||
|
nicht.
|
||||||
|
|
||||||
|
Von Hand geht es über das Zusatzfeld vom Typ Document Link. Die
|
||||||
|
Archiv-Seriennummer entspricht der ecoDMS-ID, das Auffinden ist also
|
||||||
|
unkompliziert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ausschlusslisten abarbeiten
|
||||||
|
|
||||||
|
Zwei Gruppen sind liegengeblieben:
|
||||||
|
|
||||||
|
**Nicht unterstützte Dateitypen** — aus `exportN-nacharbeit.csv`.
|
||||||
|
Typischerweise `.html`, `.zip`, `.indd`. Je nach Inhalt: HTML über den
|
||||||
|
Browser als PDF drucken, ZIP entpacken und einzeln ablegen, oder gar nicht
|
||||||
|
übernehmen.
|
||||||
|
|
||||||
|
**Fehlschläge aus dem Journal:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 -header -csv migration.sqlite \
|
||||||
|
"SELECT ecodms_docid, tranche, last_error FROM mapping
|
||||||
|
WHERE status IN ('failed','uebersprungen');" > nacharbeit-journal.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein wiederkehrendes Muster: `.eml`-Dateien, die Paperless als `text/html`
|
||||||
|
erkennt. Es sind gültige E-Mails mit korrekten Kopfzeilen, aber die
|
||||||
|
Inhaltserkennung übergewichtet den HTML-Rumpf. Ein ausdrücklich
|
||||||
|
mitgegebener MIME-Typ hilft nicht — Paperless prüft den Inhalt.
|
||||||
|
|
||||||
|
Behandlung: Mail im Mailprogramm öffnen und als PDF drucken. Das Ergebnis
|
||||||
|
ist meist besser lesbar als Paperless' eigene Darstellung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Abschlussprüfung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 paperless_import.py --verify
|
||||||
|
|
||||||
|
sqlite3 -header -column migration.sqlite \
|
||||||
|
"SELECT status, COUNT(*) FROM mapping GROUP BY status;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Stichproben in der Oberfläche:
|
||||||
|
|
||||||
|
- Dokument mit mehreren Versionen — Kette und Zeitstempel korrekt?
|
||||||
|
- Dokument mit Notiz — Text vorhanden und beim richtigen Dokument?
|
||||||
|
- Mehrfach klassifiziertes Dokument — alle Tags vorhanden?
|
||||||
|
- ZUGFeRD-Rechnung — eingebettete XML noch da?
|
||||||
|
```bash
|
||||||
|
docker compose exec webserver bash -c \
|
||||||
|
"pdfdetach -list /usr/src/paperless/media/documents/originals/*/*.pdf" | head
|
||||||
|
```
|
||||||
|
- Volltextsuche nach einem bekannten Begriff
|
||||||
|
- **Anmeldung als eingeschränkter Benutzer** — sieht er nur seine
|
||||||
|
Dokumente?
|
||||||
|
|
||||||
|
Der letzte Punkt ist der wichtigste. Ein Dokument ohne Eigentümer ist in
|
||||||
|
Paperless für jeden sichtbar, und in der Oberfläche fällt das als
|
||||||
|
Superuser nicht auf.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PT2=token_eines_eingeschraenkten_benutzers
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Token $PT2" \
|
||||||
|
http://localhost:8000/api/documents/<fremde-id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
403 ist richtig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Umzug in die Produktivumgebung
|
||||||
|
|
||||||
|
Falls die Migration auf einer anderen Maschine lief:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quelle
|
||||||
|
docker compose stop webserver
|
||||||
|
docker compose exec -T db pg_dump -U paperless -Fc paperless > paperless.dump
|
||||||
|
|
||||||
|
rsync -rlt --no-perms --no-owner --no-group --info=progress2 \
|
||||||
|
data/media/ ziel:/pfad/media/
|
||||||
|
rsync -rlt --no-perms --no-owner --no-group --info=progress2 \
|
||||||
|
data/data/ ziel:/pfad/data/
|
||||||
|
```
|
||||||
|
|
||||||
|
`--no-perms --no-owner --no-group` ist wichtig, wenn das Ziel eigene
|
||||||
|
Besitzverhältnisse oder ACLs hat — sonst überschreibt rsync sie mit denen
|
||||||
|
der Quelle.
|
||||||
|
|
||||||
|
Auf dem Ziel **erst nur Datenbank und Broker** starten, damit Paperless
|
||||||
|
nicht auf ein leeres Schema trifft und ein frisches anlegt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d db broker
|
||||||
|
docker compose logs -f db # auf "ready to accept connections" warten
|
||||||
|
|
||||||
|
docker compose exec -T db \
|
||||||
|
pg_restore -U paperless -d paperless --clean --if-exists < paperless.dump
|
||||||
|
|
||||||
|
docker compose exec -T db psql -U paperless -d paperless -c \
|
||||||
|
"SELECT count(*) FROM documents_document;"
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Was identisch sein muss
|
||||||
|
|
||||||
|
| Wert | Warum |
|
||||||
|
|---|---|
|
||||||
|
| `PAPERLESS_SECRET_KEY` | entwertet sonst alle Tokens und Freigabelinks |
|
||||||
|
| `PAPERLESS_FILENAME_FORMAT` | benennt sonst den gesamten Bestand um |
|
||||||
|
| Postgres-Hauptversion | ein Dump aus 17 läuft nicht in 16 |
|
||||||
|
| Paperless-Version | gleich oder neuer, nie älter |
|
||||||
|
|
||||||
|
Die Quellinstanz stehen lassen, bis die Zielinstanz nachweislich läuft.
|
||||||
|
Ein paar Wochen produktiver Betrieb sind eine bessere Freigabe als jede
|
||||||
|
Prüfliste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Betrieb
|
||||||
|
|
||||||
|
Was nach der Migration eingerichtet werden sollte, aber nicht Teil davon
|
||||||
|
ist:
|
||||||
|
|
||||||
|
- Reverse Proxy mit TLS, Paperless nie direkt exponieren
|
||||||
|
- Zwei-Faktor für alle Konten
|
||||||
|
- Sicherung: `pg_dump` plus `media` und `data`. Das Datenverzeichnis von
|
||||||
|
Postgres **nicht** auf Dateiebene sichern — der Stand wäre inkonsistent
|
||||||
|
- Consume-Ordner und Mail-Abruf
|
||||||
|
- Papierkorb-Frist und Audit-Log
|
||||||
|
|
||||||
|
Und der Teil, den man auslässt: **einmal wiederherstellen.** Eine
|
||||||
|
Sicherung, die nie zurückgespielt wurde, ist eine Vermutung.
|
||||||
378
docs/referenz-ecodms-exportformat.md
Normal file
378
docs/referenz-ecodms-exportformat.md
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
# Das ecoDMS-Exportformat
|
||||||
|
|
||||||
|
Nachschlagewerk zu den Eigenheiten des Exports. Der größte Teil davon steht
|
||||||
|
in keiner Dokumentation und wurde bei der Migration eines gewachsenen
|
||||||
|
Bestands aufgedeckt.
|
||||||
|
|
||||||
|
**Wer nur ein Dokument aus diesem Repository liest, sollte es dieses sein.**
|
||||||
|
Fast jeder der hier beschriebenen Punkte kann zu stiller Fehlzuordnung
|
||||||
|
führen — zu Dokumenten am falschen Platz, zu Rechten an der falschen
|
||||||
|
Stelle, zu verlorenen Versionen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aufbau eines Exports
|
||||||
|
|
||||||
|
Das ecoDMS-Exportwerkzeug erzeugt ein ZIP mit dieser Struktur:
|
||||||
|
|
||||||
|
```
|
||||||
|
offline_export/
|
||||||
|
├── archive/
|
||||||
|
│ ├── export.data SQLite-Datenbank mit Metadaten
|
||||||
|
│ ├── export.xml dieselben Daten plus Historie
|
||||||
|
│ ├── lucene/ Suchindex des Offline-Clients
|
||||||
|
│ ├── ecodms_docid_0010325.pdf Kopfversion als PDF/A
|
||||||
|
│ ├── ecodms_docid_0010325_revision_0001.odt Original je Version
|
||||||
|
│ └── …
|
||||||
|
└── (Programmdateien des Offline-Clients)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Die Dateien liegen im Dateisystem, nicht in der Datenbank.** Die
|
||||||
|
BLOB-Spalten `data`, `pdf` und `ocrtext` in `export.data` sind
|
||||||
|
durchgehend `NULL`. Das ist gut so — eine SQLite mit dem gesamten
|
||||||
|
Dateibestand wäre unhandlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallstrick 1: `docid` gegen `docs_id`
|
||||||
|
|
||||||
|
**Der wichtigste Punkt des ganzen Dokuments.**
|
||||||
|
|
||||||
|
ecoDMS führt zwei Nummern, die leicht verwechselt werden:
|
||||||
|
|
||||||
|
| Nummer | Bedeutung | Wo |
|
||||||
|
|---|---|---|
|
||||||
|
| `docid` | das Dokument | `klassifizierung.docid`, `ecosimsversions.docid` |
|
||||||
|
| `docs_id` | die Klassifizierungsinstanz | `docs.id` |
|
||||||
|
|
||||||
|
Bei niedrigen Nummern stimmen beide zufällig überein. Bei höheren laufen
|
||||||
|
sie auseinander:
|
||||||
|
|
||||||
|
```
|
||||||
|
docs.id 718 → docid 697
|
||||||
|
docs.id 2022 → docid 1960
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Join über die falsche Spalte liefert deshalb bei alten Dokumenten
|
||||||
|
korrekte Ergebnisse und bei neuen falsche. Das fällt beim Testen mit
|
||||||
|
wenigen Datensätzen **nicht** auf.
|
||||||
|
|
||||||
|
### Verschärfung: falsch benannte Spalten
|
||||||
|
|
||||||
|
Zwei Tabellen haben eine Spalte namens `docid`, die in Wahrheit die
|
||||||
|
`docs_id` enthält:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE docrollen_hist(docid bigint, role varchar, doc_right char(1), revision varchar);
|
||||||
|
-- ^^^^^ enthält docs_id
|
||||||
|
CREATE TABLE econotice(id bigint, text vcharacter, tdate timestamp, nid vcharacter, username varchar);
|
||||||
|
-- ^^^ enthält docs_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Der korrekte Join ist in beiden Fällen `… = docs.id`.
|
||||||
|
|
||||||
|
### So prüft man es
|
||||||
|
|
||||||
|
Nicht an Datensätzen mit niedrigen Nummern — dort sind beide Werte gleich.
|
||||||
|
Such gezielt Fälle mit Abweichung und prüf **inhaltlich**, welche Auflösung
|
||||||
|
zum Datensatz passt:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT n.id, n.nid,
|
||||||
|
(SELECT docid FROM docs WHERE id = CAST(n.nid AS INTEGER)) AS als_docs_id,
|
||||||
|
(SELECT id FROM docs WHERE docid = CAST(n.nid AS INTEGER)) AS als_docid
|
||||||
|
FROM econotice n
|
||||||
|
WHERE als_docs_id IS NOT als_docid;
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann die Bemerkung der beiden Kandidaten ansehen. Eine Notiz „an Herrn X
|
||||||
|
gesendet" gehört zum Dienstplan, nicht zur Zinsmitteilung der Bank.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallstrick 2: Mehrfachklassifizierung
|
||||||
|
|
||||||
|
Ein Dokument kann in ecoDMS **mehrfach abgelegt** sein — dieselbe Datei in
|
||||||
|
zwei Ordnern, mit je eigener Klassifizierung.
|
||||||
|
|
||||||
|
```
|
||||||
|
docs.id 10829 (kid 22325) → Haushalt/Kleingeräte, Zeitraum leer
|
||||||
|
docs.id 10834 (kid 22540) → Steuer/ESt/Anlage N, Zeitraum 2025
|
||||||
|
beide: docid 10347
|
||||||
|
```
|
||||||
|
|
||||||
|
Daraus folgt: **Eine `docid` entspricht nicht einer Zeile in `docs`.**
|
||||||
|
Wer nach `docs_id` gruppiert, legt das Dokument zweimal an.
|
||||||
|
|
||||||
|
Paperless bildet das sogar besser ab, weil Tags many-to-many sind: Aus zwei
|
||||||
|
Ablageorten wird ein Dokument mit den Tags beider Pfade. Man braucht aber
|
||||||
|
eine Zusammenführungsregel für die skalaren Felder — bewährt hat sich: der
|
||||||
|
jüngste `ctimestamp` gewinnt, Ordner werden vereinigt, Abweichungen bei
|
||||||
|
Titel, Dokumentart, Status und Datum werden protokolliert.
|
||||||
|
|
||||||
|
Häufigkeit im Testbestand: **eines von vier** Dokumenten einer Stichprobe.
|
||||||
|
Nicht als Randfall behandeln.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallstrick 3: Revision ist nicht Version
|
||||||
|
|
||||||
|
Zwei völlig unabhängige Zähler:
|
||||||
|
|
||||||
|
| Begriff | Wo | Zählt |
|
||||||
|
|---|---|---|
|
||||||
|
| `klassifizierung.revision` | `1.0`, `1.1`, `1.2` … | Klassifizierungsänderungen |
|
||||||
|
| `ecosimsversions.version` | `1`, `2`, `3` … | Dateiversionen |
|
||||||
|
|
||||||
|
Beispiele aus einem echten Bestand:
|
||||||
|
|
||||||
|
| docid | Dateiversionen | Revision |
|
||||||
|
|---|---|---|
|
||||||
|
| 10325 | 4 | 1.3 (= 4. Revision) |
|
||||||
|
| 10422 | **3** | **1.1 (= 2. Revision)** |
|
||||||
|
| 11072 | **0** | 1.5 (= 6. Revision) |
|
||||||
|
| 11141 | 1 | 1.2 (= 3. Revision) |
|
||||||
|
|
||||||
|
Es gibt keine Umrechnung. Für die Versionskette in Paperless zählt
|
||||||
|
ausschließlich `ecosimsversions.version`.
|
||||||
|
|
||||||
|
**Und beachte `docid 11072`:** null Dateiversionen. Solche Dokumente haben
|
||||||
|
keine Zeile in `ecosimsversions` und müssen auf `ecosimsarchive`
|
||||||
|
zurückfallen, sonst verschwinden sie stillschweigend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallstrick 4: Immer das Original importieren
|
||||||
|
|
||||||
|
`ecosimsversions` führt zwei Dateinamen:
|
||||||
|
|
||||||
|
| Spalte | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `realname` | Originaldatei |
|
||||||
|
| `pdfrealname` | PDF/A-Fassung |
|
||||||
|
|
||||||
|
**Immer `realname` verwenden.** Der Unterschied ist nicht kosmetisch:
|
||||||
|
|
||||||
|
- Bei **ZUGFeRD** enthält das Original die eingebettete XML. Eine
|
||||||
|
neu gerenderte Fassung womöglich nicht.
|
||||||
|
- Bei **E-Mails** enthält die `.eml` die Anhänge. Das gerenderte PDF nicht.
|
||||||
|
In einem Testfall: 34,0 KB Original gegen 25,8 KB PDF — die Differenz
|
||||||
|
war der Rechnungsanhang.
|
||||||
|
|
||||||
|
Bei reinen PDF-Dokumenten sind beide Spalten identisch, die Regel kostet
|
||||||
|
dort also nichts.
|
||||||
|
|
||||||
|
### Was fehlt
|
||||||
|
|
||||||
|
Bei **Nicht-PDF-Originalen** exportiert ecoDMS pro Version nur das
|
||||||
|
Original, nicht die PDF/A-Fassung — obwohl `pdfrealname` sie referenziert.
|
||||||
|
Die XML verweist dann auf Dateien, die im Archiv nicht existieren.
|
||||||
|
|
||||||
|
Für Paperless unkritisch, es erzeugt Archivdateien selbst. Aber ein
|
||||||
|
Skript, das blind allen Referenzen folgt, läuft ins Leere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallstrick 5: Rechte sind historisiert
|
||||||
|
|
||||||
|
`docrollen_hist` ist eine Historientabelle. Sie enthält alle jemals
|
||||||
|
gesetzten Rechte, nicht nur die aktuellen:
|
||||||
|
|
||||||
|
```
|
||||||
|
docs_id 10829 ecoSIMSUSER W 1.0
|
||||||
|
docs_id 10829 r_benutzer_a W 1.1
|
||||||
|
docs_id 10829 r_benutzer_b W 1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
`ecoSIMSUSER` ist das **globale Importrecht** — jeder hat Zugriff. Es wird
|
||||||
|
beim Import gesetzt und bei der ersten Klassifizierung durch die echten
|
||||||
|
Berechtigten ersetzt.
|
||||||
|
|
||||||
|
**Wer alle Zeilen übernimmt, öffnet jedes Dokument für alle.**
|
||||||
|
|
||||||
|
Maßgeblich sind nur die Zeilen mit der **höchsten in `docrollen_hist`
|
||||||
|
vorhandenen** Revision je `docs_id`. Nicht der Revisionswert aus
|
||||||
|
`klassifizierung` — der kann höher sein, wenn zuletzt nur die
|
||||||
|
Klassifizierung geändert wurde.
|
||||||
|
|
||||||
|
### Revisionen richtig sortieren
|
||||||
|
|
||||||
|
`revision` ist ein String. Lexikografisch ist `'1.10'` kleiner als
|
||||||
|
`'1.9'`. Bei einem Bestand mit mehr als neun Revisionen führt das zu
|
||||||
|
falschen Ergebnissen. Als Tupel aus Integern parsen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def revtuple(r):
|
||||||
|
return tuple(int(p) for p in str(r).split("."))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dokumente ohne Rechteeintrag
|
||||||
|
|
||||||
|
Viele Dokumente haben **gar keine Zeile** in `docrollen_hist` — in einer
|
||||||
|
Stichprobe sechs von acht. Die Tabelle speichert offenbar nur ausdrücklich
|
||||||
|
vergebene Berechtigungen.
|
||||||
|
|
||||||
|
Das ist der gefährlichste Punkt der ganzen Migration, denn:
|
||||||
|
|
||||||
|
> **In Paperless bedeutet „keine Rechte gesetzt" nicht gesperrt, sondern
|
||||||
|
> unbeschränkt.**
|
||||||
|
|
||||||
|
Ein Dokument ohne Eigentümer und ohne Objektrechte ist für jeden
|
||||||
|
angelegten Benutzer sichtbar. Die Migration muss deshalb jedem Dokument
|
||||||
|
einen Eigentümer geben, auch wenn aus ecoDMS nichts überliefert ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallstrick 6: Base64 an unregelmäßigen Stellen
|
||||||
|
|
||||||
|
Manche Felder sind base64-kodiert, benachbarte nicht:
|
||||||
|
|
||||||
|
| Kodiert | Klartext |
|
||||||
|
|---|---|
|
||||||
|
| `ecosimsversions.savedate` | `ecosimsversions.realname` |
|
||||||
|
| `ecosimsversions.filename` | `ecosimsversions.pdfrealname` |
|
||||||
|
| `ecosimsarchive.version` | `ecosimsarchive.filename` |
|
||||||
|
| `econotice.text` | `econotice.tdate` |
|
||||||
|
| XML `<date>` | XML `letzte-änderung` |
|
||||||
|
| `ecodmsexporter.exp_query` | |
|
||||||
|
|
||||||
|
Es gibt kein erkennbares System. Im Zweifel dekodieren und bei einem
|
||||||
|
Fehlschlag den Originalwert nehmen.
|
||||||
|
|
||||||
|
### Notiztexte
|
||||||
|
|
||||||
|
`econotice.text` ist base64-kodiertes **Qt-HTML** mit CSS-Block im Kopf.
|
||||||
|
Zum Auspacken:
|
||||||
|
|
||||||
|
```python
|
||||||
|
s = base64.b64decode(raw).decode("utf-8", "replace")
|
||||||
|
s = re.sub(r"<[^>]+>", " ", s) # Tags
|
||||||
|
s = html.unescape(re.sub(r"\s+", " ", s)) # Entities, Leerraum
|
||||||
|
s = re.sub(r"^\s*p,\s*li\s*\{[^}]*\}\s*", "", s) # CSS-Rest
|
||||||
|
```
|
||||||
|
|
||||||
|
Der letzte Schritt ist nötig, weil der CSS-Block nach dem Entfernen der
|
||||||
|
Tags als Text übrigbleibt: `p, li { white-space: pre-wrap; }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallstrick 7: Ordnernamen enthalten Schrägstriche
|
||||||
|
|
||||||
|
Ordner heißen in der Praxis auch `Telefon / Internet` oder `TV / Rundfunk`.
|
||||||
|
Wer den Ordnerpfad zu einem String verkettet und später am Schrägstrich
|
||||||
|
zerlegt, zerschneidet solche Namen in `Telefon ` und ` Internet`.
|
||||||
|
|
||||||
|
Die Folge sind Tags mit Leerzeichen am Rand — und wenn das Zielsystem die
|
||||||
|
beim Speichern entfernt, kollidieren sie mit einer echten Ebene gleichen
|
||||||
|
Namens.
|
||||||
|
|
||||||
|
**Ordnerpfade als Liste von Segmenten führen, nicht als verketteten
|
||||||
|
String.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wo die Wiedervorlage steckt
|
||||||
|
|
||||||
|
Nicht in `dynattribute`, sondern in **`klassifizierung.defdate`**. Es ist
|
||||||
|
eine eingebaute Funktion, kein dynamisches Attribut. Passend dazu gibt es
|
||||||
|
in `status` den Wert `Wiedervorlage`.
|
||||||
|
|
||||||
|
`econotice` ist **nicht** die Wiedervorlage, sondern die Notizfunktion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Live-Datenbank ist etwas anderes
|
||||||
|
|
||||||
|
Wer die Klassifizierungsdaten in der laufenden ecoDMS-PostgreSQL sucht,
|
||||||
|
sucht vergeblich. Dort gibt es **weder `klassifizierung` noch `docs`** —
|
||||||
|
die inhaltliche Ebene erzeugt erst der Exporter aus dem
|
||||||
|
Container-Speichersystem.
|
||||||
|
|
||||||
|
In der Live-Datenbank liegt nur die Verwaltungsebene: Archive, Dateien,
|
||||||
|
Versionen, Benutzer, Rollen, Suchindex.
|
||||||
|
|
||||||
|
Das erklärt auch, warum **Dokumentverknüpfungen** nicht auffindbar sind.
|
||||||
|
Vier unabhängige Prüfungen an einem produktiven Bestand:
|
||||||
|
|
||||||
|
- keine Tabelle mit zwei Dokumentspalten
|
||||||
|
- beim Anlegen einer Verknüpfung entsteht **keine neue Zeile** in
|
||||||
|
irgendeiner Tabelle
|
||||||
|
- die Partner-ID kommt in keiner Textspalte vor
|
||||||
|
- ebenso in keiner Zahlenspalte außer den eigenen Datensätzen
|
||||||
|
|
||||||
|
Verknüpfungen sind damit weder im Export noch in der Datenbank greifbar.
|
||||||
|
Sie müssen von Hand nachgetragen werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der Exporter selbst
|
||||||
|
|
||||||
|
### AND statt OR
|
||||||
|
|
||||||
|
Bei manueller Mehrfachauswahl kann der Exporter eine unerfüllbare Abfrage
|
||||||
|
erzeugen:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
docid = '11072' AND docid = '11094' AND docid = '11143' AND …
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Ergebnis ist ein Export mit vollständigen Stammdaten und **null
|
||||||
|
Dokumenten**, ohne Fehlermeldung. Die Abfrage steht base64-kodiert in
|
||||||
|
`ecodmsexporter.exp_query`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
base64.b64decode(row["exp_query"]).decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nach jedem Export prüfen.** Und Exporte besser über ein Suchkriterium
|
||||||
|
oder einen ID-Bereich definieren als über eine Liste einzeln markierter
|
||||||
|
Dokumente.
|
||||||
|
|
||||||
|
### Tabellen im Export
|
||||||
|
|
||||||
|
| Tabelle | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `docs` | `id` (= docs_id), `docid`, `kid`, `archiv`, `trashed`, `attachments` |
|
||||||
|
| `klassifizierung` | `kid`, `docid`, `mainfolder`, `folder`, `bemerkung`, `status`, `revision`, `docart`, `ctimestamp`, `cdate`, `defdate`, `docs_id`, `dyn_*` |
|
||||||
|
| `ecosimsversions` | `docid`, `version`, `savedate`, `realname`, `pdfrealname`, `checksum`, `comment`, `fixiert` |
|
||||||
|
| `ecosimsarchive` | Kopfdokument je `docid`, Rückfall wenn keine Versionen |
|
||||||
|
| `docrollen_hist` | `docid` (= docs_id!), `role`, `doc_right`, `revision` |
|
||||||
|
| `econotice` | `id`, `text` (base64 Qt-HTML), `tdate`, `nid` (= docs_id!), `username` |
|
||||||
|
| `systemordner` | `oid`, `name`, `parentid` — Hierarchie über `parentid` |
|
||||||
|
| `documentenart`, `status` | Stammdaten, mit `trashed`-Kennzeichen |
|
||||||
|
| `dynattribute` | Spaltenname → Anzeigename und Typ, mit `deleted`-Kennzeichen |
|
||||||
|
| `dup_items`, `duplicatesindexer` | Duplikaterkennung, für die Migration irrelevant |
|
||||||
|
|
||||||
|
### `attachments` bedeutet etwas anderes
|
||||||
|
|
||||||
|
`docs.attachments` zählt **eingebettete** Anhänge, nicht separate Dateien:
|
||||||
|
|
||||||
|
- bei ZUGFeRD die im PDF eingebettete XML
|
||||||
|
- bei E-Mails die Anhänge innerhalb der `.eml`
|
||||||
|
|
||||||
|
Beide reisen mit dem Original mit. Es fehlt nichts — solange man
|
||||||
|
`realname` importiert.
|
||||||
|
|
||||||
|
### `lucene` ist wertlos
|
||||||
|
|
||||||
|
Der Ordner enthält den Suchindex des Offline-Clients. Gespeichert sind nur
|
||||||
|
Klassifizierungsdaten, die ohnehin vorliegen. Der Dokumenttext existiert
|
||||||
|
lediglich als kleingeschriebene Termliste im invertierten Index und ist
|
||||||
|
nicht rekonstruierbar.
|
||||||
|
|
||||||
|
Schade — sonst hätte man sich das erneute OCR sparen können.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prüfliste vor jedem Import
|
||||||
|
|
||||||
|
- [ ] `exp_query` dekodiert und plausibel?
|
||||||
|
- [ ] Anzahl Dokumente im Export gegen die Erwartung?
|
||||||
|
- [ ] Alle in der XML referenzierten Dateien im Archiv vorhanden?
|
||||||
|
- [ ] Rollen im Export vollständig in der Übersetzung abgebildet?
|
||||||
|
- [ ] Vorkommende `doc_right`-Werte bekannt?
|
||||||
|
- [ ] Dateitypen bekannt und von Paperless unterstützt?
|
||||||
|
- [ ] Häufigkeit der Mehrfachklassifizierung geprüft?
|
||||||
|
- [ ] Dokumente ohne Rechteeintrag — Regel festgelegt?
|
||||||
|
|
||||||
|
`ecodms_extract.py` beantwortet all das im Prüfbericht.
|
||||||
577
ecodms_extract.py
Normal file
577
ecodms_extract.py
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ecodms_extract.py - Stufe 1 der ecoDMS -> Paperless-ngx Migration.
|
||||||
|
|
||||||
|
Liest eine Exporttranche (export.data + export.xml + archive/) und erzeugt
|
||||||
|
ein normalisiertes JSON-Manifest plus einen Pruefbericht. Beruehrt Paperless
|
||||||
|
nicht - die Stufe ist offline testbar und beliebig oft wiederholbar.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
./ecodms_extract.py /pfad/zu/offline_export/archive -o tranche_01.json
|
||||||
|
|
||||||
|
Die hier kodierten Regeln stammen aus der Analyse dreier Testexporte:
|
||||||
|
|
||||||
|
1. Gruppierung nach docid, nicht nach docs_id. Eine docid kann mehrere
|
||||||
|
Klassifizierungsinstanzen haben (Mehrfachablage in mehreren Ordnern).
|
||||||
|
2. Beim Zusammenfuehren gewinnt bei Skalaren der juengste ctimestamp,
|
||||||
|
Ordner werden als Menge vereinigt.
|
||||||
|
3. Immer realname (Original) verwenden, nie pdfrealname. Sonst gehen
|
||||||
|
eingebettete ZUGFeRD-XML und E-Mail-Anhaenge verloren.
|
||||||
|
4. Dokumente ohne Zeilen in ecosimsversions fallen auf ecosimsarchive
|
||||||
|
zurueck und werden als einzige Version behandelt.
|
||||||
|
5. docrollen_hist.docid enthaelt docs_id, nicht docid. Join ueber docs.id.
|
||||||
|
6. Rechte sind historisiert. Nur die hoechste in docrollen_hist
|
||||||
|
vorhandene Revision je docs_id gilt - nicht die aus klassifizierung.
|
||||||
|
7. Revisionen werden als Zahlentupel sortiert ('1.10' > '1.9').
|
||||||
|
8. base64 an unregelmaessigen Stellen: ecosimsversions.savedate und
|
||||||
|
.filename sowie ecosimsarchive.version sind kodiert, realname und
|
||||||
|
pdfrealname nicht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
# Dateitypen, die Paperless-ngx konsumieren kann. Massgeblich ist die
|
||||||
|
# laufende Instanz:
|
||||||
|
# docker compose exec webserver python manage.py shell -c \
|
||||||
|
# "from documents.parsers import get_supported_file_extensions as f; print(sorted(f()))"
|
||||||
|
#
|
||||||
|
# Dokumente mit anderen Endungen werden nicht ins Manifest aufgenommen,
|
||||||
|
# sondern in eine Ausschlussliste geschrieben. Sonst scheitern sie erst
|
||||||
|
# beim Upload mit HTTP 400 und muellen das Journal zu.
|
||||||
|
SUPPORTED_EXTENSIONS = {
|
||||||
|
".pdf", ".txt", ".text", ".csv", ".rtf", ".xml", ".eml", ".mail",
|
||||||
|
".mht", ".mhtml", ".nws", ".brf", ".srt", ".rdf", ".wsdl", ".xsl", ".xpdl",
|
||||||
|
".doc", ".docx", ".dot", ".odt",
|
||||||
|
".xls", ".xlsx", ".xlb", ".xlc", ".xlm", ".xlt", ".xlw", ".xla", ".ods",
|
||||||
|
".ppt", ".pptx", ".pps", ".ppsx", ".pot", ".ppa", ".pwz", ".wiz", ".odp",
|
||||||
|
".odg",
|
||||||
|
".png", ".jpg", ".jpe", ".jpeg", ".jfif", ".gif", ".bmp", ".webp",
|
||||||
|
".tif", ".tiff", ".heic", ".art",
|
||||||
|
".bat", ".c", ".h", ".ksh", ".pl",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Hilfsmittel
|
||||||
|
|
||||||
|
def b64(value):
|
||||||
|
"""Dekodiert base64, gibt den Originalwert zurueck wenn das misslingt."""
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return base64.b64decode(value, validate=True).decode("utf-8")
|
||||||
|
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def revtuple(revision):
|
||||||
|
"""'1.10' -> (1, 10). Lexikografische Sortierung waere hier falsch."""
|
||||||
|
try:
|
||||||
|
return tuple(int(p) for p in str(revision).split("."))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return (-1,)
|
||||||
|
|
||||||
|
|
||||||
|
def clean(value):
|
||||||
|
"""ecoDMS schreibt teils den String 'null' statt eines Leerwerts."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
value = str(value).strip()
|
||||||
|
return "" if value.lower() == "null" else value
|
||||||
|
|
||||||
|
|
||||||
|
class Report:
|
||||||
|
"""Sammelt Warnungen nach Kategorie, damit der Bericht lesbar bleibt."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.items = defaultdict(list)
|
||||||
|
|
||||||
|
def warn(self, category, message):
|
||||||
|
self.items[category].append(message)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return sum(len(v) for v in self.items.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Stammdaten
|
||||||
|
|
||||||
|
class Lookups:
|
||||||
|
def __init__(self, con):
|
||||||
|
self.docart = {
|
||||||
|
r["daid"]: (r["name"], r["trashed"] == "true")
|
||||||
|
for r in con.execute("SELECT daid, name, trashed FROM documentenart")
|
||||||
|
}
|
||||||
|
self.status = {
|
||||||
|
r["sid"]: (r["name"], r["trashed"] == "true")
|
||||||
|
for r in con.execute("SELECT sid, name, trashed FROM status")
|
||||||
|
}
|
||||||
|
self.folder = {
|
||||||
|
r["oid"]: dict(name=r["name"], parent=r["parentid"], deleted=r["deleted"])
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT oid, name, parentid, deleted FROM systemordner"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
# Nur nicht geloeschte Attribute werden zu Custom Fields.
|
||||||
|
self.dyn = {
|
||||||
|
r["spaltenname"]: dict(name=r["name"], typ=r["datatyp"])
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT spaltenname, name, datatyp FROM dynattribute "
|
||||||
|
"WHERE deleted <> 'true'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def folder_parts(self, oid):
|
||||||
|
"""
|
||||||
|
'6.8.1.2' -> ['Allgemeines', 'Steuer', 'ESt', 'Anlage N']
|
||||||
|
|
||||||
|
Bewusst eine Liste statt eines verketteten Pfads: Ordnernamen in
|
||||||
|
ecoDMS koennen selbst Schraegstriche enthalten ("Telefon / Internet"),
|
||||||
|
ein spaeteres Zerlegen am Schraegstrich wuerde solche Namen
|
||||||
|
zerschneiden.
|
||||||
|
"""
|
||||||
|
parts, seen = [], set()
|
||||||
|
while oid and oid not in ("-1", "") and oid not in seen:
|
||||||
|
seen.add(oid)
|
||||||
|
node = self.folder.get(oid)
|
||||||
|
if node is None:
|
||||||
|
parts.append(f"?{oid}")
|
||||||
|
break
|
||||||
|
parts.append(node["name"].strip())
|
||||||
|
oid = node["parent"]
|
||||||
|
return list(reversed(parts))
|
||||||
|
|
||||||
|
def folder_path(self, oid):
|
||||||
|
"""Nur fuer Anzeige und Bericht."""
|
||||||
|
return " / ".join(self.folder_parts(oid))
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- Rechte
|
||||||
|
|
||||||
|
def effective_rights(con, docs_ids, report):
|
||||||
|
"""
|
||||||
|
Wirksame Rechte je docs_id: nur Zeilen der hoechsten dort vorhandenen
|
||||||
|
Revision. Aeltere Revisionen enthalten z.B. noch ecoSIMSUSER aus dem
|
||||||
|
Posteingang, das darf nicht mitwandern.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for docs_id in docs_ids:
|
||||||
|
rows = list(
|
||||||
|
con.execute(
|
||||||
|
"SELECT role, doc_right, revision FROM docrollen_hist WHERE docid = ?",
|
||||||
|
(docs_id,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
report.warn("rechte_fehlen", f"docs_id {docs_id} ohne Rechteeintrag")
|
||||||
|
result[docs_id] = []
|
||||||
|
continue
|
||||||
|
top = max(revtuple(r["revision"]) for r in rows)
|
||||||
|
result[docs_id] = [
|
||||||
|
{"role": r["role"], "right": r["doc_right"], "revision": r["revision"]}
|
||||||
|
for r in rows
|
||||||
|
if revtuple(r["revision"]) == top
|
||||||
|
]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Versionen
|
||||||
|
|
||||||
|
def unsupported_ext(filename):
|
||||||
|
"""Gibt die Endung zurueck, wenn Paperless sie nicht konsumieren kann."""
|
||||||
|
ext = ("." + filename.rsplit(".", 1)[-1].lower()) if "." in filename else ""
|
||||||
|
return None if ext in SUPPORTED_EXTENSIONS else (ext or "(ohne Endung)")
|
||||||
|
|
||||||
|
|
||||||
|
def versions_for(con, docid, archive_dir, report):
|
||||||
|
rows = list(
|
||||||
|
con.execute(
|
||||||
|
"SELECT version, savedate, filename, realname, pdfrealname, size, "
|
||||||
|
" comment, fixiert, checksum "
|
||||||
|
"FROM ecosimsversions WHERE docid = ? ORDER BY version",
|
||||||
|
(docid,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
fname = r["realname"]
|
||||||
|
if not fname:
|
||||||
|
report.warn(
|
||||||
|
"datei_fehlt",
|
||||||
|
f"docid {docid} Version {r['version']}: kein realname",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"version": r["version"],
|
||||||
|
"file": fname,
|
||||||
|
"original_name": b64(r["filename"]) or r["realname"],
|
||||||
|
"saved_at": b64(r["savedate"]),
|
||||||
|
"comment": clean(r["comment"]),
|
||||||
|
"fixed": r["fixiert"] == "true",
|
||||||
|
"checksum": r["checksum"],
|
||||||
|
"source": "ecosimsversions",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Regel 4: Rueckfall auf das Archivdokument.
|
||||||
|
arc = con.execute(
|
||||||
|
"SELECT filename, realname, size, inserttime, version, fixiert, checksum "
|
||||||
|
"FROM ecosimsarchive WHERE id = ?",
|
||||||
|
(docid,),
|
||||||
|
).fetchone()
|
||||||
|
if arc is None or not arc["realname"]:
|
||||||
|
report.warn("ohne_datei", f"docid {docid}: weder Version noch Archivdatei")
|
||||||
|
return []
|
||||||
|
report.warn("nur_archiv", f"docid {docid}: keine Versionszeilen, nutze Archivdatei")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"file": arc["realname"],
|
||||||
|
"original_name": arc["filename"],
|
||||||
|
"saved_at": arc["inserttime"],
|
||||||
|
"comment": "",
|
||||||
|
"fixed": arc["fixiert"] == "true",
|
||||||
|
"checksum": arc["checksum"],
|
||||||
|
"source": "ecosimsarchive",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- Klassifizierungen
|
||||||
|
|
||||||
|
def merge_classifications(rows, lookups, report, docid):
|
||||||
|
"""
|
||||||
|
Regel 2: Ordner vereinigen, bei Skalaren gewinnt der juengste ctimestamp.
|
||||||
|
'rows' sind alle klassifizierung-Zeilen einer docid.
|
||||||
|
"""
|
||||||
|
rows = sorted(rows, key=lambda r: r["ctimestamp"] or "")
|
||||||
|
newest = rows[-1]
|
||||||
|
|
||||||
|
folders, folder_parts, conflicts = [], [], {}
|
||||||
|
for r in rows:
|
||||||
|
parts = lookups.folder_parts(r["folder"] or r["mainfolder"])
|
||||||
|
if parts and parts not in folder_parts:
|
||||||
|
folder_parts.append(parts)
|
||||||
|
folders.append(" / ".join(parts))
|
||||||
|
|
||||||
|
if len(rows) > 1:
|
||||||
|
report.warn(
|
||||||
|
"mehrfachklassifizierung",
|
||||||
|
f"docid {docid}: {len(rows)} Klassifizierungen -> {', '.join(folders)}",
|
||||||
|
)
|
||||||
|
for field in ("bemerkung", "docart", "status", "cdate", "defdate"):
|
||||||
|
values = {clean(r[field]) for r in rows}
|
||||||
|
if len(values) > 1:
|
||||||
|
conflicts[field] = sorted(values)
|
||||||
|
|
||||||
|
docart_name, docart_trashed = lookups.docart.get(
|
||||||
|
newest["docart"], (f"?{newest['docart']}", False)
|
||||||
|
)
|
||||||
|
status_name, status_trashed = lookups.status.get(
|
||||||
|
newest["status"], (f"?{newest['status']}", False)
|
||||||
|
)
|
||||||
|
if docart_trashed:
|
||||||
|
report.warn("geloeschte_stammdaten", f"docid {docid}: Dokumentart '{docart_name}' ist geloescht")
|
||||||
|
if status_trashed:
|
||||||
|
report.warn("geloeschte_stammdaten", f"docid {docid}: Status '{status_name}' ist geloescht")
|
||||||
|
|
||||||
|
custom = {}
|
||||||
|
for column, meta in lookups.dyn.items():
|
||||||
|
value = clean(newest[column] if column in newest.keys() else "")
|
||||||
|
if value:
|
||||||
|
custom[meta["name"]] = {"value": value, "type": meta["typ"]}
|
||||||
|
|
||||||
|
# Wiedervorlage steckt in klassifizierung.defdate, nicht in dynattribute -
|
||||||
|
# sie ist in ecoDMS eine eingebaute Funktion, kein dynamisches Attribut.
|
||||||
|
# Passt inhaltlich zum Statuswert "Wiedervorlage" (sid 2).
|
||||||
|
wv = clean(newest["defdate"] if "defdate" in newest.keys() else "")
|
||||||
|
if wv:
|
||||||
|
custom["Wiedervorlage"] = {"value": wv, "type": "Date"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": clean(newest["bemerkung"]) or f"ecoDMS {docid}",
|
||||||
|
"created": clean(newest["cdate"]),
|
||||||
|
"changed_at": clean(newest["ctimestamp"]),
|
||||||
|
"changed_by": clean(newest["changeid"]),
|
||||||
|
"document_type": docart_name,
|
||||||
|
"status": status_name,
|
||||||
|
"folders": folders, # nur Anzeige
|
||||||
|
"folder_parts": folder_parts, # massgeblich fuer die Tags
|
||||||
|
"custom_fields": custom,
|
||||||
|
"revision": clean(newest["revision"]),
|
||||||
|
"conflicts": conflicts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- XML-Historie
|
||||||
|
|
||||||
|
def classification_history(xml_path, report):
|
||||||
|
"""Die Klassifizierungshistorie steht nur in der XML, nicht in der SQLite."""
|
||||||
|
history = defaultdict(list)
|
||||||
|
if not xml_path.exists():
|
||||||
|
report.warn("xml_fehlt", f"{xml_path.name} nicht gefunden")
|
||||||
|
return history, set()
|
||||||
|
|
||||||
|
root = ET.parse(xml_path).getroot()
|
||||||
|
referenced = set()
|
||||||
|
for doc in root.findall("document"):
|
||||||
|
docid = int(doc.get("docid"))
|
||||||
|
for el in doc.iter():
|
||||||
|
if el.get("filePath"):
|
||||||
|
referenced.add(el.get("filePath"))
|
||||||
|
for info in doc.findall(".//classifyInfo"):
|
||||||
|
for ver in info.findall("Version"):
|
||||||
|
entry = {
|
||||||
|
child.tag: clean(child.text) for child in ver if clean(child.text)
|
||||||
|
}
|
||||||
|
entry["cla_docs_id"] = info.get("cla_docs_id")
|
||||||
|
history[docid].append(entry)
|
||||||
|
for docid in history:
|
||||||
|
history[docid].sort(key=lambda e: revtuple(e.get("revision", "0")))
|
||||||
|
return history, referenced
|
||||||
|
|
||||||
|
|
||||||
|
def notes_by_docid(con, report):
|
||||||
|
"""
|
||||||
|
econotice enthaelt die Notizfunktion, nicht die Wiedervorlage.
|
||||||
|
|
||||||
|
Zwei Fallstricke:
|
||||||
|
* nid enthaelt die docs_id, NICHT die docid - dasselbe Muster wie bei
|
||||||
|
docrollen_hist. Inhaltlich verifiziert an drei Faellen mit
|
||||||
|
auseinanderlaufenden IDs.
|
||||||
|
* text ist base64-kodiertes Qt-HTML mit CSS-Block im Kopf.
|
||||||
|
|
||||||
|
username ist im gesamten Bestand leer, eine Zuordnung zu Personen
|
||||||
|
entfaellt also.
|
||||||
|
"""
|
||||||
|
notes = defaultdict(list)
|
||||||
|
docs_id_to_docid = {
|
||||||
|
r["id"]: r["docid"] for r in con.execute("SELECT id, docid FROM docs")
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in con.execute("SELECT * FROM econotice ORDER BY id"):
|
||||||
|
try:
|
||||||
|
docs_id = int(row["nid"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
report.warn("notiz_ohne_bezug", f"econotice {row['id']}: nid={row['nid']!r}")
|
||||||
|
continue
|
||||||
|
docid = docs_id_to_docid.get(docs_id)
|
||||||
|
if docid is None:
|
||||||
|
report.warn(
|
||||||
|
"notiz_ohne_bezug",
|
||||||
|
f"econotice {row['id']}: docs_id {docs_id} nicht im Export",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw = row["text"] or ""
|
||||||
|
try:
|
||||||
|
text = base64.b64decode(raw).decode("utf-8", "replace")
|
||||||
|
except (binascii.Error, ValueError):
|
||||||
|
text = str(raw)
|
||||||
|
text = re.sub(r"<[^>]+>", " ", text) # HTML-Tags
|
||||||
|
text = html.unescape(re.sub(r"\s+", " ", text)).strip()
|
||||||
|
text = re.sub(r"^p,\s*li\s*\{[^}]*\}\s*", "", text) # CSS-Rest
|
||||||
|
|
||||||
|
notes[docid].append(
|
||||||
|
{"created": clean(row["tdate"]), "text": text,
|
||||||
|
"user": clean(row["username"])}
|
||||||
|
)
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Hauptlauf
|
||||||
|
|
||||||
|
def extract(archive_dir: Path, tranche: str):
|
||||||
|
report = Report()
|
||||||
|
db_path = archive_dir / "export.data"
|
||||||
|
xml_path = archive_dir / "export.xml"
|
||||||
|
if not db_path.exists():
|
||||||
|
sys.exit(f"export.data nicht gefunden unter {archive_dir}")
|
||||||
|
|
||||||
|
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
lookups = Lookups(con)
|
||||||
|
history, referenced = classification_history(xml_path, report)
|
||||||
|
notes = notes_by_docid(con, report)
|
||||||
|
|
||||||
|
# klassifizierung nach docid gruppieren (Regel 1)
|
||||||
|
by_docid = defaultdict(list)
|
||||||
|
for row in con.execute("SELECT * FROM klassifizierung"):
|
||||||
|
by_docid[row["docid"]].append(row)
|
||||||
|
|
||||||
|
# docid -> docs_id (fuer den Rechte-Join, Regel 5)
|
||||||
|
docs_ids = defaultdict(list)
|
||||||
|
for row in con.execute("SELECT id, docid, trashed FROM docs"):
|
||||||
|
if row["trashed"] == "true":
|
||||||
|
report.warn("papierkorb", f"docs_id {row['id']} (docid {row['docid']}) ist im Papierkorb")
|
||||||
|
continue
|
||||||
|
docs_ids[row["docid"]].append(row["id"])
|
||||||
|
|
||||||
|
on_disk = {p.name for p in archive_dir.iterdir() if p.is_file()}
|
||||||
|
documents, used_files, skipped = [], set(), []
|
||||||
|
|
||||||
|
for docid in sorted(by_docid):
|
||||||
|
meta = merge_classifications(by_docid[docid], lookups, report, docid)
|
||||||
|
versions = versions_for(con, docid, archive_dir, report)
|
||||||
|
|
||||||
|
for v in versions:
|
||||||
|
used_files.add(v["file"])
|
||||||
|
if on_disk and v["file"] not in on_disk:
|
||||||
|
report.warn("datei_fehlt", f"docid {docid} v{v['version']}: {v['file']}")
|
||||||
|
|
||||||
|
# Nicht konsumierbare Dateitypen aussortieren. Massgeblich ist die
|
||||||
|
# letzte Version - sie bestimmt, was Paperless zu sehen bekaeme.
|
||||||
|
bad = {v["file"]: unsupported_ext(v["file"]) for v in versions}
|
||||||
|
bad = {f: e for f, e in bad.items() if e}
|
||||||
|
if bad:
|
||||||
|
exts = sorted(set(bad.values()))
|
||||||
|
report.warn("nicht_unterstuetzt",
|
||||||
|
f"docid {docid}: {', '.join(exts)} ({meta['title'][:50]})")
|
||||||
|
skipped.append({
|
||||||
|
"ecodms_docid": docid,
|
||||||
|
"title": meta["title"],
|
||||||
|
"extensions": exts,
|
||||||
|
"files": sorted(bad),
|
||||||
|
"folders": meta["folders"],
|
||||||
|
"created": meta["created"],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
rights = effective_rights(con, docs_ids.get(docid, []), report)
|
||||||
|
roles = {}
|
||||||
|
for entries in rights.values(): # Vereinigung ueber alle Instanzen
|
||||||
|
for e in entries:
|
||||||
|
prev = roles.get(e["role"])
|
||||||
|
# W schlaegt R
|
||||||
|
if prev is None or (prev == "R" and e["right"] == "W"):
|
||||||
|
roles[e["role"]] = e["right"]
|
||||||
|
|
||||||
|
if not roles:
|
||||||
|
report.warn("ohne_rechte", f"docid {docid}: keine wirksamen Rechte")
|
||||||
|
|
||||||
|
documents.append(
|
||||||
|
{
|
||||||
|
"ecodms_docid": docid,
|
||||||
|
"tranche": tranche,
|
||||||
|
**meta,
|
||||||
|
"versions": versions,
|
||||||
|
"permissions": [{"role": r, "right": w} for r, w in sorted(roles.items())],
|
||||||
|
"classification_history": history.get(docid, []),
|
||||||
|
"notes": notes.get(docid, []),
|
||||||
|
"docs_ids": docs_ids.get(docid, []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dateien, die auf der Platte liegen aber nicht verwendet werden.
|
||||||
|
ignorable = {"export.xml", "export.data"}
|
||||||
|
orphans = sorted(on_disk - used_files - ignorable) if on_disk else []
|
||||||
|
for name in orphans:
|
||||||
|
# Die gerenderten Kopf-PDFs sind erwartete Waisen (Regel 3).
|
||||||
|
if "_revision_" not in name:
|
||||||
|
continue
|
||||||
|
report.warn("verwaiste_datei", name)
|
||||||
|
|
||||||
|
missing_refs = sorted(referenced - on_disk) if on_disk else []
|
||||||
|
for name in missing_refs:
|
||||||
|
report.warn("xml_referenz_ohne_datei", name)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"tranche": tranche,
|
||||||
|
"source": str(archive_dir),
|
||||||
|
"counts": {
|
||||||
|
"documents": len(documents),
|
||||||
|
"skipped": len(skipped),
|
||||||
|
"versions": sum(len(d["versions"]) for d in documents),
|
||||||
|
"multi_classified": sum(1 for d in documents if len(d["docs_ids"]) > 1),
|
||||||
|
"roles": len({p["role"] for d in documents for p in d["permissions"]}),
|
||||||
|
"notes": sum(len(d["notes"]) for d in documents),
|
||||||
|
},
|
||||||
|
"roles_seen": sorted({p["role"] for d in documents for p in d["permissions"]}),
|
||||||
|
"rights_seen": sorted({p["right"] for d in documents for p in d["permissions"]}),
|
||||||
|
"documents": documents,
|
||||||
|
"skipped": skipped,
|
||||||
|
}
|
||||||
|
return manifest, report
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(manifest, report):
|
||||||
|
c = manifest["counts"]
|
||||||
|
print(f"Tranche {manifest['tranche']}")
|
||||||
|
print(f" Dokumente {c['documents']}")
|
||||||
|
if c.get("skipped"):
|
||||||
|
exts = {}
|
||||||
|
for sk in manifest["skipped"]:
|
||||||
|
for e in sk["extensions"]:
|
||||||
|
exts[e] = exts.get(e, 0) + 1
|
||||||
|
detail = ", ".join(f"{e} {n}" for e, n in sorted(exts.items()))
|
||||||
|
print(f" Uebersprungen {c['skipped']} ({detail})")
|
||||||
|
print(f" Versionen {c['versions']}")
|
||||||
|
print(f" Mehrfachklassifiziert {c['multi_classified']}")
|
||||||
|
print(f" Notizen {c['notes']}")
|
||||||
|
print(f" Rollen {', '.join(manifest['roles_seen']) or '-'}")
|
||||||
|
print(f" Rechtearten {', '.join(manifest['rights_seen']) or '-'}")
|
||||||
|
if len(report):
|
||||||
|
print(f"\n Hinweise ({len(report)}):")
|
||||||
|
for category in sorted(report.items):
|
||||||
|
entries = report.items[category]
|
||||||
|
print(f" [{category}] {len(entries)}")
|
||||||
|
for line in entries[:5]:
|
||||||
|
print(f" {line}")
|
||||||
|
if len(entries) > 5:
|
||||||
|
print(f" ... und {len(entries) - 5} weitere")
|
||||||
|
else:
|
||||||
|
print("\n Keine Hinweise.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("archive_dir", type=Path, help="Verzeichnis mit export.data")
|
||||||
|
ap.add_argument("-o", "--output", type=Path, help="Ziel fuer das JSON-Manifest")
|
||||||
|
ap.add_argument("-t", "--tranche", default=None, help="Name der Tranche")
|
||||||
|
ap.add_argument("--skipped-csv", type=Path,
|
||||||
|
help="Ausschlussliste als CSV fuer die Nacharbeit")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
tranche = args.tranche or args.archive_dir.resolve().parent.name
|
||||||
|
manifest, report = extract(args.archive_dir, tranche)
|
||||||
|
print_report(manifest, report)
|
||||||
|
|
||||||
|
if args.skipped_csv and manifest["skipped"]:
|
||||||
|
import csv
|
||||||
|
with args.skipped_csv.open("w", newline="", encoding="utf-8") as fh:
|
||||||
|
w = csv.writer(fh)
|
||||||
|
w.writerow(["ecodms_docid", "endungen", "titel", "erstellt",
|
||||||
|
"ordner", "dateien"])
|
||||||
|
for sk in manifest["skipped"]:
|
||||||
|
w.writerow([sk["ecodms_docid"], " ".join(sk["extensions"]),
|
||||||
|
sk["title"], sk["created"],
|
||||||
|
" | ".join(sk["folders"]), " ".join(sk["files"])])
|
||||||
|
print(f" Ausschlussliste: {args.skipped_csv}")
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
args.output.write_text(
|
||||||
|
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(f"\n Manifest geschrieben: {args.output}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
258
ecodms_notizen.py
Normal file
258
ecodms_notizen.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ecodms_notizen.py - Uebertraegt die Notizen aus econotice nach Paperless.
|
||||||
|
|
||||||
|
Liest die export.data-Dateien aller Tranchen. Die Live-Postgres von ecoDMS
|
||||||
|
enthaelt weder eine Klassifizierungs- noch eine docs-Tabelle - die
|
||||||
|
inhaltliche Ebene erzeugt erst der Exporter. Die Aufloesung von econotice
|
||||||
|
auf ein Dokument ist deshalb nur ueber die Exporte moeglich.
|
||||||
|
|
||||||
|
export PT=dein_paperless_token
|
||||||
|
./ecodms_notizen.py --dry-run --csv notizen.csv
|
||||||
|
./ecodms_notizen.py
|
||||||
|
|
||||||
|
Erkenntnisse, die hier kodiert sind:
|
||||||
|
|
||||||
|
* econotice.nid enthaelt die docs_id, NICHT die docid. Dasselbe Muster
|
||||||
|
wie bei docrollen_hist. Inhaltlich verifiziert an drei Faellen mit
|
||||||
|
auseinanderlaufenden IDs (515 -> 514, 633 -> 622, 869 -> 846).
|
||||||
|
* econotice.text ist base64-kodiertes Qt-HTML mit CSS-Block im Kopf.
|
||||||
|
* econotice.username ist im gesamten Bestand leer.
|
||||||
|
* Die Zuordnung nach Paperless laeuft ueber die Archiv-Seriennummer,
|
||||||
|
die beim Import auf die ecoDMS-docid gesetzt wurde.
|
||||||
|
|
||||||
|
Eine Notiz kann zu einem Dokument aus einer anderen Tranche gehoeren.
|
||||||
|
Deshalb werden erst ALLE Tranchen eingelesen und eine gemeinsame
|
||||||
|
Zuordnungstabelle gebaut, bevor uebertragen wird.
|
||||||
|
|
||||||
|
Wiederholbar: textgleiche Notizen am selben Dokument werden uebersprungen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import csv
|
||||||
|
import html
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Benoetigt python3-requests: sudo zypper in python3-requests")
|
||||||
|
|
||||||
|
|
||||||
|
CSS_REST = re.compile(r"^\s*p,\s*li\s*\{[^}]*\}\s*")
|
||||||
|
TAGS = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
|
|
||||||
|
def qt_html_to_text(raw) -> str:
|
||||||
|
"""base64-kodiertes Qt-HTML -> Klartext."""
|
||||||
|
if raw is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
s = base64.b64decode(raw).decode("utf-8", "replace")
|
||||||
|
except (binascii.Error, ValueError, TypeError):
|
||||||
|
s = str(raw)
|
||||||
|
s = TAGS.sub(" ", s)
|
||||||
|
s = html.unescape(re.sub(r"\s+", " ", s))
|
||||||
|
return CSS_REST.sub("", s).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read_tranches(paths):
|
||||||
|
"""
|
||||||
|
Liest alle Tranchen ein und liefert:
|
||||||
|
notes {notiz_id: {...}} alle Notizen, dedupliziert
|
||||||
|
mapping {docs_id: docid} ueber alle Tranchen vereinigt
|
||||||
|
titles {docid: bemerkung} nur fuer die Anzeige
|
||||||
|
"""
|
||||||
|
notes, mapping, titles = {}, {}, {}
|
||||||
|
for p in paths:
|
||||||
|
if not p.exists():
|
||||||
|
print(f" uebersprungen (nicht vorhanden): {p}")
|
||||||
|
continue
|
||||||
|
con = sqlite3.connect(f"file:{p}?mode=ro", uri=True)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
for r in con.execute("SELECT id, docid FROM docs"):
|
||||||
|
mapping[r["id"]] = r["docid"]
|
||||||
|
try:
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT docid, bemerkung FROM klassifizierung"
|
||||||
|
):
|
||||||
|
if r["bemerkung"]:
|
||||||
|
titles.setdefault(r["docid"], r["bemerkung"])
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
n = 0
|
||||||
|
for r in con.execute("SELECT * FROM econotice"):
|
||||||
|
notes[r["id"]] = {
|
||||||
|
"notiz_id": r["id"],
|
||||||
|
"nid": r["nid"],
|
||||||
|
"erstellt": r["tdate"],
|
||||||
|
"text": qt_html_to_text(r["text"]),
|
||||||
|
"user": (r["username"] or "").strip(),
|
||||||
|
"tranche": p.parent.parent.name,
|
||||||
|
}
|
||||||
|
n += 1
|
||||||
|
con.close()
|
||||||
|
print(f" {p.parent.parent.name}: {n} Notizen, "
|
||||||
|
f"{len(mapping)} Zuordnungen kumuliert")
|
||||||
|
return notes, mapping, titles
|
||||||
|
|
||||||
|
|
||||||
|
class Paperless:
|
||||||
|
def __init__(self, base, token, dry_run=False):
|
||||||
|
self.base = base.rstrip("/")
|
||||||
|
self.dry_run = dry_run
|
||||||
|
self.s = requests.Session()
|
||||||
|
self.s.headers["Authorization"] = f"Token {token}"
|
||||||
|
self._asn_cache = {}
|
||||||
|
|
||||||
|
def load_asn_index(self):
|
||||||
|
"""Alle Dokumente einmal holen statt je Notiz einzeln abzufragen."""
|
||||||
|
url = f"{self.base}/api/documents/"
|
||||||
|
params = {"fields": "id,title,archive_serial_number", "page_size": 200}
|
||||||
|
while url:
|
||||||
|
r = self.s.get(url, params=params, timeout=120)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
for d in data.get("results", []):
|
||||||
|
asn = d.get("archive_serial_number")
|
||||||
|
if asn is not None:
|
||||||
|
self._asn_cache[int(asn)] = d
|
||||||
|
url, params = data.get("next"), None
|
||||||
|
return len(self._asn_cache)
|
||||||
|
|
||||||
|
def by_asn(self, asn):
|
||||||
|
return self._asn_cache.get(int(asn))
|
||||||
|
|
||||||
|
def notes(self, doc_id):
|
||||||
|
r = self.s.get(f"{self.base}/api/documents/{doc_id}/notes/", timeout=60)
|
||||||
|
if r.status_code == 404:
|
||||||
|
return []
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return data if isinstance(data, list) else data.get("results", [])
|
||||||
|
|
||||||
|
def add_note(self, doc_id, text):
|
||||||
|
if self.dry_run:
|
||||||
|
return {"dry_run": True}
|
||||||
|
r = self.s.post(f"{self.base}/api/documents/{doc_id}/notes/",
|
||||||
|
json={"note": text}, timeout=60)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:300]}")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--exports", default="/data/paperless/export-ecodms",
|
||||||
|
help="Verzeichnis mit exportN/archive/export.data")
|
||||||
|
ap.add_argument("--tranchen", default="1-7",
|
||||||
|
help="Bereich oder Liste, z.B. '1-7' oder '1,3,5'")
|
||||||
|
ap.add_argument("--api", default="http://localhost:8000")
|
||||||
|
ap.add_argument("--token", help="oder Umgebungsvariable PT")
|
||||||
|
ap.add_argument("--csv", help="Notizen zusaetzlich als CSV sichern")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--prefix", default="[ecoDMS {datum}] ",
|
||||||
|
help="Vorspann je Notiz, {datum} wird ersetzt")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
token = args.token or os.environ.get("PT")
|
||||||
|
if not token:
|
||||||
|
sys.exit("Token fehlt: --token oder export PT=...")
|
||||||
|
|
||||||
|
if "-" in args.tranchen:
|
||||||
|
a, b = args.tranchen.split("-")
|
||||||
|
nums = range(int(a), int(b) + 1)
|
||||||
|
else:
|
||||||
|
nums = [int(x) for x in args.tranchen.split(",")]
|
||||||
|
paths = [Path(args.exports) / f"export{n}" / "archive" / "export.data"
|
||||||
|
for n in nums]
|
||||||
|
|
||||||
|
print("Tranchen einlesen:")
|
||||||
|
notes, mapping, titles = read_tranches(paths)
|
||||||
|
print(f"\n{len(notes)} Notizen insgesamt, "
|
||||||
|
f"{len(mapping)} docs_id-Zuordnungen\n")
|
||||||
|
|
||||||
|
api = Paperless(args.api, token, dry_run=args.dry_run)
|
||||||
|
print("Paperless-Dokumente laden ...")
|
||||||
|
print(f" {api.load_asn_index()} Dokumente mit Archiv-Seriennummer\n")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("TROCKENLAUF - es wird nichts geschrieben\n")
|
||||||
|
|
||||||
|
writer = fh = None
|
||||||
|
if args.csv:
|
||||||
|
fh = open(args.csv, "w", newline="", encoding="utf-8")
|
||||||
|
writer = csv.writer(fh)
|
||||||
|
writer.writerow(["notiz_id", "docs_id", "ecodms_docid", "paperless_id",
|
||||||
|
"titel", "erstellt", "notiz", "status"])
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
def count(key):
|
||||||
|
stats[key] = stats.get(key, 0) + 1
|
||||||
|
|
||||||
|
for r in sorted(notes.values(), key=lambda x: x["notiz_id"]):
|
||||||
|
text, status = r["text"], ""
|
||||||
|
docid = paperless_id = titel = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
docs_id = int(r["nid"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
docs_id = None
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
status = "leer"; count("leer")
|
||||||
|
elif docs_id is None:
|
||||||
|
status = f"nid unlesbar: {r['nid']!r}"; count("nid_unlesbar")
|
||||||
|
elif docs_id not in mapping:
|
||||||
|
status = "docs_id in keiner Tranche"; count("ohne_zuordnung")
|
||||||
|
else:
|
||||||
|
docid = mapping[docs_id]
|
||||||
|
titel = titles.get(docid, "")
|
||||||
|
doc = api.by_asn(docid)
|
||||||
|
if doc is None:
|
||||||
|
status = f"kein Paperless-Dokument mit ASN {docid}"
|
||||||
|
count("ohne_dokument")
|
||||||
|
else:
|
||||||
|
paperless_id = doc["id"]
|
||||||
|
titel = doc.get("title") or titel
|
||||||
|
voll = args.prefix.format(datum=str(r["erstellt"])[:10]) + text
|
||||||
|
try:
|
||||||
|
if any(str(n.get("note", "")).strip() == voll.strip()
|
||||||
|
for n in api.notes(paperless_id)):
|
||||||
|
status = "bereits vorhanden"; count("vorhanden")
|
||||||
|
else:
|
||||||
|
api.add_note(paperless_id, voll)
|
||||||
|
status = "uebertragen"; count("uebertragen")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
status = f"FEHLER: {exc}"; count("fehler")
|
||||||
|
|
||||||
|
if writer:
|
||||||
|
writer.writerow([r["notiz_id"], r["nid"], docid, paperless_id,
|
||||||
|
titel, r["erstellt"], text, status])
|
||||||
|
if status.startswith("FEHLER") or status.startswith("kein ") \
|
||||||
|
or status.startswith("docs_id"):
|
||||||
|
print(f" notiz {r['notiz_id']} (docs_id {r['nid']}): {status}")
|
||||||
|
print(f" {text[:90]}")
|
||||||
|
|
||||||
|
if fh:
|
||||||
|
fh.close()
|
||||||
|
print(f"\nCSV geschrieben: {args.csv}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
for k, v in sorted(stats.items()):
|
||||||
|
print(f" {k:18s} {v}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
863
paperless_import.py
Normal file
863
paperless_import.py
Normal file
@@ -0,0 +1,863 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
paperless_import.py - Stufe 2 der ecoDMS -> Paperless-ngx Migration.
|
||||||
|
|
||||||
|
Liest ein Manifest aus ecodms_extract.py (Stufe 1) und spielt es ueber die
|
||||||
|
REST-API in Paperless ein. Fuehrt ein eigenes SQLite-Journal, ist damit
|
||||||
|
wiederaufsetzbar und tranchenuebergreifend auswertbar.
|
||||||
|
|
||||||
|
# 1. Trockenlauf: nichts wird geschrieben
|
||||||
|
./paperless_import.py tranche_03.json --dry-run
|
||||||
|
|
||||||
|
# 2. Stammdaten anlegen (Tags, Dokumentarten, Custom Fields)
|
||||||
|
./paperless_import.py tranche_03.json --setup
|
||||||
|
|
||||||
|
# 3. Import
|
||||||
|
./paperless_import.py tranche_03.json
|
||||||
|
|
||||||
|
# 4. Zeitstempel-Skript erzeugen (API kann sie nicht setzen)
|
||||||
|
./paperless_import.py --emit-timestamps > /tmp/fix_added.py
|
||||||
|
sudo docker compose exec -T webserver python manage.py shell < /tmp/fix_added.py
|
||||||
|
|
||||||
|
# 5. Nach ALLEN Tranchen: Verknuepfungen aufloesen
|
||||||
|
./paperless_import.py --links verknuepfungen.csv
|
||||||
|
|
||||||
|
# 6. Pruefbericht
|
||||||
|
./paperless_import.py --verify
|
||||||
|
|
||||||
|
Erkenntnisse aus der Testinstanz, die hier kodiert sind:
|
||||||
|
|
||||||
|
* update_version nimmt nur "document" und "version_label" entgegen -
|
||||||
|
Zeitstempel muessen nachtraeglich per Django-Shell gesetzt werden.
|
||||||
|
* Rechte werden ueber root_document vererbt. Einmal auf die Wurzel
|
||||||
|
setzen genuegt, die Versionen erben (403-Test bestaetigt).
|
||||||
|
* Die versions-Liste am Dokument ist ABSTEIGEND sortiert.
|
||||||
|
* Innerhalb einer Versionskette muss streng seriell gearbeitet werden,
|
||||||
|
parallelisiert wird ueber Dokumente hinweg.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Benoetigt python3-requests: sudo zypper in python3-requests")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Benoetigt python3-PyYAML: sudo zypper in python3-PyYAML")
|
||||||
|
|
||||||
|
|
||||||
|
TASK_TIMEOUT = 600 # Sekunden je Konsumvorgang
|
||||||
|
TASK_POLL_INTERVAL = 2
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Journal
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS mapping (
|
||||||
|
ecodms_docid INTEGER PRIMARY KEY,
|
||||||
|
tranche TEXT,
|
||||||
|
paperless_id INTEGER,
|
||||||
|
versions_total INTEGER,
|
||||||
|
versions_done INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS version_times (
|
||||||
|
paperless_id INTEGER PRIMARY KEY,
|
||||||
|
ecodms_docid INTEGER,
|
||||||
|
version INTEGER,
|
||||||
|
added TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS links (
|
||||||
|
src_docid INTEGER,
|
||||||
|
dst_docid INTEGER,
|
||||||
|
resolved INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (src_docid, dst_docid)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Journal:
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
self.con = sqlite3.connect(path)
|
||||||
|
self.con.row_factory = sqlite3.Row
|
||||||
|
self.con.executescript(SCHEMA)
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
def state(self, docid):
|
||||||
|
r = self.con.execute(
|
||||||
|
"SELECT * FROM mapping WHERE ecodms_docid = ?", (docid,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(r) if r else None
|
||||||
|
|
||||||
|
def start(self, docid, tranche, total):
|
||||||
|
self.con.execute(
|
||||||
|
"INSERT OR IGNORE INTO mapping (ecodms_docid, tranche, versions_total) "
|
||||||
|
"VALUES (?,?,?)",
|
||||||
|
(docid, tranche, total),
|
||||||
|
)
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
def update(self, docid, **fields):
|
||||||
|
fields["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
||||||
|
sets = ", ".join(f"{k} = ?" for k in fields)
|
||||||
|
self.con.execute(
|
||||||
|
f"UPDATE mapping SET {sets} WHERE ecodms_docid = ?",
|
||||||
|
(*fields.values(), docid),
|
||||||
|
)
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
def remember_time(self, paperless_id, docid, version, added):
|
||||||
|
self.con.execute(
|
||||||
|
"INSERT OR REPLACE INTO version_times VALUES (?,?,?,?)",
|
||||||
|
(paperless_id, docid, version, added),
|
||||||
|
)
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
def add_link(self, src, dst):
|
||||||
|
self.con.execute(
|
||||||
|
"INSERT OR IGNORE INTO links (src_docid, dst_docid) VALUES (?,?)",
|
||||||
|
(src, dst),
|
||||||
|
)
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- API
|
||||||
|
|
||||||
|
class Paperless:
|
||||||
|
def __init__(self, base, token, dry_run=False):
|
||||||
|
self.base = base.rstrip("/")
|
||||||
|
self.dry_run = dry_run
|
||||||
|
self.s = requests.Session()
|
||||||
|
self.s.headers["Authorization"] = f"Token {token}"
|
||||||
|
|
||||||
|
def _url(self, path):
|
||||||
|
return f"{self.base}/api/{path.lstrip('/')}"
|
||||||
|
|
||||||
|
def get(self, path, **params):
|
||||||
|
r = self.s.get(self._url(path), params=params, timeout=60)
|
||||||
|
self._raise(r)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def get_all(self, path, **params):
|
||||||
|
"""Folgt der Paginierung."""
|
||||||
|
out, params = [], {**params, "page_size": 200}
|
||||||
|
url = self._url(path)
|
||||||
|
while url:
|
||||||
|
r = self.s.get(url, params=params, timeout=60)
|
||||||
|
self._raise(r)
|
||||||
|
data = r.json()
|
||||||
|
out.extend(data.get("results", []))
|
||||||
|
url, params = data.get("next"), None
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raise(r):
|
||||||
|
"""
|
||||||
|
raise_for_status() zeigt nur den Statuscode. Die Begruendung steht
|
||||||
|
im Antwortkoerper - ohne sie ist ein 400 nicht auswertbar.
|
||||||
|
"""
|
||||||
|
if r.status_code < 400:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = json.dumps(r.json(), ensure_ascii=False)
|
||||||
|
except ValueError:
|
||||||
|
body = r.text or ""
|
||||||
|
raise requests.HTTPError(
|
||||||
|
f"HTTP {r.status_code} bei {r.request.method} {r.url}: "
|
||||||
|
f"{' '.join(body.split())[:400]}",
|
||||||
|
response=r,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, path, **kwargs):
|
||||||
|
if self.dry_run:
|
||||||
|
return {"dry_run": True}
|
||||||
|
r = self.s.post(self._url(path), timeout=300, **kwargs)
|
||||||
|
self._raise(r)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def patch(self, path, payload):
|
||||||
|
if self.dry_run:
|
||||||
|
return {"dry_run": True}
|
||||||
|
r = self.s.patch(self._url(path), json=payload, timeout=60)
|
||||||
|
self._raise(r)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
# -- Konsumvorgaenge -----------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rows(data):
|
||||||
|
"""/api/tasks/ liefert je nach Stand eine Liste oder ein Seitenobjekt."""
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data.get("results") or []
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _doc_id(task):
|
||||||
|
"""
|
||||||
|
Die Dokument-ID steht je nach Stand an unterschiedlichen Stellen.
|
||||||
|
In 3.1.0 gleich zweimal: verschachtelt in result_data.document_id
|
||||||
|
und als Liste in related_document_ids.
|
||||||
|
"""
|
||||||
|
def as_int(val):
|
||||||
|
try:
|
||||||
|
return int(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Listenfelder zuerst - dort steht die ID in 3.1.0
|
||||||
|
for key in ("related_document_ids", "document_ids"):
|
||||||
|
val = task.get(key)
|
||||||
|
if isinstance(val, (list, tuple)) and val:
|
||||||
|
got = as_int(val[0])
|
||||||
|
if got:
|
||||||
|
return got
|
||||||
|
|
||||||
|
# Verschachtelt
|
||||||
|
nested = task.get("result_data")
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
for key in ("document_id", "related_document", "id"):
|
||||||
|
got = as_int(nested.get(key))
|
||||||
|
if got:
|
||||||
|
return got
|
||||||
|
|
||||||
|
# Oberste Ebene
|
||||||
|
for key in ("related_document", "document_id", "related_document_id"):
|
||||||
|
got = as_int(task.get(key))
|
||||||
|
if got:
|
||||||
|
return got
|
||||||
|
|
||||||
|
# Notnagel: ID aus einem Ergebnistext ziehen
|
||||||
|
m = re.search(r"[Dd]ocument\D+(\d+)", str(task.get("result") or ""))
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _error_text(task, status):
|
||||||
|
"""
|
||||||
|
Die Fehlermeldung steht je nach Stand an verschiedenen Stellen.
|
||||||
|
Ein blosses "Task FAILURE" im Journal ist wertlos - dann muss man
|
||||||
|
die Ursache spaeter muehsam aus den Container-Logs rekonstruieren.
|
||||||
|
"""
|
||||||
|
rd = task.get("result_data") or {}
|
||||||
|
for src in (task.get("result"), rd.get("error"), rd.get("exc_message"),
|
||||||
|
rd.get("exc_type"), task.get("status_display")):
|
||||||
|
if src:
|
||||||
|
txt = " ".join(str(src).split())
|
||||||
|
if txt and txt.lower() not in ("failure", "failed"):
|
||||||
|
return txt[:500]
|
||||||
|
fn = (task.get("input_data") or {}).get("filename", "?")
|
||||||
|
return f"Task {status} ohne Meldung (Datei: {fn}, task_id: {task.get('task_id')})"
|
||||||
|
|
||||||
|
def find_task(self, task_id):
|
||||||
|
"""
|
||||||
|
Sucht einen Task. Greift der Serverfilter nicht, wird die
|
||||||
|
Gesamtliste durchsucht - der Filtername hat zwischen Versionen
|
||||||
|
gewechselt, und ein stiller Fehlschlag laesst das Skript sonst
|
||||||
|
bis zum Timeout warten.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = self._rows(self.get("tasks/", task_id=task_id))
|
||||||
|
for t in rows:
|
||||||
|
if str(t.get("task_id")) == str(task_id):
|
||||||
|
return t
|
||||||
|
if len(rows) == 1 and not rows[0].get("task_id"):
|
||||||
|
return rows[0]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
rows = []
|
||||||
|
for t in self._rows(self.get("tasks/")):
|
||||||
|
if str(t.get("task_id")) == str(task_id):
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
def wait_for_task(self, task_id, log=None):
|
||||||
|
"""Wartet auf einen Konsumvorgang und liefert die Dokument-ID."""
|
||||||
|
if self.dry_run:
|
||||||
|
return None
|
||||||
|
if not task_id or not isinstance(task_id, str):
|
||||||
|
raise RuntimeError(f"Unerwartete Antwort auf den Upload: {task_id!r}")
|
||||||
|
|
||||||
|
deadline = time.time() + TASK_TIMEOUT
|
||||||
|
waited = 0
|
||||||
|
while time.time() < deadline:
|
||||||
|
task = self.find_task(task_id)
|
||||||
|
if task:
|
||||||
|
# Die API liefert den Status kleingeschrieben ("success"),
|
||||||
|
# aeltere Staende grossgeschrieben. Vergleich deshalb
|
||||||
|
# unabhaengig von der Schreibweise.
|
||||||
|
status = str(task.get("status") or "").upper()
|
||||||
|
if status in ("SUCCESS", "SUCCEEDED"):
|
||||||
|
doc = self._doc_id(task)
|
||||||
|
if doc:
|
||||||
|
return doc
|
||||||
|
raise RuntimeError(f"Task erfolgreich, aber ohne Dokument-ID: {task}")
|
||||||
|
if status in ("FAILURE", "FAILED", "REVOKED"):
|
||||||
|
raise RuntimeError(self._error_text(task, status))
|
||||||
|
time.sleep(TASK_POLL_INTERVAL)
|
||||||
|
waited += TASK_POLL_INTERVAL
|
||||||
|
if log and waited % 30 == 0:
|
||||||
|
state = task.get("status") if task else "nicht gefunden"
|
||||||
|
log(f" ... warte seit {waited}s (Task: {state})")
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Task {task_id} nach {TASK_TIMEOUT}s ohne Ergebnis. "
|
||||||
|
f"Pruefen: curl -H \"Authorization: Token \\$PT\" "
|
||||||
|
f"'{self.base}/api/tasks/?task_id={task_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
def post_document(self, path: Path, fields: dict):
|
||||||
|
with path.open("rb") as fh:
|
||||||
|
data = {k: v for k, v in fields.items() if v not in (None, "", [])}
|
||||||
|
files = {"document": (path.name, fh, "application/octet-stream")}
|
||||||
|
tags = data.pop("tags", [])
|
||||||
|
payload = [(k, str(v)) for k, v in data.items()]
|
||||||
|
payload += [("tags", str(t)) for t in tags]
|
||||||
|
r = self.post("documents/post_document/", files=files, data=payload)
|
||||||
|
return None if isinstance(r, dict) and r.get("dry_run") else r
|
||||||
|
|
||||||
|
def update_version(self, doc_id: int, path: Path, label: str):
|
||||||
|
with path.open("rb") as fh:
|
||||||
|
files = {"document": (path.name, fh, "application/octet-stream")}
|
||||||
|
r = self.post(
|
||||||
|
f"documents/{doc_id}/update_version/",
|
||||||
|
files=files,
|
||||||
|
data={"version_label": label},
|
||||||
|
)
|
||||||
|
return None if isinstance(r, dict) and r.get("dry_run") else r
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Auflöser
|
||||||
|
|
||||||
|
class Resolver:
|
||||||
|
"""Legt Stammdaten bei Bedarf an und merkt sich die IDs."""
|
||||||
|
|
||||||
|
def __init__(self, api: Paperless, rollen: dict, log):
|
||||||
|
self.api, self.rollen, self.log = api, rollen, log
|
||||||
|
self.tags = self._index("tags/")
|
||||||
|
self.types = self._index("document_types/")
|
||||||
|
self.correspondents = self._index("correspondents/")
|
||||||
|
self.fields = self._index("custom_fields/")
|
||||||
|
self.users = self._index("users/", key="username")
|
||||||
|
self.groups = self._index("groups/")
|
||||||
|
|
||||||
|
def _index(self, path, key="name"):
|
||||||
|
return {row[key]: row["id"] for row in self.api.get_all(path)}
|
||||||
|
|
||||||
|
def _ensure(self, cache, path, name, extra=None):
|
||||||
|
if name in cache:
|
||||||
|
return cache[name]
|
||||||
|
if self.api.dry_run:
|
||||||
|
self.log(f" [dry-run] wuerde anlegen: {path} '{name}'")
|
||||||
|
cache[name] = -1
|
||||||
|
return -1
|
||||||
|
try:
|
||||||
|
row = self.api.post(path, json={"name": name, **(extra or {})})
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# Paperless normalisiert Namen beim Speichern (Leerzeichen).
|
||||||
|
# Ein 400 heisst deshalb meist: existiert schon unter leicht
|
||||||
|
# anderem Namen. Liste neu einlesen und erneut nachsehen.
|
||||||
|
fresh = self._index(path)
|
||||||
|
hit = fresh.get(name) or fresh.get(name.strip())
|
||||||
|
if hit is None:
|
||||||
|
low = {k.strip().casefold(): v for k, v in fresh.items()}
|
||||||
|
hit = low.get(name.strip().casefold())
|
||||||
|
if hit is None:
|
||||||
|
raise RuntimeError(f"{path} '{name}' nicht anlegbar: {exc}") from exc
|
||||||
|
cache.clear(); cache.update(fresh); cache[name] = hit
|
||||||
|
self.log(f" vorhanden: {path} '{name}' -> {hit}")
|
||||||
|
return hit
|
||||||
|
cache[name] = row["id"]
|
||||||
|
self.log(f" angelegt: {path} '{name}' -> {row['id']}")
|
||||||
|
return row["id"]
|
||||||
|
|
||||||
|
def tag(self, name):
|
||||||
|
return self._ensure(self.tags, "tags/", name)
|
||||||
|
|
||||||
|
def doc_type(self, name):
|
||||||
|
return self._ensure(self.types, "document_types/", name)
|
||||||
|
|
||||||
|
def custom_field_typed(self, name, paperless_type):
|
||||||
|
"""Feld mit ausdruecklich angegebenem Paperless-Datentyp."""
|
||||||
|
return self._ensure(self.fields, "custom_fields/", name,
|
||||||
|
{"data_type": paperless_type})
|
||||||
|
|
||||||
|
def custom_field(self, name, ecodms_type):
|
||||||
|
mapping = {"Date": "date", "CheckBox": "boolean", "String": "string"}
|
||||||
|
return self._ensure(
|
||||||
|
self.fields,
|
||||||
|
"custom_fields/",
|
||||||
|
name,
|
||||||
|
{"data_type": mapping.get(ecodms_type, "string")},
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Rechte --------------------------------------------------------------
|
||||||
|
|
||||||
|
def permissions_for(self, roles):
|
||||||
|
"""
|
||||||
|
ecoDMS-Rollen -> Paperless owner + set_permissions.
|
||||||
|
Nicht abgebildete Rollen fuehren zum Abbruch, nicht zum stillen
|
||||||
|
Verwerfen: In Paperless heisst "keine Rechte" nicht gesperrt,
|
||||||
|
sondern unbeschraenkt.
|
||||||
|
"""
|
||||||
|
cfg = self.rollen["roles"]
|
||||||
|
rights = self.rollen["rights"]
|
||||||
|
owner, view_u, view_g, chg_u, chg_g = None, set(), set(), set(), set()
|
||||||
|
|
||||||
|
for entry in roles:
|
||||||
|
role, right = entry["role"], entry["right"]
|
||||||
|
if role not in cfg:
|
||||||
|
raise KeyError(
|
||||||
|
f"Rolle '{role}' fehlt in rollen.yml. Ergaenzen oder "
|
||||||
|
f"ausdruecklich als 'type: null' eintragen."
|
||||||
|
)
|
||||||
|
spec = cfg[role]
|
||||||
|
if spec.get("type") in (None, "null"):
|
||||||
|
continue
|
||||||
|
if right not in rights:
|
||||||
|
raise KeyError(f"doc_right '{right}' fehlt in rollen.yml")
|
||||||
|
|
||||||
|
perms = rights[right]
|
||||||
|
if spec["type"] == "user":
|
||||||
|
uid = self.users.get(spec["paperless"])
|
||||||
|
if uid is None:
|
||||||
|
raise KeyError(f"Benutzer '{spec['paperless']}' existiert nicht")
|
||||||
|
if spec.get("owner") and owner is None:
|
||||||
|
owner = uid
|
||||||
|
if "view" in perms:
|
||||||
|
view_u.add(uid)
|
||||||
|
if "change" in perms:
|
||||||
|
chg_u.add(uid)
|
||||||
|
else:
|
||||||
|
gid = self.groups.get(spec["paperless"])
|
||||||
|
if gid is None:
|
||||||
|
raise KeyError(f"Gruppe '{spec['paperless']}' existiert nicht")
|
||||||
|
if "view" in perms:
|
||||||
|
view_g.add(gid)
|
||||||
|
if "change" in perms:
|
||||||
|
chg_g.add(gid)
|
||||||
|
|
||||||
|
# no_rights_policy: owner_only
|
||||||
|
if owner is None:
|
||||||
|
owner = self.users[self.rollen["default_owner"]]
|
||||||
|
if not (view_u or view_g or chg_u or chg_g):
|
||||||
|
return owner, None # nur Eigentuemer, sonst niemand
|
||||||
|
|
||||||
|
return owner, {
|
||||||
|
"view": {"users": sorted(view_u), "groups": sorted(view_g)},
|
||||||
|
"change": {"users": sorted(chg_u), "groups": sorted(chg_g)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Import
|
||||||
|
|
||||||
|
def iso_added(raw):
|
||||||
|
"""ecoDMS savedate -> ISO-8601 mit Zeitzone."""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
txt = str(raw).strip().replace(" ", "T")
|
||||||
|
if "." in txt: # Mikrosekunden auf 6 Stellen kuerzen
|
||||||
|
head, frac = txt.split(".", 1)
|
||||||
|
txt = f"{head}.{frac[:6]}"
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(txt).astimezone().isoformat()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def folder_tags(doc_or_folders, mode):
|
||||||
|
"""
|
||||||
|
Ordnerpfade -> Tagnamen.
|
||||||
|
|
||||||
|
'segments' (Vorgabe): 'Beruf/Fortbildung/Steuerberater' wird zu drei
|
||||||
|
Tags. Erlaubt Filtern nach einzelnen Ebenen, kostet aber den
|
||||||
|
Hierarchiekontext - 'Steuerberater' allein ist mehrdeutig. Bei
|
||||||
|
tiefen Baeumen entstehen entsprechend viele Tags.
|
||||||
|
'path': ein Tag je Ordnerpfad, Hierarchie bleibt lesbar.
|
||||||
|
|
||||||
|
Bei Mehrfachklassifizierung werden die Segmente vereinigt, Reihenfolge
|
||||||
|
bleibt stabil.
|
||||||
|
"""
|
||||||
|
# Stufe 1 liefert die Ebenen als Liste (folder_parts). Ordnernamen
|
||||||
|
# koennen Schraegstriche enthalten, ein Zerlegen am Schraegstrich waere
|
||||||
|
# deshalb falsch. Die Liste "folders" ist nur fuer die Anzeige.
|
||||||
|
if isinstance(doc_or_folders, dict):
|
||||||
|
parts_list = doc_or_folders.get("folder_parts")
|
||||||
|
if parts_list is None: # aeltere Manifeste
|
||||||
|
parts_list = [f.split(" / ") for f in doc_or_folders.get("folders", [])]
|
||||||
|
else:
|
||||||
|
parts_list = [f.split(" / ") for f in doc_or_folders]
|
||||||
|
|
||||||
|
out, seen = [], set()
|
||||||
|
for parts in parts_list:
|
||||||
|
names = [" / ".join(parts)] if mode == "path" else parts
|
||||||
|
for n in names:
|
||||||
|
n = str(n).strip()
|
||||||
|
if n and n.casefold() not in seen:
|
||||||
|
seen.add(n.casefold())
|
||||||
|
out.append(n)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def import_document(doc, api, res, jr, archive: Path, log, tag_mode="segments"):
|
||||||
|
docid = doc["ecodms_docid"]
|
||||||
|
state = jr.state(docid) or {}
|
||||||
|
|
||||||
|
if state.get("status") == "done":
|
||||||
|
log(f" uebersprungen (bereits importiert als {state['paperless_id']})")
|
||||||
|
return state["paperless_id"]
|
||||||
|
|
||||||
|
versions = doc["versions"]
|
||||||
|
if not versions:
|
||||||
|
raise RuntimeError("keine Version im Manifest")
|
||||||
|
|
||||||
|
jr.start(docid, doc["tranche"], len(versions))
|
||||||
|
|
||||||
|
# -- Tags: Ordnerebenen + Status
|
||||||
|
tag_ids = [res.tag(t) for t in folder_tags(doc, tag_mode)]
|
||||||
|
if doc.get("status"):
|
||||||
|
tag_ids.append(res.tag(f"Status: {doc['status']}"))
|
||||||
|
|
||||||
|
paperless_id = state.get("paperless_id")
|
||||||
|
start_at = state.get("versions_done") or 0
|
||||||
|
|
||||||
|
# -- Version 1 als Wurzeldokument
|
||||||
|
if not paperless_id:
|
||||||
|
v1 = versions[0]
|
||||||
|
f = archive / v1["file"]
|
||||||
|
if not f.exists():
|
||||||
|
raise FileNotFoundError(f)
|
||||||
|
log(f" v1: {v1['file']}")
|
||||||
|
task = api.post_document(
|
||||||
|
f,
|
||||||
|
{
|
||||||
|
"title": doc["title"][:127],
|
||||||
|
"created": doc.get("created") or None,
|
||||||
|
"document_type": res.doc_type(doc["document_type"]),
|
||||||
|
"tags": tag_ids,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
paperless_id = api.wait_for_task(task, log) if task else None
|
||||||
|
jr.update(docid, paperless_id=paperless_id, versions_done=1,
|
||||||
|
status="root_created")
|
||||||
|
if paperless_id:
|
||||||
|
jr.remember_time(paperless_id, docid, 1, iso_added(v1["saved_at"]))
|
||||||
|
start_at = 1
|
||||||
|
|
||||||
|
# -- Version 2..n strikt seriell anhaengen
|
||||||
|
for v in versions[start_at:]:
|
||||||
|
f = archive / v["file"]
|
||||||
|
if not f.exists():
|
||||||
|
raise FileNotFoundError(f)
|
||||||
|
label = f"v{v['version']}"
|
||||||
|
if v.get("saved_at"):
|
||||||
|
label += f" ({str(v['saved_at'])[:10]})"
|
||||||
|
log(f" v{v['version']}: {v['file']} [{label}]")
|
||||||
|
task = api.update_version(paperless_id, f, label)
|
||||||
|
vid = api.wait_for_task(task, log) if task else None
|
||||||
|
if vid:
|
||||||
|
jr.remember_time(vid, docid, v["version"], iso_added(v["saved_at"]))
|
||||||
|
jr.update(docid, versions_done=v["version"])
|
||||||
|
|
||||||
|
# -- Custom Fields
|
||||||
|
cf = []
|
||||||
|
for name, meta in doc.get("custom_fields", {}).items():
|
||||||
|
cf.append({"field": res.custom_field(name, meta["type"]),
|
||||||
|
"value": meta["value"]})
|
||||||
|
cf.append({"field": res.custom_field("ecoDMS-ID", "String"),
|
||||||
|
"value": str(docid)})
|
||||||
|
|
||||||
|
# -- Rechte: einmal auf die Wurzel, Versionen erben ueber root_document
|
||||||
|
owner, perms = res.permissions_for(doc["permissions"])
|
||||||
|
payload = {
|
||||||
|
"owner": owner,
|
||||||
|
"custom_fields": cf,
|
||||||
|
"archive_serial_number": docid, # ecoDMS-docid als ASN
|
||||||
|
}
|
||||||
|
if perms:
|
||||||
|
payload["set_permissions"] = perms
|
||||||
|
|
||||||
|
if paperless_id:
|
||||||
|
try:
|
||||||
|
api.patch(f"documents/{paperless_id}/", payload)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# Die ASN ist in Paperless eindeutig. Ein Konflikt darf nicht
|
||||||
|
# das ganze Dokument kosten - lieber ohne ASN weitermachen und
|
||||||
|
# den Fall protokollieren.
|
||||||
|
if "archive_serial_number" in str(exc) or "serial" in str(exc).lower():
|
||||||
|
log(f" ASN {docid} abgelehnt ({exc}) - Import ohne ASN")
|
||||||
|
payload.pop("archive_serial_number")
|
||||||
|
api.patch(f"documents/{paperless_id}/", payload)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
jr.update(docid, status="done", last_error=None)
|
||||||
|
return paperless_id
|
||||||
|
|
||||||
|
|
||||||
|
def run_import(args, manifest, api, res, jr, log):
|
||||||
|
archive = Path(manifest["source"])
|
||||||
|
docs = manifest["documents"]
|
||||||
|
if args.limit:
|
||||||
|
docs = docs[: args.limit]
|
||||||
|
|
||||||
|
ok = failed = 0
|
||||||
|
for i, doc in enumerate(docs, 1):
|
||||||
|
log(f"[{i}/{len(docs)}] docid {doc['ecodms_docid']}: {doc['title'][:60]}")
|
||||||
|
try:
|
||||||
|
import_document(doc, api, res, jr, archive, log, args.tag_mode)
|
||||||
|
ok += 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
failed += 1
|
||||||
|
jr.update(doc["ecodms_docid"], status="failed", last_error=str(exc))
|
||||||
|
log(f" FEHLER: {exc}")
|
||||||
|
if args.stop_on_error:
|
||||||
|
raise
|
||||||
|
log(f"\nFertig: {ok} erfolgreich, {failed} fehlgeschlagen.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- Zeitstempel-Skript
|
||||||
|
|
||||||
|
TIMESTAMP_TEMPLATE = '''\
|
||||||
|
# Erzeugt von paperless_import.py --emit-timestamps
|
||||||
|
#
|
||||||
|
# Ausfuehren mit:
|
||||||
|
# sudo docker compose exec -T webserver python manage.py shell < fix_added.py
|
||||||
|
#
|
||||||
|
# update() statt save(), damit auto_now-Felder und Signal-Handler nicht
|
||||||
|
# greifen - die wuerden den Wert wieder ueberschreiben und nebenbei
|
||||||
|
# Reindexierungen ausloesen.
|
||||||
|
from documents.models import Document
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
|
||||||
|
ROWS = {rows}
|
||||||
|
|
||||||
|
changed = missing = 0
|
||||||
|
for pk, added in ROWS:
|
||||||
|
n = Document.objects.filter(pk=pk).update(added=parse_datetime(added))
|
||||||
|
if n:
|
||||||
|
changed += 1
|
||||||
|
else:
|
||||||
|
missing += 1
|
||||||
|
print(f"{{changed}} Zeitstempel gesetzt, {{missing}} Dokumente nicht gefunden")
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def emit_timestamps(jr):
|
||||||
|
rows = [
|
||||||
|
(r["paperless_id"], r["added"])
|
||||||
|
for r in jr.con.execute(
|
||||||
|
"SELECT paperless_id, added FROM version_times "
|
||||||
|
"WHERE added IS NOT NULL ORDER BY ecodms_docid, version"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
print(TIMESTAMP_TEMPLATE.format(rows=repr(rows)))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Verknuepfungen
|
||||||
|
|
||||||
|
def resolve_links(path: Path, api, res, jr, log):
|
||||||
|
"""
|
||||||
|
Phase 2, erst nach ALLEN Tranchen: Erst jetzt sind alle docids im
|
||||||
|
Journal, also auch die aus spaeteren Tranchen.
|
||||||
|
|
||||||
|
CSV-Format, eine Zeile je Paar: src_docid,dst_docid
|
||||||
|
"""
|
||||||
|
with path.open() as fh:
|
||||||
|
for row in csv.reader(fh):
|
||||||
|
if len(row) >= 2 and row[0].strip().isdigit():
|
||||||
|
jr.add_link(int(row[0]), int(row[1]))
|
||||||
|
|
||||||
|
field_id = res.custom_field("ecoDMS-Verknuepfung", "documentlink")
|
||||||
|
pending = list(jr.con.execute("SELECT * FROM links WHERE resolved = 0"))
|
||||||
|
log(f"{len(pending)} Verknuepfungen aufzuloesen")
|
||||||
|
|
||||||
|
# Beide Richtungen sammeln - die Automatik ist beim Bulk-Edit
|
||||||
|
# nachweislich einseitig (Issue #8960).
|
||||||
|
partners: dict[int, set[int]] = {}
|
||||||
|
unresolved = 0
|
||||||
|
for row in pending:
|
||||||
|
a = jr.state(row["src_docid"])
|
||||||
|
b = jr.state(row["dst_docid"])
|
||||||
|
if not (a and b and a["paperless_id"] and b["paperless_id"]):
|
||||||
|
unresolved += 1
|
||||||
|
continue
|
||||||
|
partners.setdefault(a["paperless_id"], set()).add(b["paperless_id"])
|
||||||
|
partners.setdefault(b["paperless_id"], set()).add(a["paperless_id"])
|
||||||
|
|
||||||
|
for pid, others in partners.items():
|
||||||
|
api.patch(
|
||||||
|
f"documents/{pid}/",
|
||||||
|
{"custom_fields": [{"field": field_id, "value": sorted(others)}]},
|
||||||
|
)
|
||||||
|
jr.con.execute("UPDATE links SET resolved = 1 WHERE resolved = 0")
|
||||||
|
jr.con.commit()
|
||||||
|
log(f"{len(partners)} Dokumente verknuepft, {unresolved} ohne Gegenstueck")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- Pruefung
|
||||||
|
|
||||||
|
def verify(api, jr, log):
|
||||||
|
"""
|
||||||
|
Prueft das Journal gegen die Instanz. Holt die Dokumente in einem
|
||||||
|
Stapelabruf statt einzeln - bei ueber tausend Dokumenten waere ein
|
||||||
|
Aufruf je Dokument unbrauchbar langsam.
|
||||||
|
"""
|
||||||
|
rows = list(jr.con.execute("SELECT * FROM mapping ORDER BY ecodms_docid"))
|
||||||
|
log(f"Journal: {len(rows)} Dokumente")
|
||||||
|
for status in ("done", "root_created", "pending", "uebersprungen", "failed"):
|
||||||
|
n = sum(1 for r in rows if r["status"] == status)
|
||||||
|
if n:
|
||||||
|
log(f" {status:14s} {n}")
|
||||||
|
|
||||||
|
log("\n lade Dokumente aus Paperless ...")
|
||||||
|
live = {}
|
||||||
|
for d in api.get_all("documents/",
|
||||||
|
fields="id,owner,archive_serial_number,versions"):
|
||||||
|
live[d["id"]] = d
|
||||||
|
log(f" {len(live)} Dokumente in der Instanz\n")
|
||||||
|
|
||||||
|
problems = 0
|
||||||
|
def flag(msg):
|
||||||
|
nonlocal problems
|
||||||
|
problems += 1
|
||||||
|
if problems <= 40:
|
||||||
|
log(f" {msg}")
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for r in rows:
|
||||||
|
if r["status"] not in ("done", "root_created"):
|
||||||
|
continue
|
||||||
|
pid = r["paperless_id"]
|
||||||
|
if not pid:
|
||||||
|
flag(f"docid {r['ecodms_docid']}: ohne Paperless-ID")
|
||||||
|
continue
|
||||||
|
d = live.get(pid)
|
||||||
|
if d is None:
|
||||||
|
flag(f"docid {r['ecodms_docid']}: Dokument {pid} nicht in der Instanz")
|
||||||
|
continue
|
||||||
|
seen.add(pid)
|
||||||
|
n = len(d.get("versions") or []) or 1
|
||||||
|
if n != r["versions_total"]:
|
||||||
|
flag(f"docid {r['ecodms_docid']}: {n} Versionen, "
|
||||||
|
f"{r['versions_total']} erwartet")
|
||||||
|
if d.get("owner") is None:
|
||||||
|
flag(f"docid {r['ecodms_docid']}: KEIN EIGENTUEMER "
|
||||||
|
f"(waere unbeschraenkt sichtbar)")
|
||||||
|
asn = d.get("archive_serial_number")
|
||||||
|
if asn is not None and int(asn) != r["ecodms_docid"]:
|
||||||
|
flag(f"docid {r['ecodms_docid']}: ASN {asn} weicht ab")
|
||||||
|
|
||||||
|
# Dokumente in der Instanz, die keine Wurzel aus dem Journal sind und
|
||||||
|
# auch keine Version davon - Kandidaten fuer doppelte Anlage.
|
||||||
|
versions_of_known = {
|
||||||
|
v["id"] for pid in seen for v in (live.get(pid, {}).get("versions") or [])
|
||||||
|
}
|
||||||
|
orphans = sorted(set(live) - seen - versions_of_known)
|
||||||
|
if orphans:
|
||||||
|
log(f"\n {len(orphans)} Dokumente ohne Journaleintrag "
|
||||||
|
f"(moegliche Doppelanlage): {orphans[:20]}")
|
||||||
|
problems += len(orphans)
|
||||||
|
|
||||||
|
if problems > 40:
|
||||||
|
log(f" ... und {problems - 40} weitere")
|
||||||
|
log(f"\n{problems} Auffaelligkeiten." if problems else "\nKeine Auffaelligkeiten.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- Main
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("manifest", nargs="?", type=Path, help="JSON aus Stufe 1")
|
||||||
|
ap.add_argument("--api", default="http://localhost:8000")
|
||||||
|
ap.add_argument("--token", help="oder Umgebungsvariable PT")
|
||||||
|
ap.add_argument("--rollen", type=Path, default=Path("rollen.yml"))
|
||||||
|
ap.add_argument("--journal", type=Path, default=Path("migration.sqlite"))
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--limit", type=int, help="nur die ersten N Dokumente")
|
||||||
|
ap.add_argument("--stop-on-error", action="store_true")
|
||||||
|
ap.add_argument("--tag-mode", choices=("segments", "path"), default="segments",
|
||||||
|
help="segments: jede Ordnerebene ein eigener Tag (Vorgabe). "
|
||||||
|
"path: ein Tag je vollstaendigem Ordnerpfad.")
|
||||||
|
ap.add_argument("--setup", action="store_true",
|
||||||
|
help="nur Stammdaten anlegen, nichts importieren")
|
||||||
|
ap.add_argument("--emit-timestamps", action="store_true")
|
||||||
|
ap.add_argument("--links", type=Path, metavar="CSV")
|
||||||
|
ap.add_argument("--verify", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
log = print
|
||||||
|
jr = Journal(args.journal)
|
||||||
|
|
||||||
|
if args.emit_timestamps:
|
||||||
|
emit_timestamps(jr)
|
||||||
|
return
|
||||||
|
|
||||||
|
import os
|
||||||
|
token = args.token or os.environ.get("PT")
|
||||||
|
if not token:
|
||||||
|
sys.exit("Token fehlt: --token oder export PT=...")
|
||||||
|
|
||||||
|
api = Paperless(args.api, token, dry_run=args.dry_run)
|
||||||
|
rollen = yaml.safe_load(args.rollen.read_text(encoding="utf-8"))
|
||||||
|
res = Resolver(api, rollen, log)
|
||||||
|
|
||||||
|
if args.verify:
|
||||||
|
verify(api, jr, log)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.links:
|
||||||
|
resolve_links(args.links, api, res, jr, log)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.manifest:
|
||||||
|
sys.exit("Manifest fehlt.")
|
||||||
|
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
log(f"Tranche {manifest['tranche']}: "
|
||||||
|
f"{manifest['counts']['documents']} Dokumente, "
|
||||||
|
f"{manifest['counts']['versions']} Versionen")
|
||||||
|
if args.dry_run:
|
||||||
|
log("TROCKENLAUF - es wird nichts geschrieben\n")
|
||||||
|
|
||||||
|
# Rollen vorab pruefen, bevor die erste Datei hochgeht
|
||||||
|
seen = {p["role"] for d in manifest["documents"] for p in d["permissions"]}
|
||||||
|
unknown = seen - set(rollen["roles"])
|
||||||
|
if unknown:
|
||||||
|
sys.exit(f"Unbekannte Rollen in rollen.yml ergaenzen: {sorted(unknown)}")
|
||||||
|
|
||||||
|
if args.setup:
|
||||||
|
for d in manifest["documents"]:
|
||||||
|
for t in folder_tags(d, args.tag_mode):
|
||||||
|
res.tag(t)
|
||||||
|
if d.get("status"):
|
||||||
|
res.tag(f"Status: {d['status']}")
|
||||||
|
res.doc_type(d["document_type"])
|
||||||
|
for name, meta in d.get("custom_fields", {}).items():
|
||||||
|
res.custom_field(name, meta["type"])
|
||||||
|
# Felder aus rollen.yml unabhaengig vom Tranchen-Inhalt anlegen,
|
||||||
|
# damit Namen und Typen nicht vom Zufall der ersten Tranche abhaengen.
|
||||||
|
for name, dtype in (rollen.get("custom_fields") or {}).items():
|
||||||
|
res.custom_field_typed(name, dtype)
|
||||||
|
log("Stammdaten angelegt.")
|
||||||
|
return
|
||||||
|
|
||||||
|
run_import(args, manifest, api, res, jr, log)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
101
rollen.yml.example
Normal file
101
rollen.yml.example
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Uebersetzung der ecoDMS-Rollen nach Paperless-ngx.
|
||||||
|
# Nach rollen.yml kopieren und anpassen.
|
||||||
|
#
|
||||||
|
# GRUNDREGEL: Nicht eingetragene Rollen fuehren zum ABBRUCH, nicht zum
|
||||||
|
# stillen Verwerfen. Denn in Paperless bedeutet "keine Rechte gesetzt"
|
||||||
|
# nicht gesperrt, sondern UNBESCHRAENKT - ein Tippfehler wuerde Dokumente
|
||||||
|
# oeffnen, nicht schliessen. Bewusstes Weglassen wird als "type: null"
|
||||||
|
# notiert.
|
||||||
|
#
|
||||||
|
# Vorkommende Rollen ermitteln: der Pruefbericht von ecodms_extract.py
|
||||||
|
# gibt sie je Tranche unter "Rollen" aus. Ueber alle Tranchen zusammenfuehren.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Zielstruktur - vor dem ersten Import in Paperless anlegen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nur Dokumentation; angelegt wird ueber die Django-Shell, siehe docs/01.
|
||||||
|
paperless_users:
|
||||||
|
- benutzer_a
|
||||||
|
- benutzer_b
|
||||||
|
|
||||||
|
paperless_groups:
|
||||||
|
Gruppe_A:
|
||||||
|
- benutzer_a
|
||||||
|
- benutzer_b
|
||||||
|
|
||||||
|
# Keiner Gruppe das Recht zum endgueltigen Loeschen geben. Zusammen mit
|
||||||
|
# Audit-Log, Versionierung und Papierkorb-Frist ist das der vierte Baustein
|
||||||
|
# der Nachvollziehbarkeit.
|
||||||
|
group_permissions:
|
||||||
|
default: [view, change] # ausdruecklich ohne delete
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Abbildung der ecoDMS-Rollen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Greift, wenn nach der Uebersetzung kein Eigentuemer uebrig bleibt.
|
||||||
|
default_owner: benutzer_a
|
||||||
|
|
||||||
|
roles:
|
||||||
|
|
||||||
|
# Einzelbenutzer-Rollen tragen in ecoDMS ueblicherweise ein r_-Praefix.
|
||||||
|
r_benutzer_a:
|
||||||
|
type: user
|
||||||
|
paperless: benutzer_a
|
||||||
|
owner: true # wird Eigentuemer, wenn an einem Dokument beteiligt
|
||||||
|
|
||||||
|
r_benutzer_b:
|
||||||
|
type: user
|
||||||
|
paperless: benutzer_b
|
||||||
|
|
||||||
|
# Echte Gruppen ohne Praefix.
|
||||||
|
GRUPPENNAME:
|
||||||
|
type: group
|
||||||
|
paperless: Gruppe_A
|
||||||
|
|
||||||
|
# Globales Importrecht: Jeder hat Zugriff. Wird beim Import gesetzt und
|
||||||
|
# bei der ersten Klassifizierung durch die echten Berechtigten ersetzt.
|
||||||
|
# Wer es uebernimmt, oeffnet jedes Dokument fuer alle.
|
||||||
|
ecoSIMSUSER:
|
||||||
|
type: null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# doc_right -> Paperless-Objektrechte
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 'change' impliziert in Paperless kein 'view'. W muss deshalb beides setzen.
|
||||||
|
rights:
|
||||||
|
R: [view]
|
||||||
|
W: [view, change]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dokumente ohne Rechteeintrag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In der Praxis haben viele Dokumente GAR KEINE Zeile in docrollen_hist -
|
||||||
|
# ecoDMS speichert offenbar nur ausdruecklich vergebene Berechtigungen.
|
||||||
|
#
|
||||||
|
# owner_only - nur default_owner, sonst niemand (empfohlen)
|
||||||
|
# unrestricted - kein Eigentuemer, fuer alle sichtbar
|
||||||
|
no_rights_policy: owner_only
|
||||||
|
|
||||||
|
# Optional: Eigentuemer aus klassifizierung.changeid ableiten statt pauschal
|
||||||
|
# default_owner. Das Feld enthaelt Klarnamen, keine Rollennamen.
|
||||||
|
owner_from_changeid: false
|
||||||
|
users_by_name:
|
||||||
|
"Vorname Nachname": benutzer_a
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Zusatzfelder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Werden von "--setup" angelegt, unabhaengig davon, ob in der ersten Tranche
|
||||||
|
# schon Werte vorkommen. Der Name MUSS exakt dem Namen in der ecoDMS-Tabelle
|
||||||
|
# dynattribute entsprechen, sonst legt der Import ein zweites, gleichnamiges
|
||||||
|
# Feld an und die Werte verteilen sich auf beide.
|
||||||
|
#
|
||||||
|
# Typen: string, date, boolean, integer, float, monetary, url, documentlink
|
||||||
|
custom_fields:
|
||||||
|
Belegnummer: string
|
||||||
|
Zeitraum: string
|
||||||
|
Eingangsdatum: date
|
||||||
|
Zahlungsdatum: date
|
||||||
|
Wiedervorlage: date # aus klassifizierung.defdate
|
||||||
|
ecoDMS-ID: string
|
||||||
|
ecoDMS-Verknuepfung: documentlink
|
||||||
Reference in New Issue
Block a user