259 lines
9.1 KiB
Python
259 lines
9.1 KiB
Python
#!/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()
|