Goalsystem V1 #50
|
|
@ -181,7 +181,7 @@ def get_goal_type_config(conn, type_key: str) -> Optional[Dict[str, Any]]:
|
|||
|
||||
cur.execute("""
|
||||
SELECT type_key, source_table, source_column, aggregation_method,
|
||||
calculation_formula, label_de, unit, icon, category
|
||||
calculation_formula, filter_conditions, label_de, unit, icon, category
|
||||
FROM goal_type_definitions
|
||||
WHERE type_key = %s AND is_active = true
|
||||
LIMIT 1
|
||||
|
|
@ -221,7 +221,8 @@ def get_current_value_for_goal(conn, profile_id: str, goal_type: str) -> Optiona
|
|||
profile_id,
|
||||
config['source_table'],
|
||||
config['source_column'],
|
||||
config['aggregation_method']
|
||||
config['aggregation_method'],
|
||||
config.get('filter_conditions')
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -230,7 +231,8 @@ def _fetch_by_aggregation_method(
|
|||
profile_id: str,
|
||||
table: str,
|
||||
column: str,
|
||||
method: str
|
||||
method: str,
|
||||
filter_conditions: Optional[Any] = None
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Fetch value using specified aggregation method.
|
||||
|
|
@ -244,84 +246,119 @@ def _fetch_by_aggregation_method(
|
|||
- count_30d: Count of entries in last 30 days
|
||||
- min_30d: Minimum value in last 30 days
|
||||
- max_30d: Maximum value in last 30 days
|
||||
|
||||
Args:
|
||||
filter_conditions: Optional JSON filters (e.g., {"training_type": "strength"})
|
||||
"""
|
||||
# Guard: source_table/column required for simple aggregation
|
||||
if not table or not column:
|
||||
print(f"[WARNING] Missing source_table or source_column for aggregation")
|
||||
return None
|
||||
|
||||
# Build filter SQL from JSON conditions
|
||||
filter_sql = ""
|
||||
filter_params = []
|
||||
|
||||
if filter_conditions:
|
||||
try:
|
||||
if isinstance(filter_conditions, str):
|
||||
filters = json.loads(filter_conditions)
|
||||
else:
|
||||
filters = filter_conditions
|
||||
|
||||
for filter_col, filter_val in filters.items():
|
||||
if isinstance(filter_val, list):
|
||||
# IN clause for multiple values
|
||||
placeholders = ', '.join(['%s'] * len(filter_val))
|
||||
filter_sql += f" AND {filter_col} IN ({placeholders})"
|
||||
filter_params.extend(filter_val)
|
||||
else:
|
||||
# Single value equality
|
||||
filter_sql += f" AND {filter_col} = %s"
|
||||
filter_params.append(filter_val)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError) as e:
|
||||
print(f"[WARNING] Invalid filter_conditions: {e}, ignoring filters")
|
||||
|
||||
cur = get_cursor(conn)
|
||||
|
||||
try:
|
||||
if method == 'latest':
|
||||
params = [profile_id] + filter_params
|
||||
cur.execute(f"""
|
||||
SELECT {column} FROM {table}
|
||||
WHERE profile_id = %s AND {column} IS NOT NULL
|
||||
WHERE profile_id = %s AND {column} IS NOT NULL{filter_sql}
|
||||
ORDER BY date DESC LIMIT 1
|
||||
""", (profile_id,))
|
||||
""", params)
|
||||
row = cur.fetchone()
|
||||
return float(row[column]) if row else None
|
||||
|
||||
elif method == 'avg_7d':
|
||||
days_ago = date.today() - timedelta(days=7)
|
||||
params = [profile_id, days_ago] + filter_params
|
||||
cur.execute(f"""
|
||||
SELECT AVG({column}) as avg_value FROM {table}
|
||||
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL
|
||||
""", (profile_id, days_ago))
|
||||
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql}
|
||||
""", params)
|
||||
row = cur.fetchone()
|
||||
return float(row['avg_value']) if row and row['avg_value'] is not None else None
|
||||
|
||||
elif method == 'avg_30d':
|
||||
days_ago = date.today() - timedelta(days=30)
|
||||
params = [profile_id, days_ago] + filter_params
|
||||
cur.execute(f"""
|
||||
SELECT AVG({column}) as avg_value FROM {table}
|
||||
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL
|
||||
""", (profile_id, days_ago))
|
||||
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql}
|
||||
""", params)
|
||||
row = cur.fetchone()
|
||||
return float(row['avg_value']) if row and row['avg_value'] is not None else None
|
||||
|
||||
elif method == 'sum_30d':
|
||||
days_ago = date.today() - timedelta(days=30)
|
||||
params = [profile_id, days_ago] + filter_params
|
||||
cur.execute(f"""
|
||||
SELECT SUM({column}) as sum_value FROM {table}
|
||||
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL
|
||||
""", (profile_id, days_ago))
|
||||
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql}
|
||||
""", params)
|
||||
row = cur.fetchone()
|
||||
return float(row['sum_value']) if row and row['sum_value'] is not None else None
|
||||
|
||||
elif method == 'count_7d':
|
||||
days_ago = date.today() - timedelta(days=7)
|
||||
params = [profile_id, days_ago] + filter_params
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) as count_value FROM {table}
|
||||
WHERE profile_id = %s AND date >= %s
|
||||
""", (profile_id, days_ago))
|
||||
WHERE profile_id = %s AND date >= %s{filter_sql}
|
||||
""", params)
|
||||
row = cur.fetchone()
|
||||
return float(row['count_value']) if row else 0.0
|
||||
|
||||
elif method == 'count_30d':
|
||||
days_ago = date.today() - timedelta(days=30)
|
||||
params = [profile_id, days_ago] + filter_params
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) as count_value FROM {table}
|
||||
WHERE profile_id = %s AND date >= %s
|
||||
""", (profile_id, days_ago))
|
||||
WHERE profile_id = %s AND date >= %s{filter_sql}
|
||||
""", params)
|
||||
row = cur.fetchone()
|
||||
return float(row['count_value']) if row else 0.0
|
||||
|
||||
elif method == 'min_30d':
|
||||
days_ago = date.today() - timedelta(days=30)
|
||||
params = [profile_id, days_ago] + filter_params
|
||||
cur.execute(f"""
|
||||
SELECT MIN({column}) as min_value FROM {table}
|
||||
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL
|
||||
""", (profile_id, days_ago))
|
||||
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql}
|
||||
""", params)
|
||||
row = cur.fetchone()
|
||||
return float(row['min_value']) if row and row['min_value'] is not None else None
|
||||
|
||||
elif method == 'max_30d':
|
||||
days_ago = date.today() - timedelta(days=30)
|
||||
params = [profile_id, days_ago] + filter_params
|
||||
cur.execute(f"""
|
||||
SELECT MAX({column}) as max_value FROM {table}
|
||||
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL
|
||||
""", (profile_id, days_ago))
|
||||
WHERE profile_id = %s AND date >= %s AND {column} IS NOT NULL{filter_sql}
|
||||
""", params)
|
||||
row = cur.fetchone()
|
||||
return float(row['max_value']) if row and row['max_value'] is not None else None
|
||||
|
||||
|
|
|
|||
40
backend/migrations/026_goal_type_filters.sql
Normal file
40
backend/migrations/026_goal_type_filters.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
-- Migration 026: Goal Type Filters
|
||||
-- Date: 2026-03-27
|
||||
-- Purpose: Enable filtered counting/aggregation (e.g., count only strength training)
|
||||
|
||||
-- Add filter_conditions column for flexible filtering
|
||||
ALTER TABLE goal_type_definitions
|
||||
ADD COLUMN IF NOT EXISTS filter_conditions JSONB;
|
||||
|
||||
COMMENT ON COLUMN goal_type_definitions.filter_conditions IS
|
||||
'Optional filter conditions as JSON. Example: {"training_type": "strength"} to count only strength training sessions.
|
||||
Supports any column in the source table. Format: {"column_name": "value"} or {"column_name": ["value1", "value2"]} for IN clause.';
|
||||
|
||||
-- Example usage (commented out):
|
||||
/*
|
||||
-- Count only strength training sessions per week
|
||||
INSERT INTO goal_type_definitions (
|
||||
type_key, label_de, unit, icon, category,
|
||||
source_table, source_column, aggregation_method,
|
||||
filter_conditions,
|
||||
description, is_system
|
||||
) VALUES (
|
||||
'strength_frequency', 'Krafttraining Häufigkeit', 'x/Woche', '🏋️', 'activity',
|
||||
'activity_log', 'id', 'count_7d',
|
||||
'{"training_type": "strength"}',
|
||||
'Anzahl Krafttraining-Einheiten pro Woche', false
|
||||
) ON CONFLICT (type_key) DO NOTHING;
|
||||
|
||||
-- Count only cardio sessions per week
|
||||
INSERT INTO goal_type_definitions (
|
||||
type_key, label_de, unit, icon, category,
|
||||
source_table, source_column, aggregation_method,
|
||||
filter_conditions,
|
||||
description, is_system
|
||||
) VALUES (
|
||||
'cardio_frequency', 'Cardio Häufigkeit', 'x/Woche', '🏃', 'activity',
|
||||
'activity_log', 'id', 'count_7d',
|
||||
'{"training_type": "cardio"}',
|
||||
'Anzahl Cardio-Einheiten pro Woche', false
|
||||
) ON CONFLICT (type_key) DO NOTHING;
|
||||
*/
|
||||
|
|
@ -76,6 +76,7 @@ class GoalTypeCreate(BaseModel):
|
|||
source_column: Optional[str] = None
|
||||
aggregation_method: Optional[str] = 'latest'
|
||||
calculation_formula: Optional[str] = None
|
||||
filter_conditions: Optional[dict] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
class GoalTypeUpdate(BaseModel):
|
||||
|
|
@ -89,6 +90,7 @@ class GoalTypeUpdate(BaseModel):
|
|||
source_column: Optional[str] = None
|
||||
aggregation_method: Optional[str] = None
|
||||
calculation_formula: Optional[str] = None
|
||||
filter_conditions: Optional[dict] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
|
@ -685,17 +687,20 @@ def create_goal_type_definition(
|
|||
)
|
||||
|
||||
# Insert new goal type
|
||||
import json as json_lib
|
||||
filter_json = json_lib.dumps(data.filter_conditions) if data.filter_conditions else None
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO goal_type_definitions (
|
||||
type_key, label_de, label_en, unit, icon, category,
|
||||
source_table, source_column, aggregation_method,
|
||||
calculation_formula, description, is_active, is_system
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
calculation_formula, filter_conditions, description, is_active, is_system
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.type_key, data.label_de, data.label_en, data.unit, data.icon,
|
||||
data.category, data.source_table, data.source_column,
|
||||
data.aggregation_method, data.calculation_formula, data.description,
|
||||
data.aggregation_method, data.calculation_formula, filter_json, data.description,
|
||||
True, False # is_active=True, is_system=False
|
||||
))
|
||||
|
||||
|
|
@ -780,6 +785,12 @@ def update_goal_type_definition(
|
|||
updates.append("calculation_formula = %s")
|
||||
params.append(data.calculation_formula)
|
||||
|
||||
if data.filter_conditions is not None:
|
||||
import json as json_lib
|
||||
filter_json = json_lib.dumps(data.filter_conditions) if data.filter_conditions else None
|
||||
updates.append("filter_conditions = %s")
|
||||
params.append(filter_json)
|
||||
|
||||
if data.description is not None:
|
||||
updates.append("description = %s")
|
||||
params.append(data.description)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export default function AdminGoalTypesPage() {
|
|||
source_table: '',
|
||||
source_column: '',
|
||||
aggregation_method: 'latest',
|
||||
filter_conditions: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
|
|
@ -75,6 +76,7 @@ export default function AdminGoalTypesPage() {
|
|||
source_table: '',
|
||||
source_column: '',
|
||||
aggregation_method: 'latest',
|
||||
filter_conditions: '',
|
||||
description: ''
|
||||
})
|
||||
setShowForm(true)
|
||||
|
|
@ -91,6 +93,7 @@ export default function AdminGoalTypesPage() {
|
|||
source_table: type.source_table || '',
|
||||
source_column: type.source_column || '',
|
||||
aggregation_method: type.aggregation_method || 'latest',
|
||||
filter_conditions: type.filter_conditions ? JSON.stringify(type.filter_conditions, null, 2) : '',
|
||||
description: type.description || ''
|
||||
})
|
||||
setShowForm(true)
|
||||
|
|
@ -102,16 +105,29 @@ export default function AdminGoalTypesPage() {
|
|||
return
|
||||
}
|
||||
|
||||
// Parse filter_conditions from string to JSON
|
||||
let payload = { ...formData }
|
||||
if (formData.filter_conditions && formData.filter_conditions.trim()) {
|
||||
try {
|
||||
payload.filter_conditions = JSON.parse(formData.filter_conditions)
|
||||
} catch (e) {
|
||||
setError('Ungültiges JSON in Filter-Bedingungen')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
payload.filter_conditions = null
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingType) {
|
||||
await api.updateGoalType(editingType, formData)
|
||||
await api.updateGoalType(editingType, payload)
|
||||
showToast('✓ Goal Type aktualisiert')
|
||||
} else {
|
||||
if (!formData.type_key) {
|
||||
setError('Bitte eindeutigen Key angeben (z.B. meditation_minutes)')
|
||||
return
|
||||
}
|
||||
await api.createGoalType(formData)
|
||||
await api.createGoalType(payload)
|
||||
showToast('✓ Goal Type erstellt')
|
||||
}
|
||||
|
||||
|
|
@ -452,6 +468,21 @@ export default function AdminGoalTypesPage() {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filter Conditions */}
|
||||
<div>
|
||||
<label className="form-label">Filter (optional, JSON)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
style={{ width: '100%', minHeight: 80, fontFamily: 'monospace', fontSize: 13 }}
|
||||
value={formData.filter_conditions}
|
||||
onChange={e => setFormData(f => ({ ...f, filter_conditions: e.target.value }))}
|
||||
placeholder={'Beispiel:\n{\n "training_type": "strength"\n}\n\nOder mehrere Werte:\n{\n "training_type": ["strength", "hiit"]\n}'}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>
|
||||
💡 Filtert Einträge nach Spalten. Beispiel: <code>{`{"training_type": "strength"}`}</code> zählt nur Krafttraining
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="form-label">Beschreibung (optional)</label>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user