feat: admin CRUD for training types + distribution chart in ActivityPage
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Backend (v9d Phase 1b):
- Migration 006: Add abilities JSONB column + descriptions
- admin_training_types.py: Full CRUD endpoints for training types
  - List, Get, Create, Update, Delete
  - Abilities taxonomy endpoint (5 dimensions: koordinativ, konditionell, kognitiv, psychisch, taktisch)
  - Validation: Cannot delete types in use
- Register admin_training_types router in main.py

Frontend:
- AdminTrainingTypesPage: Full CRUD UI
  - Create/edit form with all fields (category, subcategory, names, icon, descriptions, sort_order)
  - List grouped by category with color coding
  - Delete with usage check
  - Note about abilities mapping coming in v9f
- Add TrainingTypeDistribution to ActivityPage stats tab
- Add admin link in AdminPanel (v9d section)
- Update api.js with admin training types methods

Notes:
- Abilities mapping UI deferred to v9f (flexible prompt system)
- Placeholders (abilities column) in place for future AI analysis

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-21 15:32:32 +01:00
parent d164ab932d
commit eecc00e824
8 changed files with 730 additions and 2 deletions

View File

@ -19,7 +19,7 @@ from routers import auth, profiles, weight, circumference, caliper
from routers import activity, nutrition, photos, insights, prompts
from routers import admin, stats, exportdata, importdata
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
from routers import user_restrictions, access_grants, training_types
from routers import user_restrictions, access_grants, training_types, admin_training_types
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -86,7 +86,8 @@ app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
app.include_router(access_grants.router) # /api/access-grants (admin)
# v9d Training Types
app.include_router(training_types.router) # /api/training-types/*
app.include_router(training_types.router) # /api/training-types/*
app.include_router(admin_training_types.router) # /api/admin/training-types/*
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")

View File

@ -0,0 +1,29 @@
-- Migration 006: Training Types - Abilities Mapping
-- Add abilities JSONB column for future AI analysis
-- Maps to: koordinativ, konditionell, kognitiv, psychisch, taktisch
-- Created: 2026-03-21
-- ========================================
-- Add abilities column
-- ========================================
ALTER TABLE training_types
ADD COLUMN IF NOT EXISTS abilities JSONB DEFAULT '{}';
-- ========================================
-- Add description columns for better documentation
-- ========================================
ALTER TABLE training_types
ADD COLUMN IF NOT EXISTS description_de TEXT,
ADD COLUMN IF NOT EXISTS description_en TEXT;
-- ========================================
-- Add index for abilities queries
-- ========================================
CREATE INDEX IF NOT EXISTS idx_training_types_abilities ON training_types USING GIN (abilities);
-- ========================================
-- Comment
-- ========================================
COMMENT ON COLUMN training_types.abilities IS 'JSONB: Maps to athletic abilities for AI analysis (koordinativ, konditionell, kognitiv, psychisch, taktisch)';
COMMENT ON COLUMN training_types.description_de IS 'German description for admin UI and AI context';
COMMENT ON COLUMN training_types.description_en IS 'English description for admin UI and AI context';

View File

