feat(tags): Backend mit REST-API und Client-Anbindung fuer hierarchische Tags
- SQLAlchemy-Modell Tag mit Selbst-Referenz (parent_id), is_parent-Flag - DB-Migration V01.10 (idempotent via create_all) - Geschaeftslogik tag.py: CRUD, Hierarchie-Aufbau, kaskadierendes Loeschen, Zirkelbezug-Pruefung - REST-Endpunkte /xhr/tag/*: Lesen fuer Mitarbeiter, Schreiben fuer AppAdmin - Client-Modell tag.ts mit REST-Funktionen - Frontend tags.ts auf REST-API umgestellt, hartcodierte Daten entfernt, Loesch-Dialog mit kaskadierender Bestaetigung Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
1 parent
675d7775c1
commit
5abcb2d96c
6 files changed
+600
-648
No files matched your search
@@ -276,4 +276,38 @@ class CallLog(dbapp.Model):
|
||||
return result
|
||||
|
||||
|
||||
class Tag(dbapp.Model):
|
||||
"""
|
||||
Hierarchischer Tag (Taxonomie). Tags koennen beliebig verschachtelt
|
||||
werden ueber die Selbst-Referenz parent_id -> tags.id. Ein Tag mit
|
||||
is_parent=True ist ein reiner Gruppierungsknoten (nicht zuordenbar);
|
||||
is_parent=False kennzeichnet einen echten, zuordenbaren Tag.
|
||||
"""
|
||||
__tablename__ = 'tags'
|
||||
__bind_key__ = BINDKEY
|
||||
__table_args__ = (
|
||||
Index('ix_tags_parent_id', 'parent_id'),
|
||||
)
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(String(2000), nullable=True)
|
||||
color = Column(String(20), nullable=True)
|
||||
parent_id = Column(Integer, ForeignKey('tags.id'), nullable=True)
|
||||
is_parent = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Selbst-Referenzierende Relationship: parent verweist auf den
|
||||
# uebergeordneten Tag, children auf alle direkten Kinder. Die
|
||||
# Sortierung der Kinder erfolgt alphabetisch nach name.
|
||||
parent = relationship(
|
||||
'Tag', remote_side='Tag.id', back_populates='children'
|
||||
)
|
||||
children = relationship(
|
||||
'Tag', back_populates='parent', order_by='Tag.name'
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Liefert die Tag-Daten als Dictionary (fuer JSON-Serialisierung)."""
|
||||
return columns2dict(self)
|
||||
|
||||
|
||||
print("########## crm/model importiert")
|
||||
@@ -221,6 +221,17 @@ def _db_V01_09():
|
||||
session.commit()
|
||||
|
||||
|
||||
def _db_V01_10():
|
||||
"""
|
||||
Legt die Tabelle 'tags' an. Tags sind eine hierarchische Taxonomie
|
||||
mit Selbst-Referenz (parent_id -> tags.id). create_all erzeugt nur
|
||||
fehlende Tabellen, bestehende bleiben unangetastet.
|
||||
"""
|
||||
dbapp.create_all(bind_key=BINDKEY)
|
||||
_updatesysteminfo('01.10')
|
||||
session.commit()
|
||||
|
||||
|
||||
def checkupdates():
|
||||
"""
|
||||
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Geschäftslogik für die Entität Tag (hierarchische Taxonomie).
|
||||
|
||||
Tags koennen beliebig verschachtelt werden (parent_id -> tags.id).
|
||||
Ein Tag mit is_parent=True ist ein Gruppierungsknoten und nicht
|
||||
zuordenbar; is_parent=False kennzeichnet einen echten, zuordenbaren Tag.
|
||||
|
||||
Tags sind nicht projektbezogen — die projektbezogene Sichtbarkeits-
|
||||
Filterung entfällt hier vollständig.
|
||||
|
||||
:author: Mistral Vibe <vibe@mistral.ai>
|
||||
:date: 2026-09-19
|
||||
:version: 1.0
|
||||
"""
|
||||
from db import dbapp
|
||||
from crm.model import Tag
|
||||
|
||||
from auth.model.errors import ApplicationError, HtmlStatusCode
|
||||
|
||||
session = dbapp.session
|
||||
|
||||
|
||||
def get_tags(parent_id: int | None = None) -> list[Tag]:
|
||||
"""
|
||||
Liefert Tags, sortiert nach name (alphabetisch).
|
||||
|
||||
Ist parent_id gesetzt, werden nur die direkten Kinder dieses
|
||||
Tags geliefert. Ohne parent_id werden alle Tags geliefert.
|
||||
"""
|
||||
query = Tag.query
|
||||
if parent_id is not None:
|
||||
query = query.filter(Tag.parent_id == parent_id)
|
||||
return query.order_by(Tag.name).all()
|
||||
|
||||
|
||||
def get_tag(tag_id: int) -> Tag:
|
||||
"""
|
||||
Liefert einen einzelnen Tag anhand seiner ID.
|
||||
Wirft ApplicationError (404), falls der Tag nicht existiert.
|
||||
"""
|
||||
tag = Tag.query.get(tag_id)
|
||||
if tag is None:
|
||||
raise ApplicationError(
|
||||
"Tag mit der ID {} wurde nicht gefunden.".format(tag_id),
|
||||
htmlstatus=HtmlStatusCode.Not_Found,
|
||||
)
|
||||
return tag
|
||||
|
||||
|
||||
def create_tag(data: dict) -> Tag:
|
||||
"""
|
||||
Legt einen neuen Tag an. Die vom Client übergebene ID wird ignoriert;
|
||||
die ID wird serverseitig per Auto-Increment vergeben.
|
||||
|
||||
- data: dict mit den Feldern name, description, color, parent_id,
|
||||
is_parent
|
||||
"""
|
||||
if not data.get("name") or not str(data["name"]).strip():
|
||||
raise ApplicationError(
|
||||
"Der Tag-Name darf nicht leer sein.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
parent_id = data.get("parent_id")
|
||||
if parent_id is not None:
|
||||
parent_id = int(parent_id)
|
||||
# Prüfen, ob der Eltern-Tag existiert
|
||||
parent = Tag.query.get(parent_id)
|
||||
if parent is None:
|
||||
raise ApplicationError(
|
||||
"Der übergeordnete Tag existiert nicht.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
tag = Tag()
|
||||
tag.name = data["name"].strip()
|
||||
tag.description = (data.get("description") or "").strip() or None
|
||||
tag.color = (data.get("color") or "").strip() or None
|
||||
tag.parent_id = parent_id
|
||||
tag.is_parent = bool(data.get("is_parent", False))
|
||||
session.add(tag)
|
||||
session.commit()
|
||||
return tag
|
||||
|
||||
|
||||
def update_tag(data: dict) -> Tag:
|
||||
"""
|
||||
Aktualisiert einen bestehenden Tag. Die ID im data-Dict bestimmt
|
||||
den zu ändernden Datensatz.
|
||||
|
||||
Verhindert Zirkelbezüge: Ein Tag kann nicht sein eigener Vorfahr
|
||||
als Eltern-Tag gesetzt werden.
|
||||
"""
|
||||
tag = get_tag(data["id"])
|
||||
if not data.get("name") or not str(data["name"]).strip():
|
||||
raise ApplicationError(
|
||||
"Der Tag-Name darf nicht leer sein.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
parent_id = data.get("parent_id")
|
||||
if parent_id is not None:
|
||||
parent_id = int(parent_id)
|
||||
# Prüfen, ob der Eltern-Tag existiert
|
||||
parent = Tag.query.get(parent_id)
|
||||
if parent is None:
|
||||
raise ApplicationError(
|
||||
"Der übergeordnete Tag existiert nicht.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
# Zirkelbezug-Prüfung: Der neue Eltern-Tag darf nicht ein
|
||||
# Nachfahre des zu ändernden Tags sein (sonst entsteht ein Zyklus).
|
||||
if parent_id == tag.id:
|
||||
raise ApplicationError(
|
||||
"Ein Tag kann nicht sich selbst als übergeordneten Tag haben.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
descendant_ids = {d.id for d in get_descendants(tag.id)}
|
||||
if parent_id in descendant_ids:
|
||||
raise ApplicationError(
|
||||
"Der übergeordnete Tag ist ein Nachfahre dieses Tags. "
|
||||
"Das würde einen Zirkelbezug erzeugen.",
|
||||
htmlstatus=HtmlStatusCode.Bad_Request,
|
||||
)
|
||||
tag.name = data["name"].strip()
|
||||
tag.description = (data.get("description") or "").strip() or None
|
||||
tag.color = (data.get("color") or "").strip() or None
|
||||
tag.parent_id = parent_id
|
||||
tag.is_parent = bool(data.get("is_parent", False))
|
||||
session.commit()
|
||||
return tag
|
||||
|
||||
|
||||
def delete_tag(tag_id: int):
|
||||
"""
|
||||
Löscht einen Tag inkl. aller Nachfahren (kaskadierend).
|
||||
|
||||
Die Nachfahren werden zuerst (bottom-up) gelöscht, damit der
|
||||
Fremdschlüssel auf parent_id nicht verletzt wird. Zuletzt wird
|
||||
der Tag selbst gelöscht.
|
||||
|
||||
Wirft ApplicationError (404), falls der Tag nicht existiert.
|
||||
"""
|
||||
tag = get_tag(tag_id)
|
||||
# Alle Nachfahren ermitteln und bottom-up löschen (Kinder vor Eltern)
|
||||
descendants = get_descendants(tag_id)
|
||||
for descendant in reversed(descendants):
|
||||
session.delete(descendant)
|
||||
session.delete(tag)
|
||||
session.commit()
|
||||
|
||||
|
||||
def get_descendants(tag_id: int) -> list[Tag]:
|
||||
"""
|
||||
Liefert alle Nachfahren (Kinder, Enkel, ...) eines Tags als flache
|
||||
Liste. Die Reihenfolge ist top-down (Eltern vor Kindern), was für
|
||||
die kaskadierende Löschung bottom-up (reversed) verwendet wird.
|
||||
"""
|
||||
descendants: list[Tag] = []
|
||||
children = Tag.query.filter(Tag.parent_id == tag_id).order_by(Tag.name).all()
|
||||
for child in children:
|
||||
descendants.append(child)
|
||||
descendants.extend(get_descendants(child.id))
|
||||
return descendants
|
||||
|
||||
|
||||
def get_tag_hierarchy() -> list[dict]:
|
||||
"""
|
||||
Liefert die vollständige Tag-Hierarchie als verschachtelte Struktur.
|
||||
|
||||
Wurzel-Tags (parent_id=None) bilden die oberste Ebene. Jeder Tag
|
||||
erhält ein 'children'-Array mit seinen rekursiv eingebetteten Kindern.
|
||||
Die Sortierung erfolgt alphabetisch nach name (durch die Relationship-
|
||||
Definition order_by='Tag.name').
|
||||
"""
|
||||
roots = Tag.query.filter(Tag.parent_id.is_(None)).order_by(Tag.name).all()
|
||||
return [_tag_to_dict_with_children(t) for t in roots]
|
||||
|
||||
|
||||
def _tag_to_dict_with_children(tag: Tag) -> dict:
|
||||
"""Wandelt einen Tag inkl. Kinder in ein verschachteltes Dict um."""
|
||||
result = tag.to_dict()
|
||||
result['children'] = [_tag_to_dict_with_children(c) for c in tag.children]
|
||||
return result
|
||||
Reference in new issue
Block a user