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:
parent
ad619d45fc
commit
7b6a7c7347
2 changed files with 32 additions and 26 deletions
|
|
@ -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}}},
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue