Compare commits

...
1 Commits
Author SHA1 Message Date
alientim 9d5eac1db2 Version 2.8.7: drei Standardgruppen, Module abschaltbar 2026-09-13 00:19:54 +02:00
11 changed files with 425 additions and 94 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.8.6"
version = "2.8.7"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+80 -1
View File
@@ -18,6 +18,8 @@ from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from tesm_core.licensing_client import LicenseError
import tesm_licensing as lic
bp = Blueprint("settings", __name__, url_prefix="/administration")
@@ -46,6 +48,28 @@ def _license_hint(feature: str) -> str:
return ""
def _module_rows(conn: Any) -> list[dict[str, Any]]:
"""Die drei Module mit ihrem Zustand -- lizenziert, benutzt.
Zwei verschiedene Fragen, die die Seite auseinanderhalten muss: was die
Lizenz erlaubt (dagegen hilft kein Schalter) und was dieses Haus benutzen
will (dafuer ist der Schalter da).
"""
manager = core().license
if manager is None:
return []
state = manager.reload(conn)
return [
{
"key": name,
"label": lic.MODULE_LABELS.get(name, name),
"licensed": name in state.modules,
"enabled": name not in state.modules_off,
}
for name in lic.ALL_MODULES
]
def _current_timezone() -> str:
sysops = core().sysops
if sysops is None:
@@ -94,11 +118,16 @@ def system():
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
timezone=_current_timezone(),
timezones=_timezone_choices(),
nav_items=extension.nav.items,
# Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
nav_baum=extension.nav.items,
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
license_hint=_license_hint("monitor_interval"),
sysops_available=bool(sysops and sysops.available),
module_rows=_module_rows(conn),
)
@@ -167,6 +196,56 @@ def system_save():
return redirect(url_for("settings.system"))
@bp.post("/system/modules")
@permission_required("settings_system.write")
def modules_save():
"""Module ein- und ausschalten.
Abgeschaltet heisst wirklich abgeschaltet: der Punkt verschwindet aus der
Navigation, und die Routen antworten mit 404 -- dieselbe Sperre, die auch
eine fehlende Lizenz zieht. Daten bleiben unangetastet; wer das Modul
wieder einschaltet, findet alles vor wie zuvor.
"""
extension = core()
manager = extension.license
if manager is None:
flash("Diese Installation nutzt keine Lizenzierung.", "info")
return redirect(url_for("settings.system"))
aus = [name for name in lic.ALL_MODULES if request.form.get(f"module_{name}") != "1"]
with transaction() as conn:
eingeschaltet, abgeschaltet = manager.module_abschalten(
conn, aus, actor=current_user.username
)
if eingeschaltet or abgeschaltet:
teile = []
if abgeschaltet:
teile.append(
"abgeschaltet: "
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(abgeschaltet))
)
if eingeschaltet:
teile.append(
"eingeschaltet: "
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(eingeschaltet))
)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="settings.modules_changed",
detail="; ".join(teile),
ip=client_ip(),
severity="notice",
)
if eingeschaltet or abgeschaltet:
flash("Module gespeichert.", "success")
else:
flash("Keine Änderungen.", "info")
return redirect(url_for("settings.system"))
@bp.post("/system/hostname")
@permission_required("settings_system.execute")
def set_hostname():
+21 -1
View File
@@ -69,13 +69,33 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
)
user_id = int(cursor.lastrowid or 0)
action = "angelegt"
# Der Vollzugriff gehoert dazu. Seit 2.8.0 gibt das
# Administratorkennzeichen den Verwaltungsbereich und sonst
# nichts; Funktionsrechte kommen ueber Gruppen. Ein Konto, das
# jemand an der Kommandozeile anlegt, ist aber der Notausgang --
# es entsteht, wenn sonst niemand mehr hineinkommt, und dann
# nuetzt eine Verwaltung ohne Geraete, Freigaben und Protokolle
# wenig. Wer das nicht will, nimmt die Gruppe in einem Klick
# wieder weg.
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
gruppe = rbac.funktionsgruppe_anlegen(
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
)
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
(user_id, gruppe),
)
audit.log(
conn,
actor="cli",
actor_id=user_id,
action="user.admin_created",
target=username,
detail=f"über die Kommandozeile {action}",
detail=(
f"über die Kommandozeile {action}, Gruppe "
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
),
severity="warning",
)
print(f"Administrator {username!r} {action}.")
+9 -72
View File
@@ -388,6 +388,15 @@ FUNKTIONEN_VOLLZUGRIFF = RolePreset(
)
#: Die Gruppen, die eine frische Installation mitbringt -- drei, nicht sieben.
#:
#: Vorher standen hier zusaetzlich Betrieb, Support, Netzwerk und Revision.
#: Gut gemeint, aber sie beantworten eine Frage, die jeder Betrieb anders
#: beantwortet: welche Rolle es gibt, weiss der Kunde, nicht die Anwendung.
#: Was bleibt, sind die drei, ohne die man nicht anfangen kann -- eine
#: Standardgruppe fuer jeden neuen Nutzer, eine fuer die Verwaltung und eine,
#: die alle Funktionen buendelt. Weitere legt man in der Matrix an, und die
#: Matrix zeigt seit 2.8.0 je Funktion eine Zeile.
ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset(
key="viewer",
@@ -400,78 +409,6 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
),
is_default=True,
),
RolePreset(
key="operations",
name="Betrieb",
description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.",
grants=(
("devices", "*"),
("device_restart_poe", "*"),
("device_restart_ssh", "*"),
("device_restart_rpc", "*"),
("device_console", "*"),
("switches", (A.READ, A.CREATE, A.WRITE)),
("switch_console", "*"),
("hostkeys", "*"),
("credentials", (A.READ,)),
("servicing_update", "*"),
("servicing_reboot", "*"),
("servicing_tool", "*"),
("logs_live", (A.READ,)),
("logs_history", (A.READ,)),
("trash", (A.READ, A.WRITE)),
),
),
RolePreset(
key="support",
name="Support",
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.",
grants=(
("devices", (A.READ,)),
# Nur der PoE-Weg: der wirkt auch bei einem haengenden Geraet und
# unterbricht nichts Laufendes. SSH und RPC fahren ein arbeitendes
# System herunter -- das gehoert nicht in die erste Hilfe.
("device_restart_poe", "*"),
("switches", (A.READ,)),
("logs_live", (A.READ,)),
),
),
RolePreset(
key="network",
name="Netzwerk",
description="DHCP und Host-Netzwerkeinstellungen.",
grants=(
("dhcp_subnets", "*"),
("dhcp_options", "*"),
("dhcp_reservations", "*"),
("dhcp_config", "*"),
("dhcp_service", "*"),
("settings_network", "*"),
("logs_dhcp", (A.READ,)),
("devices", (A.READ,)),
("switches", (A.READ,)),
),
),
RolePreset(
key="auditor",
name="Revision",
description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
grants=(
("devices", (A.READ,)),
("switches", (A.READ,)),
("credentials", (A.READ,)),
("dhcp_subnets", (A.READ,)),
("dhcp_options", (A.READ,)),
("dhcp_reservations", (A.READ,)),
("users", (A.READ,)),
("groups", (A.READ,)),
("logs_live", (A.READ,)),
("logs_history", (A.READ, A.EXPORT)),
("logs_audit", (A.READ, A.EXPORT)),
("diagnostics", (A.READ,)),
("trash", (A.READ,)),
),
),
RolePreset(
key="administration",
name="Administration",
+120
View File
@@ -23,6 +23,8 @@ Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
from __future__ import annotations
from typing import Any
from tesm_core.migrations import Migration
V1 = """
@@ -393,6 +395,123 @@ def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
#: Die vier Vorlagen, die es bis 2.8.6 gab und ab 2.8.7 nicht mehr.
#:
#: Stehen hier als eingefrorene Kopie, weil eine Migration ihre eigenen Daten
#: mitbringen muss: in ``permissions.py`` sind sie weg, und trotzdem muss
#: dieser Schritt noch erkennen koennen, ob eine vorgefundene Gruppe wirklich
#: unveraendert die ausgelieferte ist.
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
(
"operations",
"Betrieb",
(
("devices", "*"),
("device_restart_poe", "*"),
("device_restart_ssh", "*"),
("device_restart_rpc", "*"),
("device_console", "*"),
("switches", ("read", "create", "write")),
("switch_console", "*"),
("hostkeys", "*"),
("credentials", ("read",)),
("servicing_update", "*"),
("servicing_reboot", "*"),
("servicing_tool", "*"),
("logs_live", ("read",)),
("logs_history", ("read",)),
("trash", ("read", "write")),
),
),
(
"support",
"Support",
(
("devices", ("read",)),
("device_restart_poe", "*"),
("switches", ("read",)),
("logs_live", ("read",)),
),
),
(
"network",
"Netzwerk",
(
("dhcp_subnets", "*"),
("dhcp_options", "*"),
("dhcp_reservations", "*"),
("dhcp_config", "*"),
("dhcp_service", "*"),
("settings_network", "*"),
("logs_dhcp", ("read",)),
("devices", ("read",)),
("switches", ("read",)),
),
),
(
"auditor",
"Revision",
(
("devices", ("read",)),
("switches", ("read",)),
("credentials", ("read",)),
("dhcp_subnets", ("read",)),
("dhcp_options", ("read",)),
("dhcp_reservations", ("read",)),
("users", ("read",)),
("groups", ("read",)),
("logs_live", ("read",)),
("logs_history", ("read", "export")),
("logs_audit", ("read", "export")),
("diagnostics", ("read",)),
("trash", ("read",)),
),
),
)
def eingefrorene_vorlagen(
beschreibung: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...],
) -> list[Any]:
"""Baut aus der Kopie oben wieder ``RolePreset``-Objekte.
Die Aktionen stehen dort als Text und nicht als ``Action``, damit diese
Migration auch dann noch lesbar bleibt, wenn das Enum sich weiterentwickelt.
"""
from tesm_core.rbac.model import Action, RolePreset
vorlagen = []
for key, name, grants in beschreibung:
gewandelt = tuple(
(ressource, aktionen if aktionen == "*" else tuple(Action(a) for a in aktionen))
for ressource, aktionen in grants
)
vorlagen.append(RolePreset(key=key, name=name, description="", grants=gewandelt))
return vorlagen
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
Was eine Anwendung an Rollen mitbringt, ist eine Vermutung darueber, wie
ein Betrieb organisiert ist. Sieben davon sind sechs zu viele: wer sie
nicht braucht, muss sie trotzdem jedes Mal ueberlesen, und in der
Rechtematrix stand fuer jede eine Spalte. Es bleiben "Benutzer",
"Administration" und "Funktionen (Vollzugriff)".
Weggeraeumt wird nur, was unberuehrt ist -- keine Mitglieder, Rechte
unveraendert. Alles andere bleibt stehen, wo es steht. Und auch das
Weggeraeumte liegt nur im Papierkorb.
"""
from tesm_core.rbac import service as rbac
from .permissions import PERMISSIONS
rbac.unberuehrte_vorlagen_entfernen(
conn, PERMISSIONS, eingefrorene_vorlagen(ABGELEGTE_VORLAGEN)
)
TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
@@ -412,4 +531,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
),
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
)
+10 -1
View File
@@ -60,9 +60,18 @@
<dd>
{% if state.modules %}
{% for module in modules %}
{% if module in state.modules %}{{ pill(module_labels[module], 'online') }}
{% if module in state.modules and module in state.modules_off %}
{{ pill(module_labels[module] ~ ' (abgeschaltet)', 'warning') }}
{% elif module in state.modules %}{{ pill(module_labels[module], 'online') }}
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
{% endfor %}
{% if state.modules_off %}
<div class="fs-xs faint">
Abgeschaltet wurde in
<a href="{{ url_for('settings.system') }}">Verwaltung → System</a>;
die Lizenz ist davon unberührt.
</div>
{% endif %}
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
</dd>
<dt>Lizenzserver</dt>
@@ -51,6 +51,34 @@
</form>
</section>
{% if module_rows %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} Module</div>
<div class="card__sub">
Die Lizenz sagt, was erlaubt ist – hier steht, was dieses Haus benutzt.
Ein abgeschaltetes Modul verschwindet aus der Navigation, und seine Seiten
antworten wie ohne Lizenz. Bestehende Daten bleiben unverändert stehen und
sind nach dem Wiedereinschalten wieder da.
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.modules_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{% for modul in module_rows %}
{{ checkbox('module_' ~ modul.key, modul.label, checked=modul.enabled,
hint_text=('In der Lizenz enthalten.' if modul.licensed
else 'Nicht lizenziert – bleibt aus, bis die Lizenz es
freischaltet. Der Haken gilt dann sofort.')) }}
{% endfor %}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
{% endif %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -189,7 +217,7 @@
</section>
{% if current_user.is_admin %}
{{ nav_order_card(nav_items, nav_order, nav_child_order,
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
{% endif %}
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.8.2"
version = "2.8.3"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -102,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
),
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
SettingSpec(
"modules_disabled",
[],
"json",
"Vor Ort abgeschaltete Module",
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
section="system",
),
SettingSpec(
"license_server_ca",
"",
@@ -34,7 +34,7 @@ import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from typing import Any, Callable, Iterable
import tesm_licensing as lic
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
EXPIRY_HINT_DAYS = 90
SETTING_EXPORT_GRACE = "license_export_grace"
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
#:
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
#: mitbringen.
SETTING_MODULES_OFF = "modules_disabled"
STATUS_INACTIVE = "inactive"
STATUS_PENDING = "pending"
STATUS_ACTIVE = "active"
@@ -121,6 +136,8 @@ class LicenseState:
last_heartbeat_at: str = ""
heartbeat_error: str = ""
pending_action: str = ""
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
modules_off: frozenset[str] = frozenset()
@property
def role(self) -> str:
@@ -128,8 +145,14 @@ class LicenseState:
@property
def modules(self) -> frozenset[str]:
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
return self.status.modules if self.status else frozenset()
@property
def modules_in_use(self) -> frozenset[str]:
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
return self.modules - self.modules_off
@property
def customer_name(self) -> str:
return str((self.status.customer if self.status else {}).get("name") or "")
@@ -179,6 +202,7 @@ class LicenseManager:
"""Liest Lizenzdatei und Status neu ein."""
with self._lock:
state = LicenseState()
state.modules_off = self.abgeschaltete_module(conn)
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
pending = self._pending(conn)
@@ -239,9 +263,51 @@ class LicenseManager:
)
def module_active(self, name: str) -> bool:
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
"""
return (
self.active()
and name in self._state.modules
and name not in self._state.modules_off
)
def module_licensed(self, name: str) -> bool:
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
return self.active() and name in self._state.modules
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
"""Liest die Liste aus den Einstellungen."""
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
gespeichert = [gespeichert]
return frozenset(str(name) for name in gespeichert)
def module_abschalten(
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
) -> tuple[frozenset[str], frozenset[str]]:
"""Setzt die Liste der abgeschalteten Module neu.
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
Protokolleintrag.
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
das Einzige, was jetzt anders ist.
"""
vorher = self.abgeschaltete_module(conn)
nachher = frozenset(str(name) for name in namen)
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
with self._lock:
self._state.modules_off = nachher
return vorher - nachher, nachher - vorher
def feature_allowed(self, feature: str) -> bool:
"""Lizenzpflichtige Einzelfunktion.
@@ -328,6 +328,83 @@ def ensure_presets(
return created
def unberuehrte_vorlagen_entfernen(
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
) -> list[str]:
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
arbeitet.
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
jemand hat sie uebernommen.
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
jemand hat sie angepasst, also ist sie seine, nicht unsere.
* Sie ist keine Systemgruppe.
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
"""
entfernt: list[str] = []
gruppen = list_groups(conn)
for preset in presets:
gruppe = next(
(
g
for g in gruppen
if g.preset_key
and g.preset_key == preset.key
and g.name.lower() == preset.name.lower()
),
None,
)
if gruppe is None or gruppe.is_system or gruppe.member_count:
continue
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
continue
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
entfernt.append(gruppe.name)
return entfernt
def funktionsgruppe_anlegen(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
abgelegt hat, sie beim naechsten Aufruf wieder ein.
"""
zeile = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if zeile is not None:
return int(zeile["id"])
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
(preset.name, preset.description, preset.key, _now(), _now()),
)
gruppe = int(cursor.lastrowid or 0)
_write_permissions(conn, gruppe, tree.expand_preset(preset))
return gruppe
def funktionsgruppe_fuer_administratoren(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
@@ -345,20 +422,7 @@ def funktionsgruppe_fuer_administratoren(
Gibt die Nummer der Gruppe zurueck.
"""
zeile = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if zeile is None:
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
(preset.name, preset.description, preset.key, _now(), _now()),
)
gruppe = int(cursor.lastrowid or 0)
_write_permissions(conn, gruppe, tree.expand_preset(preset))
else:
gruppe = int(zeile["id"])
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[