Refactor user growth chart data model and enhance demo user creation

- Renamed `totalUsers` and `activeUsers` to `total_users` and `active_users` across frontend and backend for consistency.
- Enhanced demo user creation by randomizing `created_at` dates for realistic charts.
- Expanded demo data to include `is_active` for demo users, improving user status representation.
- Refined admin dashboard statistics to support updated user growth data model.
This commit is contained in:
Felipe Cardoso
2025-11-21 14:15:05 +01:00
parent 8c83e2a699
commit 2e4700ae9b
6 changed files with 138 additions and 53 deletions

View File

@@ -7,12 +7,14 @@ for managing the application.
""" """
import logging import logging
from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Query, status from fastapi import APIRouter, Depends, Query, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.permissions import require_superuser from app.api.dependencies.permissions import require_superuser
@@ -26,8 +28,9 @@ from app.core.exceptions import (
from app.crud.organization import organization as organization_crud from app.crud.organization import organization as organization_crud
from app.crud.session import session as session_crud from app.crud.session import session as session_crud
from app.crud.user import user as user_crud from app.crud.user import user as user_crud
from app.models.organization import Organization
from app.models.user import User from app.models.user import User
from app.models.user_organization import OrganizationRole from app.models.user_organization import OrganizationRole, UserOrganization
from app.schemas.common import ( from app.schemas.common import (
MessageResponse, MessageResponse,
PaginatedResponse, PaginatedResponse,
@@ -79,19 +82,23 @@ class BulkActionResult(BaseModel):
# ===== User Management Endpoints ===== # ===== User Management Endpoints =====
class UserGrowthData(BaseModel): class UserGrowthData(BaseModel):
date: str date: str
totalUsers: int total_users: int
activeUsers: int active_users: int
class OrgDistributionData(BaseModel): class OrgDistributionData(BaseModel):
name: str name: str
value: int value: int
class UserStatusData(BaseModel): class UserStatusData(BaseModel):
name: str name: str
value: int value: int
class AdminStatsResponse(BaseModel): class AdminStatsResponse(BaseModel):
user_growth: list[UserGrowthData] user_growth: list[UserGrowthData]
organization_distribution: list[OrgDistributionData] organization_distribution: list[OrgDistributionData]
@@ -110,27 +117,28 @@ async def admin_get_stats(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Any: ) -> Any:
"""Get admin dashboard statistics.""" """Get admin dashboard statistics."""
from sqlalchemy import func, select
from datetime import datetime, timedelta
# 1. User Growth (Last 30 days) # 1. User Growth (Last 30 days)
# Note: This is a simplified implementation. For production, consider a dedicated stats table or materialized view. # Note: This is a simplified implementation. For production, consider a dedicated stats table or materialized view.
thirty_days_ago = datetime.utcnow() - timedelta(days=30) thirty_days_ago = datetime.utcnow() - timedelta(days=30)
# Get all users created in last 30 days # Get all users created in last 30 days
query = select(User).where(User.created_at >= thirty_days_ago).order_by(User.created_at) query = (
select(User).where(User.created_at >= thirty_days_ago).order_by(User.created_at)
)
result = await db.execute(query) result = await db.execute(query)
recent_users = result.scalars().all() recent_users = result.scalars().all()
# Get total count before 30 days # Get total count before 30 days
count_query = select(func.count()).select_from(User).where(User.created_at < thirty_days_ago) count_query = (
result = await db.execute(count_query) select(func.count()).select_from(User).where(User.created_at < thirty_days_ago)
base_count = result.scalar() or 0 )
count_result = await db.execute(count_query)
base_count = count_result.scalar() or 0
# Aggregate by day # Aggregate by day
user_growth = [] user_growth = []
current_total = base_count current_total = base_count
# Create a map of date -> count # Create a map of date -> count
daily_counts = {} daily_counts = {}
for user in recent_users: for user in recent_users:
@@ -140,31 +148,30 @@ async def admin_get_stats(
daily_counts[date_str]["total"] += 1 daily_counts[date_str]["total"] += 1
if user.is_active: if user.is_active:
daily_counts[date_str]["active"] += 1 daily_counts[date_str]["active"] += 1
# Fill in the last 30 days # Fill in the last 30 days
for i in range(29, -1, -1): for i in range(29, -1, -1):
date = datetime.utcnow() - timedelta(days=i) date = datetime.utcnow() - timedelta(days=i)
date_str = date.strftime("%b %d") date_str = date.strftime("%b %d")
day_data = daily_counts.get(date_str, {"total": 0, "active": 0}) day_data = daily_counts.get(date_str, {"total": 0, "active": 0})
current_total += day_data["total"] current_total += day_data["total"]
# For active users, we'd ideally track history, but for now let's approximate # For active users, we'd ideally track history, but for now let's approximate
# by just counting current active users created up to this point # by just counting current active users created up to this point
# This is a simplification # This is a simplification
active_count = current_total # Simplified user_growth.append(
UserGrowthData(
user_growth.append(UserGrowthData( date=date_str,
date=date_str, total_users=current_total,
totalUsers=current_total, active_users=int(
activeUsers=int(current_total * 0.8) # Mocking active ratio for demo visual appeal if real data lacks history current_total * 0.8
)) ), # Mocking active ratio for demo visual appeal if real data lacks history
)
)
# 2. Organization Distribution # 2. Organization Distribution
# Get top 5 organizations by member count # Get top 5 organizations by member count
from app.models.user_organization import UserOrganization
from app.models.organization import Organization
org_query = ( org_query = (
select(Organization.name, func.count(UserOrganization.user_id).label("count")) select(Organization.name, func.count(UserOrganization.user_id).label("count"))
.join(UserOrganization, Organization.id == UserOrganization.organization_id) .join(UserOrganization, Organization.id == UserOrganization.organization_id)
@@ -173,24 +180,28 @@ async def admin_get_stats(
.limit(5) .limit(5)
) )
result = await db.execute(org_query) result = await db.execute(org_query)
org_dist = [OrgDistributionData(name=row.name, value=row.count) for row in result.all()] org_dist = [
OrgDistributionData(name=row.name, value=row.count) for row in result.all()
]
# 3. User Status # 3. User Status
active_query = select(func.count()).select_from(User).where(User.is_active == True) active_query = select(func.count()).select_from(User).where(User.is_active)
inactive_query = select(func.count()).select_from(User).where(User.is_active == False) inactive_query = (
select(func.count()).select_from(User).where(User.is_active.is_(False))
)
active_count = (await db.execute(active_query)).scalar() or 0 active_count = (await db.execute(active_query)).scalar() or 0
inactive_count = (await db.execute(inactive_query)).scalar() or 0 inactive_count = (await db.execute(inactive_query)).scalar() or 0
user_status = [ user_status = [
UserStatusData(name="Active", value=active_count), UserStatusData(name="Active", value=active_count),
UserStatusData(name="Inactive", value=inactive_count) UserStatusData(name="Inactive", value=inactive_count),
] ]
return AdminStatsResponse( return AdminStatsResponse(
user_growth=user_growth, user_growth=user_growth,
organization_distribution=org_dist, organization_distribution=org_dist,
user_status=user_status user_status=user_status,
) )

View File

@@ -34,7 +34,8 @@
"last_name": "Smith", "last_name": "Smith",
"is_superuser": false, "is_superuser": false,
"organization_slug": "acme-corp", "organization_slug": "acme-corp",
"role": "admin" "role": "admin",
"is_active": true
}, },
{ {
"email": "bob@acme.com", "email": "bob@acme.com",
@@ -43,7 +44,18 @@
"last_name": "Jones", "last_name": "Jones",
"is_superuser": false, "is_superuser": false,
"organization_slug": "acme-corp", "organization_slug": "acme-corp",
"role": "member" "role": "member",
"is_active": true
},
{
"email": "charlie@acme.com",
"password": "Demo123!",
"first_name": "Charlie",
"last_name": "Brown",
"is_superuser": false,
"organization_slug": "acme-corp",
"role": "member",
"is_active": false
}, },
{ {
"email": "carol@globex.com", "email": "carol@globex.com",
@@ -52,7 +64,18 @@
"last_name": "Williams", "last_name": "Williams",
"is_superuser": false, "is_superuser": false,
"organization_slug": "globex", "organization_slug": "globex",
"role": "owner" "role": "owner",
"is_active": true
},
{
"email": "dan@globex.com",
"password": "Demo123!",
"first_name": "Dan",
"last_name": "Miller",
"is_superuser": false,
"organization_slug": "globex",
"role": "member",
"is_active": true
}, },
{ {
"email": "dave@soylent.com", "email": "dave@soylent.com",
@@ -61,7 +84,8 @@
"last_name": "Brown", "last_name": "Brown",
"is_superuser": false, "is_superuser": false,
"organization_slug": "soylent", "organization_slug": "soylent",
"role": "member" "role": "member",
"is_active": true
}, },
{ {
"email": "eve@initech.com", "email": "eve@initech.com",
@@ -70,7 +94,8 @@
"last_name": "Davis", "last_name": "Davis",
"is_superuser": false, "is_superuser": false,
"organization_slug": "initech", "organization_slug": "initech",
"role": "admin" "role": "admin",
"is_active": true
}, },
{ {
"email": "frank@umbrella.com", "email": "frank@umbrella.com",
@@ -79,7 +104,18 @@
"last_name": "Miller", "last_name": "Miller",
"is_superuser": false, "is_superuser": false,
"organization_slug": "umbrella", "organization_slug": "umbrella",
"role": "member" "role": "member",
"is_active": true
},
{
"email": "george@umbrella.com",
"password": "Demo123!",
"first_name": "George",
"last_name": "Costanza",
"is_superuser": false,
"organization_slug": "umbrella",
"role": "member",
"is_active": false
}, },
{ {
"email": "grace@example.com", "email": "grace@example.com",
@@ -88,7 +124,8 @@
"last_name": "Hopper", "last_name": "Hopper",
"is_superuser": false, "is_superuser": false,
"organization_slug": null, "organization_slug": null,
"role": null "role": null,
"is_active": true
}, },
{ {
"email": "heidi@example.com", "email": "heidi@example.com",
@@ -97,7 +134,18 @@
"last_name": "Klum", "last_name": "Klum",
"is_superuser": false, "is_superuser": false,
"organization_slug": null, "organization_slug": null,
"role": null "role": null,
"is_active": true
},
{
"email": "ivan@example.com",
"password": "Demo123!",
"first_name": "Ivan",
"last_name": "Drago",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": false
} }
] ]
} }

View File

@@ -8,6 +8,8 @@ Creates the first superuser if configured and doesn't already exist.
import asyncio import asyncio
import json import json
import logging import logging
import random
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from sqlalchemy import select, text from sqlalchemy import select, text
@@ -137,15 +139,38 @@ async def load_demo_data(session):
session, email=user_data["email"] session, email=user_data["email"]
) )
if not existing_user: if not existing_user:
# Create user
user_in = UserCreate( user_in = UserCreate(
email=user_data["email"], email=user_data["email"],
password=user_data["password"], password=user_data["password"],
first_name=user_data["first_name"], first_name=user_data["first_name"],
last_name=user_data["last_name"], last_name=user_data["last_name"],
is_superuser=user_data["is_superuser"], is_superuser=user_data["is_superuser"],
is_active=user_data.get("is_active", True),
) )
user = await user_crud.create(session, obj_in=user_in) user = await user_crud.create(session, obj_in=user_in)
logger.info(f"Created demo user: {user.email}")
# Randomize created_at for demo data (last 30 days)
# This makes the charts look more realistic
days_ago = random.randint(0, 30) # noqa: S311
random_time = datetime.utcnow() - timedelta(days=days_ago)
# Add some random hours/minutes variation
random_time = random_time.replace(
hour=random.randint(0, 23), # noqa: S311
minute=random.randint(0, 59), # noqa: S311
)
# Update the timestamp directly in the database
await session.execute(
text(
"UPDATE users SET created_at = :created_at WHERE id = :user_id"
),
{"created_at": random_time, "user_id": user.id},
)
logger.info(
f"Created demo user: {user.email} (created {days_ago} days ago)"
)
# Add to organization if specified # Add to organization if specified
org_slug = user_data.get("organization_slug") org_slug = user_data.get("organization_slug")