@ -0,0 +1,281 @@
"""
Admin Training Types Management - v9d Phase 1b
CRUD operations for training types with abilities mapping preparation.
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from db import get_db, get_cursor, r2d
from auth import require_auth, require_admin
router = APIRouter(prefix="/api/admin/training-types", tags=["admin", "training-types"])
logger = logging.getLogger(__name__)
class TrainingTypeCreate(BaseModel):
category: str
subcategory: Optional[str] = None
name_de: str
name_en: str
icon: Optional[str] = None
description_de: Optional[str] = None
description_en: Optional[str] = None
sort_order: int = 0
abilities: Optional[dict] = None
class TrainingTypeUpdate(BaseModel):
category: Optional[str] = None
subcategory: Optional[str] = None
name_de: Optional[str] = None
name_en: Optional[str] = None
icon: Optional[str] = None
description_de: Optional[str] = None
description_en: Optional[str] = None
sort_order: Optional[int] = None
abilities: Optional[dict] = None
@router.get("")
def list_training_types_admin(session: dict = Depends(require_admin)):
"""
Get all training types for admin management.
Returns full details including abilities.
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, category, subcategory, name_de, name_en, icon,
description_de, description_en, sort_order, abilities,
created_at
FROM training_types
ORDER BY sort_order, category, subcategory
""")
rows = cur.fetchall()
return [r2d(r) for r in rows]
@router.get("/{type_id}")
def get_training_type(type_id: int, session: dict = Depends(require_admin)):
"""Get single training type by ID."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, category, subcategory, name_de, name_en, icon,
description_de, description_en, sort_order, abilities,
created_at
FROM training_types
WHERE id = %s
""", (type_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Training type not found")
return r2d(row)
@router.post("")
def create_training_type(data: TrainingTypeCreate, session: dict = Depends(require_admin)):
"""Create new training type."""
with get_db() as conn:
cur = get_cursor(conn)
# Convert abilities dict to JSONB
abilities_json = data.abilities if data.abilities else {}
cur.execute("""
INSERT INTO training_types
(category, subcategory, name_de, name_en, icon,
description_de, description_en, sort_order, abilities)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
data.category,
data.subcategory,
data.name_de,
data.name_en,
data.icon,
data.description_de,
data.description_en,
data.sort_order,
abilities_json
))
new_id = cur.fetchone()['id']
logger.info(f"[ADMIN] Training type created: {new_id} - {data.name_de} ({data.category}/{data.subcategory})")
return {"id": new_id, "message": "Training type created"}
@router.put("/{type_id}")
def update_training_type(
type_id: int,
data: TrainingTypeUpdate,
session: dict = Depends(require_admin)
):
"""Update existing training type."""
with get_db() as conn:
cur = get_cursor(conn)
# Build update query dynamically
updates = []
values = []
if data.category is not None:
updates.append("category = %s")
values.append(data.category)
if data.subcategory is not None:
updates.append("subcategory = %s")
values.append(data.subcategory)
if data.name_de is not None:
updates.append("name_de = %s")
values.append(data.name_de)
if data.name_en is not None:
updates.append("name_en = %s")
values.append(data.name_en)
if data.icon is not None:
updates.append("icon = %s")
values.append(data.icon)
if data.description_de is not None:
updates.append("description_de = %s")
values.append(data.description_de)
if data.description_en is not None:
updates.append("description_en = %s")
values.append(data.description_en)
if data.sort_order is not None:
updates.append("sort_order = %s")
values.append(data.sort_order)
if data.abilities is not None:
updates.append("abilities = %s")
values.append(data.abilities)
if not updates:
raise HTTPException(400, "No fields to update")
values.append(type_id)
cur.execute(f"""
UPDATE training_types
SET {', '.join(updates)}
WHERE id = %s
""", values)
if cur.rowcount == 0:
raise HTTPException(404, "Training type not found")
logger.info(f"[ADMIN] Training type updated: {type_id}")
return {"id": type_id, "message": "Training type updated"}
@router.delete("/{type_id}")
def delete_training_type(type_id: int, session: dict = Depends(require_admin)):
"""
Delete training type.
WARNING: This will fail if any activities reference this type.
Consider adding a soft-delete or archive mechanism if needed.
"""
with get_db() as conn:
cur = get_cursor(conn)
# Check if any activities use this type
cur.execute("""
SELECT COUNT(*) as count
FROM activity_log
WHERE training_type_id = %s
""", (type_id,))
count = cur.fetchone()['count']
if count > 0:
raise HTTPException(
400,
f"Cannot delete: {count} activities are using this training type. "
"Please reassign or delete those activities first."
)
cur.execute("DELETE FROM training_types WHERE id = %s", (type_id,))
if cur.rowcount == 0:
raise HTTPException(404, "Training type not found")
logger.info(f"[ADMIN] Training type deleted: {type_id}")
return {"message": "Training type deleted"}
@router.get("/taxonomy/abilities")
def get_abilities_taxonomy(session: dict = Depends(require_auth)):
"""
Get abilities taxonomy for UI and AI analysis.
This defines the 5 dimensions of athletic development.
"""
taxonomy = {
"koordinativ": {
"name_de": "Koordinative Fähigkeiten",
"name_en": "Coordination Abilities",
"icon": "🎯",
"abilities": [
{"key": "orientierung", "name_de": "Orientierung", "name_en": "Orientation"},
{"key": "differenzierung", "name_de": "Differenzierung", "name_en": "Differentiation"},
{"key": "kopplung", "name_de": "Kopplung", "name_en": "Coupling"},
{"key": "gleichgewicht", "name_de": "Gleichgewicht", "name_en": "Balance"},
{"key": "rhythmus", "name_de": "Rhythmisierung", "name_en": "Rhythm"},
{"key": "reaktion", "name_de": "Reaktion", "name_en": "Reaction"},
{"key": "umstellung", "name_de": "Umstellung", "name_en": "Adaptation"}
]
},
"konditionell": {
"name_de": "Konditionelle Fähigkeiten",
"name_en": "Conditional Abilities",
"icon": "💪",
"abilities": [
{"key": "kraft", "name_de": "Kraft", "name_en": "Strength"},
{"key": "ausdauer", "name_de": "Ausdauer", "name_en": "Endurance"},
{"key": "schnelligkeit", "name_de": "Schnelligkeit", "name_en": "Speed"},
{"key": "flexibilitaet", "name_de": "Flexibilität", "name_en": "Flexibility"}
]
},
"kognitiv": {
"name_de": "Kognitive Fähigkeiten",
"name_en": "Cognitive Abilities",
"icon": "🧠",
"abilities": [
{"key": "konzentration", "name_de": "Konzentration", "name_en": "Concentration"},
{"key": "aufmerksamkeit", "name_de": "Aufmerksamkeit", "name_en": "Attention"},
{"key": "wahrnehmung", "name_de": "Wahrnehmung", "name_en": "Perception"},
{"key": "entscheidung", "name_de": "Entscheidungsfindung", "name_en": "Decision Making"}
]
},
"psychisch": {
"name_de": "Psychische Fähigkeiten",
"name_en": "Psychological Abilities",
"icon": "🎭",
"abilities": [
{"key": "motivation", "name_de": "Motivation", "name_en": "Motivation"},
{"key": "willenskraft", "name_de": "Willenskraft", "name_en": "Willpower"},
{"key": "stressresistenz", "name_de": "Stressresistenz", "name_en": "Stress Resistance"},
{"key": "selbstvertrauen", "name_de": "Selbstvertrauen", "name_en": "Self-Confidence"}
]
},
"taktisch": {
"name_de": "Taktische Fähigkeiten",
"name_en": "Tactical Abilities",
"icon": "♟️",
"abilities": [
{"key": "timing", "name_de": "Timing", "name_en": "Timing"},
{"key": "strategie", "name_de": "Strategie", "name_en": "Strategy"},
{"key": "antizipation", "name_de": "Antizipation", "name_en": "Anticipation"},
{"key": "situationsanalyse", "name_de": "Situationsanalyse", "name_en": "Situation Analysis"}
]
}
}
return taxonomy

