Erste Version - Universal CSV Importer für EAV und activity_log #85

Merged
Lars merged 17 commits from develop into main 2026-04-15 11:46:31 +02:00
3 changed files with 156 additions and 43 deletions
Showing only changes of commit f718785145 - Show all commits

View File

@ -7,6 +7,9 @@ import csv
import io import io
import uuid import uuid
import logging import logging
import re
import calendar
from datetime import date
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query
@ -22,6 +25,19 @@ from data_layer.activity_session_metrics import sync_column_backed_session_metri
router = APIRouter(prefix="/api/activity", tags=["activity"]) router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
def _month_date_bounds(ym: str) -> tuple[date, date]:
m = _MONTH_RE.match((ym or "").strip())
if not m:
raise HTTPException(status_code=400, detail="month muss YYYY-MM sein")
y, mo = int(m.group(1)), int(m.group(2))
if mo < 1 or mo > 12:
raise HTTPException(status_code=400, detail="Ungültiger Monat")
last = calendar.monthrange(y, mo)[1]
return date(y, mo, 1), date(y, mo, last)
# Evaluation import with error handling (Phase 1.2) # Evaluation import with error handling (Phase 1.2)
try: try:
from evaluation_helper import evaluate_and_save_activity from evaluation_helper import evaluate_and_save_activity
@ -37,6 +53,10 @@ def list_activity(
limit: int = Query(200, ge=1, le=50_000), limit: int = Query(200, ge=1, le=50_000),
offset: int = Query(0, ge=0, le=100_000, description="SQL OFFSET für Pagination"), offset: int = Query(0, ge=0, le=100_000, description="SQL OFFSET für Pagination"),
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE days (Kalendertage)"), days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE days (Kalendertage)"),
month: Optional[str] = Query(
None,
description='Kalendermonat "YYYY-MM" (ganzer Monat; schließt days und offset aus)',
),
skip_quality_filter: bool = Query( skip_quality_filter: bool = Query(
False, False,
description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.", description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.",
@ -57,6 +77,25 @@ def list_activity(
profile = r2d(cur.fetchone()) profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile or {}) quality_filter = get_quality_filter_sql(profile or {})
if month:
if days is not None:
raise HTTPException(status_code=400, detail="month und days schließen sich aus")
if offset != 0:
raise HTTPException(status_code=400, detail="month und offset schließen sich aus")
d0, d1 = _month_date_bounds(month)
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
AND date >= %s AND date <= %s
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT %s
""",
(pid, d0, d1, limit),
)
return [r2d(r) for r in cur.fetchall()]
if days is not None: if days is not None:
cur.execute( cur.execute(
f""" f"""

View File

@ -9,8 +9,34 @@ import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
/** Erfassungsseite /activity: erste Ladung + „Mehr laden“ (ohne Qualitätsfilter, siehe Backend). */ /** Erfassungsseite /activity: pro Kalendermonat laden (ohne Qualitätsfilter, siehe Backend). */
const ACTIVITY_LIST_PAGE_SIZE = 40 const ACTIVITY_MONTH_FETCH_LIMIT = 25_000
function ymdMonth(d = dayjs()) {
return d.format('YYYY-MM')
}
function prevMonthYm(ym) {
return dayjs(`${ym}-01`).subtract(1, 'month').format('YYYY-MM')
}
function compareActivities(a, b) {
const da = a.date || ''
const db = b.date || ''
if (da !== db) return db.localeCompare(da)
const sa = String(a.start_time || '')
const sb = String(b.start_time || '')
if (sa !== sb) return sb.localeCompare(sa)
return String(b.id || '').localeCompare(String(a.id || ''))
}
function dedupeActivitiesById(rows) {
const m = new Map()
for (const r of rows) {
if (r?.id) m.set(r.id, r)
}
return [...m.values()].sort(compareActivities)
}
const ACTIVITY_TYPES = [ const ACTIVITY_TYPES = [
'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang', 'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang',
@ -304,54 +330,69 @@ export default function ActivityPage() {
const [metricDraft, setMetricDraft] = useState({}) const [metricDraft, setMetricDraft] = useState({})
const [sessionLoadError, setSessionLoadError] = useState(null) const [sessionLoadError, setSessionLoadError] = useState(null)
const [savingEdit, setSavingEdit] = useState(false) const [savingEdit, setSavingEdit] = useState(false)
const [listHasMore, setListHasMore] = useState(false)
const [listLoadingMore, setListLoadingMore] = useState(false) const [listLoadingMore, setListLoadingMore] = useState(false)
const [selectedMonth, setSelectedMonth] = useState(() => ymdMonth())
const [monthsIncluded, setMonthsIncluded] = useState(() => [ymdMonth()])
const monthsIncludedRef = useRef(monthsIncluded)
const loadFirstPage = useCallback(async () => { useEffect(() => {
const n = ACTIVITY_LIST_PAGE_SIZE monthsIncludedRef.current = monthsIncluded
const [e, s] = await Promise.all([ }, [monthsIncluded])
api.listActivity(n, undefined, { skipQualityFilter: true, offset: 0 }),
api.activityStats({ skipQualityFilter: true }), const fetchMonthsChain = useCallback(async (chain) => {
]) const lists = await Promise.all(
setEntries(e) chain.map((ym) =>
api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, {
skipQualityFilter: true,
month: ym,
})
)
)
const merged = dedupeActivitiesById(lists.flat())
const s = await api.activityStats({ skipQualityFilter: true })
setEntries(merged)
setStats(s) setStats(s)
setListHasMore(e.length === n)
}, []) }, [])
const entriesRef = useRef(entries) const load = useCallback(async () => {
useEffect(() => { await fetchMonthsChain(monthsIncludedRef.current)
entriesRef.current = entries }, [fetchMonthsChain])
}, [entries])
const loadMore = async () => { const onMonthPickerChange = (e) => {
if (listLoadingMore || !listHasMore) return const ym = e.target.value
if (!ym) return
setSelectedMonth(ym)
const chain = [ym]
monthsIncludedRef.current = chain
setMonthsIncluded(chain)
void fetchMonthsChain(chain)
}
const loadPreviousMonth = async () => {
const chain = monthsIncludedRef.current
if (chain.length === 0) return
const oldest = chain[chain.length - 1]
const prev = prevMonthYm(oldest)
if (chain.includes(prev)) return
if (prev < '2000-01') return
setListLoadingMore(true) setListLoadingMore(true)
try { try {
const n = ACTIVITY_LIST_PAGE_SIZE const more = await api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, {
const offset = entriesRef.current.length
const more = await api.listActivity(n, undefined, {
skipQualityFilter: true, skipQualityFilter: true,
offset, month: prev,
}) })
if (more.length === 0) { const newChain = [...chain, prev]
setListHasMore(false) monthsIncludedRef.current = newChain
return setMonthsIncluded(newChain)
} setEntries((cur) => dedupeActivitiesById([...cur, ...more]))
const prev = entriesRef.current
const seen = new Set(prev.map((r) => r.id))
const add = more.filter((r) => r.id && !seen.has(r.id))
if (add.length === 0) {
setListHasMore(false)
return
}
setEntries((p) => [...p, ...add])
setListHasMore(more.length === n)
} finally { } finally {
setListLoadingMore(false) setListLoadingMore(false)
} }
} }
const load = loadFirstPage const oldestLoadedYm = monthsIncluded.length ? monthsIncluded[monthsIncluded.length - 1] : selectedMonth
const nextOlderYm = prevMonthYm(oldestLoadedYm)
const canLoadOlder = nextOlderYm >= '2000-01' && !monthsIncluded.includes(nextOlderYm)
const loadUsage = () => { const loadUsage = () => {
api.getFeatureUsage().then(features => { api.getFeatureUsage().then(features => {
@ -361,10 +402,14 @@ export default function ActivityPage() {
} }
useEffect(() => { useEffect(() => {
void loadFirstPage() const ym = ymdMonth()
monthsIncludedRef.current = [ym]
setMonthsIncluded([ym])
setSelectedMonth(ym)
void fetchMonthsChain([ym])
loadUsage() loadUsage()
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
}, [loadFirstPage]) }, [fetchMonthsChain])
useEffect(() => { useEffect(() => {
if (!editing?.id) { if (!editing?.id) {
@ -594,10 +639,36 @@ export default function ActivityPage() {
{tab==='list' && ( {tab==='list' && (
<div> <div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 12,
flexWrap: 'wrap',
}}
>
<label className="form-label" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
Monat
<input
type="month"
className="form-input"
value={selectedMonth}
onChange={onMonthPickerChange}
style={{ width: 'auto', minWidth: 150, margin: 0 }}
/>
</label>
{monthsIncluded.length > 1 && (
<span style={{ fontSize: 11, color: 'var(--text3)' }}>
Zeitraum: {dayjs(`${selectedMonth}-01`).format('MMMM YYYY')} bis{' '}
{dayjs(`${oldestLoadedYm}-01`).format('MMMM YYYY')}
</span>
)}
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.5 }}> <p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.5 }}>
Hier sind <strong>alle</strong> Trainings sichtbar (Profil-Qualitätsfilter aus auch ohne Bewertung oder bei Hier sind <strong>alle</strong> Trainings sichtbar (Profil-Qualitätsfilter aus auch ohne Bewertung oder bei
abweichender Einordnung). Unter Verlauf / Auswertung bleibt der Filter aktiv. Es werden jeweils bis zu{' '} abweichender Einordnung). Unter Verlauf / Auswertung bleibt der Filter aktiv. Es wird jeweils ein
{ACTIVITY_LIST_PAGE_SIZE} Einträge nachgeladen. kompletter Kalendermonat geladen; Vorheriger Monat hängt den nächstälteren Monat an (ohne OFFSET-Pagination).
</p> </p>
{entries.length===0 && ( {entries.length===0 && (
<div className="empty-state"> <div className="empty-state">
@ -741,16 +812,18 @@ export default function ActivityPage() {
</div> </div>
) )
})} })}
{listHasMore && ( {canLoadOlder && (
<div style={{ marginTop: 12, marginBottom: 8 }}> <div style={{ marginTop: 12, marginBottom: 8 }}>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ width: '100%' }} style={{ width: '100%' }}
disabled={listLoadingMore} disabled={listLoadingMore}
onClick={() => void loadMore()} onClick={() => void loadPreviousMonth()}
> >
{listLoadingMore ? 'Lade…' : 'Weitere Einträge laden'} {listLoadingMore
? 'Lade…'
: `Vorherigen Monat laden (${dayjs(`${nextOlderYm}-01`).format('MMMM YYYY')})`}
</button> </button>
</div> </div>
)} )}

View File

@ -141,11 +141,12 @@ export const api = {
/** /**
* @param {number} [limit=200] * @param {number} [limit=200]
* @param {number} [days] nur Einträge ab HEUTEdays (Kalendertage), backend-filtert * @param {number} [days] nur Einträge ab HEUTEdays (Kalendertage), backend-filtert
* @param {{ offset?: number, skipQualityFilter?: boolean }} [opts] * @param {{ offset?: number, skipQualityFilter?: boolean, month?: string }} [opts] month = YYYY-MM (schließt days/offset aus)
*/ */
listActivity: (limit=200, days, opts={})=> { listActivity: (limit=200, days, opts={})=> {
const q = new URLSearchParams({ limit: String(limit) }) const q = new URLSearchParams({ limit: String(limit) })
if (days != null && days !== '') q.set('days', String(days)) if (days != null && days !== '') q.set('days', String(days))
if (opts.month) q.set('month', String(opts.month))
if (opts.offset != null && opts.offset > 0) q.set('offset', String(opts.offset)) if (opts.offset != null && opts.offset > 0) q.set('offset', String(opts.offset))
if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true') if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true')
return req(`/activity?${q}`) return req(`/activity?${q}`)