Einstieg Schritt 7: Einladung zur Personalisierung ohne Seitenwechsel

Geräteseite: Formular immer da; fehlt "Personalisierung möglich", kommt
das Gerät beim Senden in die Gruppe "Personalisierte Geräte" (eine je
Organisation, bei Bedarf angelegt, Merkmal aktiv). Gruppen- und
Standortseite: mit aktivem Merkmal eine Liste der Geräte mit E-Mail-Feld,
nach dem Senden zurück auf dieselbe Seite. Gefunden im Erstkontakt-Probelauf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-25 15:58:51 +01:00
1 parent e44b1b64b8
commit 6e79baedf6
9 files changed
+248 -56

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
"""
Einladungen zur Personalisierung dort, wo der Kunde gerade ist (Einstieg
vereinfachen, Schritt 7, 25.10.2026): auf der Geräteseite ohne vorher das
Merkmal setzen zu müssen, und auf der Seite einer Gruppe bzw. eines
Standorts/Zwecks mit aktivem Merkmal für alle ihre Geräte.
"""
from anode_client import anode_request
PERSONALISIERUNG_MERKMAL = "personalisierung"
# Eine gemeinsame Gruppe je Organisation statt einer Ein-Geräte-Gruppe pro
# Gerät; Gruppen können ein Merkmal nur einschalten, nie abschalten.
PERSONALISIERT_GRUPPE = "Personalisierte Geräte"
PERSONALISIERUNG_STATUS = {
"eingeladen": "Einladung verschickt, noch nicht eingelöst",
"code_offen": "Code erzeugt, noch nicht eingelöst",
"eingereicht": "eingelöst, wartet auf Bestätigung per Mail",
"bestaetigt": "Konto gewählt, wird beim nächsten Start eingerichtet",
"angelegt": "eingerichtet",
"zurueckgezogen": "zurückgezogen",
"verfallen": "verfallen",
"fehlgeschlagen": "am Gerät fehlgeschlagen",
"beendet": "beendet",
}
def personalisierung_moeglich(katalog: list[dict]) -> bool:
return any(e.get("merkmal") == PERSONALISIERUNG_MERKMAL and e.get("state") == "present" for e in katalog)
def personalisierung_einschalten(organization_id: str, device_id: str) -> str | None:
"""Gerät in die Gruppe "Personalisierte Geräte" (bei Bedarf angelegt, Merkmal
aktiv). Rückgabe: Fehlermeldung oder None."""
gruppen = anode_request("GET", f"/api/v1/organizations/{organization_id}/gruppen")
gruppe = next(
(g for g in gruppen.get("gruppen", []) if g["name"] == PERSONALISIERT_GRUPPE),
None,
) if gruppen.get("success") else None
if gruppe is None:
result = anode_request(
"POST", f"/api/v1/organizations/{organization_id}/gruppen", json={"name": PERSONALISIERT_GRUPPE}
)
if not result.get("success"):
return result.get("message") or "Die Gruppe „Personalisierte Geräte“ ließ sich nicht anlegen."
gruppe = result["gruppe"]
result = anode_request(
"POST", f"/api/v1/gruppen/{gruppe['id']}/merkmale/{PERSONALISIERUNG_MERKMAL}/select", json={"optionen": {}}
)
if not result.get("success"):
return result.get("message") or "„Personalisierung möglich“ ließ sich nicht setzen."
result = anode_request("POST", f"/api/v1/devices/{device_id}/gruppen/{gruppe['id']}")
if not result.get("success"):
return result.get("message") or "Das Gerät ließ sich der Gruppe nicht zuordnen."
return None
def email_domaenen(organization_id: str) -> list[str]:
result = anode_request("GET", f"/api/v1/organizations/{organization_id}")
return result["organization"].get("email_domaenen", []) if result.get("success") else []
def einladungs_zeilen(devices: list[dict]) -> list[dict]:
"""Je Gerät der Stand der Personalisierung für die Liste auf Gruppen- und
Standortseiten."""
zeilen = []
for device in devices:
result = anode_request("GET", f"/api/v1/devices/{device['id']}/personalisierung")
pers = result.get("personalisierungen", []) if result.get("success") else []
zeilen.append({
"device": device,
"konten": [p for p in pers if p["status"] == "angelegt"],
"laufend": next((p for p in pers if p["status"] in ("eingeladen", "bestaetigt")), None),
})
return zeilen
+35 -22
View File
@@ -5,6 +5,7 @@ from fastapi.responses import RedirectResponse
from anode_client import anode_request
from auth import get_current_user
from einladungen import PERSONALISIERUNG_STATUS, personalisierung_einschalten, personalisierung_moeglich
from routers.gruppen import fetch_gruppen
from routers.personalisierung import _passwort_hash, einladung_senden
from templating import templates
@@ -150,19 +151,6 @@ async def gruppen_aktualisieren(request: Request, device_id: str, user: dict = D
return RedirectResponse("/geraete", status_code=303)
PERSONALISIERUNG_STATUS = {
"eingeladen": "Einladung verschickt, noch nicht eingelöst",
"code_offen": "Code erzeugt, noch nicht eingelöst",
"eingereicht": "eingelöst, wartet auf Bestätigung per Mail",
"bestaetigt": "Konto gewählt, wird beim nächsten Start eingerichtet",
"angelegt": "eingerichtet",
"zurueckgezogen": "zurückgezogen",
"verfallen": "verfallen",
"fehlgeschlagen": "am Gerät fehlgeschlagen",
"beendet": "beendet",
}
def email_sichtbar(email: str | None, domaenen: list[str]) -> bool:
"""
ADR-0041 (15.10.2026): Adressen aus der Domänenliste der Organisation
@@ -219,11 +207,11 @@ def geraete_info(
(p for p in personalisierungen if p["status"] in ("eingeladen", "bestaetigt")), None
),
"personalisierung_status": PERSONALISIERUNG_STATUS,
# Die Adresse lässt sich nur bei Geräten mit dem Merkmal eintragen;
# anode prüft das noch einmal.
"personalisierung_moeglich": any(
e.get("merkmal") == "personalisierung" and e.get("state") == "present" for e in katalog
),
# Ohne das Merkmal setzt "Einladung senden" es über die Gruppe
# "Personalisierte Geräte" (Schritt 7, 25.10.2026). Fehlt es im
# Katalog ganz (etwa Ubuntu), gibt es keine Einladung.
"personalisierung_moeglich": personalisierung_moeglich(katalog),
"personalisierung_verfuegbar": any(e.get("merkmal") == "personalisierung" for e in katalog),
"email_domaenen": org_result["organization"].get("email_domaenen", []) if org_result.get("success") else [],
"einladung": einladung,
"neues_passwort": neues_passwort,
@@ -240,17 +228,34 @@ def personalisierung_einladen(
email_lokal: str = Form(""),
email_domain: str = Form(""),
bestehenden_behalten: str = Form(""),
zurueck: str = Form(""),
user: dict = Depends(get_current_user),
):
"""
ADR-0041, vereinfachter Ablauf (24.10.2026): Adresse des Benutzers
eintragen, die Einladung mit dem Link geht sofort raus. "Erneut senden"
schickt dieselbe Adresse noch einmal; der alte Link wird ungültig.
Schritt 7 (25.10.2026): Fehlt dem Gerät "Personalisierung möglich", kommt
es vorher in die Gruppe "Personalisierte Geräte". Mit "zurueck" (Seite
einer Gruppe oder eines Standorts/Zwecks) geht es danach dorthin zurück.
"""
if find_device_in_organization(device_id, user.organization_id) is None:
raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.")
if not (zurueck.startswith("/gruppen/") or zurueck.startswith("/struktur/")):
zurueck = ""
def antwort(einladung: dict, status_code: int = 200):
if not zurueck:
return geraete_info(request, device_id, user, einladung, status_code=status_code)
if einladung.get("fehler"):
query = {"einladung_fehler": einladung["fehler"]}
else:
query = {"einladung_ok": einladung["gesendet_an"]}
return RedirectResponse(f"{zurueck}?{urlencode(query)}#einladungen", status_code=303)
# Mit Domänenliste nur der Teil vor dem @ plus gewählte Domäne.
if email_lokal.strip():
lokal = email_lokal.strip()
@@ -258,7 +263,15 @@ def personalisierung_einladen(
else:
adresse = email.strip()
if not adresse:
return geraete_info(request, device_id, user, {"fehler": "Bitte eine E-Mail-Adresse angeben."}, status_code=400)
return antwort({"fehler": "Bitte eine E-Mail-Adresse angeben."}, status_code=400)
katalog = anode_request("GET", f"/api/v1/devices/{device_id}/auftragskatalog").get("auftragskatalog", [])
if not personalisierung_moeglich(katalog):
if not any(e.get("merkmal") == "personalisierung" for e in katalog):
return antwort({"fehler": "Für dieses Gerät gibt es keine Personalisierung."}, status_code=400)
fehler = personalisierung_einschalten(user.organization_id, device_id)
if fehler:
return antwort({"fehler": fehler}, status_code=400)
result = anode_request(
"POST",
@@ -267,15 +280,15 @@ def personalisierung_einladen(
)
if not result.get("success"):
fehler = result.get("message") or "Die Einladung wurde abgelehnt."
return geraete_info(request, device_id, user, {"fehler": fehler}, status_code=400)
return antwort({"fehler": fehler}, status_code=400)
try:
einladung_senden(result)
except ValueError:
anode_request("POST", f"/api/v1/devices/{device_id}/personalisierung/zurueckziehen")
return geraete_info(request, device_id, user, {"fehler": "Die E-Mail-Adresse ist ungültig."}, status_code=400)
return antwort({"fehler": "Die E-Mail-Adresse ist ungültig."}, status_code=400)
return geraete_info(request, device_id, user, {"gesendet_an": adresse})
return antwort({"gesendet_an": adresse})
@router.post("/geraete/{device_id}/personalisierung/{personalisierung_id}/sperren")
+11
View File
@@ -4,6 +4,7 @@ from fastapi.responses import RedirectResponse
from anode_client import anode_request
from auth import get_current_user
from einladungen import PERSONALISIERUNG_MERKMAL, PERSONALISIERUNG_STATUS, einladungs_zeilen, email_domaenen
from optionen_felder import OPTIONEN_FELDER, parse_optionen_form
from templating import templates
@@ -114,6 +115,12 @@ def gruppe_detail(request: Request, gruppe_id: str, user=Depends(get_current_use
if any(g["id"] == gruppe_id for g in device_gruppen):
mitglied_ids.add(device["id"])
# Schritt 7 (25.10.2026): mit aktivem "Personalisierung möglich" gleich
# hier einladen, ohne Wechsel auf die einzelnen Geräteseiten.
einladungen = None
if any(e["merkmal"] == PERSONALISIERUNG_MERKMAL and e.get("aktiv") for e in merkmale):
einladungen = einladungs_zeilen([d for d in devices if d["id"] in mitglied_ids])
return templates.TemplateResponse(
request,
"gruppe_detail.html",
@@ -124,6 +131,10 @@ def gruppe_detail(request: Request, gruppe_id: str, user=Depends(get_current_use
"devices": devices,
"mitglied_ids": mitglied_ids,
"optionen_felder": OPTIONEN_FELDER,
"einladungen": einladungen,
"email_domaenen": email_domaenen(user.organization_id) if einladungen is not None else [],
"personalisierung_status": PERSONALISIERUNG_STATUS,
"einladung_zurueck": f"/gruppen/{gruppe_id}",
},
)
+26 -1
View File
@@ -4,6 +4,7 @@ from urllib.parse import urlencode
from anode_client import anode_request
from auth import get_current_user
from einladungen import PERSONALISIERUNG_MERKMAL, PERSONALISIERUNG_STATUS, einladungs_zeilen, email_domaenen
from optionen_felder import OPTIONEN_FELDER, parse_optionen_form
from routers.workspace import creator_frei, zuweisbare_workspaces
from templating import templates
@@ -174,10 +175,34 @@ def oe_merkmale_ansicht(request: Request, oe_id: str, user=Depends(get_current_u
kategorie_name = eintrag["kategorie"]["name"] if eintrag["kategorie"] else "Ohne Kategorie"
kategorien.setdefault(kategorie_name, []).append(eintrag)
# Schritt 7 (25.10.2026): wie bei Gruppen, für die Geräte hier und
# in den Standorten/Zwecken darunter.
einladungen = None
if any(e["merkmal"] == PERSONALISIERUNG_MERKMAL and e.get("aktiv") for e in merkmale):
oes = fetch_organisationseinheiten(user.organization_id)
unten = {oe_id}
while True:
neu = {o["id"] for o in oes if o.get("parent_id") in unten} - unten
if not neu:
break
unten |= neu
devices_result = anode_request("GET", f"/api/v1/organizations/{user.organization_id}/devices")
devices = devices_result.get("devices", []) if devices_result.get("success") else []
einladungen = einladungs_zeilen([d for d in devices if d.get("oe_id") in unten])
return templates.TemplateResponse(
request,
"struktur_merkmale.html",
{"user": user, "oe": oe, "kategorien": kategorien, "optionen_felder": OPTIONEN_FELDER},
{
"user": user,
"oe": oe,
"kategorien": kategorien,
"optionen_felder": OPTIONEN_FELDER,
"einladungen": einladungen,
"email_domaenen": email_domaenen(user.organization_id) if einladungen is not None else [],
"personalisierung_status": PERSONALISIERUNG_STATUS,
"einladung_zurueck": f"/struktur/{oe_id}/merkmale",
},
)
+39
View File
@@ -0,0 +1,39 @@
{# Formular „Einladung senden“ für ein Gerät. Erwartet: einladung_device_id,
email_domaenen, einladung_konten (bool), einladung_laufend (bool),
optional einladung_zurueck (Seite, auf die es danach zurückgeht). #}
<form method="post" action="/geraete/{{ einladung_device_id }}/personalisierung/einladung">
{% if einladung_zurueck %}<input type="hidden" name="zurueck" value="{{ einladung_zurueck }}">{% endif %}
{% if email_domaenen %}
<fieldset role="group">
<input type="text" name="email_lokal" required autocomplete="off" placeholder="vorname.nachname" aria-label="E-Mail-Adresse">
{% if email_domaenen | length == 1 %}
<input type="hidden" name="email_domain" value="{{ email_domaenen[0] }}">
<input type="text" value="@{{ email_domaenen[0] }}" readonly tabindex="-1" aria-label="Domäne">
{% else %}
<select name="email_domain" aria-label="Domäne">
{% for d in email_domaenen %}<option value="{{ d }}">@{{ d }}</option>{% endfor %}
</select>
{% endif %}
{% if einladung_zurueck %}<button type="submit" class="outline">Einladung senden</button>{% endif %}
</fieldset>
{% elif einladung_zurueck %}
<fieldset role="group">
<input type="email" name="email" required autocomplete="off" placeholder="E-Mail-Adresse" aria-label="E-Mail-Adresse">
<button type="submit" class="outline">Einladung senden</button>
</fieldset>
{% else %}
<input type="email" name="email" required autocomplete="off" aria-label="E-Mail-Adresse">
{% endif %}
{% if einladung_konten and not einladung_zurueck %}
<label>
<input type="checkbox" name="bestehenden_behalten" value="1">
Bestehenden Zugang behalten
</label>
<small>Ohne Haken wird das bisherige Konto sofort gesperrt und samt seinen Dateien entfernt, sobald das neue eingerichtet ist.</small>
{% endif %}
{% if not einladung_zurueck %}
<button type="submit" class="outline">
{% if einladung_laufend %}Neue Einladung senden (ersetzt die laufende){% elif einladung_konten %}Erneut personalisieren{% else %}Einladung senden{% endif %}
</button>
{% endif %}
</form>
+39
View File
@@ -0,0 +1,39 @@
{# Einladungen für alle Geräte einer Gruppe bzw. eines Standorts/Zwecks mit
aktivem „Personalisierung möglich“. Erwartet: einladungen, email_domaenen,
einladung_zurueck. #}
<section id="einladungen">
<h3>Personalisierung: Einladungen</h3>
{% if request.query_params.get("einladung_ok") %}<p><ins>Einladung an {{ request.query_params.get("einladung_ok") }} verschickt. Der Link gilt 7 Tage.</ins></p>{% endif %}
{% if request.query_params.get("einladung_fehler") %}<p><mark>{{ request.query_params.get("einladung_fehler") }}</mark></p>{% endif %}
<p><small>Trage bei jedem Gerät die E-Mail-Adresse der Person ein, die es bekommt. Sie erhält einen Link, wählt Anmeldenamen und Passwort und startet das Gerät neu. Bestehende Konten ersetzen, sperren oder zurücksetzen geht auf der Seite des Geräts.</small></p>
{% if not einladungen %}
<p>Hier sind noch keine Geräte.</p>
{% else %}
<figure>
<table>
<thead><tr><th>Gerät</th><th>Stand</th><th>Einladen</th></tr></thead>
<tbody>
{% for z in einladungen %}
<tr>
<td><a href="/geraete/{{ z.device.id }}#personalisierung">{{ z.device.hostname or z.device.device_fingerprint }}</a></td>
<td>
{% if z.konten %}{% for k in z.konten %}<strong>{{ k.anmeldename }}</strong> ({{ k.email }}){% if not loop.last %}, {% endif %}{% endfor %}
{% elif z.laufend %}<small>{{ personalisierung_status[z.laufend.status] }} – {{ z.laufend.email }}</small>
{% else %}<small>noch niemand</small>{% endif %}
</td>
<td>
{% if z.konten or z.laufend %}
<a href="/geraete/{{ z.device.id }}#personalisierung"><small>auf der Geräteseite</small></a>
{% else %}
{% with einladung_device_id=z.device.id, einladung_konten=false, einladung_laufend=false %}
{% include "_einladung_formular.html" %}
{% endwith %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% endif %}
</section>
+10 -33
View File
@@ -53,8 +53,7 @@
<p><small>
Trage die E-Mail-Adresse der Person ein, die dieses Gerät bekommt. Sie
erhält einen Link, wählt darüber Anmeldenamen und Passwort und startet
das Gerät danach neu; nach bis zu 2 Minuten kann sie sich anmelden. Das
Gerät braucht dafür das Merkmal „Personalisierung möglich“.
das Gerät danach neu; nach bis zu 2 Minuten kann sie sich anmelden.
</small></p>
{% if einladung and einladung.gesendet_an %}
@@ -130,38 +129,16 @@
</div>
{% endif %}
{% if personalisierung_moeglich %}
<form method="post" action="/geraete/{{ device.id }}/personalisierung/einladung">
{% if email_domaenen %}
<label for="email_lokal">E-Mail-Adresse</label>
<fieldset role="group">
<input type="text" id="email_lokal" name="email_lokal" required autocomplete="off" placeholder="vorname.nachname">
{% if email_domaenen | length == 1 %}
<input type="hidden" name="email_domain" value="{{ email_domaenen[0] }}">
<input type="text" value="@{{ email_domaenen[0] }}" readonly tabindex="-1" aria-label="Domäne">
{% else %}
<select name="email_domain" aria-label="Domäne">
{% for d in email_domaenen %}<option value="{{ d }}">@{{ d }}</option>{% endfor %}
</select>
{% endif %}
</fieldset>
{% else %}
<label for="email">E-Mail-Adresse</label>
<input type="email" id="email" name="email" required autocomplete="off">
{% endif %}
{% if personalisierung_konten %}
<label>
<input type="checkbox" name="bestehenden_behalten" value="1">
Bestehenden Zugang behalten
</label>
<small>Ohne Haken wird das bisherige Konto sofort gesperrt und samt seinen Dateien entfernt, sobald das neue eingerichtet ist.</small>
{% endif %}
<button type="submit" class="outline">
{% if personalisierung_laufend %}Neue Einladung senden (ersetzt die laufende){% elif personalisierung_konten %}Erneut personalisieren{% else %}Einladung senden{% endif %}
</button>
</form>
{% if not personalisierung_verfuegbar %}
<p><small>Für dieses Gerät gibt es keine Personalisierung (etwa bei Ubuntu-Workspaces).</small></p>
{% else %}
<p><small>Für dieses Gerät ist „Personalisierung möglich“ nicht gesetzt (Workspace, Standort/Zweck oder Gruppe).</small></p>
{% if not personalisierung_moeglich %}
<p><small>Mit dem Senden kommt das Gerät in die Gruppe „Personalisierte Geräte“; damit ist „Personalisierung möglich“ für das Gerät gesetzt.</small></p>
{% endif %}
<label>E-Mail-Adresse</label>
{% with einladung_device_id=device.id, einladung_konten=personalisierung_konten | length > 0, einladung_laufend=personalisierung_laufend is not none, einladung_zurueck=none %}
{% include "_einladung_formular.html" %}
{% endwith %}
{% endif %}
{% if personalisierungen %}
+4
View File
@@ -75,6 +75,10 @@
{% endif %}
</section>
{% if einladungen is not none %}
{% include "_einladungen_liste.html" %}
{% endif %}
<section>
<h3>Geräte in dieser Gruppe</h3>
{% if not devices %}
+4
View File
@@ -71,4 +71,8 @@
</details>
{% endfor %}
{% endif %}
{% if einladungen is not none %}
{% include "_einladungen_liste.html" %}
{% endif %}
{% endblock %}