All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m16s
- Introduced new functions for handling exercise visibility in progression graphs, including `library_content_visibility_for_progression_graph_sql` to manage visibility based on graph context. - Added `_supplemental_exercise_ids_from_body` to extract exercise IDs from request bodies, improving data handling in path suggestions. - Implemented visibility promotion candidate retrieval in the API, allowing for the identification of private exercises that need visibility adjustments when promoting graph visibility. - Enhanced existing SQL queries and retrieval functions to incorporate new visibility logic, ensuring accurate exercise visibility based on user roles and graph settings. - Updated frontend components to support visibility promotion workflows, including user prompts for managing private exercises during graph visibility changes. - Added tests to validate new visibility logic and ensure robustness in exercise retrieval and promotion processes.
613 lines
21 KiB
JavaScript
613 lines
21 KiB
JavaScript
/**
|
|
* Übungen: Liste/CRUD, Medien & Archiv-Anbindung, Progressionsgraphen, KI-Hilfen.
|
|
*/
|
|
|
|
import { stripHtmlToText } from '../utils/htmlUtils'
|
|
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
|
|
import { normalizeExerciseSkillIntensity } from '../constants/exerciseSkillIntensity'
|
|
import { request, API_URL, ACTIVE_CLUB_STORAGE_KEY } from './client.js'
|
|
|
|
/** Wie `mergeActiveClubHeader` in client.js — lokal, damit Raw-`fetch`-Pfade nicht von einem Namensimport abhängen. */
|
|
function withActiveClubHeaders(headers = {}) {
|
|
const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
|
|
if (cid && /^\d+$/.test(String(cid).trim())) {
|
|
return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
|
|
}
|
|
return { ...headers }
|
|
}
|
|
|
|
// ============================================================================
|
|
// Exercises
|
|
// ============================================================================
|
|
|
|
export async function listExercises(filters = {}) {
|
|
const q = new URLSearchParams()
|
|
Object.entries(filters).forEach(([k, v]) => {
|
|
if (v === undefined || v === null) return
|
|
if (typeof v === 'boolean') {
|
|
q.set(k, v ? 'true' : 'false')
|
|
return
|
|
}
|
|
if (Array.isArray(v)) {
|
|
if (v.length === 0) return
|
|
v.forEach((item) => {
|
|
if (item !== '' && item !== undefined && item !== null && String(item).trim() !== '') {
|
|
q.append(k, String(item))
|
|
}
|
|
})
|
|
return
|
|
}
|
|
if (String(v).trim() !== '') q.set(k, String(v))
|
|
})
|
|
const query = q.toString()
|
|
return request(`/api/exercises${query ? '?' + query : ''}`)
|
|
}
|
|
|
|
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */
|
|
export function buildExerciseApiPayload(formData, extras = {}) {
|
|
const num = (v) => (v === '' || v == null ? null : Number(v))
|
|
|
|
const goalHtml = formData.goal || ''
|
|
const execHtml = formData.execution || ''
|
|
const goalText = stripHtmlToText(goalHtml)
|
|
const execText = stripHtmlToText(execHtml)
|
|
|
|
if (!goalText && !execText) {
|
|
throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).')
|
|
}
|
|
|
|
const mapFocus = (formData.focus_areas_multi || [])
|
|
.filter((x) => x && x.focus_area_id)
|
|
.map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary }))
|
|
const mapStyles = (formData.training_styles_multi || [])
|
|
.filter((x) => x && x.training_style_id)
|
|
.map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary }))
|
|
const mapTTypes = (formData.training_types_multi || [])
|
|
.filter((x) => x && x.training_type_id)
|
|
.map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary }))
|
|
const mapTg = (formData.target_groups_multi || [])
|
|
.filter((x) => x && x.target_group_id)
|
|
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
|
|
|
|
const visibilityNorm = String(formData.visibility || 'private').trim().toLowerCase()
|
|
|
|
const payload = {
|
|
title: (formData.title || '').trim(),
|
|
summary: formData.summary || null,
|
|
goal: goalHtml.trim() ? goalHtml : null,
|
|
execution: execHtml.trim() ? execHtml : null,
|
|
preparation: formData.preparation || null,
|
|
trainer_notes: formData.trainer_notes || null,
|
|
duration_min: num(formData.duration_min),
|
|
duration_max: num(formData.duration_max),
|
|
group_size_min: num(formData.group_size_min),
|
|
group_size_max: num(formData.group_size_max),
|
|
equipment: Array.isArray(formData.equipment) ? formData.equipment : [],
|
|
focus_areas_multi: mapFocus,
|
|
training_styles_multi: mapStyles,
|
|
training_types_multi: mapTTypes,
|
|
target_groups_multi: mapTg,
|
|
age_groups: [],
|
|
skills: (formData.skills || []).map((s) => ({
|
|
skill_id: s.skill_id,
|
|
is_primary: !!s.is_primary,
|
|
intensity: normalizeExerciseSkillIntensity(s.intensity),
|
|
required_level: s.required_level || null,
|
|
target_level: s.target_level || null,
|
|
ai_suggested: !!s.ai_suggested,
|
|
})),
|
|
visibility: visibilityNorm,
|
|
status: formData.status || 'draft',
|
|
club_id: visibilityNorm === 'club' ? num(formData.club_id) : null,
|
|
exercise_kind:
|
|
String(formData.exercise_kind || 'simple').toLowerCase() === 'combination'
|
|
? 'combination'
|
|
: 'simple',
|
|
...extras,
|
|
}
|
|
|
|
const isCombo = payload.exercise_kind === 'combination'
|
|
|
|
if (isCombo) {
|
|
let mpObj = {}
|
|
const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : ''
|
|
if (mpRaw) {
|
|
try {
|
|
const parsed = JSON.parse(mpRaw)
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('Ablaufprofil muss ein JSON-Objekt sein.')
|
|
}
|
|
mpObj = parsed
|
|
} catch (e) {
|
|
if (e instanceof SyntaxError) {
|
|
throw new Error('Ablaufprofil (JSON): Syntax ungültig.')
|
|
}
|
|
throw e
|
|
}
|
|
}
|
|
|
|
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
|
|
const combination_slots = []
|
|
|
|
function parseTimingField(raw) {
|
|
if (raw === '' || raw == null || raw === undefined) return undefined
|
|
const n = parseInt(String(raw), 10)
|
|
return Number.isFinite(n) ? n : undefined
|
|
}
|
|
|
|
for (let i = 0; i < slotRows.length; i += 1) {
|
|
const row = slotRows[i] || {}
|
|
let ids = Array.isArray(row.candidate_exercise_ids)
|
|
? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
|
|
: []
|
|
|
|
/** Legacy: noch idsText Unterstützung für Import von älteren FormStand */
|
|
if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) {
|
|
ids = row.idsText
|
|
.split(/[\s,;]+/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
.map((s) => parseInt(s, 10))
|
|
.filter((n) => Number.isFinite(n))
|
|
}
|
|
|
|
combination_slots.push({
|
|
slot_index: i,
|
|
title: (typeof row.title === 'string' && row.title.trim()) || null,
|
|
candidate_exercise_ids: ids,
|
|
})
|
|
}
|
|
|
|
const slot_profiles_v1_next = []
|
|
for (let i = 0; i < slotRows.length; i += 1) {
|
|
const row = slotRows[i] || {}
|
|
const o = { slot_index: i }
|
|
const advanceMode = normalizeAdvanceMode(row.advance_mode)
|
|
if (advanceMode !== 'timed') o.advance_mode = advanceMode
|
|
const load = parseTimingField(row.load_sec)
|
|
const crs = parseTimingField(row.consecutive_reps)
|
|
const rsc = parseTimingField(row.rep_series_count)
|
|
const intra = parseTimingField(row.intra_rep_rest_sec)
|
|
const tran = parseTimingField(row.transition_after_sec)
|
|
const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
|
|
const allowInterSeriesPause =
|
|
advanceMode === 'timed' ||
|
|
((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2)
|
|
|
|
if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load)
|
|
if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
|
|
if (
|
|
rsc !== undefined &&
|
|
rsc >= 1 &&
|
|
(advanceMode === 'rep' || advanceMode === 'manual')
|
|
) {
|
|
o.rep_series_count = Math.round(rsc)
|
|
}
|
|
if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra)
|
|
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
|
|
if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o)
|
|
}
|
|
|
|
payload.method_archetype = (formData.method_archetype || '').trim() || null
|
|
if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next
|
|
else delete mpObj.slot_profiles_v1
|
|
payload.method_profile = mpObj
|
|
payload.combination_slots = combination_slots
|
|
} else {
|
|
payload.method_archetype = null
|
|
payload.method_profile = {}
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
export async function uploadExerciseMedia(exerciseId, formData) {
|
|
const token = localStorage.getItem('authToken')
|
|
const headers = withActiveClubHeaders({})
|
|
if (token) headers['X-Auth-Token'] = token
|
|
const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
})
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
let parsed = null
|
|
try {
|
|
parsed = text ? JSON.parse(text) : null
|
|
} catch {
|
|
parsed = null
|
|
}
|
|
const d = parsed?.detail
|
|
if (
|
|
response.status === 409 &&
|
|
d &&
|
|
typeof d === 'object' &&
|
|
!Array.isArray(d) &&
|
|
typeof d.code === 'string'
|
|
) {
|
|
const e = new Error(
|
|
typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden',
|
|
)
|
|
e.code = d.code
|
|
e.status = 409
|
|
e.payload = d
|
|
throw e
|
|
}
|
|
if (response.status === 413) {
|
|
const nginx = (text || '').toLowerCase().includes('nginx')
|
|
throw new Error(
|
|
nginx
|
|
? 'Die Anfrage ist zu groß (413). Häufig: nginx „client_max_body_size“ — z. B. große/r mehrere Videos oder Bulk-Upload. Dateien kleiner aufteilen oder Server-Limit erhöhen (Frontend-Image Neu bauen).'
|
|
: 'Die Anfrage ist zu groß (413). Dateigröße oder Server-Limit prüfen.',
|
|
)
|
|
}
|
|
const msg =
|
|
typeof d === 'string'
|
|
? d
|
|
: d != null && typeof d === 'object' && typeof d.message === 'string'
|
|
? d.message
|
|
: d != null
|
|
? JSON.stringify(d)
|
|
: text && text.length < 400 && !/^\s*</.test(text)
|
|
? `HTTP ${response.status}: ${text.trim()}`
|
|
: `HTTP ${response.status}`
|
|
throw new Error(msg)
|
|
}
|
|
return response.json()
|
|
}
|
|
|
|
export async function updateExerciseMedia(exerciseId, mediaId, data) {
|
|
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function deleteExerciseMedia(exerciseId, mediaId) {
|
|
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, { method: 'DELETE' })
|
|
}
|
|
|
|
export async function reorderExerciseMedia(exerciseId, mediaIds) {
|
|
return request(`/api/exercises/${exerciseId}/media/reorder`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ media_ids: mediaIds }),
|
|
})
|
|
}
|
|
|
|
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
|
|
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
|
|
return request(`/api/media-assets/${assetId}/lifecycle`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ action, ...extra }),
|
|
})
|
|
}
|
|
|
|
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
|
|
export async function listMediaAssets(params = {}) {
|
|
const sp = new URLSearchParams()
|
|
if (params.q) sp.set('q', params.q)
|
|
if (params.limit != null) sp.set('limit', String(params.limit))
|
|
if (params.offset != null) sp.set('offset', String(params.offset))
|
|
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
|
|
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
|
|
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
|
|
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
|
|
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
|
|
const qs = sp.toString()
|
|
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
|
|
}
|
|
|
|
export async function patchMediaAsset(assetId, data) {
|
|
return request(`/api/media-assets/${assetId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function bulkMediaLifecycle(data) {
|
|
return request('/api/media-assets/bulk-lifecycle', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function bulkPatchMediaAssets(data) {
|
|
return request('/api/media-assets/bulk-patch', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
|
|
* @param {File[]} files
|
|
* @param {{ visibility?: string, club_id?: number }} [options]
|
|
*/
|
|
export async function bulkUploadMediaAssets(files, options = {}) {
|
|
const visibility = options.visibility || 'private'
|
|
const token = localStorage.getItem('authToken')
|
|
const headers = withActiveClubHeaders({})
|
|
if (token) headers['X-Auth-Token'] = token
|
|
const formData = new FormData()
|
|
formData.append('visibility', String(visibility))
|
|
if (options.club_id != null && options.club_id !== '') {
|
|
formData.append('club_id', String(options.club_id))
|
|
}
|
|
// Copyright + P-06: Rechte-Erklaerung + Kontextfelder
|
|
if (options.copyright_notice != null && String(options.copyright_notice).trim())
|
|
formData.append('copyright_notice', String(options.copyright_notice).trim())
|
|
const p06Fields = [
|
|
'rights_holder_confirmed',
|
|
'contains_identifiable_persons',
|
|
'person_consent_confirmed',
|
|
'person_consent_context',
|
|
'contains_minors',
|
|
'parental_consent_confirmed',
|
|
'parental_consent_context',
|
|
'contains_music',
|
|
'music_rights_confirmed',
|
|
'music_rights_context',
|
|
'contains_third_party_content',
|
|
'third_party_rights_confirmed',
|
|
'third_party_rights_context',
|
|
]
|
|
for (const f of p06Fields) {
|
|
if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
|
|
}
|
|
const arr = Array.isArray(files) ? files : [files]
|
|
for (const f of arr) {
|
|
if (f) formData.append('files', f)
|
|
}
|
|
const url = `${API_URL}/api/media-assets/bulk-upload`
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
})
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
let parsed = null
|
|
try {
|
|
parsed = JSON.parse(text)
|
|
} catch {
|
|
parsed = null
|
|
}
|
|
if (parsed?.detail != null) {
|
|
const d = parsed.detail
|
|
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
|
|
}
|
|
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
|
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
|
}
|
|
return response.json()
|
|
}
|
|
|
|
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
|
|
export async function getMediaAssetJournal(assetId) {
|
|
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
|
|
}
|
|
|
|
export async function addMediaAssetDeclarationCorrection(assetId, body) {
|
|
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
export async function attachExerciseMediaFromAsset(exerciseId, body) {
|
|
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
// P-11: Legal-Hold-Endpunkte
|
|
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
|
|
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
|
|
})
|
|
}
|
|
|
|
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
|
|
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ release_note: releaseNote }),
|
|
})
|
|
}
|
|
|
|
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
|
|
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
|
|
}
|
|
|
|
export async function getExercise(id) {
|
|
return request(`/api/exercises/${id}`)
|
|
}
|
|
|
|
export async function createExercise(data) {
|
|
return request('/api/exercises', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function updateExercise(id, data) {
|
|
const token = localStorage.getItem('authToken')
|
|
const headers = withActiveClubHeaders({ 'Content-Type': 'application/json' })
|
|
if (token) headers['X-Auth-Token'] = token
|
|
const url = `${API_URL}/api/exercises/${id}`
|
|
const response = await fetch(url, {
|
|
method: 'PUT',
|
|
headers,
|
|
body: JSON.stringify(data),
|
|
})
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
let parsed = null
|
|
try {
|
|
parsed = JSON.parse(text)
|
|
} catch {
|
|
parsed = null
|
|
}
|
|
const d = parsed?.detail
|
|
if (
|
|
response.status === 422 &&
|
|
d &&
|
|
typeof d === 'object' &&
|
|
!Array.isArray(d) &&
|
|
typeof d.code === 'string'
|
|
) {
|
|
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
|
|
e.status = 422
|
|
e.code = d.code
|
|
e.payload = d
|
|
throw e
|
|
}
|
|
if (parsed?.detail != null) {
|
|
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
|
|
throw new Error(msg)
|
|
}
|
|
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
|
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
|
}
|
|
return response.json()
|
|
}
|
|
|
|
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
|
|
export async function bulkPatchExercisesMetadata(data) {
|
|
return request('/api/exercises/bulk-metadata', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function deleteExercise(id) {
|
|
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
|
}
|
|
|
|
export async function createExerciseVariant(exerciseId, data) {
|
|
return request(`/api/exercises/${exerciseId}/variants`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function updateExerciseVariant(exerciseId, variantId, data) {
|
|
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function deleteExerciseVariant(exerciseId, variantId) {
|
|
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
|
|
}
|
|
|
|
export async function reorderExerciseVariants(exerciseId, variantIds) {
|
|
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ variant_ids: variantIds }),
|
|
})
|
|
}
|
|
|
|
// Progressionsgraphen (Übung → Übung), Migration 032/033
|
|
export async function listExerciseProgressionGraphs() {
|
|
return request('/api/exercise-progression-graphs')
|
|
}
|
|
|
|
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
|
|
const q = includeEdges ? '?include_edges=true' : ''
|
|
return request(`/api/exercise-progression-graphs/${id}${q}`)
|
|
}
|
|
|
|
export async function getProgressionGraphVisibilityPromotionCandidates(
|
|
graphId,
|
|
{ targetVisibility = 'club' } = {},
|
|
) {
|
|
const q = new URLSearchParams({ target_visibility: targetVisibility })
|
|
return request(
|
|
`/api/exercise-progression-graphs/${graphId}/visibility-promotion-candidates?${q}`,
|
|
)
|
|
}
|
|
|
|
export async function createExerciseProgressionGraph(data) {
|
|
return request('/api/exercise-progression-graphs', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function updateExerciseProgressionGraph(id, data) {
|
|
return request(`/api/exercise-progression-graphs/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function deleteExerciseProgressionGraph(id) {
|
|
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
|
|
}
|
|
|
|
export async function listExerciseProgressionEdges(graphId, query = {}) {
|
|
const q = new URLSearchParams()
|
|
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
|
|
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
|
|
const qs = q.toString()
|
|
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
|
|
}
|
|
|
|
export async function createExerciseProgressionEdge(graphId, data) {
|
|
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
|
|
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
|
|
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
|
|
}
|
|
|
|
export async function createExerciseProgressionSequence(graphId, data) {
|
|
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
|
|
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ edge_ids: edgeIds }),
|
|
})
|
|
}
|
|
|
|
/** KI (OpenRouter): Nur Vorschlaege; Speichern ueber normales exercise PUT/POST. */
|
|
export async function suggestExerciseAi(payload = {}) {
|
|
return request('/api/exercises/ai/suggest', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
include_summary: true,
|
|
include_skills: true,
|
|
...payload,
|
|
}),
|
|
})
|
|
}
|
|
|
|
export async function regenerateExerciseAi(exerciseId, payload = {}) {
|
|
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
regenerate: ['summary', 'skills'],
|
|
...payload,
|
|
}),
|
|
})
|
|
}
|