Initial commit
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user