From 7b6a7c734792c975dc3d9dcc01f9ef17604c26dd Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Fri, 24 Apr 2026 19:17:57 +0100 Subject: [PATCH] Fix admin filters: ISO Z parsing crash + All time period returning month data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caused filters to show 0 and period selector to have no effect: 1. Python < 3.11 can't parse JS toISOString() Z suffix — every request with a period filter threw ValueError → 500 → frontend received no data. Fixed with _parse_iso() helper that replaces Z with +00:00 before fromisoformat(). 2. 'All time' sends no from/to params, but backend defaulted to _month_start() instead of omitting the ts filter. Fixed with _period_match() helper that returns {} (no filter) when both from and to are absent. Also: stale _user_mtd_cost reference in get_user route replaced with _user_period_cost(user_id, None, None); adminApi types updated with from/to. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routes/admin.py | 54 ++++++++++++++++++++----------------- src/lib/api.ts | 4 +-- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index a3d980b4..c472f2c4 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -44,12 +44,25 @@ def _month_start() -> datetime: return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) -async def _user_period_cost(user_id: str, from_dt: datetime, to_dt: datetime) -> float: - """Cost for a single user within the given period.""" - return await UsageEvent.sum_cost({ - "user_id": user_id, - "ts": {"$gte": from_dt, "$lte": to_dt}, - }) +def _parse_iso(s: str) -> datetime: + """Parse ISO-8601 datetime string. Handles Z suffix (Python < 3.11 compat).""" + return datetime.fromisoformat(s.replace('Z', '+00:00')) + + +def _period_match(from_str: str | None, to_str: str | None) -> dict: + """Build a MongoDB ts-range filter. Returns {} when both are absent (All time).""" + if not from_str and not to_str: + return {} + now = datetime.now(timezone.utc) + from_dt = _parse_iso(from_str) if from_str else _month_start() + to_dt = _parse_iso(to_str) if to_str else now + return {'ts': {'$gte': from_dt, '$lte': to_dt}} + + +async def _user_period_cost(user_id: str, from_str: str | None, to_str: str | None) -> float: + """Cost for a single user within the given period (or all time).""" + match = {'user_id': user_id, **_period_match(from_str, to_str)} + return await UsageEvent.sum_cost(match) # ───────────────────────────────────────────────────────────────────────────── @@ -65,9 +78,8 @@ async def list_users(): role_filter = request.args.get('role', '').strip() skip = max(0, int(request.args.get('skip', 0))) limit = min(100, max(1, int(request.args.get('limit', 50)))) - now = datetime.now(timezone.utc) - from_dt = datetime.fromisoformat(request.args['from']) if request.args.get('from') else _month_start() - to_dt = datetime.fromisoformat(request.args['to']) if request.args.get('to') else now + from_str = request.args.get('from') + to_str = request.args.get('to') query = {} if q: @@ -85,7 +97,7 @@ async def list_users(): for u in users: user_id = str(u.get('_id', '')) safe = _safe_user(u) - safe['cost_mtd'] = await _user_period_cost(user_id, from_dt, to_dt) + safe['cost_mtd'] = await _user_period_cost(user_id, from_str, to_str) result.append(safe) return jsonify({'users': result, 'total': total, 'skip': skip, 'limit': limit}), 200 @@ -105,7 +117,7 @@ async def get_user(user_id): return jsonify({'error': 'User not found'}), 404 safe = _safe_user(user) - safe['cost_mtd'] = await _user_mtd_cost(user_id) + safe['cost_mtd'] = await _user_period_cost(user_id, None, None) return jsonify(safe), 200 @@ -207,11 +219,7 @@ async def usage_summary(): filter_user = request.args.get('user_id') filter_fg = request.args.get('focus_group_id') - now = datetime.now(timezone.utc) - from_dt = datetime.fromisoformat(from_str) if from_str else _month_start() - to_dt = datetime.fromisoformat(to_str) if to_str else now - - match: dict = {'ts': {'$gte': from_dt, '$lte': to_dt}} + match: dict = _period_match(from_str, to_str) if filter_user: match['user_id'] = filter_user if filter_fg: @@ -261,8 +269,8 @@ async def usage_summary(): return jsonify({ 'rows': make_serializable(rows), 'totals': make_serializable(totals), - 'from': from_dt.isoformat(), - 'to': to_dt.isoformat(), + 'from': from_str, + 'to': to_str, 'group_by': group_by, }), 200 @@ -445,9 +453,9 @@ async def list_focus_groups(): """GET /api/admin/focus-groups?skip=&limit=&from=ISO&to=ISO — list with cost totals.""" skip = max(0, int(request.args.get('skip', 0))) limit = min(200, max(1, int(request.args.get('limit', 50)))) - now = datetime.now(timezone.utc) - from_dt = datetime.fromisoformat(request.args['from']) if request.args.get('from') else None - to_dt = datetime.fromisoformat(request.args['to']) if request.args.get('to') else now + from_str = request.args.get('from') + to_str = request.args.get('to') + period_filter = _period_match(from_str, to_str) db = await get_db() cursor = db.focus_groups.find( @@ -459,9 +467,7 @@ async def list_focus_groups(): result = [] for fg in fgs: fg_id = str(fg['_id']) - cost_match: dict = {'focus_group_id': fg_id} - if from_dt: - cost_match['ts'] = {'$gte': from_dt, '$lte': to_dt} + cost_match: dict = {'focus_group_id': fg_id, **period_filter} pipeline = [ {'$match': cost_match}, {'$group': {'_id': None, 'total': {'$sum': '$cost_usd.total'}, 'calls': {'$sum': 1}}}, diff --git a/src/lib/api.ts b/src/lib/api.ts index 3bc000df..6dc5a098 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -711,7 +711,7 @@ export const foldersApi = { export const adminApi = { // Users - listUsers: (params?: { q?: string; role?: string; skip?: number; limit?: number }) => + listUsers: (params?: { q?: string; role?: string; skip?: number; limit?: number; from?: string; to?: string }) => api.get('/admin/users', { params }), getUser: (id: string) => @@ -747,7 +747,7 @@ export const adminApi = { api.post(`/admin/users/${id}/reset-password`, { password }), // Focus Groups (admin view) - listFocusGroups: (params?: { skip?: number; limit?: number }) => + listFocusGroups: (params?: { skip?: number; limit?: number; from?: string; to?: string }) => api.get('/admin/focus-groups', { params }), };