feat: Migration 009 - Zielgruppen M:N Refactoring
Backend Phase A (Database + API):
- Migration 009: target_groups.training_style_id entfernt
- Migration 009: Neue Junction-Tabelle training_style_target_groups
- Migration 009: Datenmigration alte → neue Struktur (idempotent)
- API: 5 neue Endpoints für M:N Management
* GET /training-style-target-groups (mit Enrichment)
* POST /training-style-target-groups (Upsert-Logik)
* PUT /training-style-target-groups/{id} (is_primary)
* DELETE /training-style-target-groups/{id}
* GET /training-styles/hierarchy (für Tree-View)
- API: GET/POST/PUT /target-groups jetzt global (ohne training_style_id)
- API: DELETE /target-groups prüft beide Tabellen (CASCADE-Schutz)
Architecture:
- Eine Zielgruppe kann mehreren Stilen zugeordnet werden
- Basis für Admin Tree-View + M:N Matrix UI
Version: 0.3.4
Module: catalogs 1.3.0
This commit is contained in:
parent
2a5f06a8f5
commit
1e5e18c0b3
|
|
@ -721,3 +721,254 @@ def delete_target_group(target_group_id: int, session=Depends(require_auth)):
|
|||
conn.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
# TRAINING STYLE → TARGET GROUPS (M:N Assignments)
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/training-style-target-groups")
|
||||
def list_training_style_target_groups(
|
||||
training_style_id: Optional[int] = Query(default=None),
|
||||
target_group_id: Optional[int] = Query(default=None),
|
||||
is_primary: Optional[bool] = Query(default=None),
|
||||
session=Depends(require_auth)
|
||||
):
|
||||
"""List M:N assignments between training styles and target groups.
|
||||
|
||||
Returns enriched data with training_style_name, target_group_name,
|
||||
focus_area_name for easy display in Matrix UI.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
tstg.id,
|
||||
tstg.training_style_id,
|
||||
tstg.target_group_id,
|
||||
tstg.is_primary,
|
||||
tstg.created_at,
|
||||
ts.name as training_style_name,
|
||||
ts.focus_area_id,
|
||||
fa.name as focus_area_name,
|
||||
tg.name as target_group_name,
|
||||
tg.min_age,
|
||||
tg.max_age
|
||||
FROM training_style_target_groups tstg
|
||||
LEFT JOIN training_styles ts ON tstg.training_style_id = ts.id
|
||||
LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id
|
||||
LEFT JOIN target_groups tg ON tstg.target_group_id = tg.id
|
||||
"""
|
||||
params = []
|
||||
where = []
|
||||
|
||||
if training_style_id is not None:
|
||||
where.append("tstg.training_style_id = %s")
|
||||
params.append(training_style_id)
|
||||
|
||||
if target_group_id is not None:
|
||||
where.append("tstg.target_group_id = %s")
|
||||
params.append(target_group_id)
|
||||
|
||||
if is_primary is not None:
|
||||
where.append("tstg.is_primary = %s")
|
||||
params.append(is_primary)
|
||||
|
||||
if where:
|
||||
query += " WHERE " + " AND ".join(where)
|
||||
|
||||
query += " ORDER BY fa.sort_order, ts.sort_order, tg.sort_order"
|
||||
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
return [r2d(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/training-style-target-groups")
|
||||
def create_training_style_target_group(data: dict, session=Depends(require_auth)):
|
||||
"""Assign target group to training style (admin only).
|
||||
|
||||
Uses UPSERT logic - if assignment exists, updates is_primary flag.
|
||||
"""
|
||||
role = session.get('role')
|
||||
if role not in ['admin', 'superadmin']:
|
||||
raise HTTPException(403, "Nur Admins dürfen Zuordnungen erstellen")
|
||||
|
||||
training_style_id = data.get('training_style_id')
|
||||
target_group_id = data.get('target_group_id')
|
||||
|
||||
if not training_style_id or not target_group_id:
|
||||
raise HTTPException(400, "training_style_id und target_group_id sind Pflichtfelder")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Upsert logic
|
||||
cur.execute("""
|
||||
INSERT INTO training_style_target_groups
|
||||
(training_style_id, target_group_id, is_primary)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (training_style_id, target_group_id)
|
||||
DO UPDATE SET is_primary = EXCLUDED.is_primary
|
||||
RETURNING id
|
||||
""", (
|
||||
training_style_id,
|
||||
target_group_id,
|
||||
data.get('is_primary', False)
|
||||
))
|
||||
|
||||
assignment_id = cur.fetchone()['id']
|
||||
conn.commit()
|
||||
|
||||
# Return enriched record
|
||||
cur.execute("""
|
||||
SELECT
|
||||
tstg.id,
|
||||
tstg.training_style_id,
|
||||
tstg.target_group_id,
|
||||
tstg.is_primary,
|
||||
tstg.created_at,
|
||||
ts.name as training_style_name,
|
||||
ts.focus_area_id,
|
||||
fa.name as focus_area_name,
|
||||
tg.name as target_group_name
|
||||
FROM training_style_target_groups tstg
|
||||
LEFT JOIN training_styles ts ON tstg.training_style_id = ts.id
|
||||
LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id
|
||||
LEFT JOIN target_groups tg ON tstg.target_group_id = tg.id
|
||||
WHERE tstg.id = %s
|
||||
""", (assignment_id,))
|
||||
|
||||
return r2d(cur.fetchone())
|
||||
|
||||
|
||||
@router.put("/training-style-target-groups/{assignment_id}")
|
||||
def update_training_style_target_group(
|
||||
assignment_id: int,
|
||||
data: dict,
|
||||
session=Depends(require_auth)
|
||||
):
|
||||
"""Update M:N assignment (admin only).
|
||||
|
||||
Currently only supports updating is_primary flag.
|
||||
"""
|
||||
role = session.get('role')
|
||||
if role not in ['admin', 'superadmin']:
|
||||
raise HTTPException(403, "Nur Admins dürfen Zuordnungen bearbeiten")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute("""
|
||||
UPDATE training_style_target_groups
|
||||
SET is_primary = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
data.get('is_primary', False),
|
||||
assignment_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Return enriched record
|
||||
cur.execute("""
|
||||
SELECT
|
||||
tstg.id,
|
||||
tstg.training_style_id,
|
||||
tstg.target_group_id,
|
||||
tstg.is_primary,
|
||||
tstg.created_at,
|
||||
ts.name as training_style_name,
|
||||
ts.focus_area_id,
|
||||
fa.name as focus_area_name,
|
||||
tg.name as target_group_name
|
||||
FROM training_style_target_groups tstg
|
||||
LEFT JOIN training_styles ts ON tstg.training_style_id = ts.id
|
||||
LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id
|
||||
LEFT JOIN target_groups tg ON tstg.target_group_id = tg.id
|
||||
WHERE tstg.id = %s
|
||||
""", (assignment_id,))
|
||||
|
||||
return r2d(cur.fetchone())
|
||||
|
||||
|
||||
@router.delete("/training-style-target-groups/{assignment_id}")
|
||||
def delete_training_style_target_group(assignment_id: int, session=Depends(require_auth)):
|
||||
"""Remove M:N assignment (admin only)."""
|
||||
role = session.get('role')
|
||||
if role not in ['admin', 'superadmin']:
|
||||
raise HTTPException(403, "Nur Admins dürfen Zuordnungen löschen")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("DELETE FROM training_style_target_groups WHERE id = %s", (assignment_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/training-styles/hierarchy")
|
||||
def get_training_styles_hierarchy(
|
||||
status: Optional[str] = Query(default='active'),
|
||||
session=Depends(require_auth)
|
||||
):
|
||||
"""Get hierarchical structure: Focus Areas → Training Styles → Target Groups.
|
||||
|
||||
Returns nested structure for Tree-View rendering in Admin UI.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get all focus areas
|
||||
fa_query = "SELECT * FROM focus_areas"
|
||||
fa_params = []
|
||||
|
||||
if status:
|
||||
fa_query += " WHERE status = %s"
|
||||
fa_params.append(status)
|
||||
|
||||
fa_query += " ORDER BY sort_order, name"
|
||||
|
||||
cur.execute(fa_query, fa_params)
|
||||
focus_areas = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
# For each focus area, get training styles with their target groups
|
||||
for fa in focus_areas:
|
||||
ts_query = """
|
||||
SELECT * FROM training_styles
|
||||
WHERE focus_area_id = %s
|
||||
"""
|
||||
ts_params = [fa['id']]
|
||||
|
||||
if status:
|
||||
ts_query += " AND status = %s"
|
||||
ts_params.append(status)
|
||||
|
||||
ts_query += " ORDER BY sort_order, name"
|
||||
|
||||
cur.execute(ts_query, ts_params)
|
||||
training_styles = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
# For each training style, get assigned target groups
|
||||
for ts in training_styles:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
tg.id,
|
||||
tg.name,
|
||||
tg.description,
|
||||
tg.min_age,
|
||||
tg.max_age,
|
||||
tstg.is_primary,
|
||||
tstg.id as assignment_id
|
||||
FROM training_style_target_groups tstg
|
||||
LEFT JOIN target_groups tg ON tstg.target_group_id = tg.id
|
||||
WHERE tstg.training_style_id = %s
|
||||
ORDER BY tg.sort_order, tg.name
|
||||
""", (ts['id'],))
|
||||
|
||||
ts['target_groups'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
fa['training_styles'] = training_styles
|
||||
|
||||
return focus_areas
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.3.3"
|
||||
APP_VERSION = "0.3.4"
|
||||
BUILD_DATE = "2026-04-23"
|
||||
DB_SCHEMA_VERSION = "20260423"
|
||||
|
||||
|
|
@ -18,10 +18,25 @@ MODULE_VERSIONS = {
|
|||
"import_wiki": "0.1.0",
|
||||
"admin": "1.0.0",
|
||||
"membership": "1.0.0",
|
||||
"catalogs": "1.2.0", # Updated: Target Groups CRUD + Admin UI
|
||||
"catalogs": "1.3.0", # Updated: M:N Zielgruppen-Zuordnung (Migration 009)
|
||||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.3.4",
|
||||
"date": "2026-04-23",
|
||||
"changes": [
|
||||
"BREAKING: Migration 009 - Zielgruppen M:N Refactoring",
|
||||
"DB: target_groups.training_style_id entfernt (jetzt global unabhängig)",
|
||||
"DB: Neue Junction-Tabelle training_style_target_groups (M:N)",
|
||||
"API: 5 neue Endpoints für M:N Management (GET/POST/PUT/DELETE + hierarchy)",
|
||||
"API: GET /training-style-target-groups mit Enrichment (focus_area_name, training_style_name)",
|
||||
"API: GET /training-styles/hierarchy für Tree-View (verschachtelte Struktur)",
|
||||
"API: POST /training-style-target-groups mit Upsert-Logik",
|
||||
"Backward-Compatible: exercise_target_groups weiterhin unterstützt",
|
||||
"Architecture: Eine Zielgruppe kann mehreren Stilen zugeordnet werden",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.3.3",
|
||||
"date": "2026-04-23",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Shinkan Jinkendo Frontend Version
|
||||
|
||||
export const APP_VERSION = "0.3.3"
|
||||
export const APP_VERSION = "0.3.4"
|
||||
export const BUILD_DATE = "2026-04-23"
|
||||
|
||||
export const PAGE_VERSIONS = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user