Workspace-Creator für Kunden je Organisation freischaltbar

- Freischaltung je Organisation (Portal-Migration 0010, alle bisherigen
  Organisationen = Testschulen frei), Schalter unter Admin › Organisation ›
  Organisationsdaten
- Kunden-Creator auf den Stand des Admin-Creators: Assistent mit Distro,
  Entwurf/Veröffentlichen, Webapps, Standardbrowser, Kopie, Löschen;
  gemeinsame Vorlagen _workspace_detail.html/_workspace_assistent.html
- Vorlagen von tuxflotte nur lesend, ohne fremde Nutzung; Kopie immer in
  die eigene Organisation
- Organisation › Struktur: Workspace je OE zuweisen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-23 07:57:07 +02:00
1 parent 90e7b967c4
commit 8cc98698e3
17 files changed
+1046 -899

No files matched your search

@@ -0,0 +1,27 @@
BEGIN;
-- Workspace-Creator je Organisation freischalten (GUI-Runde 23.10.2026,
-- platform-docs/roadmap/roadmap-2026-10-13.md): bis dahin war er für alle
-- Kunden gesperrt (routers/workspace.py, Schalter vom 07.10.2026). Nur der
-- Root-Admin schaltet; ohne Zeile gilt "gesperrt". Alle Organisationen, die
-- es bisher gibt, sind Testschulen und bekommen ihn freigeschaltet.
CREATE TABLE workspace_creator_freischaltung (
organization_id UUID PRIMARY KEY,
frei BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Stand 23.10.2026 (anode, organizations): Default Lab, Liebherr,
-- Mittelschule Scheßlitz, Mittelschule Simmernstraße, MS Gaißach,
-- Sindelsdorf, WLAN-Test, WVSG.
INSERT INTO workspace_creator_freischaltung (organization_id, frei) VALUES
('11111111-1111-4111-8111-111111111111', TRUE),
('ab49c741-6795-43d3-abd4-50772565365a', TRUE),
('470d0822-840c-40db-8e3b-14d50a3a61b8', TRUE),
('4ea2e221-ad9f-40db-9c26-eb73cda0145c', TRUE),
('45b39011-30e3-4e3e-af10-18b01dd92af2', TRUE),
('a8e7cbfc-a954-46a0-823e-bd781a0b50cc', TRUE),
('b6eb4075-ca26-4a2f-925f-3186d35610d1', TRUE),
('3bc76ecc-f764-4e48-be7e-2e8139bb0073', TRUE);
COMMIT;
+10
View File
@@ -14,6 +14,7 @@ from optionen_felder import OPTIONEN_FELDER, parse_optionen_form
import ressourcen_formular
from routers.admin_flows import flows_kontext
from templating import templates
import workspace_creator
router = APIRouter(prefix="/admin/kunden")
@@ -89,6 +90,7 @@ def detail(request: Request, organization_id: str, fehler: str = "", user: dict
return _render_detail_tab(
request, organization_id, user, "organisationsdaten",
kundengruppen=_fetch_kundengruppen(), hostname_konvention=hostname_konvention, fehler=fehler,
workspace_creator_frei=workspace_creator.ist_freigeschaltet(organization_id),
**ressourcen_formular.lade_ressourcen_kontext(organization_id),
)
@@ -365,6 +367,14 @@ def ausrollstufe_setzen(organization_id: str, ausrollstufe: str = Form(...), use
return RedirectResponse(f"/admin/kunden/{organization_id}#einstufung", status_code=303)
@router.post("/{organization_id}/workspace-creator")
def workspace_creator_schalten(organization_id: str, frei: str = Form(default=""), user: dict = Depends(require_admin)):
if _fetch_organization(organization_id) is None:
raise HTTPException(status_code=404, detail="Organisation wurde nicht gefunden.")
workspace_creator.setze_freischaltung(organization_id, frei == "ja")
return RedirectResponse(f"/admin/kunden/{organization_id}#einstufung", status_code=303)
@router.post("/{organization_id}/kundengruppe")
def kundengruppe_setzen(organization_id: str, kundengruppe_id: str = Form(default=""), user: dict = Depends(require_admin)):
result = anode_request(
+87 -55
View File
@@ -58,14 +58,15 @@ def _nach_kategorie(kategorien: list[dict], eintraege_mit_kategorie: list[tuple[
def katalog_aufbauen(
kategorien: list[dict], merkmale: list[dict], webapp_templates: list[dict],
backends: list[dict], blueprints: list[dict],
backends: list[dict], blueprints: list[dict], icon_basis: str = "/admin/workspaces",
) -> list[dict]:
"""
Drei Bereiche (Apps, Webapps, Konfigurationsrichtlinien), darin
Kategorien, darin Einträge mit Distro-Verfügbarkeit. Ob ein Merkmal auf
einer Distro geht, ergibt sich wie bei der früheren Flottenmatrix aus
den Blueprints (Merkmal × Distro → Ansible-Rolle). Webapps laufen im
Browser und sind damit auf jeder Distro verfügbar.
Browser und sind damit auf jeder Distro verfügbar. icon_basis ist der
Pfad der Icon-Proxys (Admin oder Kundenportal, seit 23.10.2026).
"""
# Rolle je (Merkmal, Distro) - die Katalogseite zeigt sie im Tooltip
@@ -91,7 +92,7 @@ def katalog_aufbauen(
rollen = rollen_je_merkmal.get(m["key"], {})
eintrag = _eintrag(
m["name"], m.get("description"),
f"/admin/workspaces{m['icon_path']}" if m.get("icon_path") else None,
f"{icon_basis}{m['icon_path']}" if m.get("icon_path") else None,
distro_liste(set(rollen), rollen),
m["id"],
{"art": "merkmal", "key": m["key"], "kategorie_id": m.get("kategorie_id"),
@@ -106,7 +107,7 @@ def katalog_aufbauen(
t.get("kategorie_id"),
_eintrag(
t["name"], t.get("description"),
f"/admin/workspaces/webapp-icons/{t['icon_path'].split('/')[-1]}" if t.get("icon_path") else None,
f"{icon_basis}/webapp-icons/{t['icon_path'].split('/')[-1]}" if t.get("icon_path") else None,
distro_liste(alle_distros),
t["id"],
{"art": "webapp", "url": t.get("url"), "kategorie_id": t.get("kategorie_id")},
@@ -173,7 +174,7 @@ def alte_technik_adresse(request: Request, rest: str = ""):
return RedirectResponse(neuer_pfad_fuer_technik(rest), status_code=status)
def katalog_laden(mit_kategorien: bool = False) -> tuple:
def katalog_laden(mit_kategorien: bool = False, icon_basis: str = "/admin/workspaces") -> tuple:
"""(bereiche, backends) - gemeinsame Datengrundlage für Katalog und
Assistent; mit_kategorien hängt die Kategorienliste an (für die
Bearbeiten-Formulare der Katalogseite)."""
@@ -184,7 +185,7 @@ def katalog_laden(mit_kategorien: bool = False) -> tuple:
backends = _liste(anode_request("GET", "/api/v1/backends"), "backends")
blueprints = _liste(anode_request("GET", "/api/v1/blueprints"), "blueprints")
bereiche = katalog_aufbauen(kategorien, merkmale, webapp_templates, backends, blueprints)
bereiche = katalog_aufbauen(kategorien, merkmale, webapp_templates, backends, blueprints, icon_basis)
if mit_kategorien:
return bereiche, backends, kategorien
return bereiche, backends
@@ -211,9 +212,15 @@ def katalog(request: Request, user: dict = Depends(require_admin)):
# --- Assistent "Workspace erstellen" (Schritt D, 07.10.2026) ---
def _assistent_seite(request: Request, user: dict, werte: dict, fehler: str | None = None):
bereiche, backends = katalog_laden()
organisationen = _liste(anode_request("GET", "/api/v1/organizations"), "organizations")
def assistent_seite(
request: Request, user, werte: dict, fehler: str | None = None, *,
ist_admin: bool = True, assistent_ziel: str = "/admin/workspaces/neu", icon_basis: str = "/admin/workspaces",
):
"""Assistent für Admin und (seit 23.10.2026) Kundenportal - der Kunde
wählt keinen Eigentümer, der Workspace gehört immer seiner Organisation."""
bereiche, backends = katalog_laden(icon_basis=icon_basis)
organisationen = _liste(anode_request("GET", "/api/v1/organizations"), "organizations") if ist_admin else []
# Für die Distro-Leiste im Browser: je Eintrag Name, Bereich, Kategorie
# und die Distros, auf denen er umgesetzt ist.
@@ -224,9 +231,11 @@ def _assistent_seite(request: Request, user: dict, werte: dict, fehler: str | No
return templates.TemplateResponse(
request,
"admin/workspace_assistent.html",
"admin/workspace_assistent.html" if ist_admin else "workspace_assistent.html",
{
"user": user,
"ist_admin": ist_admin,
"assistent_ziel": assistent_ziel,
"bereiche": bereiche,
"backends": backends,
"organisationen": organisationen,
@@ -244,7 +253,7 @@ def _assistent_seite(request: Request, user: dict, werte: dict, fehler: str | No
@router.get("/neu")
def assistent(request: Request, user: dict = Depends(require_admin)):
return _assistent_seite(request, user, {"merkmal_ids": [], "webapp_template_ids": []})
return assistent_seite(request, user, {"merkmal_ids": [], "webapp_template_ids": []})
@router.post("/neu")
@@ -260,9 +269,9 @@ async def assistent_speichern(request: Request, user: dict = Depends(require_adm
}
if not werte["name"]:
return _assistent_seite(request, user, werte, "Bitte einen Namen angeben.")
return assistent_seite(request, user, werte, "Bitte einen Namen angeben.")
if not werte["backend_id"]:
return _assistent_seite(request, user, werte, "Bitte eine Distro wählen.")
return assistent_seite(request, user, werte, "Bitte eine Distro wählen.")
result = anode_request(
"POST", "/api/v1/workspaces/assistent",
@@ -277,7 +286,7 @@ async def assistent_speichern(request: Request, user: dict = Depends(require_adm
)
if not result.get("success"):
return _assistent_seite(request, user, werte, result.get("message", "Workspace konnte nicht angelegt werden."))
return assistent_seite(request, user, werte, result.get("message", "Workspace konnte nicht angelegt werden."))
return RedirectResponse(f"/admin/workspaces/liste/{result['workspace']['id']}", status_code=303)
@@ -301,17 +310,15 @@ def _ergebnis(workspace_id: str, result: dict, erfolg: str | None = None) -> Red
return _zurueck(workspace_id, hinweis=erfolg) if erfolg else _zurueck(workspace_id)
@router.get("/liste/{workspace_id}")
def workspace_detail(request: Request, workspace_id: str, user: dict = Depends(require_admin)):
result = anode_request("GET", f"/api/v1/workspaces/{workspace_id}")
workspace = result.get("workspace") if result.get("success") else None
if workspace is None:
return templates.TemplateResponse(
request, "admin/workspace_detail.html", {"user": user, "workspace": None}, status_code=404
)
def detail_kontext(
request: Request, workspace_id: str, workspace: dict, *, ist_admin: bool, darf_bearbeiten: bool, icon_basis: str,
) -> dict:
"""Alles, was templates/_workspace_detail.html braucht - für Admin und
(seit 23.10.2026) Kundenportal. Ohne Bearbeitungsrecht (fremde Vorlage)
keine Nutzung und keine Wirkung: die nennen andere Organisationen."""
ist_entwurf = bool(workspace.get("version") and workspace["version"]["status"] == "entwurf")
bereiche, backends = katalog_laden()
ist_entwurf = darf_bearbeiten and bool(workspace.get("version") and workspace["version"]["status"] == "entwurf")
bereiche, backends = katalog_laden(icon_basis=icon_basis)
ausgewaehlt = {m["id"] for m in workspace["merkmale"]}
distro_key = next((b["key"] for b in backends if workspace["backend"] and b["id"] == workspace["backend"]["id"]), None)
@@ -324,9 +331,11 @@ def workspace_detail(request: Request, workspace_id: str, user: dict = Depends(r
if ist_entwurf:
w = anode_request("GET", f"/api/v1/workspaces/{workspace_id}/wirkung")
wirkung = w.get("wirkung") if w.get("success") else None
nutzung = (wirkung or {}).get("nutzung") or anode_request(
"GET", f"/api/v1/workspaces/{workspace_id}/nutzung"
).get("nutzung", {})
nutzung = {}
if darf_bearbeiten:
nutzung = (wirkung or {}).get("nutzung") or anode_request(
"GET", f"/api/v1/workspaces/{workspace_id}/nutzung"
).get("nutzung", {})
# Kopie-Prüfung (zweistufig: erst prüfen, dann anlegen)
kopie = None
@@ -338,33 +347,47 @@ def workspace_detail(request: Request, workspace_id: str, user: dict = Depends(r
kopie = {
"name": request.query_params.get("kopie_name", ""),
"backend_id": request.query_params["kopie_backend"],
"organization_id": request.query_params.get("kopie_org", ""),
"organization_id": request.query_params.get("kopie_org", "") if ist_admin else "",
"weggefallen": k.get("weggefallen", []) if k.get("success") else [],
}
return {
"workspace": workspace,
"workspace_id": workspace_id,
"ist_entwurf": ist_entwurf,
"bereiche": [b for b in bereiche if b["key"] != "webapps"],
"ausgewaehlt": ausgewaehlt,
"distro_key": distro_key,
"webapps": webapps,
"vorlagen_verfuegbar": vorlagen_verfuegbar,
"wirkung": wirkung,
"nutzung": nutzung,
"versionen": _liste(anode_request("GET", f"/api/v1/workspaces/{workspace_id}/versionen"), "versionen"),
"backends": backends,
"kundengruppen": _liste(anode_request("GET", "/api/v1/kundengruppen"), "kundengruppen") if ist_admin else [],
"organisationen": _liste(anode_request("GET", "/api/v1/organizations"), "organizations") if ist_admin else [],
"kopie": kopie,
"fehler": request.query_params.get("fehler"),
"hinweis": request.query_params.get("hinweis"),
"ist_admin": ist_admin,
"darf_bearbeiten": darf_bearbeiten,
"icon_basis": icon_basis,
}
@router.get("/liste/{workspace_id}")
def workspace_detail(request: Request, workspace_id: str, user: dict = Depends(require_admin)):
basis = {"user": user, "ws_basis": "/admin/workspaces/liste", "ws_liste_url": "/admin/workspaces/liste"}
result = anode_request("GET", f"/api/v1/workspaces/{workspace_id}")
workspace = result.get("workspace") if result.get("success") else None
if workspace is None:
return templates.TemplateResponse(
request, "admin/workspace_detail.html", {**basis, "workspace": None}, status_code=404
)
return templates.TemplateResponse(
request,
"admin/workspace_detail.html",
{
"user": user,
"workspace": workspace,
"workspace_id": workspace_id,
"ist_entwurf": ist_entwurf,
"bereiche": [b for b in bereiche if b["key"] != "webapps"],
"ausgewaehlt": ausgewaehlt,
"distro_key": distro_key,
"webapps": webapps,
"vorlagen_verfuegbar": vorlagen_verfuegbar,
"wirkung": wirkung,
"nutzung": nutzung,
"versionen": _liste(anode_request("GET", f"/api/v1/workspaces/{workspace_id}/versionen"), "versionen"),
"backends": backends,
"kundengruppen": _liste(anode_request("GET", "/api/v1/kundengruppen"), "kundengruppen"),
"organisationen": _liste(anode_request("GET", "/api/v1/organizations"), "organizations"),
"kopie": kopie,
"fehler": request.query_params.get("fehler"),
"hinweis": request.query_params.get("hinweis"),
},
request, "admin/workspace_detail.html",
{**basis, **detail_kontext(request, workspace_id, workspace, ist_admin=True, darf_bearbeiten=True, icon_basis="/admin/workspaces")},
)
@@ -395,7 +418,17 @@ async def workspace_merkmale_speichern(request: Request, workspace_id: str, user
der Distro fehlt - die erste Ablehnung wird angezeigt)."""
form = await request.form()
ausgewaehlt = set(form.getlist("merkmal_ids"))
fehler = merkmale_diff_speichern(workspace_id, set(form.getlist("merkmal_ids")))
if fehler:
return _ergebnis(workspace_id, fehler)
return _zurueck(workspace_id, hinweis="Entwurf gespeichert.")
def merkmale_diff_speichern(workspace_id: str, ausgewaehlt: set[str]) -> dict | None:
"""Gewählte Merkmale in den Entwurf übernehmen; liefert die erste
Ablehnung von anode oder None."""
result = anode_request("GET", f"/api/v1/workspaces/{workspace_id}")
workspace = result.get("workspace") if result.get("success") else None
aktuell = {m["id"] for m in workspace["merkmale"]} if workspace else set()
@@ -403,13 +436,12 @@ async def workspace_merkmale_speichern(request: Request, workspace_id: str, user
for merkmal_id in ausgewaehlt - aktuell:
r = anode_request("POST", f"/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
if not r.get("success"):
return _ergebnis(workspace_id, r)
return r
for merkmal_id in aktuell - ausgewaehlt:
r = anode_request("DELETE", f"/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
if not r.get("success"):
return _ergebnis(workspace_id, r)
return _zurueck(workspace_id, hinweis="Entwurf gespeichert.")
return r
return None
@router.post("/liste/{workspace_id}/webapps")
+40 -1
View File
@@ -1,10 +1,13 @@
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from urllib.parse import urlencode
from anode_client import anode_request
from auth import get_current_user
from optionen_felder import OPTIONEN_FELDER, parse_optionen_form
from routers.workspace import creator_frei, zuweisbare_workspaces
from templating import templates
import workspace_creator
router = APIRouter()
@@ -33,12 +36,48 @@ def find_oe_in_organization(oe_id: str, organization_id: str) -> dict | None:
@router.get("/struktur")
def struktur_ansicht(request: Request, user=Depends(get_current_user)):
oes = fetch_organisationseinheiten(user.organization_id)
# Workspace-Zuweisung je OE nur mit freigeschaltetem Creator (GUI-Runde
# 23.10.2026); sonst richtet tuxflotte sie ein.
creator = workspace_creator.ist_freigeschaltet(user.organization_id)
return templates.TemplateResponse(
request, "struktur.html", {"user": user, "organisationseinheiten": oes}
request, "struktur.html",
{
"user": user, "organisationseinheiten": oes,
"workspaces": zuweisbare_workspaces(str(user.organization_id)) if creator else None,
"fehler": request.query_params.get("fehler"), "hinweis": request.query_params.get("hinweis"),
},
)
@router.post("/struktur/{oe_id}/workspace")
def oe_workspace(
oe_id: str, workspace_id: str = Form(default=""), version_id: str = Form(default=""), user=Depends(creator_frei),
):
"""Workspace an der OE (Schritt F), wie im Admin-Reiter "Struktur &
Workspaces". Die Distro-Konfliktregel prüft anode; hier nur, dass OE,
Workspace und Version zur eigenen Organisation passen."""
organization_id = str(user.organization_id)
if find_oe_in_organization(oe_id, organization_id) is None:
raise HTTPException(status_code=404, detail="OE wurde nicht gefunden.")
if workspace_id:
workspace = next((w for w in zuweisbare_workspaces(organization_id) if w["id"] == workspace_id), None)
if workspace is None:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
if version_id and version_id not in {v["id"] for v in workspace["versionen"]}:
raise HTTPException(status_code=400, detail="Die Version gehört nicht zu diesem Workspace.")
else:
version_id = ""
result = anode_request(
"PUT", f"/api/v1/organisationseinheiten/{oe_id}/workspace",
json={"workspace_id": workspace_id or None, "version_id": version_id or None, "automatisch": not version_id},
)
meldung = {"hinweis": "Gespeichert."} if result.get("success") else {"fehler": result.get("message", "Nicht möglich.")}
return RedirectResponse("/struktur?" + urlencode(meldung), status_code=303)
@router.post("/struktur")
def oe_anlegen(
name: str = Form(...),
+286 -201
View File
@@ -1,42 +1,38 @@
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
from fastapi.responses import RedirectResponse, StreamingResponse
from anode_client import anode_request, anode_stream
from auth import get_current_user
from routers.admin_technik import gruppiere_merkmale_nach_kategorie
from routers.admin_workspaces import _liste, assistent_seite, detail_kontext, merkmale_diff_speichern
from templating import templates
import workspace_creator
router = APIRouter()
# Workspace-Creator kundenseitig gesperrt (07.10.2026, Nutzer-Entscheidung):
# Der Creator ist noch nicht verständlich genug bedienbar, Workspaces von
# Kunden-Organisationen richtet bis auf Weiteres nur der root-admin über
# Admin → Workspaces → Workspaces ein (Organisation im
# Anlege-Formular wählen). Gruppen tragen seit Schritt C (07.10.2026)
# keinen eigenen Workspace mehr; die Zuweisung an OEs kommt mit Schritt F
# (platform-docs/roadmap/workspace-creator-neu.md). Freischalten: True setzen.
WORKSPACE_CREATOR_FREIGEGEBEN = False
templates.env.globals["workspace_creator_freigegeben"] = WORKSPACE_CREATOR_FREIGEGEBEN
# Workspace-Creator im Kundenportal. Vom 07.10.2026 an für alle Kunden
# gesperrt (der alte Kunden-Creator kannte weder Distro noch Entwurf und
# Versionen); seit der GUI-Runde 23.10.2026 je Organisation freischaltbar
# (workspace_creator.py, Admin › Organisation › Organisationsdaten) und mit
# denselben Seiten wie im Admin-Bereich (templates/_workspace_detail.html,
# _workspace_assistent.html). Der Kunde bearbeitet nur Workspaces seiner
# Organisation; Vorlagen von tuxflotte sieht er lesend und kann sie kopieren
# oder direkt einer OE zuweisen (routers/struktur.py).
templates.env.globals["workspace_creator_frei"] = workspace_creator.ist_freigeschaltet
ICON_BASIS = "/workspace"
def creator_freigegeben():
"""Dependency für alle schreibenden Endpunkte - die Sperre greift
def creator_frei(user=Depends(get_current_user)):
"""Dependency für alle Creator-Endpunkte - die Sperre greift
serverseitig, nicht nur über den ausgegrauten Menüpunkt."""
if not WORKSPACE_CREATOR_FREIGEGEBEN:
if not workspace_creator.ist_freigeschaltet(user.organization_id):
raise HTTPException(status_code=403, detail="Workspaces werden derzeit von tuxflotte eingerichtet.")
def _gesperrt_seite(request: Request, user):
# Nur lesend: welche Workspaces für die Organisation bereitstehen
# (eigene + Standards), damit klar ist, was bei Gruppen und
# Installationsmedium zur Auswahl steht.
alle = fetch_verfuegbare_workspaces(str(user.organization_id))
return templates.TemplateResponse(
request, "workspace_gesperrt.html", {"user": user, "workspaces": alle}
)
return user
def fetch_verfuegbare_workspaces(organization_id: str) -> list[dict]:
@@ -44,218 +40,142 @@ def fetch_verfuegbare_workspaces(organization_id: str) -> list[dict]:
return result.get("workspaces", []) if result.get("success") else []
def fetch_sichtbaren_workspace(workspace_id: str, organization_id: str) -> dict | None:
"""
Besitz-/Sichtbarkeits-Validierung (analog find_oe_in_organization(),
ADR-0011) - ein Workspace ist sichtbar, wenn er entweder der eigenen
Organisation gehört (bearbeitbar) oder global ist (organization_id
NULL, nur lesend). Ein privater Workspace einer fremden Organisation
bleibt unsichtbar.
"""
def _sichtbarer_workspace(workspace_id: str, organization_id: str) -> tuple[dict, bool]:
"""(Workspace, darf_bearbeiten) oder 404. Sichtbar ist, was anode der
Organisation anbietet (eigene und für sie freigegebene Vorlagen) - nicht
jeder Workspace ohne Eigentümer; bearbeitbar nur die eigenen."""
if workspace_id not in {w["id"] for w in fetch_verfuegbare_workspaces(organization_id)}:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
result = anode_request("GET", f"/api/v1/workspaces/{workspace_id}")
workspace = result.get("workspace") if result.get("success") else None
if workspace is None:
return None
ws_org = workspace.get("organization_id")
if ws_org is not None and ws_org != organization_id:
return None
return workspace
def _eigenen_workspace_oder_404(workspace_id: str, organization_id: str) -> dict:
"""Wie fetch_sichtbaren_workspace(), aber verlangt zusätzlich
Bearbeitbarkeit (eigene Organisation, nicht global) - für alle
schreibenden Endpunkte (Merkmale/Webapps ändern)."""
workspace = fetch_sichtbaren_workspace(workspace_id, organization_id)
if workspace is None or workspace.get("organization_id") != organization_id:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
return workspace, workspace.get("organization_id") == organization_id
def _eigener_workspace(workspace_id: str, organization_id: str) -> dict:
workspace, darf_bearbeiten = _sichtbarer_workspace(workspace_id, organization_id)
if not darf_bearbeiten:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
return workspace
def zuweisbare_workspaces(organization_id: str) -> list[dict]:
"""Eigene Workspaces und freigegebene Vorlagen mit Distro und Versionen,
für Liste und OE-Zuweisung. Workspaces ohne Distro (Altbestand) lassen
sich keiner OE zuweisen und fehlen deshalb."""
verfuegbar = fetch_verfuegbare_workspaces(organization_id)
alle = {w["id"]: w for w in _liste(anode_request("GET", "/api/v1/workspaces"), "workspaces")}
ergebnis = []
for w in verfuegbar:
voll = alle.get(w["id"], {})
if not voll.get("backend"):
continue
versionen = _liste(anode_request("GET", f"/api/v1/workspaces/{w['id']}/versionen"), "versionen")
ergebnis.append({
**w,
"eigener": not w["ist_global"],
"backend": voll["backend"],
"version": voll.get("version"),
"hat_entwurf": voll.get("hat_entwurf"),
"versionen": versionen,
})
return ergebnis
def _gesperrt_seite(request: Request, user):
# Nur lesend: welche Workspaces für die Organisation bereitstehen
# (eigene + Vorlagen), damit klar ist, was bei Installationsmedium und
# Struktur zur Auswahl steht.
alle = fetch_verfuegbare_workspaces(str(user.organization_id))
return templates.TemplateResponse(
request, "workspace_gesperrt.html", {"user": user, "workspaces": alle}
)
def _zurueck(workspace_id: str, **meldung) -> RedirectResponse:
ziel = f"/workspace/{workspace_id}"
if meldung:
ziel += "?" + urlencode(meldung)
return RedirectResponse(ziel, status_code=303)
def _ergebnis(workspace_id: str, result: dict, erfolg: str | None = None) -> RedirectResponse:
if not result.get("success"):
return _zurueck(workspace_id, fehler=result.get("message", "Das hat nicht geklappt."))
return _zurueck(workspace_id, hinweis=erfolg) if erfolg else _zurueck(workspace_id)
@router.get("/workspace")
def workspace_liste(request: Request, user=Depends(get_current_user)):
if not WORKSPACE_CREATOR_FREIGEGEBEN:
if not workspace_creator.ist_freigeschaltet(user.organization_id):
return _gesperrt_seite(request, user)
alle = fetch_verfuegbare_workspaces(str(user.organization_id))
alle = zuweisbare_workspaces(str(user.organization_id))
return templates.TemplateResponse(
request,
"workspace_liste.html",
{
"user": user,
"eigene_workspaces": [w for w in alle if not w["ist_global"]],
"standard_workspaces": [w for w in alle if w["ist_global"]],
"eigene_workspaces": [w for w in alle if w["eigener"]],
"vorlagen": [w for w in alle if not w["eigener"]],
"hinweis": request.query_params.get("hinweis"),
},
)
@router.post("/workspace", dependencies=[Depends(creator_freigegeben)])
def workspace_anlegen(
key: str = Form(...), name: str = Form(...), description: str = Form(default=""),
user=Depends(get_current_user),
):
# organization_id kommt nie aus dem Formular, sondern immer aus der
# Session - gleiches Muster wie /organisation/konten (verhindert einen
# global sichtbaren Workspace per manipuliertem Request).
result = anode_request(
"POST", "/api/v1/workspaces",
json={"key": key, "name": name, "description": description or None, "organization_id": str(user.organization_id)},
)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "Workspace konnte nicht angelegt werden."))
return RedirectResponse(f"/workspace/{result['workspace']['id']}", status_code=303)
@router.post("/workspace/{workspace_id}/uebernehmen", dependencies=[Depends(creator_freigegeben)])
def workspace_uebernehmen(workspace_id: str, user=Depends(get_current_user)):
"""
"Diesen Standard übernehmen" (Nachtrag 11.09.2026, live gefundene
Lücke: ohne einen eigenen Workspace gab es keinen Weg mehr zu
Webapps, siehe clone_workspace() in provisioning-server). Kopiert die
Merkmal-Zusammensetzung eines sichtbaren (global oder eigenen)
Workspace in einen neuen, garantiert eigenen - der Ursprung bleibt
unangetastet.
"""
organization_id = str(user.organization_id)
if fetch_sichtbaren_workspace(workspace_id, organization_id) is None:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
result = anode_request(
"POST", f"/api/v1/workspaces/{workspace_id}/clone", json={"organization_id": organization_id}
)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "Workspace konnte nicht übernommen werden."))
return RedirectResponse(f"/workspace/{result['workspace']['id']}", status_code=303)
@router.get("/workspace/{workspace_id}")
def workspace_detail(request: Request, workspace_id: str, user=Depends(get_current_user)):
if not WORKSPACE_CREATOR_FREIGEGEBEN:
return _gesperrt_seite(request, user)
organization_id = str(user.organization_id)
workspace = fetch_sichtbaren_workspace(workspace_id, organization_id)
if workspace is None:
raise HTTPException(status_code=404, detail="Workspace wurde nicht gefunden.")
bearbeitbar = workspace.get("organization_id") == organization_id
merkmale_result = anode_request("GET", "/api/v1/merkmale")
alle_merkmale = merkmale_result.get("merkmale", []) if merkmale_result.get("success") else []
komponierte_ids = {m["id"] for m in workspace["merkmale"]}
kategorien = gruppiere_merkmale_nach_kategorie(alle_merkmale, komponierte_ids)
# Kiosk-Modus braucht Chrome oder Chromium als Merkmal im selben
# Workspace (Priorität siehe KIOSK_BROWSER_PRIORITAET in
# provisioning-server/app.py) - hier nur eine Ja/Nein-Vorschau fürs
# Formular, die eigentliche Auflösung passiert serverseitig beim
# Geräte-Check-in.
hat_kiosk_browser = any(m["key"] in ("google-chrome", "chromium") for m in workspace["merkmale"])
webapps = []
verfuegbare_vorlagen = []
webapp_kategorien = []
if bearbeitbar:
webapps_result = anode_request("GET", f"/api/v1/workspaces/{workspace_id}/webapps")
webapps = webapps_result.get("webapps", []) if webapps_result.get("success") else []
vorlagen_result = anode_request("GET", f"/api/v1/workspaces/{workspace_id}/webapp-templates/available")
verfuegbare_vorlagen = vorlagen_result.get("webapp_templates", []) if vorlagen_result.get("success") else []
# Für die "Ins Menü"-Auswahl beim Anlegen einer Webapp (Phase 4,
# 11.09.2026) - dieselben App-Kategorien wie bei den Merkmalen,
# keine eigene, separate Webapps-Kategorieliste.
webapp_kategorien_result = anode_request("GET", "/api/v1/kategorien")
webapp_kategorien = webapp_kategorien_result.get("kategorien", []) if webapp_kategorien_result.get("success") else []
return templates.TemplateResponse(
request,
"workspace_detail.html",
{
"user": user,
"workspace": workspace,
"workspace_id": workspace_id,
"kategorien": kategorien,
"bearbeitbar": bearbeitbar,
"webapps": webapps,
"verfuegbare_vorlagen": verfuegbare_vorlagen,
"webapp_kategorien": webapp_kategorien,
"hat_kiosk_browser": hat_kiosk_browser,
},
@router.get("/workspace/neu")
def assistent(request: Request, user=Depends(creator_frei)):
return assistent_seite(
request, user, {"merkmal_ids": [], "webapp_template_ids": []},
ist_admin=False, assistent_ziel="/workspace/neu", icon_basis=ICON_BASIS,
)
@router.post("/workspace/{workspace_id}/merkmale", dependencies=[Depends(creator_freigegeben)])
async def workspace_merkmale_speichern(request: Request, workspace_id: str, user=Depends(get_current_user)):
"""Diff-basiertes Sammel-Speichern, gleiches Muster wie
admin_technik.py:workspace_merkmale_speichern()."""
organization_id = str(user.organization_id)
workspace = _eigenen_workspace_oder_404(workspace_id, organization_id)
@router.post("/workspace/neu")
async def assistent_speichern(request: Request, user=Depends(creator_frei)):
form = await request.form()
ausgewaehlt = set(form.getlist("merkmal_ids"))
aktuell = {m["id"] for m in workspace["merkmale"]}
werte = {
"name": (form.get("name") or "").strip(),
"description": (form.get("description") or "").strip(),
"backend_id": form.get("backend_id") or "",
"merkmal_ids": form.getlist("merkmal_ids"),
"webapp_template_ids": form.getlist("webapp_template_ids"),
}
seite = dict(ist_admin=False, assistent_ziel="/workspace/neu", icon_basis=ICON_BASIS)
for merkmal_id in ausgewaehlt - aktuell:
anode_request("POST", f"/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
if not werte["name"]:
return assistent_seite(request, user, werte, "Bitte einen Namen angeben.", **seite)
if not werte["backend_id"]:
return assistent_seite(request, user, werte, "Bitte eine Distro wählen.", **seite)
for merkmal_id in aktuell - ausgewaehlt:
anode_request("DELETE", f"/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}")
return RedirectResponse(f"/workspace/{workspace_id}", status_code=303)
@router.post("/workspace/{workspace_id}/webapps", dependencies=[Depends(creator_freigegeben)])
def webapp_hinzufuegen(
workspace_id: str, name: str = Form(...), url: str = Form(...),
kategorie_id: str = Form(default=""), kiosk_modus: bool = Form(default=False),
auf_desktop: bool = Form(default=False), user=Depends(get_current_user),
):
_eigenen_workspace_oder_404(workspace_id, str(user.organization_id))
anode_request(
"POST", f"/api/v1/workspaces/{workspace_id}/webapps",
# Eigentümer kommt nie aus dem Formular, sondern aus der Sitzung.
result = anode_request(
"POST", "/api/v1/workspaces/assistent",
json={
"name": name, "url": url, "kategorie_id": kategorie_id or None,
"kiosk_modus": kiosk_modus, "auf_desktop": auf_desktop,
"name": werte["name"],
"description": werte["description"] or None,
"organization_id": str(user.organization_id),
"backend_id": werte["backend_id"],
"merkmal_ids": werte["merkmal_ids"],
"webapp_template_ids": werte["webapp_template_ids"],
},
)
return RedirectResponse(f"/workspace/{workspace_id}", status_code=303)
if not result.get("success"):
return assistent_seite(
request, user, werte, result.get("message", "Workspace konnte nicht angelegt werden."), **seite
)
return RedirectResponse(f"/workspace/{result['workspace']['id']}", status_code=303)
@router.post("/workspace/{workspace_id}/webapps/{webapp_id}/entfernen", dependencies=[Depends(creator_freigegeben)])
def webapp_entfernen(workspace_id: str, webapp_id: str, user=Depends(get_current_user)):
_eigenen_workspace_oder_404(workspace_id, str(user.organization_id))
anode_request("DELETE", f"/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}")
return RedirectResponse(f"/workspace/{workspace_id}", status_code=303)
@router.post("/workspace/{workspace_id}/vorlagen/{template_id}/aktivieren", dependencies=[Depends(creator_freigegeben)])
def webapp_vorlage_aktivieren(workspace_id: str, template_id: str, user=Depends(get_current_user)):
_eigenen_workspace_oder_404(workspace_id, str(user.organization_id))
anode_request("POST", f"/api/v1/workspaces/{workspace_id}/webapp-templates/{template_id}/activate")
return RedirectResponse(f"/workspace/{workspace_id}", status_code=303)
@router.get("/webapp-icons/{filename}")
@router.get("/workspace/webapp-icons/{filename}")
def webapp_icon_proxy(filename: str, user=Depends(get_current_user)):
# Gleiches Proxy-Muster wie im Admin-Bereich (admin_technik.py) und
# beim ISO-Download - kundenplattform kennt anodes öffentliche Adresse
@@ -267,3 +187,168 @@ def webapp_icon_proxy(filename: str, user=Depends(get_current_user)):
yield chunk
return StreamingResponse(stream_datei(), media_type="image/x-icon")
@router.get("/workspace/merkmal-icons/{filename}")
def merkmal_icon_proxy(filename: str, user=Depends(get_current_user)):
with anode_stream("GET", f"/merkmal-icons/{filename}") as response:
response.raise_for_status()
media_type = response.headers.get("content-type", "application/octet-stream")
daten = response.read()
return Response(content=daten, media_type=media_type)
@router.get("/workspace/{workspace_id}")
def workspace_detail(request: Request, workspace_id: str, user=Depends(get_current_user)):
if not workspace_creator.ist_freigeschaltet(user.organization_id):
return RedirectResponse("/workspace", status_code=303)
workspace, darf_bearbeiten = _sichtbarer_workspace(workspace_id, str(user.organization_id))
return templates.TemplateResponse(
request,
"workspace_detail.html",
{
"user": user, "ws_basis": "/workspace", "ws_liste_url": "/workspace",
**detail_kontext(
request, workspace_id, workspace,
ist_admin=False, darf_bearbeiten=darf_bearbeiten, icon_basis=ICON_BASIS,
),
},
)
@router.post("/workspace/{workspace_id}/entwurf")
def entwurf_anlegen(workspace_id: str, user=Depends(creator_frei)):
_eigener_workspace(workspace_id, str(user.organization_id))
return _ergebnis(workspace_id, anode_request("POST", f"/api/v1/workspaces/{workspace_id}/entwurf"))
@router.post("/workspace/{workspace_id}/entwurf/verwerfen")
def entwurf_verwerfen(workspace_id: str, user=Depends(creator_frei)):
_eigener_workspace(workspace_id, str(user.organization_id))
return _ergebnis(
workspace_id, anode_request("DELETE", f"/api/v1/workspaces/{workspace_id}/entwurf"), "Entwurf verworfen."
)
@router.post("/workspace/{workspace_id}/veroeffentlichen")
def veroeffentlichen(workspace_id: str, notiz: str = Form(default=""), user=Depends(creator_frei)):
_eigener_workspace(workspace_id, str(user.organization_id))
result = anode_request(
"POST", f"/api/v1/workspaces/{workspace_id}/veroeffentlichen", json={"notiz": notiz.strip() or None}
)
return _ergebnis(workspace_id, result, f"Version {result.get('nummer')} veröffentlicht.")
@router.post("/workspace/{workspace_id}/merkmale")
async def workspace_merkmale_speichern(request: Request, workspace_id: str, user=Depends(creator_frei)):
_eigener_workspace(workspace_id, str(user.organization_id))
form = await request.form()
fehler = merkmale_diff_speichern(workspace_id, set(form.getlist("merkmal_ids")))
if fehler:
return _ergebnis(workspace_id, fehler)
return _zurueck(workspace_id, hinweis="Entwurf gespeichert.")
@router.post("/workspace/{workspace_id}/webapps")
def webapp_hinzufuegen(
workspace_id: str, name: str = Form(...), url: str = Form(...), auf_desktop: bool = Form(default=False),
user=Depends(creator_frei),
):
_eigener_workspace(workspace_id, str(user.organization_id))
return _ergebnis(
workspace_id,
anode_request(
"POST", f"/api/v1/workspaces/{workspace_id}/webapps",
json={"name": name, "url": url, "auf_desktop": auf_desktop},
),
)
@router.post("/workspace/{workspace_id}/webapps/vorlage")
def webapp_aus_vorlage(workspace_id: str, template_id: str = Form(...), user=Depends(creator_frei)):
_eigener_workspace(workspace_id, str(user.organization_id))
return _ergebnis(
workspace_id,
anode_request("POST", f"/api/v1/workspaces/{workspace_id}/webapp-templates/{template_id}/activate"),
)
@router.post("/workspace/{workspace_id}/webapps/{webapp_id}/desktop")
def webapp_desktop(
workspace_id: str, webapp_id: str, auf_desktop: bool = Form(default=False), user=Depends(creator_frei),
):
_eigener_workspace(workspace_id, str(user.organization_id))
return _ergebnis(
workspace_id,
anode_request(
"PUT", f"/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}/desktop",
json={"auf_desktop": auf_desktop},
),
)
@router.post("/workspace/{workspace_id}/webapps/{webapp_id}/entfernen")
def webapp_entfernen(workspace_id: str, webapp_id: str, user=Depends(creator_frei)):
_eigener_workspace(workspace_id, str(user.organization_id))
return _ergebnis(workspace_id, anode_request("DELETE", f"/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}"))
@router.post("/workspace/{workspace_id}/standardbrowser")
def standardbrowser_setzen(workspace_id: str, browser: str = Form(default=""), user=Depends(creator_frei)):
_eigener_workspace(workspace_id, str(user.organization_id))
return _ergebnis(
workspace_id,
anode_request("PUT", f"/api/v1/workspaces/{workspace_id}/standardbrowser", json={"browser": browser or None}),
"Standardbrowser gespeichert.",
)
@router.post("/workspace/{workspace_id}/einstellungen")
def einstellungen(
workspace_id: str, name: str = Form(...), description: str = Form(default=""), user=Depends(creator_frei),
):
_eigener_workspace(workspace_id, str(user.organization_id))
return _ergebnis(
workspace_id,
anode_request("PUT", f"/api/v1/workspaces/{workspace_id}", json={"name": name, "description": description or None}),
"Gespeichert.",
)
@router.post("/workspace/{workspace_id}/kopie")
def kopie_anlegen(workspace_id: str, name: str = Form(...), backend_id: str = Form(...), user=Depends(creator_frei)):
# Quelle darf eine Vorlage sein, die Kopie gehört immer der eigenen
# Organisation.
organization_id = str(user.organization_id)
_sichtbarer_workspace(workspace_id, organization_id)
result = anode_request(
"POST", f"/api/v1/workspaces/{workspace_id}/kopie",
json={"name": name, "backend_id": backend_id, "organization_id": organization_id},
)
if not result.get("success"):
return _ergebnis(workspace_id, result)
neu = result["workspace"]
hinweis = "Kopie angelegt."
if neu.get("weggefallen"):
hinweis += " Auf der neuen Distro weggefallen: " + ", ".join(neu["weggefallen"])
return _zurueck(neu["id"], hinweis=hinweis)
@router.post("/workspace/{workspace_id}/loeschen")
def loeschen(workspace_id: str, bestaetigt: str = Form(default=""), user=Depends(creator_frei)):
_eigener_workspace(workspace_id, str(user.organization_id))
if bestaetigt != "ja":
return _zurueck(workspace_id, fehler="Bitte das Löschen bestätigen.")
result = anode_request("DELETE", f"/api/v1/workspaces/{workspace_id}")
if not result.get("success"):
return _ergebnis(workspace_id, result)
return RedirectResponse("/workspace?" + urlencode({"hinweis": "Workspace gelöscht."}), status_code=303)
# Alte Adresse der Webapp-Icons (bis 23.10.2026), noch in gespeicherten Seiten.
@router.get("/webapp-icons/{filename}")
def alte_webapp_icon_adresse(filename: str, user=Depends(get_current_user)):
return webapp_icon_proxy(filename, user)
+153
View File
@@ -0,0 +1,153 @@
{# Assistent "Workspace erstellen" (Workspace-Creator neu, Schritt D,
07.10.2026). Ohne JavaScript ist das ein langes Formular mit allen
Abschnitten; mit JavaScript ein Schritt-für-Schritt-Ablauf mit
mitlaufender Distro-Leiste. Der Server prüft beim Speichern ohnehin, dass
jedes gewählte Merkmal auf der gewählten Distro umgesetzt ist. Seit
23.10.2026 gemeinsam für Admin und Kundenportal; erwartet zusätzlich
assistent_ziel und ist_admin (nur der Admin wählt den Eigentümer). #}
<style>
.assistent details { margin-bottom: 0.4rem; }
.assistent details details { margin-left: 1rem; }
.assistent summary small { color: var(--pico-muted-color); }
.assistent-liste { list-style: none; padding: 0; margin: 0 0 0 1rem; }
.assistent-liste li { list-style: none; padding: 0.3rem 0; border-bottom: 1px solid var(--pico-muted-border-color); }
.assistent-liste label { display: flex; gap: 0.6rem; align-items: center; margin: 0; }
.assistent-liste input[type=checkbox] { flex: 0 0 auto; margin: 0; }
.assistent-liste .text { flex: 1 1 auto; }
.assistent-liste .text small { display: block; color: var(--pico-muted-color); }
.assistent-icon { flex: 0 0 22px; width: 22px; height: 22px; font-size: 18px; line-height: 22px; text-align: center; }
.distro-chip { font-size: 0.75rem; padding: 0.05rem 0.45rem; border-radius: 1rem; border: 1px solid var(--pico-primary-border); white-space: nowrap; }
.distro-chip.fehlt { border-style: dashed; border-color: var(--pico-muted-border-color); color: var(--pico-muted-color); text-decoration: line-through; }
.eintrag-konflikt { outline: 2px solid var(--pico-del-color); outline-offset: 2px; border-radius: 0.25rem; }
.schritte { display: flex; flex-wrap: wrap; gap: 0.4rem; list-style: none; padding: 0; margin: 0 0 1rem 0; }
.schritte li { list-style: none; }
.schritte button { padding: 0.2rem 0.7rem; font-size: 0.85rem; margin: 0; }
.distro-leiste { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
padding: 0.5rem 0.75rem; margin-bottom: 1rem; border-radius: var(--pico-border-radius);
background: var(--pico-card-background-color); border: 1px solid var(--pico-muted-border-color); }
.distro-leiste .status { font-size: 0.85rem; padding: 0.1rem 0.6rem; border-radius: 1rem; border: 1px solid var(--pico-ins-color); cursor: pointer; background: none; color: inherit; margin: 0; width: auto; }
.distro-leiste .status.konflikt { border-color: var(--pico-del-color); }
.distro-karte { border: 1px solid var(--pico-muted-border-color); border-radius: var(--pico-border-radius); padding: 0.75rem 1rem; margin-bottom: 0.75rem; }
.distro-karte.konflikt { border-color: var(--pico-del-color); }
.distro-karte ul { margin: 0.5rem 0 0 0; }
.distro-karte li { list-style: none; margin-bottom: 0.35rem; }
.distro-karte button { padding: 0.1rem 0.5rem; font-size: 0.8rem; width: auto; display: inline-block; margin: 0.15rem 0.25rem 0 0; }
.schritt-nav { display: flex; gap: 0.75rem; justify-content: space-between; margin-top: 1rem; }
.schritt-nav button { width: auto; }
form.js-aktiv section[data-schritt] { display: none; }
form.js-aktiv section[data-schritt].aktiv { display: block; }
form:not(.js-aktiv) .nur-js { display: none; }
</style>
<h2>Workspace erstellen</h2>
{% if fehler %}<p><mark>{{ fehler }}</mark></p>{% endif %}
<form method="post" action="{{ assistent_ziel }}" id="assistent" class="assistent"
data-start-schritt="{{ 5 if fehler and werte.name else 1 }}">
<ol class="schritte nur-js">
<li><button type="button" class="outline" data-gehe="1">1 Name</button></li>
<li><button type="button" class="outline" data-gehe="2">2 Apps</button></li>
<li><button type="button" class="outline" data-gehe="3">3 Webapps</button></li>
<li><button type="button" class="outline" data-gehe="4">4 Richtlinien</button></li>
<li><button type="button" class="outline" data-gehe="5">5 Distro</button></li>
<li><button type="button" class="outline" data-gehe="6">6 Prüfen</button></li>
</ol>
<div class="distro-leiste nur-js" id="distro-leiste" hidden>
<small>Möglich mit:</small>
</div>
<section data-schritt="1">
<h3>1 · Name und Zweck</h3>
<label>Name
<input type="text" name="name" value="{{ werte.name or '' }}" required placeholder="z. B. Computerraum Grundschule">
</label>
<label>Zweck <small>(wofür ist dieser Workspace gedacht?)</small>
<input type="text" name="description" value="{{ werte.description or '' }}" placeholder="z. B. Unterricht Klassen 1–4 mit Lernspielen">
</label>
{% if ist_admin %}
<label>Eigentümer
<select name="organization_id">
<option value="">tuxflotte (z. B. für Vorlagen)</option>
{% for org in organisationen %}
<option value="{{ org.id }}" {% if werte.organization_id == org.id %}selected{% endif %}>{{ org.name }}</option>
{% endfor %}
</select>
</label>
{% endif %}
</section>
{% for bereich in bereiche %}
{% set schritt = {"apps": 2, "webapps": 3, "richtlinien": 4}[bereich.key] %}
{% set feld = "webapp_template_ids" if bereich.key == "webapps" else "merkmal_ids" %}
{% set gewaehlt = werte[feld] %}
<section data-schritt="{{ schritt }}">
<h3>{{ schritt }} · {{ bereich.symbol }} {{ bereich.name }}</h3>
{% if bereich.key == "webapps" %}
<p><small>Webapps laufen im Browser und damit auf jeder Distro. Eigene, freie Webapps kannst du nach dem Speichern auf der Workspace-Seite ergänzen.</small></p>
{% endif %}
{% if not bereich.kategorien %}<p><small>Noch keine Einträge im Katalog.</small></p>{% endif %}
{% for kategorie in bereich.kategorien %}
<details {% if kategorie.eintraege | selectattr("id", "in", gewaehlt) | list %}open{% endif %}>
<summary>{{ kategorie.name }} <small data-zaehler>({{ kategorie.eintraege | length }})</small></summary>
<ul class="assistent-liste">
{% for e in kategorie.eintraege %}
<li>
<label data-eintrag="{{ e.id }}">
<input type="checkbox" name="{{ feld }}" value="{{ e.id }}" {% if e.id in gewaehlt %}checked{% endif %}>
{% if e.icon_url %}
<img class="assistent-icon" src="{{ e.icon_url }}" alt="" width="22" height="22" loading="lazy">
{% else %}
<span class="assistent-icon" aria-hidden="true">⚙️</span>
{% endif %}
<span class="text">{{ e.name }}{% if e.description %}<small>{{ e.description }}</small>{% endif %}</span>
{% if bereich.key != "webapps" %}
<span>
{% for d in e.distros %}
<span class="distro-chip{% if not d.verfuegbar %} fehlt{% endif %}" title="{{ d.name }}: {{ 'verfügbar' if d.verfuegbar else 'nicht verfügbar' }}">{{ d.name }}</span>
{% endfor %}
</span>
{% endif %}
</label>
</li>
{% endfor %}
</ul>
</details>
{% endfor %}
</section>
{% endfor %}
<section data-schritt="5">
<h3>5 · Distro</h3>
<p><small>Ein Workspace läuft auf genau einer Distro. Wählbar ist nur, was alle gewählten Apps und Richtlinien umsetzen kann.</small></p>
<div id="distro-karten">
{% for b in backends %}
<label class="distro-karte" data-distro="{{ b.key }}">
<input type="radio" name="backend_id" value="{{ b.id }}" {% if werte.backend_id == b.id %}checked{% endif %}>
<strong>{{ b.name }} {{ b.version }}</strong>
<span class="nur-js" data-distro-status></span>
<ul class="nur-js" data-distro-probleme></ul>
</label>
{% endfor %}
</div>
<p class="nur-js" id="keine-distro" hidden><mark>Mit dieser Auswahl ist keine Distro möglich. Passe die rot markierten Einträge an – Vorschläge stehen bei der jeweiligen Distro.</mark></p>
</section>
<section data-schritt="6">
<h3>6 · Prüfen und speichern</h3>
<div id="zusammenfassung" class="nur-js"></div>
<button type="submit" id="speichern">Workspace speichern</button>
</section>
<div class="schritt-nav nur-js">
<button type="button" class="secondary outline" id="zurueck">← Zurück</button>
<button type="button" id="weiter">Weiter →</button>
</div>
</form>
<script id="assistent-daten" type="application/json">{{ assistent_daten | safe }}</script>
<script src="/static/workspace-assistent.js?v={{ static_version }}" defer></script>
+330
View File
@@ -0,0 +1,330 @@
<p><a href="{{ ws_liste_url }}">← Workspaces</a></p>
{% if not workspace %}
<p><mark>Workspace wurde nicht gefunden.</mark></p>
{% else %}
{# Workspace-Detail und -Verwaltung (Workspace-Creator neu, Schritt E,
07.10.2026). Inhalte ändern sich nur im Entwurf; die veröffentlichte
Version ist eingefroren. Seit 23.10.2026 gemeinsam für Admin
(admin/workspace_detail.html) und Kundenportal (workspace_detail.html).
Erwartet zusätzlich: ws_basis, ws_liste_url, icon_basis, ist_admin,
darf_bearbeiten (eigener Workspace bzw. Admin). #}
<style>
.ws-kopf small { color: var(--pico-muted-color); }
.ws-marke { font-size: 0.75rem; padding: 0.05rem 0.5rem; border-radius: 1rem; border: 1px solid var(--pico-primary-border); white-space: nowrap; }
.ws-marke.entwurf { border-color: var(--pico-del-color); }
.ws-box { border: 1px solid var(--pico-muted-border-color); border-radius: var(--pico-border-radius); padding: 0.75rem 1rem; margin-bottom: 1rem; }
.ws-box.entwurf { border-color: var(--pico-del-color); }
.ws-liste { list-style: none; padding: 0; margin: 0 0 0 1rem; }
.ws-liste li { list-style: none; padding: 0.25rem 0; border-bottom: 1px solid var(--pico-muted-border-color); }
.ws-liste label, .ws-liste .zeile { display: flex; gap: 0.6rem; align-items: center; margin: 0; }
.ws-liste .zeile .text { flex: 1 1 auto; }
.ws-liste input[type=checkbox] { flex: 0 0 auto; margin: 0; }
.ws-liste .text small { display: block; color: var(--pico-muted-color); }
.ws-icon { flex: 0 0 20px; width: 20px; height: 20px; font-size: 16px; line-height: 20px; text-align: center; }
.ws-diff ins, .ws-diff del { text-decoration: none; padding: 0 0.3rem; border-radius: 0.2rem; }
details.ws-einstellung { margin-bottom: 0.6rem; }
/* Aufklapp-Bereiche der Einstellungen als Knöpfe erkennbar (Nutzer-Feedback 07.10.2026) */
details.ws-einstellung > summary { display: inline-block; padding: 0.45rem 0.9rem; border: 1px solid var(--pico-primary-border);
border-radius: var(--pico-border-radius); color: var(--pico-primary); font-weight: 600; cursor: pointer; }
details.ws-einstellung > summary:hover { background: var(--pico-primary-focus); }
details.ws-einstellung[open] > summary { background: var(--pico-primary-background); color: #fff !important; }
details.ws-einstellung[open] > summary::after { filter: brightness(0) invert(1); }
details.ws-einstellung[open] { border: 1px solid var(--pico-muted-border-color); border-radius: var(--pico-border-radius); padding: 0 1rem 0.75rem 0; }
details.ws-einstellung[open] > *:not(summary) { margin-left: 1rem; }
form.inline { display: inline; }
form.inline button { width: auto; }
</style>
{% if fehler %}<p><mark>{{ fehler }}</mark></p>{% endif %}
{% if hinweis %}<p><ins>{{ hinweis }}</ins></p>{% endif %}
<div class="ws-kopf">
<h2>{{ workspace.name }}
{% if not darf_bearbeiten %}<span class="ws-marke">Vorlage von tuxflotte, nur lesbar</span>
{% elif workspace.ist_vorlage and ist_admin %}<span class="ws-marke">Vorlage für
{%- if workspace.sichtbarkeit == "global" %} alle
{%- elif workspace.sichtbarkeit == "kundengruppe" %} {{ kundengruppen | selectattr("id", "in", workspace.kundengruppe_ids) | map(attribute="name") | map("replace", "Schule", "Schulen") | join(", ") }}
{%- else %} {{ organisationen | selectattr("id", "in", workspace.organization_ids) | map(attribute="name") | join(", ") }}{% endif %}</span>{% endif %}
{% if ist_entwurf %}<span class="ws-marke entwurf">Entwurf v{{ workspace.version.nummer }} offen</span>{% endif %}
</h2>
<p>
{% if workspace.description %}{{ workspace.description }}<br>{% endif %}
<small>
Distro: <strong>{% if workspace.backend %}{{ workspace.backend.name }} {{ workspace.backend.version }}{% else %}noch offen{% endif %}</strong>
· Eigentümer: {{ workspace.organization_name or "tuxflotte" }}
{% set veroeffentlicht = versionen | selectattr("status", "equalto", "veroeffentlicht") | list %}
· Aktuelle Version: {% if veroeffentlicht %}v{{ veroeffentlicht[0].nummer }}{% else %}keine{% endif %}
</small>
</p>
</div>
{% if darf_bearbeiten %}
<div class="ws-box">
<strong>Nutzung</strong>
{% if not nutzung.in_benutzung %}
<p><small>Wird nirgends verwendet – Änderungen betreffen niemanden.</small></p>
{% else %}
<ul>
{% if nutzung.profile %}<li>{{ nutzung.profile | length }} Installationsprofil(e): {% for p in nutzung.profile %}{{ p.label }} <small>({{ p.organisation }})</small>{% if not loop.last %}, {% endif %}{% endfor %}</li>{% endif %}
{% if nutzung.oes %}<li>{{ nutzung.oes | length }} OE(s): {% for o in nutzung.oes %}{{ o.name }} <small>({{ o.organisation }}, {{ "automatisch" if o.automatisch else "auf v" ~ o.version ~ " gepinnt" }})</small>{% if not loop.last %}, {% endif %}{% endfor %}</li>{% endif %}
<li>{{ nutzung.geraete_automatisch }} Gerät(e) übernehmen neue Versionen automatisch
{%- for g in nutzung.geraete_gepinnt %}, {{ g.anzahl }} auf v{{ g.version }} gepinnt{% endfor %}</li>
</ul>
{% endif %}
</div>
{% else %}
<p><small>Diese Vorlage pflegt tuxflotte. Du kannst sie unten unter „Kopieren“ als eigenen Workspace übernehmen und dann anpassen,
oder sie unter <a href="/struktur">Organisation › Struktur</a> direkt einer OE zuweisen; dann bekommen deine Geräte die Änderungen von tuxflotte automatisch.</small></p>
{% endif %}
{% if not ist_entwurf %}
{# ---------- Veröffentlichte Version: nur lesend ---------- #}
<h3>Inhalt (v{{ workspace.version.nummer if workspace.version else "–" }}, veröffentlicht)</h3>
{% if darf_bearbeiten %}
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/entwurf" class="inline">
<button type="submit">Bearbeiten – Entwurf anlegen</button>
<small>Änderungen wirken erst, wenn du den Entwurf veröffentlichst.</small>
</form>
{% endif %}
{% for bereich in bereiche %}
{% set eintraege = [] %}
{% for k in bereich.kategorien %}{% for e in k.eintraege %}{% if e.id in ausgewaehlt %}{% set _ = eintraege.append(e.name) %}{% endif %}{% endfor %}{% endfor %}
<p><strong>{{ bereich.symbol }} {{ bereich.name }} ({{ eintraege | length }}):</strong>
{% if eintraege %}{{ eintraege | sort | join(", ") }}{% else %}<small>keine</small>{% endif %}</p>
{% endfor %}
<p><strong>🌐 Webapps ({{ webapps | length }}):</strong>
{% if webapps %}{{ webapps | map(attribute="name") | sort | join(", ") }}{% else %}<small>keine</small>{% endif %}</p>
{% set sb_name = (workspace.standardbrowser_auswahl | selectattr("key", "equalto", workspace.standardbrowser) | map(attribute="name") | first) if workspace.standardbrowser else None %}
<p><strong>🧭 Standardbrowser:</strong> {% if sb_name %}{{ sb_name }}{% else %}<small>keine Vorgabe</small>{% endif %}</p>
{% else %}
{# ---------- Entwurf: bearbeiten, Wirkung, veröffentlichen ---------- #}
<div class="ws-box entwurf">
<strong>Entwurf v{{ workspace.version.nummer }}</strong> – Änderungen wirken erst nach dem Veröffentlichen.
</div>
<h3>Inhalt bearbeiten</h3>
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/merkmale">
{% for bereich in bereiche %}
<h4>{{ bereich.symbol }} {{ bereich.name }}</h4>
{% for kategorie in bereich.kategorien %}
{% set an = kategorie.eintraege | selectattr("id", "in", ausgewaehlt) | list | length %}
<details {% if an %}open{% endif %}>
<summary>{{ kategorie.name }} <small>({{ kategorie.eintraege | length }}{% if an %}, {{ an }} gewählt{% endif %})</small></summary>
<ul class="ws-liste">
{% for e in kategorie.eintraege %}
{% set geht = not distro_key or distro_key in e.distro_keys %}
<li>
<label {% if not geht %}title="Auf der Distro dieses Workspace nicht umgesetzt"{% endif %}>
<input type="checkbox" name="merkmal_ids" value="{{ e.id }}"
{% if e.id in ausgewaehlt %}checked{% endif %} {% if not geht and e.id not in ausgewaehlt %}disabled{% endif %}>
{% if e.icon_url %}<img class="ws-icon" src="{{ e.icon_url }}" alt="" width="20" height="20" loading="lazy">{% else %}<span class="ws-icon" aria-hidden="true">⚙️</span>{% endif %}
<span class="text">{{ e.name }}{% if not geht %} <small>– nicht auf dieser Distro</small>{% elif e.description %}<small>{{ e.description }}</small>{% endif %}</span>
</label>
</li>
{% endfor %}
</ul>
</details>
{% endfor %}
{% endfor %}
<button type="submit">Entwurf speichern</button>
</form>
<h4>🌐 Webapps</h4>
{% if webapps %}
<ul class="ws-liste">
{% for w in webapps %}
<li>
<div class="zeile">
{% if w.icon_path %}<img class="ws-icon" src="{{ icon_basis }}/webapp-icons/{{ w.icon_path.split('/')[-1] }}" alt="" width="20" height="20">{% else %}<span class="ws-icon" aria-hidden="true">⚙️</span>{% endif %}
<span class="text">{{ w.name }}<small><a href="{{ w.url }}" target="_blank" rel="noopener">{{ w.url }}</a>{% if w.auf_desktop %} · auch auf dem Desktop{% endif %}</small></span>
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/webapps/{{ w.id }}/desktop" class="inline">
{% if not w.auf_desktop %}<input type="hidden" name="auf_desktop" value="true">{% endif %}
<button type="submit" class="secondary outline">{% if w.auf_desktop %}Vom Desktop nehmen{% else %}Auf den Desktop{% endif %}</button>
</form>
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/webapps/{{ w.id }}/entfernen" class="inline">
<button type="submit" class="secondary outline">Entfernen</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}<p><small>Noch keine Webapps.</small></p>{% endif %}
<div class="grid">
{% if vorlagen_verfuegbar %}
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/webapps/vorlage">
<label>Aus Webapp-Vorlage
<select name="template_id" required>
{% for t in vorlagen_verfuegbar %}<option value="{{ t.id }}">{{ t.name }}</option>{% endfor %}
</select>
</label>
<button type="submit" class="outline">Hinzufügen</button>
</form>
{% endif %}
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/webapps">
<label>Eigene Webapp<input type="text" name="name" required placeholder="Name, z. B. Schulportal"></label>
<input type="url" name="url" required placeholder="https://…">
<label><input type="checkbox" name="auf_desktop" value="true"> Auch auf dem Desktop</label>
<button type="submit" class="outline">Hinzufügen</button>
</form>
</div>
<h4>🧭 Standardbrowser</h4>
<div class="ws-box">
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/standardbrowser">
<label>Webapps ohne Kiosk-Modus und Links aus anderen Programmen öffnen sich in
<select name="browser">
<option value="" {% if not workspace.standardbrowser %}selected{% endif %}>keine Vorgabe (Standard der Distro)</option>
{% for b in workspace.standardbrowser_auswahl %}<option value="{{ b.key }}" {% if b.key == workspace.standardbrowser %}selected{% endif %}>{{ b.name }}</option>{% endfor %}
</select>
</label>
<small>Zur Wahl stehen Firefox und die Browser, die im gespeicherten Entwurf enthalten sind. Webapps mit Kiosk-Modus starten weiterhin als eigenes Chrome- bzw. Chromium-Fenster.</small>
<button type="submit" class="outline">Speichern</button>
</form>
</div>
<h3>Wirkung beim Veröffentlichen</h3>
<div class="ws-box ws-diff">
{% if wirkung %}
{% if wirkung.unveraendert %}
<p><small>Noch keine Änderungen gegenüber der veröffentlichten Version.</small></p>
{% else %}
<ul>
{% for titel, neu, weg in [("Apps", wirkung.apps_neu, wirkung.apps_weg), ("Webapps", wirkung.webapps_neu, wirkung.webapps_weg), ("Richtlinien", wirkung.richtlinien_neu, wirkung.richtlinien_weg)] %}
{% if neu or weg %}<li>{{ titel }}:
{% for n in neu %}<ins>+ {{ n }}</ins> {% endfor %}
{% for n in weg %}<del>− {{ n }}</del> {% endfor %}</li>{% endif %}
{% endfor %}
{% if wirkung.standardbrowser_geaendert %}<li>Standardbrowser:
{% if wirkung.standardbrowser_neu %}<ins>+ {{ wirkung.standardbrowser_neu }}</ins>{% else %}<ins>keine Vorgabe</ins>{% endif %}
{% if wirkung.standardbrowser_alt %}<del>− {{ wirkung.standardbrowser_alt }}</del>{% endif %}</li>{% endif %}
</ul>
{% endif %}
<p>
{% if wirkung.nutzung.geraete_automatisch %}<strong>{{ wirkung.nutzung.geraete_automatisch }} Gerät(e)</strong> übernehmen die neue Version beim nächsten Check-in{% if wirkung.nutzung.organisationen %} ({{ wirkung.nutzung.organisationen | join(", ") }}){% endif %}.{% else %}Kein Gerät übernimmt die neue Version sofort.{% endif %}
{% for g in wirkung.nutzung.geraete_gepinnt %}{{ g.anzahl }} Gerät(e) bleiben auf v{{ g.version }}. {% endfor %}
{% if wirkung.nutzung.profile %}Künftige Installationen über {{ wirkung.nutzung.profile | length }} Profil(e) bekommen die neue Version.{% endif %}
</p>
{% endif %}
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/veroeffentlichen">
<label>Notiz zur Version <small>(was hat sich geändert?)</small>
<input type="text" name="notiz" placeholder="z. B. Audacity ergänzt">
</label>
<button type="submit">Als v{{ workspace.version.nummer }} veröffentlichen</button>
</form>
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/entwurf/verwerfen" class="inline">
<button type="submit" class="secondary outline">Entwurf verwerfen</button>
</form>
</div>
{% endif %}
<h3>Versionen</h3>
<figure>
<table>
<thead><tr><th>Version</th><th>Status</th><th>Veröffentlicht</th><th>Inhalt</th><th>Notiz</th></tr></thead>
<tbody>
{% for v in versionen %}
<tr>
<td>v{{ v.nummer }}</td>
<td>{{ "Entwurf" if v.status == "entwurf" else "veröffentlicht" }}</td>
<td data-sort-wert="{{ v.veroeffentlicht_am or '' }}">{{ v.veroeffentlicht_am | datum }}</td>
<td><small>{{ v.anzahl_merkmale }} Merkmale, {{ v.anzahl_webapps }} Webapps</small></td>
<td><small>{{ v.notiz or "" }}</small></td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
<h3>Einstellungen</h3>
{% if darf_bearbeiten %}
<details class="ws-einstellung">
<summary>Name und Zweck</summary>
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/einstellungen">
<label>Name<input type="text" name="name" value="{{ workspace.name }}" required></label>
<label>Zweck<input type="text" name="description" value="{{ workspace.description or '' }}"></label>
<button type="submit">Speichern</button>
</form>
</details>
{% endif %}
{% if ist_admin %}
<details class="ws-einstellung" {% if fehler and "Kundengruppe" in fehler or fehler and "Organisation" in fehler %}open{% endif %}>
<summary>Vorlage und Sichtbarkeit</summary>
<p><small>Als Vorlage markierte Workspaces können Kunden-Admins auswählen (per Verweis – deine späteren Versionen erreichen sie je nach ihrer Einstellung automatisch).</small></p>
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/vorlage">
<label><input type="checkbox" name="ist_vorlage" value="ja" {% if workspace.ist_vorlage %}checked{% endif %}> Als Vorlage anbieten</label>
<fieldset>
<legend>Sichtbar für</legend>
<label><input type="radio" name="sichtbarkeit" value="global" {% if workspace.sichtbarkeit == "global" %}checked{% endif %}> alle Organisationen</label>
<label><input type="radio" name="sichtbarkeit" value="kundengruppe" {% if workspace.sichtbarkeit == "kundengruppe" %}checked{% endif %}> bestimmte Kundengruppen (z. B. alle Schulen):</label>
<div style="margin-left:1.5rem">
{% for kg in kundengruppen %}
<label><input type="checkbox" name="kundengruppe_ids" value="{{ kg.id }}" {% if kg.id in workspace.kundengruppe_ids %}checked{% endif %}> {{ kg.name }}</label>
{% endfor %}
</div>
<label><input type="radio" name="sichtbarkeit" value="organisation" {% if workspace.sichtbarkeit == "organisation" %}checked{% endif %}> bestimmte Organisationen:</label>
<div style="margin-left:1.5rem">
{% for org in organisationen %}
<label><input type="checkbox" name="organization_ids" value="{{ org.id }}" {% if org.id in workspace.organization_ids %}checked{% endif %}> {{ org.name }}</label>
{% endfor %}
</div>
</fieldset>
<button type="submit">Speichern</button>
</form>
</details>
{% endif %}
<details class="ws-einstellung" {% if kopie %}open{% endif %}>
<summary>Kopieren (auch für eine andere Distro)</summary>
<p><small>Kopiert die aktuelle veröffentlichte Version als neuen Workspace (Version 1).</small></p>
{% if kopie %}
<div class="ws-box">
{% if kopie.weggefallen %}
<p>Auf der gewählten Distro <strong>fallen weg</strong>: {{ kopie.weggefallen | join(", ") }}</p>
{% else %}
<p>Alles lässt sich übernehmen.</p>
{% endif %}
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/kopie">
<input type="hidden" name="name" value="{{ kopie.name }}">
<input type="hidden" name="backend_id" value="{{ kopie.backend_id }}">
<input type="hidden" name="organization_id" value="{{ kopie.organization_id }}">
<button type="submit">Kopie „{{ kopie.name }}“ anlegen</button>
</form>
</div>
{% endif %}
<form method="get" action="{{ ws_basis }}/{{ workspace_id }}">
<label>Name der Kopie<input type="text" name="kopie_name" required value="{{ kopie.name if kopie else workspace.name ~ ' (Kopie)' }}"></label>
<label>Distro
<select name="kopie_backend">
{% for b in backends %}<option value="{{ b.id }}" {% if (kopie and kopie.backend_id == b.id) or (not kopie and workspace.backend and workspace.backend.id == b.id) %}selected{% endif %}>{{ b.name }} {{ b.version }}</option>{% endfor %}
</select>
</label>
{% if ist_admin %}
<label>Eigentümer
<select name="kopie_org">
<option value="">tuxflotte</option>
{% for org in organisationen %}<option value="{{ org.id }}" {% if kopie and kopie.organization_id == org.id %}selected{% endif %}>{{ org.name }}</option>{% endfor %}
</select>
</label>
{% endif %}
<button type="submit" class="outline">Prüfen</button>
</form>
</details>
{% if darf_bearbeiten %}
<details class="ws-einstellung">
<summary>Löschen</summary>
{% if nutzung.in_benutzung %}
<p><small>Nicht möglich, solange der Workspace verwendet wird (siehe Nutzung oben).</small></p>
{% else %}
<form method="post" action="{{ ws_basis }}/{{ workspace_id }}/loeschen">
<label><input type="checkbox" name="bestaetigt" value="ja" required> Ja, „{{ workspace.name }}“ mit allen Versionen endgültig löschen</label>
<button type="submit" class="secondary">Löschen</button>
</form>
{% endif %}
</details>
{% endif %}
{% endif %}
+12 -1
View File
@@ -38,7 +38,7 @@
</details>
<details class="abschnitt" id="einstufung">
<summary>Kundengruppe und Ausrollstufe <small>{{ kg.name if kg else "keine Kundengruppe" }} · {{ "Vorab" if organization and organization.ausrollstufe == "vorab" else "Stabil" }}</small></summary>
<summary>Kundengruppe, Ausrollstufe, Workspace-Creator <small>{{ kg.name if kg else "keine Kundengruppe" }} · {{ "Vorab" if organization and organization.ausrollstufe == "vorab" else "Stabil" }} · Creator {{ "frei" if workspace_creator_frei else "gesperrt" }}</small></summary>
<h4>Kundengruppe</h4>
<p><small>
Legt fest, welche Vorlagen-Workspaces die Organisation sieht und
@@ -69,6 +69,17 @@
</select>
<button type="submit" class="outline">Ausrollstufe speichern</button>
</form>
<h4>Workspace-Creator</h4>
<p><small>
Freigeschaltet können die Kunden-Admins dieser Organisation unter „Workspace“ eigene Workspaces erstellen,
bearbeiten, veröffentlichen und unter „Organisation › Struktur“ ihren OEs zuweisen. Gesperrt sehen sie nur,
welche Workspaces für sie bereitstehen, und tuxflotte richtet alles ein.
</small></p>
<form method="post" action="/admin/kunden/{{ organization_id }}/workspace-creator">
<label><input type="checkbox" name="frei" value="ja" {% if workspace_creator_frei %}checked{% endif %}> Workspace-Creator für diese Organisation freischalten</label>
<button type="submit" class="outline">Speichern</button>
</form>
</details>
<details class="abschnitt" id="hostname">
+1 -149
View File
@@ -2,153 +2,5 @@
{% block title %}Workspace erstellen — Tuxflotte Admin{% endblock %}
{% block admin_content %}
{% include "admin/_workspaces_nav.html" %}
{# Assistent "Workspace erstellen" (Workspace-Creator neu, Schritt D,
07.10.2026). Ohne JavaScript ist das ein langes Formular mit allen
Abschnitten; mit JavaScript ein Schritt-für-Schritt-Ablauf mit
mitlaufender Distro-Leiste. Der Server prüft beim Speichern ohnehin, dass
jedes gewählte Merkmal auf der gewählten Distro umgesetzt ist. #}
<style>
.assistent details { margin-bottom: 0.4rem; }
.assistent details details { margin-left: 1rem; }
.assistent summary small { color: var(--pico-muted-color); }
.assistent-liste { list-style: none; padding: 0; margin: 0 0 0 1rem; }
.assistent-liste li { list-style: none; padding: 0.3rem 0; border-bottom: 1px solid var(--pico-muted-border-color); }
.assistent-liste label { display: flex; gap: 0.6rem; align-items: center; margin: 0; }
.assistent-liste input[type=checkbox] { flex: 0 0 auto; margin: 0; }
.assistent-liste .text { flex: 1 1 auto; }
.assistent-liste .text small { display: block; color: var(--pico-muted-color); }
.assistent-icon { flex: 0 0 22px; width: 22px; height: 22px; font-size: 18px; line-height: 22px; text-align: center; }
.distro-chip { font-size: 0.75rem; padding: 0.05rem 0.45rem; border-radius: 1rem; border: 1px solid var(--pico-primary-border); white-space: nowrap; }
.distro-chip.fehlt { border-style: dashed; border-color: var(--pico-muted-border-color); color: var(--pico-muted-color); text-decoration: line-through; }
.eintrag-konflikt { outline: 2px solid var(--pico-del-color); outline-offset: 2px; border-radius: 0.25rem; }
.schritte { display: flex; flex-wrap: wrap; gap: 0.4rem; list-style: none; padding: 0; margin: 0 0 1rem 0; }
.schritte li { list-style: none; }
.schritte button { padding: 0.2rem 0.7rem; font-size: 0.85rem; margin: 0; }
.distro-leiste { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
padding: 0.5rem 0.75rem; margin-bottom: 1rem; border-radius: var(--pico-border-radius);
background: var(--pico-card-background-color); border: 1px solid var(--pico-muted-border-color); }
.distro-leiste .status { font-size: 0.85rem; padding: 0.1rem 0.6rem; border-radius: 1rem; border: 1px solid var(--pico-ins-color); cursor: pointer; background: none; color: inherit; margin: 0; width: auto; }
.distro-leiste .status.konflikt { border-color: var(--pico-del-color); }
.distro-karte { border: 1px solid var(--pico-muted-border-color); border-radius: var(--pico-border-radius); padding: 0.75rem 1rem; margin-bottom: 0.75rem; }
.distro-karte.konflikt { border-color: var(--pico-del-color); }
.distro-karte ul { margin: 0.5rem 0 0 0; }
.distro-karte li { list-style: none; margin-bottom: 0.35rem; }
.distro-karte button { padding: 0.1rem 0.5rem; font-size: 0.8rem; width: auto; display: inline-block; margin: 0.15rem 0.25rem 0 0; }
.schritt-nav { display: flex; gap: 0.75rem; justify-content: space-between; margin-top: 1rem; }
.schritt-nav button { width: auto; }
form.js-aktiv section[data-schritt] { display: none; }
form.js-aktiv section[data-schritt].aktiv { display: block; }
form:not(.js-aktiv) .nur-js { display: none; }
</style>
<h2>Workspace erstellen</h2>
{% if fehler %}<p><mark>{{ fehler }}</mark></p>{% endif %}
<form method="post" action="/admin/workspaces/neu" id="assistent" class="assistent"
data-start-schritt="{{ 5 if fehler and werte.name else 1 }}">
<ol class="schritte nur-js">
<li><button type="button" class="outline" data-gehe="1">1 Name</button></li>
<li><button type="button" class="outline" data-gehe="2">2 Apps</button></li>
<li><button type="button" class="outline" data-gehe="3">3 Webapps</button></li>
<li><button type="button" class="outline" data-gehe="4">4 Richtlinien</button></li>
<li><button type="button" class="outline" data-gehe="5">5 Distro</button></li>
<li><button type="button" class="outline" data-gehe="6">6 Prüfen</button></li>
</ol>
<div class="distro-leiste nur-js" id="distro-leiste" hidden>
<small>Möglich mit:</small>
</div>
<section data-schritt="1">
<h3>1 · Name und Zweck</h3>
<label>Name
<input type="text" name="name" value="{{ werte.name or '' }}" required placeholder="z. B. Computerraum Grundschule">
</label>
<label>Zweck <small>(wofür ist dieser Workspace gedacht?)</small>
<input type="text" name="description" value="{{ werte.description or '' }}" placeholder="z. B. Unterricht Klassen 1–4 mit Lernspielen">
</label>
<label>Eigentümer
<select name="organization_id">
<option value="">tuxflotte (z. B. für Vorlagen)</option>
{% for org in organisationen %}
<option value="{{ org.id }}" {% if werte.organization_id == org.id %}selected{% endif %}>{{ org.name }}</option>
{% endfor %}
</select>
</label>
</section>
{% for bereich in bereiche %}
{% set schritt = {"apps": 2, "webapps": 3, "richtlinien": 4}[bereich.key] %}
{% set feld = "webapp_template_ids" if bereich.key == "webapps" else "merkmal_ids" %}
{% set gewaehlt = werte[feld] %}
<section data-schritt="{{ schritt }}">
<h3>{{ schritt }} · {{ bereich.symbol }} {{ bereich.name }}</h3>
{% if bereich.key == "webapps" %}
<p><small>Webapps laufen im Browser und damit auf jeder Distro. Eigene, freie Webapps kannst du nach dem Speichern auf der Workspace-Seite ergänzen.</small></p>
{% endif %}
{% if not bereich.kategorien %}<p><small>Noch keine Einträge im Katalog.</small></p>{% endif %}
{% for kategorie in bereich.kategorien %}
<details {% if kategorie.eintraege | selectattr("id", "in", gewaehlt) | list %}open{% endif %}>
<summary>{{ kategorie.name }} <small data-zaehler>({{ kategorie.eintraege | length }})</small></summary>
<ul class="assistent-liste">
{% for e in kategorie.eintraege %}
<li>
<label data-eintrag="{{ e.id }}">
<input type="checkbox" name="{{ feld }}" value="{{ e.id }}" {% if e.id in gewaehlt %}checked{% endif %}>
{% if e.icon_url %}
<img class="assistent-icon" src="{{ e.icon_url }}" alt="" width="22" height="22" loading="lazy">
{% else %}
<span class="assistent-icon" aria-hidden="true">⚙️</span>
{% endif %}
<span class="text">{{ e.name }}{% if e.description %}<small>{{ e.description }}</small>{% endif %}</span>
{% if bereich.key != "webapps" %}
<span>
{% for d in e.distros %}
<span class="distro-chip{% if not d.verfuegbar %} fehlt{% endif %}" title="{{ d.name }}: {{ 'verfügbar' if d.verfuegbar else 'nicht verfügbar' }}">{{ d.name }}</span>
{% endfor %}
</span>
{% endif %}
</label>
</li>
{% endfor %}
</ul>
</details>
{% endfor %}
</section>
{% endfor %}
<section data-schritt="5">
<h3>5 · Distro</h3>
<p><small>Ein Workspace läuft auf genau einer Distro. Wählbar ist nur, was alle gewählten Apps und Richtlinien umsetzen kann.</small></p>
<div id="distro-karten">
{% for b in backends %}
<label class="distro-karte" data-distro="{{ b.key }}">
<input type="radio" name="backend_id" value="{{ b.id }}" {% if werte.backend_id == b.id %}checked{% endif %}>
<strong>{{ b.name }} {{ b.version }}</strong>
<span class="nur-js" data-distro-status></span>
<ul class="nur-js" data-distro-probleme></ul>
</label>
{% endfor %}
</div>
<p class="nur-js" id="keine-distro" hidden><mark>Mit dieser Auswahl ist keine Distro möglich. Passe die rot markierten Einträge an – Vorschläge stehen bei der jeweiligen Distro.</mark></p>
</section>
<section data-schritt="6">
<h3>6 · Prüfen und speichern</h3>
<div id="zusammenfassung" class="nur-js"></div>
<button type="submit" id="speichern">Workspace speichern</button>
</section>
<div class="schritt-nav nur-js">
<button type="button" class="secondary outline" id="zurueck">← Zurück</button>
<button type="button" id="weiter">Weiter →</button>
</div>
</form>
<script id="assistent-daten" type="application/json">{{ assistent_daten | safe }}</script>
<script src="/static/workspace-assistent.js?v={{ static_version }}" defer></script>
{% include "_workspace_assistent.html" %}
{% endblock %}
+1 -310
View File
@@ -2,314 +2,5 @@
{% block title %}{{ workspace.name if workspace else "Workspace" }} — Tuxflotte Admin{% endblock %}
{% block admin_content %}
{% include "admin/_workspaces_nav.html" %}
<p><a href="/admin/workspaces/liste">← Workspaces</a></p>
{% if not workspace %}
<p><mark>Workspace wurde nicht gefunden.</mark></p>
{% else %}
{# Workspace-Detail und -Verwaltung (Workspace-Creator neu, Schritt E,
07.10.2026). Inhalte ändern sich nur im Entwurf; die veröffentlichte
Version ist eingefroren. #}
<style>
.ws-kopf small { color: var(--pico-muted-color); }
.ws-marke { font-size: 0.75rem; padding: 0.05rem 0.5rem; border-radius: 1rem; border: 1px solid var(--pico-primary-border); white-space: nowrap; }
.ws-marke.entwurf { border-color: var(--pico-del-color); }
.ws-box { border: 1px solid var(--pico-muted-border-color); border-radius: var(--pico-border-radius); padding: 0.75rem 1rem; margin-bottom: 1rem; }
.ws-box.entwurf { border-color: var(--pico-del-color); }
.ws-liste { list-style: none; padding: 0; margin: 0 0 0 1rem; }
.ws-liste li { list-style: none; padding: 0.25rem 0; border-bottom: 1px solid var(--pico-muted-border-color); }
.ws-liste label, .ws-liste .zeile { display: flex; gap: 0.6rem; align-items: center; margin: 0; }
.ws-liste .zeile .text { flex: 1 1 auto; }
.ws-liste input[type=checkbox] { flex: 0 0 auto; margin: 0; }
.ws-liste .text small { display: block; color: var(--pico-muted-color); }
.ws-icon { flex: 0 0 20px; width: 20px; height: 20px; font-size: 16px; line-height: 20px; text-align: center; }
.ws-diff ins, .ws-diff del { text-decoration: none; padding: 0 0.3rem; border-radius: 0.2rem; }
details.ws-einstellung { margin-bottom: 0.6rem; }
/* Aufklapp-Bereiche der Einstellungen als Knöpfe erkennbar (Nutzer-Feedback 07.10.2026) */
details.ws-einstellung > summary { display: inline-block; padding: 0.45rem 0.9rem; border: 1px solid var(--pico-primary-border);
border-radius: var(--pico-border-radius); color: var(--pico-primary); font-weight: 600; cursor: pointer; }
details.ws-einstellung > summary:hover { background: var(--pico-primary-focus); }
details.ws-einstellung[open] > summary { background: var(--pico-primary-background); color: #fff !important; }
details.ws-einstellung[open] > summary::after { filter: brightness(0) invert(1); }
details.ws-einstellung[open] { border: 1px solid var(--pico-muted-border-color); border-radius: var(--pico-border-radius); padding: 0 1rem 0.75rem 0; }
details.ws-einstellung[open] > *:not(summary) { margin-left: 1rem; }
form.inline { display: inline; }
form.inline button { width: auto; }
</style>
{% if fehler %}<p><mark>{{ fehler }}</mark></p>{% endif %}
{% if hinweis %}<p><ins>{{ hinweis }}</ins></p>{% endif %}
<div class="ws-kopf">
<h2>{{ workspace.name }}
{% if workspace.ist_vorlage %}<span class="ws-marke">Vorlage für
{%- if workspace.sichtbarkeit == "global" %} alle
{%- elif workspace.sichtbarkeit == "kundengruppe" %} {{ kundengruppen | selectattr("id", "in", workspace.kundengruppe_ids) | map(attribute="name") | map("replace", "Schule", "Schulen") | join(", ") }}
{%- else %} {{ organisationen | selectattr("id", "in", workspace.organization_ids) | map(attribute="name") | join(", ") }}{% endif %}</span>{% endif %}
{% if ist_entwurf %}<span class="ws-marke entwurf">Entwurf v{{ workspace.version.nummer }} offen</span>{% endif %}
</h2>
<p>
{% if workspace.description %}{{ workspace.description }}<br>{% endif %}
<small>
Distro: <strong>{% if workspace.backend %}{{ workspace.backend.name }} {{ workspace.backend.version }}{% else %}noch offen{% endif %}</strong>
· Eigentümer: {{ workspace.organization_name or "tuxflotte" }}
{% set veroeffentlicht = versionen | selectattr("status", "equalto", "veroeffentlicht") | list %}
· Aktuelle Version: {% if veroeffentlicht %}v{{ veroeffentlicht[0].nummer }}{% else %}keine{% endif %}
</small>
</p>
</div>
<div class="ws-box">
<strong>Nutzung</strong>
{% if not nutzung.in_benutzung %}
<p><small>Wird nirgends verwendet – Änderungen betreffen niemanden.</small></p>
{% else %}
<ul>
{% if nutzung.profile %}<li>{{ nutzung.profile | length }} Installationsprofil(e): {% for p in nutzung.profile %}{{ p.label }} <small>({{ p.organisation }})</small>{% if not loop.last %}, {% endif %}{% endfor %}</li>{% endif %}
{% if nutzung.oes %}<li>{{ nutzung.oes | length }} OE(s): {% for o in nutzung.oes %}{{ o.name }} <small>({{ o.organisation }}, {{ "automatisch" if o.automatisch else "auf v" ~ o.version ~ " gepinnt" }})</small>{% if not loop.last %}, {% endif %}{% endfor %}</li>{% endif %}
<li>{{ nutzung.geraete_automatisch }} Gerät(e) übernehmen neue Versionen automatisch
{%- for g in nutzung.geraete_gepinnt %}, {{ g.anzahl }} auf v{{ g.version }} gepinnt{% endfor %}</li>
</ul>
{% endif %}
</div>
{% if not ist_entwurf %}
{# ---------- Veröffentlichte Version: nur lesend ---------- #}
<h3>Inhalt (v{{ workspace.version.nummer if workspace.version else "–" }}, veröffentlicht)</h3>
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/entwurf" class="inline">
<button type="submit">Bearbeiten – Entwurf anlegen</button>
<small>Änderungen wirken erst, wenn du den Entwurf veröffentlichst.</small>
</form>
{% for bereich in bereiche %}
{% set eintraege = [] %}
{% for k in bereich.kategorien %}{% for e in k.eintraege %}{% if e.id in ausgewaehlt %}{% set _ = eintraege.append(e.name) %}{% endif %}{% endfor %}{% endfor %}
<p><strong>{{ bereich.symbol }} {{ bereich.name }} ({{ eintraege | length }}):</strong>
{% if eintraege %}{{ eintraege | sort | join(", ") }}{% else %}<small>keine</small>{% endif %}</p>
{% endfor %}
<p><strong>🌐 Webapps ({{ webapps | length }}):</strong>
{% if webapps %}{{ webapps | map(attribute="name") | sort | join(", ") }}{% else %}<small>keine</small>{% endif %}</p>
{% set sb_name = (workspace.standardbrowser_auswahl | selectattr("key", "equalto", workspace.standardbrowser) | map(attribute="name") | first) if workspace.standardbrowser else None %}
<p><strong>🧭 Standardbrowser:</strong> {% if sb_name %}{{ sb_name }}{% else %}<small>keine Vorgabe</small>{% endif %}</p>
{% else %}
{# ---------- Entwurf: bearbeiten, Wirkung, veröffentlichen ---------- #}
<div class="ws-box entwurf">
<strong>Entwurf v{{ workspace.version.nummer }}</strong> – Änderungen wirken erst nach dem Veröffentlichen.
</div>
<h3>Inhalt bearbeiten</h3>
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/merkmale">
{% for bereich in bereiche %}
<h4>{{ bereich.symbol }} {{ bereich.name }}</h4>
{% for kategorie in bereich.kategorien %}
{% set an = kategorie.eintraege | selectattr("id", "in", ausgewaehlt) | list | length %}
<details {% if an %}open{% endif %}>
<summary>{{ kategorie.name }} <small>({{ kategorie.eintraege | length }}{% if an %}, {{ an }} gewählt{% endif %})</small></summary>
<ul class="ws-liste">
{% for e in kategorie.eintraege %}
{% set geht = not distro_key or distro_key in e.distro_keys %}
<li>
<label {% if not geht %}title="Auf der Distro dieses Workspace nicht umgesetzt"{% endif %}>
<input type="checkbox" name="merkmal_ids" value="{{ e.id }}"
{% if e.id in ausgewaehlt %}checked{% endif %} {% if not geht and e.id not in ausgewaehlt %}disabled{% endif %}>
{% if e.icon_url %}<img class="ws-icon" src="{{ e.icon_url }}" alt="" width="20" height="20" loading="lazy">{% else %}<span class="ws-icon" aria-hidden="true">⚙️</span>{% endif %}
<span class="text">{{ e.name }}{% if not geht %} <small>– nicht auf dieser Distro</small>{% elif e.description %}<small>{{ e.description }}</small>{% endif %}</span>
</label>
</li>
{% endfor %}
</ul>
</details>
{% endfor %}
{% endfor %}
<button type="submit">Entwurf speichern</button>
</form>
<h4>🌐 Webapps</h4>
{% if webapps %}
<ul class="ws-liste">
{% for w in webapps %}
<li>
<div class="zeile">
{% if w.icon_path %}<img class="ws-icon" src="/admin/workspaces/webapp-icons/{{ w.icon_path.split('/')[-1] }}" alt="" width="20" height="20">{% else %}<span class="ws-icon" aria-hidden="true">⚙️</span>{% endif %}
<span class="text">{{ w.name }}<small><a href="{{ w.url }}" target="_blank" rel="noopener">{{ w.url }}</a>{% if w.auf_desktop %} · auch auf dem Desktop{% endif %}</small></span>
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/webapps/{{ w.id }}/desktop" class="inline">
{% if not w.auf_desktop %}<input type="hidden" name="auf_desktop" value="true">{% endif %}
<button type="submit" class="secondary outline">{% if w.auf_desktop %}Vom Desktop nehmen{% else %}Auf den Desktop{% endif %}</button>
</form>
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/webapps/{{ w.id }}/entfernen" class="inline">
<button type="submit" class="secondary outline">Entfernen</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}<p><small>Noch keine Webapps.</small></p>{% endif %}
<div class="grid">
{% if vorlagen_verfuegbar %}
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/webapps/vorlage">
<label>Aus Webapp-Vorlage
<select name="template_id" required>
{% for t in vorlagen_verfuegbar %}<option value="{{ t.id }}">{{ t.name }}</option>{% endfor %}
</select>
</label>
<button type="submit" class="outline">Hinzufügen</button>
</form>
{% endif %}
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/webapps">
<label>Eigene Webapp<input type="text" name="name" required placeholder="Name, z. B. Schulportal"></label>
<input type="url" name="url" required placeholder="https://…">
<label><input type="checkbox" name="auf_desktop" value="true"> Auch auf dem Desktop</label>
<button type="submit" class="outline">Hinzufügen</button>
</form>
</div>
<h4>🧭 Standardbrowser</h4>
<div class="ws-box">
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/standardbrowser">
<label>Webapps ohne Kiosk-Modus und Links aus anderen Programmen öffnen sich in
<select name="browser">
<option value="" {% if not workspace.standardbrowser %}selected{% endif %}>keine Vorgabe (Standard der Distro)</option>
{% for b in workspace.standardbrowser_auswahl %}<option value="{{ b.key }}" {% if b.key == workspace.standardbrowser %}selected{% endif %}>{{ b.name }}</option>{% endfor %}
</select>
</label>
<small>Zur Wahl stehen Firefox und die Browser, die im gespeicherten Entwurf enthalten sind. Webapps mit Kiosk-Modus starten weiterhin als eigenes Chrome- bzw. Chromium-Fenster.</small>
<button type="submit" class="outline">Speichern</button>
</form>
</div>
<h3>Wirkung beim Veröffentlichen</h3>
<div class="ws-box ws-diff">
{% if wirkung %}
{% if wirkung.unveraendert %}
<p><small>Noch keine Änderungen gegenüber der veröffentlichten Version.</small></p>
{% else %}
<ul>
{% for titel, neu, weg in [("Apps", wirkung.apps_neu, wirkung.apps_weg), ("Webapps", wirkung.webapps_neu, wirkung.webapps_weg), ("Richtlinien", wirkung.richtlinien_neu, wirkung.richtlinien_weg)] %}
{% if neu or weg %}<li>{{ titel }}:
{% for n in neu %}<ins>+ {{ n }}</ins> {% endfor %}
{% for n in weg %}<del>− {{ n }}</del> {% endfor %}</li>{% endif %}
{% endfor %}
{% if wirkung.standardbrowser_geaendert %}<li>Standardbrowser:
{% if wirkung.standardbrowser_neu %}<ins>+ {{ wirkung.standardbrowser_neu }}</ins>{% else %}<ins>keine Vorgabe</ins>{% endif %}
{% if wirkung.standardbrowser_alt %}<del>− {{ wirkung.standardbrowser_alt }}</del>{% endif %}</li>{% endif %}
</ul>
{% endif %}
<p>
{% if wirkung.nutzung.geraete_automatisch %}<strong>{{ wirkung.nutzung.geraete_automatisch }} Gerät(e)</strong> übernehmen die neue Version beim nächsten Check-in{% if wirkung.nutzung.organisationen %} ({{ wirkung.nutzung.organisationen | join(", ") }}){% endif %}.{% else %}Kein Gerät übernimmt die neue Version sofort.{% endif %}
{% for g in wirkung.nutzung.geraete_gepinnt %}{{ g.anzahl }} Gerät(e) bleiben auf v{{ g.version }}. {% endfor %}
{% if wirkung.nutzung.profile %}Künftige Installationen über {{ wirkung.nutzung.profile | length }} Profil(e) bekommen die neue Version.{% endif %}
</p>
{% endif %}
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/veroeffentlichen">
<label>Notiz zur Version <small>(was hat sich geändert?)</small>
<input type="text" name="notiz" placeholder="z. B. Audacity ergänzt">
</label>
<button type="submit">Als v{{ workspace.version.nummer }} veröffentlichen</button>
</form>
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/entwurf/verwerfen" class="inline">
<button type="submit" class="secondary outline">Entwurf verwerfen</button>
</form>
</div>
{% endif %}
<h3>Versionen</h3>
<figure>
<table>
<thead><tr><th>Version</th><th>Status</th><th>Veröffentlicht</th><th>Inhalt</th><th>Notiz</th></tr></thead>
<tbody>
{% for v in versionen %}
<tr>
<td>v{{ v.nummer }}</td>
<td>{{ "Entwurf" if v.status == "entwurf" else "veröffentlicht" }}</td>
<td data-sort-wert="{{ v.veroeffentlicht_am or '' }}">{{ v.veroeffentlicht_am | datum }}</td>
<td><small>{{ v.anzahl_merkmale }} Merkmale, {{ v.anzahl_webapps }} Webapps</small></td>
<td><small>{{ v.notiz or "" }}</small></td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
<h3>Einstellungen</h3>
<details class="ws-einstellung">
<summary>Name und Zweck</summary>
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/einstellungen">
<label>Name<input type="text" name="name" value="{{ workspace.name }}" required></label>
<label>Zweck<input type="text" name="description" value="{{ workspace.description or '' }}"></label>
<button type="submit">Speichern</button>
</form>
</details>
<details class="ws-einstellung" {% if fehler and "Kundengruppe" in fehler or fehler and "Organisation" in fehler %}open{% endif %}>
<summary>Vorlage und Sichtbarkeit</summary>
<p><small>Als Vorlage markierte Workspaces können Kunden-Admins auswählen (per Verweis – deine späteren Versionen erreichen sie je nach ihrer Einstellung automatisch).</small></p>
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/vorlage">
<label><input type="checkbox" name="ist_vorlage" value="ja" {% if workspace.ist_vorlage %}checked{% endif %}> Als Vorlage anbieten</label>
<fieldset>
<legend>Sichtbar für</legend>
<label><input type="radio" name="sichtbarkeit" value="global" {% if workspace.sichtbarkeit == "global" %}checked{% endif %}> alle Organisationen</label>
<label><input type="radio" name="sichtbarkeit" value="kundengruppe" {% if workspace.sichtbarkeit == "kundengruppe" %}checked{% endif %}> bestimmte Kundengruppen (z. B. alle Schulen):</label>
<div style="margin-left:1.5rem">
{% for kg in kundengruppen %}
<label><input type="checkbox" name="kundengruppe_ids" value="{{ kg.id }}" {% if kg.id in workspace.kundengruppe_ids %}checked{% endif %}> {{ kg.name }}</label>
{% endfor %}
</div>
<label><input type="radio" name="sichtbarkeit" value="organisation" {% if workspace.sichtbarkeit == "organisation" %}checked{% endif %}> bestimmte Organisationen:</label>
<div style="margin-left:1.5rem">
{% for org in organisationen %}
<label><input type="checkbox" name="organization_ids" value="{{ org.id }}" {% if org.id in workspace.organization_ids %}checked{% endif %}> {{ org.name }}</label>
{% endfor %}
</div>
</fieldset>
<button type="submit">Speichern</button>
</form>
</details>
<details class="ws-einstellung" {% if kopie %}open{% endif %}>
<summary>Kopieren (auch für eine andere Distro)</summary>
<p><small>Kopiert die aktuelle veröffentlichte Version als neuen Workspace (Version 1).</small></p>
{% if kopie %}
<div class="ws-box">
{% if kopie.weggefallen %}
<p>Auf der gewählten Distro <strong>fallen weg</strong>: {{ kopie.weggefallen | join(", ") }}</p>
{% else %}
<p>Alles lässt sich übernehmen.</p>
{% endif %}
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/kopie">
<input type="hidden" name="name" value="{{ kopie.name }}">
<input type="hidden" name="backend_id" value="{{ kopie.backend_id }}">
<input type="hidden" name="organization_id" value="{{ kopie.organization_id }}">
<button type="submit">Kopie „{{ kopie.name }}“ anlegen</button>
</form>
</div>
{% endif %}
<form method="get" action="/admin/workspaces/liste/{{ workspace_id }}">
<label>Name der Kopie<input type="text" name="kopie_name" required value="{{ kopie.name if kopie else workspace.name ~ ' (Kopie)' }}"></label>
<label>Distro
<select name="kopie_backend">
{% for b in backends %}<option value="{{ b.id }}" {% if (kopie and kopie.backend_id == b.id) or (not kopie and workspace.backend and workspace.backend.id == b.id) %}selected{% endif %}>{{ b.name }} {{ b.version }}</option>{% endfor %}
</select>
</label>
<label>Eigentümer
<select name="kopie_org">
<option value="">tuxflotte</option>
{% for org in organisationen %}<option value="{{ org.id }}" {% if kopie and kopie.organization_id == org.id %}selected{% endif %}>{{ org.name }}</option>{% endfor %}
</select>
</label>
<button type="submit" class="outline">Prüfen</button>
</form>
</details>
<details class="ws-einstellung">
<summary>Löschen</summary>
{% if nutzung.in_benutzung %}
<p><small>Nicht möglich, solange der Workspace verwendet wird (siehe Nutzung oben).</small></p>
{% else %}
<form method="post" action="/admin/workspaces/liste/{{ workspace_id }}/loeschen">
<label><input type="checkbox" name="bestaetigt" value="ja" required> Ja, „{{ workspace.name }}“ mit allen Versionen endgültig löschen</label>
<button type="submit" class="secondary">Löschen</button>
</form>
{% endif %}
</details>
{% endif %}
{% include "_workspace_detail.html" %}
{% endblock %}
+1 -1
View File
@@ -288,7 +288,7 @@
{% else %}
<li><a href="/dashboard" {% if aktiver_bereich == "dashboard" %}class="active"{% endif %}>📊 Dashboard</a></li>
<li><a href="/organisation" {% if aktiver_bereich == "organisation" %}class="active"{% endif %}>🏢 Organisation</a></li>
{% if workspace_creator_freigegeben %}
{% if workspace_creator_frei(user.organization_id) %}
<li><a href="/workspace" {% if aktiver_bereich == "workspace" %}class="active"{% endif %}>🧩 Workspace</a></li>
{% else %}
<li><a href="/workspace" class="nav-gesperrt{% if aktiver_bereich == "workspace" %} active{% endif %}" title="Dein Workspace wird derzeit von tuxflotte eingerichtet.">🧩 Workspace</a></li>
+1 -1
View File
@@ -9,7 +9,7 @@
<ul class="dashboard-checkliste">
<li class="{{ 'erledigt' if naechste_schritte.hat_workspace }}">
{{ "✅" if naechste_schritte.hat_workspace else "⬜" }}
{% if workspace_creator_freigegeben %}
{% if workspace_creator_frei(user.organization_id) %}
<a href="/workspace">Workspace komponiert</a>
{% else %}
Workspace eingerichtet <small>(übernimmt derzeit tuxflotte für dich)</small>
+30
View File
@@ -2,6 +2,8 @@
{% block title %}Struktur — Tuxflotte{% endblock %}
{% block content %}
<h2>Struktur</h2>
{% if fehler %}<p><mark>{{ fehler }}</mark></p>{% endif %}
{% if hinweis %}<p><ins>{{ hinweis }}</ins></p>{% endif %}
<p>Organisationseinheiten (OEs) zum Verorten deiner Geräte — z.B. Gebäude oder Klassen. Ein Gerät gehört immer zu genau einer OE, neu aktivierte Geräte landen automatisch in "Neue Geräte".</p>
<p><small>
Wofür: Du kannst jeder OE eigene Merkmal-Einstellungen geben, die
@@ -28,6 +30,34 @@
· <a href="/struktur/{{ oe.id }}/loeschen">Löschen</a>
{% endif %}
</small>
{% if workspaces is not none %}
<details>
<summary role="button" class="secondary outline">Workspace zuweisen</summary>
<p><small>Gilt für alle Geräte dieser OE und ihrer Unter-OEs, außer eine Unter-OE hat einen eigenen.
Die Geräte müssen zur Distro des Workspace passen, sonst lehnt das Speichern mit Begründung ab.</small></p>
<form method="post" action="/struktur/{{ oe.id }}/workspace">
<div class="grid">
<label>Workspace
<select name="workspace_id">
<option value="">— kein eigener (erben bzw. Installation) —</option>
{% for w in workspaces %}
<option value="{{ w.id }}" {% if oe.workspace and oe.workspace.id == w.id %}selected{% endif %}>{{ w.name }} ({{ w.backend.name }}{{ ", Vorlage" if not w.eigener else "" }})</option>
{% endfor %}
</select>
</label>
<label>Version
<select name="version_id">
<option value="">automatisch neueste</option>
{% for w in workspaces %}{% for v in w.versionen if v.status == "veroeffentlicht" %}
<option value="{{ v.id }}" {% if oe.workspace and not oe.workspace.automatisch and oe.workspace.id == w.id and oe.workspace.version == v.nummer %}selected{% endif %}>{{ w.name }} v{{ v.nummer }} festhalten</option>
{% endfor %}{% endfor %}
</select>
</label>
</div>
<button type="submit">Speichern</button>
</form>
</details>
{% endif %}
<details>
<summary role="button" class="secondary outline">Unter-OE anlegen</summary>
<form method="post" action="/struktur">
+6
View File
@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}Workspace erstellen — Tuxflotte{% endblock %}
{% block content %}
<p><a href="/workspace">← Workspaces</a></p>
{% include "_workspace_assistent.html" %}
{% endblock %}
+4 -150
View File
@@ -1,153 +1,7 @@
{% extends "base.html" %}
{% block title %}{{ workspace.name if workspace else workspace_id }} — Tuxflotte{% endblock %}
{% block title %}{{ workspace.name if workspace else "Workspace" }} — Tuxflotte{% endblock %}
{% block content %}
<p><a href="/workspace">← Workspace</a></p>
<h2>{{ workspace.name }}</h2>
<p>
{% if bearbeitbar %}Eigener Workspace{% else %}Bereitgestellter Standard <small>(schreibgeschützt)</small>{% endif %}
{% if workspace.description %} — {{ workspace.description }}{% endif %}
</p>
{% if not bearbeitbar %}
<form method="post" action="/workspace/{{ workspace_id }}/uebernehmen">
<button type="submit">Diesen Standard übernehmen</button>
<small>Erstellt eine eigene, voll bearbeitbare Kopie (inkl. Webapps) - der Standard selbst bleibt für alle unverändert.</small>
</form>
{% endif %}
<section>
<h3>Zusammensetzung</h3>
{% if bearbeitbar %}
<p><small>
Angehakte Merkmale gehören zum Grundbestand dieses Workspace - gilt
additiv für alle Geräte, die ihm zugewiesen sind (per
Installationsmedium, oder nachträglich per Gerätegruppe mit
eigenem Workspace).
</small></p>
{% if not kategorien %}
<p>Keine katalogfähigen Merkmale vorhanden.</p>
{% else %}
<form method="post" action="/workspace/{{ workspace_id }}/merkmale">
{% for kategorie_name, eintraege in kategorien.items() %}
<fieldset>
<legend><strong>{{ kategorie_name }}</strong></legend>
{% for m in eintraege %}
<label>
<input type="checkbox" name="merkmal_ids" value="{{ m.id }}" {% if m.zugeordnet %}checked{% endif %}>
{{ m.name }}
{% if m.description %}<br><small>{{ m.description }}</small>{% endif %}
</label>
{% endfor %}
</fieldset>
{% endfor %}
<button type="submit">Zusammensetzung speichern</button>
</form>
{% endif %}
{% else %}
{% for kategorie_name, eintraege in kategorien.items() %}
{% set zugeordnete = eintraege | selectattr("zugeordnet") | list %}
{% if zugeordnete %}
<p><strong>{{ kategorie_name }}</strong></p>
<ul>
{% for m in zugeordnete %}
<li>{{ m.name }}{% if m.description %} — <small>{{ m.description }}</small>{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
{% endif %}
</section>
{% if bearbeitbar %}
<section>
<h3>Webapps</h3>
<p><small>URLs, die auf den Geräten dieses Workspace als Eintrag im Menü erscheinen, mit dem Favicon der Seite. Auf Wunsch zusätzlich als Symbol auf dem Desktop.</small></p>
{% if webapps %}
<figure>
<table>
<tbody>
{% for webapp in webapps %}
<tr>
<td style="width:2rem">
{% if webapp.icon_path %}
<img src="/webapp-icons/{{ webapp.icon_path.split('/')[-1] }}" alt="" width="24" height="24"
title="Bild verlinkt zum Betreiber ({{ webapp.url }})">
{% endif %}
</td>
<td>
{{ webapp.name }}<br><small><a href="{{ webapp.url }}" target="_blank" rel="noopener">{{ webapp.url }}</a></small><br>
{% if webapp.kategorie_name %}<small>📁 {{ webapp.kategorie_name }}</small>{% endif %}
{% if webapp.kiosk_modus %}<small>🖥️ Kiosk-Fenster</small>{% endif %}
{% if webapp.auf_desktop %}<small>📌 auch auf dem Desktop</small>{% endif %}
</td>
<td>
<form method="post" action="/workspace/{{ workspace_id }}/webapps/{{ webapp.id }}/entfernen">
<button type="submit" class="secondary outline">Entfernen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% else %}
<p><small>Noch keine Webapps für diesen Workspace.</small></p>
{% endif %}
{% if verfuegbare_vorlagen %}
<details>
<summary role="button" class="outline">Vorlage aktivieren</summary>
<table>
<tbody>
{% for vorlage in verfuegbare_vorlagen %}
<tr>
<td style="width:2rem">
{% if vorlage.icon_path %}
<img src="/webapp-icons/{{ vorlage.icon_path.split('/')[-1] }}" alt="" width="24" height="24"
title="Bild verlinkt zum Betreiber ({{ vorlage.url }})">
{% endif %}
</td>
<td>{{ vorlage.name }}{% if vorlage.description %}<br><small>{{ vorlage.description }}</small>{% endif %}</td>
<td>
<form method="post" action="/workspace/{{ workspace_id }}/vorlagen/{{ vorlage.id }}/aktivieren">
<button type="submit" class="secondary outline">Aktivieren</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
<details>
<summary role="button" class="secondary outline">Eigene Webapp hinzufügen</summary>
<form method="post" action="/workspace/{{ workspace_id }}/webapps">
<label>Name<input type="text" name="name" required placeholder="z.B. Schulportal"></label>
<label>URL<input type="url" name="url" required placeholder="https://..."></label>
<label>Ins Menü einordnen (optional)
<select name="kategorie_id">
<option value="">— ohne Kategorie (Internet) —</option>
{% for k in webapp_kategorien %}
<option value="{{ k.id }}">{{ k.name }}</option>
{% endfor %}
</select>
</label>
<label>
<input type="checkbox" name="kiosk_modus" {% if not hat_kiosk_browser %}disabled{% endif %}>
Als Kiosk-Fenster öffnen (ohne Adressleiste/Tabs)
</label>
<label>
<input type="checkbox" name="auf_desktop">
Auch auf dem Desktop anzeigen (sonst nur im Menü)
</label>
{% if not hat_kiosk_browser %}
<p><small>Braucht Chrome oder Chromium als Merkmal in diesem Workspace (siehe Zusammensetzung oben) - sonst wird die Webapp im normalen Standardbrowser geöffnet.</small></p>
{% endif %}
<button type="submit">Hinzufügen</button>
</form>
</details>
</section>
{% endif %}
{# Kunden-Workspace-Creator (GUI-Runde 23.10.2026): dieselbe Seite wie im
Admin-Bereich, ohne Vorlagen-Einstellungen; fremde Vorlagen nur lesend. #}
{% include "_workspace_detail.html" %}
{% endblock %}
+25 -30
View File
@@ -1,63 +1,58 @@
{% extends "base.html" %}
{% block title %}Workspace — Tuxflotte{% endblock %}
{% block content %}
{# Kunden-Workspace-Creator (GUI-Runde 23.10.2026): eigene Workspaces und
Vorlagen von tuxflotte, Anlegen über den Assistenten. #}
<h2>Workspace</h2>
{% if hinweis %}<p><ins>{{ hinweis }}</ins></p>{% endif %}
<p><small>
Ein Workspace ist der Merkmal-Grundbestand, den du einem
Installationsmedium mitgeben - wähle einen bereitgestellten
Standard unverändert, oder stelle einen eigenen zusammen.
Ein Workspace legt fest, was auf deinen Geräten ist: Programme, Webapps und Einstellungen. Er läuft auf genau einer Distro.
Auf die Geräte kommt er, indem du ihn unter <a href="/struktur">Organisation › Struktur</a> einer OE zuweist; er gilt dann für
alle Geräte darin und in den Unter-OEs. Änderungen machst du in einem Entwurf und veröffentlichst sie als neue Version, erst dann
erreichen sie die Geräte.
</small></p>
<p><a href="/workspace/neu" role="button">+ Workspace erstellen</a></p>
<section>
<h3>Deine Workspaces</h3>
{% if not eigene_workspaces %}
<p>Noch kein eigener Workspace angelegt.</p>
<p><small>Noch kein eigener Workspace. Erstelle einen mit dem Assistenten oder kopiere unten eine Vorlage.</small></p>
{% else %}
<figure>
<table>
<thead><tr><th>Name</th><th>Beschreibung</th><th></th></tr></thead>
<thead><tr><th>Name</th><th>Distro</th><th>Version</th><th></th></tr></thead>
<tbody>
{% for w in eigene_workspaces %}
<tr>
<td>{{ w.name }}</td>
<td>{{ w.description or "—" }}</td>
<td><a href="/workspace/{{ w.id }}" role="button" class="outline">Bearbeiten →</a></td>
<td>{{ w.name }}{% if w.description %}<br><small>{{ w.description }}</small>{% endif %}</td>
<td>{{ w.backend.name }} {{ w.backend.version }}</td>
<td>{% if w.version %}v{{ w.version }}{% else %}<small>noch keine</small>{% endif %}{% if w.hat_entwurf %} <small>+ Entwurf</small>{% endif %}</td>
<td><a href="/workspace/{{ w.id }}" role="button" class="outline">Öffnen</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% endif %}
<details>
<summary role="button" class="secondary outline">Neuen Workspace anlegen</summary>
<form method="post" action="/workspace">
<label for="key">Key <small>(technischer Kurzname, z.B. "schulcomputer-klasse5")</small></label>
<input type="text" id="key" name="key" required>
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="description">Beschreibung</label>
<input type="text" id="description" name="description">
<button type="submit">Anlegen</button>
</form>
</details>
</section>
<section>
<h3>Bereitgestellte Standards</h3>
<p><small>Schreibgeschützte Vorlagen - für ein Installationsmedium direkt unverändert auswählbar.</small></p>
{% if not standard_workspaces %}
<p>Aktuell keine Standards verfügbar.</p>
<h3>Vorlagen von tuxflotte</h3>
<p><small>Nur lesbar. Du kannst eine Vorlage direkt einer OE zuweisen (dann kommen Verbesserungen von tuxflotte automatisch)
oder sie kopieren und die Kopie selbst anpassen.</small></p>
{% if not vorlagen %}
<p><small>Aktuell keine Vorlagen verfügbar.</small></p>
{% else %}
<figure>
<table>
<thead><tr><th>Name</th><th>Beschreibung</th><th></th></tr></thead>
<thead><tr><th>Name</th><th>Distro</th><th>Version</th><th></th></tr></thead>
<tbody>
{% for w in standard_workspaces %}
{% for w in vorlagen %}
<tr>
<td>{{ w.name }}</td>
<td>{{ w.description or "—" }}</td>
<td><a href="/workspace/{{ w.id }}" role="button" class="outline secondary">Ansehen →</a></td>
<td>{{ w.name }}{% if w.description %}<br><small>{{ w.description }}</small>{% endif %}</td>
<td>{{ w.backend.name }} {{ w.backend.version }}</td>
<td>{% if w.version %}v{{ w.version }}{% endif %}</td>
<td><a href="/workspace/{{ w.id }}" role="button" class="outline secondary">Ansehen</a></td>
</tr>
{% endfor %}
</tbody>
+32
View File
@@ -0,0 +1,32 @@
from db import get_database_connection
# Workspace-Creator je Organisation (GUI-Runde 23.10.2026, siehe
# migrations/0010_workspace_creator_freischaltung.sql): der Root-Admin
# schaltet ihn frei, ohne Eintrag ist er gesperrt.
def ist_freigeschaltet(organization_id) -> bool:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT frei FROM workspace_creator_freischaltung WHERE organization_id = %s",
(str(organization_id),),
)
row = cur.fetchone()
return bool(row and row[0])
def setze_freischaltung(organization_id: str, frei: bool) -> None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO workspace_creator_freischaltung (organization_id, frei, updated_at)
VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (organization_id) DO UPDATE
SET frei = EXCLUDED.frei, updated_at = CURRENT_TIMESTAMP
""",
(organization_id, frei),
)