Version 2.3.0: Speichern mit Ordner und Namen

This commit is contained in:
alientim committed 2026-09-09 22:41:07 +02:00
1 parent 8c2cd10ee4
commit c0a6dd057c
11 files changed
+217 -30

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.2.1"
version = "2.3.0"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+17 -4
View File
@@ -353,19 +353,32 @@ def _start_fileshare_sweep(app: Flask) -> None:
weiter eingebunden.
"""
import threading
import time
from .services import fileshare
def schleife() -> None:
while True:
time.sleep(FILESHARE_SWEEP_SECONDS)
try:
def einmal() -> None:
with app.app_context():
geloest = fileshare.sweep_expired(core(app).database)
if geloest:
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
def schleife() -> None:
# Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
# Durchlauf scheitert; das aeussere sorgt dafuer, dass sein Ende
# ueberhaupt auffaellt. Vorher fehlte der Import von "time" -- der
# NameError kam beim ersten sleep, also vor jedem try, und der Faden
# war weg, ohne eine Zeile zu hinterlassen.
try:
while True:
time.sleep(FILESHARE_SWEEP_SECONDS)
try:
einmal()
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert
logger.exception("Der Aufraeumfaden der Dateifreigaben ist beendet")
raise
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
+25 -2
View File
@@ -80,6 +80,8 @@ def browse():
# vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen
# wurde.
max_upload_mb=uploads.limit_mb(conn),
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
verzeichnisse=(service.all_directories(mount) if listing else []),
)
@@ -205,15 +207,36 @@ def save_form(token: str):
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
return {"error": "Das ist keine PDF-Datei."}, 415
# Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
ordner = (request.form.get("ordner") or "").strip().strip("/")
wunschname = (request.form.get("name") or "").strip()
ersetzen = request.form.get("ersetzen") == "1"
try:
original = service.resolve_path(mount, relative)
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
if not zielordner.is_dir():
return {"error": f"Den Ordner '{ordner}' gibt es in dieser Freigabe nicht."}, 400
if wunschname:
name = service.safe_filename(wunschname)
if not name.lower().endswith(".pdf"):
name += ".pdf"
ziel = zielordner / name
if ziel.exists() and not ersetzen:
return {
"error": f"'{name}' gibt es dort schon. Anderen Namen waehlen oder "
"'Vorhandene ersetzen' ankreuzen."
}, 409
else:
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
ziel = service.free_name(original.parent, original.stem, "-ausgefuellt", ".pdf")
ziel.write_bytes(daten)
except service.FileshareError as exc:
return {"error": str(exc)}, 400
_audit("fileshare.form_saved", f"{mount.label}:{relative}", ziel.name, severity="notice")
return {"message": f"Als {ziel.name} gespeichert."}
wohin = ziel.relative_to(mount.path.resolve()).as_posix()
_audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice")
return {"message": f"Gespeichert als {wohin}.", "pfad": wohin}
@bp.post("/<token>/ordner")
+30
View File
@@ -588,6 +588,36 @@ def safe_filename(name: str) -> str:
return cleaned
def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[str]:
"""Die Ordner der Freigabe, relativ und alphabetisch -- fuer eine Auswahlliste.
Begrenzt in Tiefe und Anzahl: eine Netzwerkfreigabe kann zehntausende
Verzeichnisse haben, und eine Liste, die den Browser lahmlegt, hilft
niemandem. Wer tiefer speichern will, tippt den Pfad -- das Feld nimmt
beides.
"""
wurzel = mount.path.resolve()
gefunden: list[str] = []
def hinein(verzeichnis: Path, ebene: int) -> None:
if ebene > tiefe or len(gefunden) >= grenze:
return
try:
eintraege = sorted(verzeichnis.iterdir(), key=lambda e: e.name.lower())
except OSError:
return
for eintrag in eintraege:
if len(gefunden) >= grenze:
return
if not eintrag.is_dir() or eintrag.name.startswith("."):
continue
gefunden.append(eintrag.relative_to(wurzel).as_posix())
hinein(eintrag, ebene + 1)
hinein(wurzel, 1)
return gefunden
def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
@@ -209,16 +209,40 @@
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
<div class="modal__body modal__body--flush" data-viewer
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/') }}">
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}">
<div class="viewer">
<div class="viewer__stage" data-viewer-bildbuehne hidden>
<img class="viewer__img" alt="" data-viewer-bild>
</div>
<div class="viewer__pdf" data-viewer-pdf hidden>
{# Zwei Ebenen: aussen ein normaler Kasten, der sich die Hoehe mit
Leiste und Speicherzeile teilt, innen der absolute Scrollbereich,
den pdf.js verlangt. #}
<div class="viewer__pdfscroll" data-viewer-pdfscroll>
<div class="pdfViewer" data-viewer-pdfseiten></div>
</div>
</div>
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
eine Zeile Formular ueber jeder Bildvorschau. #}
<div class="viewer__save" data-viewer-speichernzeile hidden>
<label class="viewer__feld">
<span class="field__label">Ordner</span>
<input class="input" list="freigabe-ordner" data-viewer-ordner
placeholder="Wurzelverzeichnis" autocomplete="off">
</label>
<label class="viewer__feld">
<span class="field__label">Dateiname</span>
<input class="input" data-viewer-name autocomplete="off">
</label>
<label class="check">
<input type="checkbox" data-viewer-ersetzen>
<span>Vorhandene ersetzen</span>
</label>
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button>
</div>
<div class="viewer__bar" data-viewer-leiste hidden>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
@@ -242,6 +266,10 @@
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
</div>
{% endcall %}
{# Die Ordner der Freigabe als Vorschlag; getippt werden darf trotzdem. #}
<datalist id="freigabe-ordner">
{% for eintrag in verzeichnisse %}<option value="{{ eintrag }}"></option>{% endfor %}
</datalist>
{% endif %}
{% if listing and current_user.has('fileshare.edit') %}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.2.1"
version = "2.3.0"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -135,7 +135,7 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen.
auth_tagline: str = "",
app_version: str = "2.2.1",
app_version: str = "2.3.0",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
@@ -754,11 +754,19 @@
user-select: none;
-webkit-user-drag: none;
}
/* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter. */
/* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter --
der liegt deshalb *innen*. Aussen ein gewoehnlicher Flex-Kasten, damit
Zoomleiste und Speicherzeile ihren Platz behalten. Vorher rechnete der
Bereich mit "bottom: var(--sp-10)", also mit genau einer Leiste; die
zweite Zeile lag dann unter dem Dialogrand. */
.viewer__pdf {
position: relative;
flex: 1;
min-height: 0;
}
.viewer__pdfscroll {
position: absolute;
inset: 0;
bottom: var(--sp-10);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
@@ -773,6 +781,26 @@
.viewer__hint {
margin-inline-start: auto;
}
.viewer__save {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--border);
background: var(--surface-2, var(--surface-1));
}
.viewer__save .viewer__feld {
flex: 1 1 12rem;
margin: 0;
}
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
.viewer__feld {
display: flex;
flex-direction: column;
gap: 2px;
}
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
unruhig. */
@@ -902,7 +902,11 @@ function boot() {
einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */
function initViewer() {
if (!document.querySelector("[data-viewer]")) return;
import("./viewer.js")
// Dieselbe Version wie diese Datei: app.js wird als ".../app.js?v=<Zeit>"
// eingebunden, und nginx liefert /core-assets/ mit "expires 7d". Ohne das
// Anhaengsel bliebe der alte Betrachter nach einem Update im Cache liegen.
const version = new URL(import.meta.url).search;
import(`./viewer.js${version}`)
.then((modul) => modul.initFilePreview())
.catch((fehler) => console.error("Dateibetrachter", fehler));
}
@@ -146,21 +146,41 @@ function bildBetrachter(buehne, bild) {
let pdfjsGeladen = null;
/** Die Adressen der pdf.js-Dateien -- alle mit derselben Version.
*
* Uebergeben wird die Adresse von pdf.min.js samt "?v=<Zeit>"; die
* Geschwister liegen daneben. Ohne die Version bliebe nach einem Update die
* alte Fassung im Cache von nginx ("expires 7d") haengen.
*/
function pdfjsAdressen(adresse) {
const url = new URL(adresse, location.href);
const verzeichnis = url.pathname.replace(/[^/]*$/, "");
const mit = (datei) => `${url.origin}${verzeichnis}${datei}${url.search}`;
return {
lib: mit("pdf.min.js"),
komponenten: mit("pdf_viewer.js"),
worker: mit("pdf.worker.min.js"),
stil: mit("pdf_viewer.css"),
schriften: `${url.origin}${verzeichnis}standard_fonts/`,
};
}
/** Laedt pdf.js einmalig nach; das Paket liegt neben dieser Datei. */
async function pdfjsLaden(basis) {
const adressen = pdfjsAdressen(basis);
if (!pdfjsGeladen) {
pdfjsGeladen = (async () => {
const lib = await import(`${basis}pdf.min.js`);
const lib = await import(adressen.lib);
// Der Komponentenbau von pdf.js sucht die Bibliothek unter diesem Namen.
globalThis.pdfjsLib = lib;
const komponenten = await import(`${basis}pdf_viewer.js`);
const komponenten = await import(adressen.komponenten);
// Das Stylesheet von pdf.js zeichnet die Formularfelder. 100 KB gehoeren
// nicht in jeden Seitenaufruf -- also erst hier.
const stil = document.createElement("link");
stil.rel = "stylesheet";
stil.href = `${basis}pdf_viewer.css`;
stil.href = adressen.stil;
document.head.append(stil);
lib.GlobalWorkerOptions.workerSrc = `${basis}pdf.worker.min.js`;
lib.GlobalWorkerOptions.workerSrc = adressen.worker;
return { lib, komponenten };
})().catch((fehler) => {
pdfjsGeladen = null;
@@ -173,6 +193,7 @@ async function pdfjsLaden(basis) {
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */
async function pdfBetrachter(behaelter, seiten, url, basis) {
const { lib, komponenten } = await pdfjsLaden(basis);
const adressen = pdfjsAdressen(basis);
const eventBus = new komponenten.EventBus();
const linkService = new komponenten.PDFLinkService({ eventBus });
const viewer = new komponenten.PDFViewer({
@@ -190,7 +211,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
url,
// Ohne die Standardschriften bleibt jede Seite mit Text leer: Helvetica
// und Verwandte liegen als Datei im Paket, nicht im Browser.
standardFontDataUrl: `${basis}standard_fonts/`,
standardFontDataUrl: adressen.schriften,
isEvalSupported: false,
}).promise;
viewer.setDocument(dokument);
@@ -261,11 +282,18 @@ export function initFilePreview(wurzel = document) {
const bildBuehne = stueck("bildbuehne");
const bild = stueck("bild");
const pdfBehaelter = stueck("pdf");
const pdfScroll = stueck("pdfscroll");
const pdfSeiten = stueck("pdfseiten");
const rahmen = stueck("rahmen");
const leiste = stueck("leiste");
const hinweis = stueck("hinweis");
const speichern = stueck("speichern");
const zeile = stueck("speichernzeile");
const ordnerFeld = stueck("ordner");
const nameFeld = stueck("name");
const ersetzenFeld = stueck("ersetzen");
const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen");
const basis = rumpf.dataset.viewerPdfjs || "";
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
@@ -309,6 +337,7 @@ export function initFilePreview(wurzel = document) {
}
pdfSeiten.replaceChildren();
if (hinweis) hinweis.textContent = "";
if (zeile) zeile.hidden = true;
aktuell = null;
};
@@ -328,7 +357,7 @@ export function initFilePreview(wurzel = document) {
}
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
try {
pdf = await pdfBetrachter(pdfBehaelter, pdfSeiten, quelle, basis);
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis);
if (hinweis) hinweis.textContent = "";
} catch (fehler) {
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
@@ -337,8 +366,12 @@ export function initFilePreview(wurzel = document) {
zeigen("text");
rahmen.src = quelle;
if (hinweis) {
// Den Grund mitgeben: auf einem fremden Geraet ist die Meldung des
// Browsers die einzige Spur, die man hat.
const grund = String(fehler?.message || fehler || "").slice(0, 120);
hinweis.textContent =
"Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige.";
"Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige." +
(grund ? ` (${grund})` : "");
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
// es sah aus, als koennte die Vorschau einfach keine Formulare.
@@ -365,33 +398,61 @@ export function initFilePreview(wurzel = document) {
dialog.addEventListener("close", aufraeumen);
/* Ausgefuelltes Formular zurueck in die Freigabe. */
speichern?.addEventListener("click", async () => {
/* Ausgefuelltes Formular zurueck in die Freigabe -- erst fragen, wohin. */
const vorschlagen = () => {
if (!aktuell) return;
const teile = aktuell.pfad.split("/");
const datei = teile.pop() || "formular.pdf";
ordnerFeld.value = teile.join("/");
// Der Name der Vorlage mit Zusatz -- die Vorlage selbst bleibt so heil,
// auch wenn jemand blind auf Speichern drueckt.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt.pdf";
ersetzenFeld.checked = false;
};
speichern?.addEventListener("click", () => {
if (!pdf || !aktuell) return;
vorschlagen();
zeile.hidden = false;
nameFeld.focus();
nameFeld.select();
});
abbrechen?.addEventListener("click", () => {
zeile.hidden = true;
});
jetzt?.addEventListener("click", async () => {
if (!pdf || !aktuell) return;
const ziel = speichern.dataset.url;
speichern.disabled = true;
const vorher = speichern.textContent;
speichern.textContent = "Speichern ...";
jetzt.disabled = true;
const vorher = jetzt.textContent;
jetzt.textContent = "Speichern ...";
try {
const bytes = await pdf.speichern();
const daten = new FormData();
daten.append("pfad", aktuell.pfad);
daten.append("ordner", ordnerFeld.value.trim());
daten.append("name", nameFeld.value.trim());
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
daten.append(
"datei",
new Blob([bytes], { type: "application/pdf" }),
(aktuell.pfad.split("/").pop() || "formular.pdf")
nameFeld.value.trim() || "formular.pdf"
);
const antwort = await window.tesm.api(ziel, { method: "POST", body: daten });
window.tesm.showToast(antwort.message || "Gespeichert.", "success");
dialog.close();
window.location.reload();
} catch (fehler) {
// Die Meldung fuer den Benutzer, die Einzelheiten fuer die Konsole.
// Die Meldung fuer den Benutzer, die Einzelheiten fuer die Konsole. Die
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
console.error("Formular speichern", fehler);
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
} finally {
speichern.disabled = false;
speichern.textContent = vorher;
jetzt.disabled = false;
jetzt.textContent = vorher;
}
});
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing"
version = "2.2.1"
version = "2.3.0"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]