View File

@@ -23,6 +23,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
password: str password: str
is_superuser: bool = False is_superuser: bool = False
is_active: bool = True
@field_validator("password") @field_validator("password")
@classmethod @classmethod

View File

@@ -21,8 +21,8 @@ import { CHART_PALETTES } from '@/lib/chart-colors';
export interface UserGrowthData { export interface UserGrowthData {
date: string; date: string;
totalUsers: number; total_users: number;
activeUsers: number; active_users: number;
} }
interface UserGrowthChartProps { interface UserGrowthChartProps {
@@ -41,8 +41,8 @@ function generateMockData(): UserGrowthData[] {
const baseUsers = 100 + i * 3; const baseUsers = 100 + i * 3;
data.push({ data.push({
date: format(date, 'MMM d'), date: format(date, 'MMM d'),
totalUsers: baseUsers + Math.floor(Math.random() * 10), total_users: baseUsers + Math.floor(Math.random() * 10),
activeUsers: Math.floor(baseUsers * 0.8) + Math.floor(Math.random() * 5), active_users: Math.floor(baseUsers * 0.8) + Math.floor(Math.random() * 5),
}); });
} }
@@ -89,7 +89,7 @@ export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps)
/> />
<Line <Line
type="monotone" type="monotone"
dataKey="totalUsers" dataKey="total_users"
name="Total Users" name="Total Users"
stroke={CHART_PALETTES.line[0]} stroke={CHART_PALETTES.line[0]}
strokeWidth={2} strokeWidth={2}
@@ -98,7 +98,7 @@ export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps)
/> />
<Line <Line
type="monotone" type="monotone"
dataKey="activeUsers" dataKey="active_users"
name="Active Users" name="Active Users"
stroke={CHART_PALETTES.line[1]} stroke={CHART_PALETTES.line[1]}
strokeWidth={2} strokeWidth={2}

View File

@@ -3,8 +3,8 @@ import type { Options } from './generated/sdk.gen';
export interface UserGrowthData { export interface UserGrowthData {
date: string; date: string;
totalUsers: number; total_users: number;
activeUsers: number; active_users: number;
} }
export interface OrgDistributionData { export interface OrgDistributionData {