Fix admin filters: ISO Z parsing crash + All time period returning month data

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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-24 19:17:57 +01:00
parent ad619d45fc
commit 7b6a7c7347
2 changed files with 32 additions and 26 deletions

View file

@ -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}}},

View file

@ -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 }),
};