View File

@ -27,6 +27,7 @@ import AdminFeaturesPage from './pages/AdminFeaturesPage'
import AdminTiersPage from './pages/AdminTiersPage'
import AdminCouponsPage from './pages/AdminCouponsPage'
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
import SubscriptionPage from './pages/SubscriptionPage'
import './app.css'
@ -172,6 +173,7 @@ function AppShell() {
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes>
</main>

View File

@ -5,6 +5,7 @@ import { api } from '../utils/api'
import UsageBadge from '../components/UsageBadge'
import TrainingTypeSelect from '../components/TrainingTypeSelect'
import BulkCategorize from '../components/BulkCategorize'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
@ -302,6 +303,11 @@ export default function ActivityPage() {
{tab==='stats' && stats && (
<div>
<div className="card section-gap">
<div className="card-title">🏋 Trainingstyp-Verteilung (30 Tage)</div>
<TrainingTypeDistribution days={30} />
</div>
{chartData.length>=2 && (
<div className="card section-gap">
<div className="card-title">Aktive Kalorien pro Tag</div>

View File

@ -424,6 +424,23 @@ export default function AdminPanel() {
</Link>
</div>
</div>
{/* v9d Training Types Management */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Trainingstypen (v9d)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Trainingstypen, Kategorien und Fähigkeiten-Mapping.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/training-types">
<button className="btn btn-secondary btn-full">
🏋 Trainingstypen verwalten
</button>
</Link>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,384 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Pencil, Trash2, Plus, Save, X, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api'
/**
* AdminTrainingTypesPage - CRUD for training types
* v9d Phase 1b - Basic CRUD without abilities mapping
*/
export default function AdminTrainingTypesPage() {
const nav = useNavigate()
const [types, setTypes] = useState([])
const [categories, setCategories] = useState({})
const [loading, setLoading] = useState(true)
const [editingId, setEditingId] = useState(null)
const [formData, setFormData] = useState(null)
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
load()
}, [])
const load = () => {
setLoading(true)
Promise.all([
api.adminListTrainingTypes(),
api.getTrainingCategories()
]).then(([typesData, catsData]) => {
setTypes(typesData)
setCategories(catsData)
setLoading(false)
}).catch(err => {
console.error('Failed to load training types:', err)
setError(err.message)
setLoading(false)
})
}
const startCreate = () => {
setFormData({
category: 'cardio',
subcategory: '',
name_de: '',
name_en: '',
icon: '',
description_de: '',
description_en: '',
sort_order: 0
})
setEditingId('new')
}
const startEdit = (type) => {
setFormData({
category: type.category,
subcategory: type.subcategory || '',
name_de: type.name_de,
name_en: type.name_en,
icon: type.icon || '',
description_de: type.description_de || '',
description_en: type.description_en || '',
sort_order: type.sort_order
})
setEditingId(type.id)
}
const cancelEdit = () => {
setEditingId(null)
setFormData(null)
setError(null)
}
const handleSave = async () => {
if (!formData.name_de || !formData.name_en) {
setError('Name (DE) und Name (EN) sind Pflichtfelder')
return
}
setSaving(true)
setError(null)
try {
if (editingId === 'new') {
await api.adminCreateTrainingType(formData)
} else {
await api.adminUpdateTrainingType(editingId, formData)
}
await load()
cancelEdit()
} catch (err) {
console.error('Save failed:', err)
setError(err.message)
} finally {
setSaving(false)
}
}
const handleDelete = async (id, name) => {
if (!confirm(`Trainingstyp "${name}" wirklich löschen?\n\nHinweis: Löschen ist nur möglich wenn keine Aktivitäten diesen Typ verwenden.`)) {
return
}
try {
await api.adminDeleteTrainingType(id)
await load()
} catch (err) {
alert('Löschen fehlgeschlagen: ' + err.message)
}
}
// Group by category
const grouped = {}
types.forEach(type => {
if (!grouped[type.category]) {
grouped[type.category] = []
}
grouped[type.category].push(type)
})
if (loading) {
return (
<div style={{ padding: 20, textAlign: 'center' }}>
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
</div>
)
}
return (
<div style={{ padding: '16px 16px 80px' }}>
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
<button
onClick={() => nav('/settings')}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
display: 'flex',
color: 'var(--text2)'
}}
>
<ArrowLeft size={20} />
</button>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>Trainingstypen verwalten</h1>
</div>
{error && (
<div className="card" style={{ padding: 12, marginBottom: 16, background: '#FCEBEB', color: '#D85A30' }}>
{error}
</div>
)}
{/* Create new button */}
{!editingId && (
<button
onClick={startCreate}
className="btn btn-primary btn-full"
style={{ marginBottom: 16 }}
>
<Plus size={16} /> Neuen Trainingstyp anlegen
</button>
)}
{/* Edit form */}
{editingId && formData && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 12 }}>
{editingId === 'new' ? ' Neuer Trainingstyp' : '✏️ Trainingstyp bearbeiten'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label className="form-label">Kategorie *</label>
<select
className="form-input"
value={formData.category}
onChange={e => setFormData({ ...formData, category: e.target.value })}
>
{Object.keys(categories).map(cat => (
<option key={cat} value={cat}>
{categories[cat].icon} {categories[cat].name_de}
</option>
))}
</select>
</div>
<div>
<label className="form-label">Subkategorie</label>
<input
className="form-input"
value={formData.subcategory}
onChange={e => setFormData({ ...formData, subcategory: e.target.value })}
placeholder="z.B. running, hypertrophy, meditation"
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Kleingeschrieben, ohne Leerzeichen, eindeutig
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label">Name (Deutsch) *</label>
<input
className="form-input"
value={formData.name_de}
onChange={e => setFormData({ ...formData, name_de: e.target.value })}
placeholder="z.B. Laufen"
/>
</div>
<div>
<label className="form-label">Name (English) *</label>
<input
className="form-input"
value={formData.name_en}
onChange={e => setFormData({ ...formData, name_en: e.target.value })}
placeholder="e.g. Running"
/>
</div>
</div>
<div>
<label className="form-label">Icon (Emoji)</label>
<input
className="form-input"
value={formData.icon}
onChange={e => setFormData({ ...formData, icon: e.target.value })}
placeholder="🏃"
maxLength={10}
/>
</div>
<div>
<label className="form-label">Sortierung</label>
<input
type="number"
className="form-input"
value={formData.sort_order}
onChange={e => setFormData({ ...formData, sort_order: parseInt(e.target.value) })}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Niedrigere Zahlen werden zuerst angezeigt
</div>
</div>
<div>
<label className="form-label">Beschreibung (Deutsch)</label>
<textarea
className="form-input"
value={formData.description_de}
onChange={e => setFormData({ ...formData, description_de: e.target.value })}
placeholder="Optional: Beschreibung für KI-Analyse"
rows={2}
/>
</div>
<div>
<label className="form-label">Beschreibung (English)</label>
<textarea
className="form-input"
value={formData.description_en}
onChange={e => setFormData({ ...formData, description_en: e.target.value })}
placeholder="Optional: Description for AI analysis"
rows={2}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
onClick={handleSave}
disabled={saving}
className="btn btn-primary"
style={{ flex: 1 }}
>
{saving ? (
<>
<div className="spinner" style={{ width: 14, height: 14 }} />
Speichere...
</>
) : (
<>
<Save size={16} /> Speichern
</>
)}
</button>
<button
onClick={cancelEdit}
disabled={saving}
className="btn btn-secondary"
style={{ flex: 1 }}
>
<X size={16} /> Abbrechen
</button>
</div>
</div>
</div>
)}
{/* List grouped by category */}
{Object.entries(grouped).sort((a, b) => {
const orderA = categories[a[0]]?.sort_order || 999
const orderB = categories[b[0]]?.sort_order || 999
return orderA - orderB
}).map(([cat, catTypes]) => (
<div key={cat} className="card" style={{ padding: 16, marginBottom: 12 }}>
<div style={{
fontWeight: 600,
fontSize: 14,
marginBottom: 12,
color: categories[cat]?.color || 'var(--text1)'
}}>
{categories[cat]?.icon} {categories[cat]?.name_de}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{catTypes.sort((a, b) => a.sort_order - b.sort_order).map(type => (
<div
key={type.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 8,
background: 'var(--surface)',
borderRadius: 6
}}
>
<div style={{ fontSize: 18 }}>{type.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
{type.name_de} <span style={{ color: 'var(--text3)' }}>/ {type.name_en}</span>
</div>
{type.subcategory && (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
Subkategorie: {type.subcategory}
</div>
)}
</div>
<button
onClick={() => startEdit(type)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
color: 'var(--accent)'
}}
title="Bearbeiten"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(type.id, type.name_de)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
color: '#D85A30'
}}
title="Löschen"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
</div>
))}
<div style={{
marginTop: 20,
padding: 12,
background: 'var(--surface2)',
borderRadius: 8,
fontSize: 12,
color: 'var(--text3)'
}}>
<strong>Hinweis:</strong> Das Fähigkeiten-Mapping (koordinativ, konditionell, etc.) wird in einer späteren Version hinzugefügt.
</div>
</div>
)
}

View File

@ -196,4 +196,12 @@ export const api = {
listTrainingTypes: () => req('/training-types'), // Grouped by category
listTrainingTypesFlat:() => req('/training-types/flat'), // Flat list
getTrainingCategories:() => req('/training-types/categories'), // Category metadata
// Admin: Training Types (v9d Phase 1b)
adminListTrainingTypes: () => req('/admin/training-types'),
adminGetTrainingType: (id) => req(`/admin/training-types/${id}`),
adminCreateTrainingType: (d) => req('/admin/training-types', json(d)),
adminUpdateTrainingType: (id,d) => req(`/admin/training-types/${id}`, jput(d)),
adminDeleteTrainingType: (id) => req(`/admin/training-types/${id}`, {method:'DELETE'}),
getAbilitiesTaxonomy: () => req('/admin/training-types/taxonomy/abilities'),
}