diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index 467e03c..7b39207 100755 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -7,12 +7,14 @@ for managing the application. """ import logging +from datetime import datetime, timedelta from enum import Enum from typing import Any from uuid import UUID from fastapi import APIRouter, Depends, Query, status from pydantic import BaseModel, Field +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession 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.session import session as session_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_organization import OrganizationRole +from app.models.user_organization import OrganizationRole, UserOrganization from app.schemas.common import ( MessageResponse, PaginatedResponse, @@ -79,19 +82,23 @@ class BulkActionResult(BaseModel): # ===== User Management Endpoints ===== + class UserGrowthData(BaseModel): date: str - totalUsers: int - activeUsers: int + total_users: int + active_users: int + class OrgDistributionData(BaseModel): name: str value: int + class UserStatusData(BaseModel): name: str value: int + class AdminStatsResponse(BaseModel): user_growth: list[UserGrowthData] organization_distribution: list[OrgDistributionData] @@ -110,27 +117,28 @@ async def admin_get_stats( db: AsyncSession = Depends(get_db), ) -> Any: """Get admin dashboard statistics.""" - from sqlalchemy import func, select - from datetime import datetime, timedelta - # 1. User Growth (Last 30 days) # Note: This is a simplified implementation. For production, consider a dedicated stats table or materialized view. thirty_days_ago = datetime.utcnow() - timedelta(days=30) - + # 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) recent_users = result.scalars().all() - + # Get total count before 30 days - count_query = select(func.count()).select_from(User).where(User.created_at < thirty_days_ago) - result = await db.execute(count_query) - base_count = result.scalar() or 0 - + count_query = ( + select(func.count()).select_from(User).where(User.created_at < thirty_days_ago) + ) + count_result = await db.execute(count_query) + base_count = count_result.scalar() or 0 + # Aggregate by day user_growth = [] current_total = base_count - + # Create a map of date -> count daily_counts = {} for user in recent_users: @@ -140,31 +148,30 @@ async def admin_get_stats( daily_counts[date_str]["total"] += 1 if user.is_active: daily_counts[date_str]["active"] += 1 - + # Fill in the last 30 days for i in range(29, -1, -1): date = datetime.utcnow() - timedelta(days=i) date_str = date.strftime("%b %d") - + day_data = daily_counts.get(date_str, {"total": 0, "active": 0}) current_total += day_data["total"] - + # 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 # This is a simplification - active_count = current_total # Simplified - - user_growth.append(UserGrowthData( - date=date_str, - totalUsers=current_total, - activeUsers=int(current_total * 0.8) # Mocking active ratio for demo visual appeal if real data lacks history - )) + user_growth.append( + UserGrowthData( + date=date_str, + total_users=current_total, + active_users=int( + current_total * 0.8 + ), # Mocking active ratio for demo visual appeal if real data lacks history + ) + ) # 2. Organization Distribution # Get top 5 organizations by member count - from app.models.user_organization import UserOrganization - from app.models.organization import Organization - org_query = ( select(Organization.name, func.count(UserOrganization.user_id).label("count")) .join(UserOrganization, Organization.id == UserOrganization.organization_id) @@ -173,24 +180,28 @@ async def admin_get_stats( .limit(5) ) 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 - active_query = select(func.count()).select_from(User).where(User.is_active == True) - inactive_query = select(func.count()).select_from(User).where(User.is_active == False) - + active_query = select(func.count()).select_from(User).where(User.is_active) + inactive_query = ( + select(func.count()).select_from(User).where(User.is_active.is_(False)) + ) + active_count = (await db.execute(active_query)).scalar() or 0 inactive_count = (await db.execute(inactive_query)).scalar() or 0 - + user_status = [ UserStatusData(name="Active", value=active_count), - UserStatusData(name="Inactive", value=inactive_count) + UserStatusData(name="Inactive", value=inactive_count), ] - + return AdminStatsResponse( user_growth=user_growth, organization_distribution=org_dist, - user_status=user_status + user_status=user_status, ) diff --git a/backend/app/core/demo_data.json b/backend/app/core/demo_data.json index d542738..c955533 100644 --- a/backend/app/core/demo_data.json +++ b/backend/app/core/demo_data.json @@ -34,7 +34,8 @@ "last_name": "Smith", "is_superuser": false, "organization_slug": "acme-corp", - "role": "admin" + "role": "admin", + "is_active": true }, { "email": "bob@acme.com", @@ -43,7 +44,18 @@ "last_name": "Jones", "is_superuser": false, "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", @@ -52,7 +64,18 @@ "last_name": "Williams", "is_superuser": false, "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", @@ -61,7 +84,8 @@ "last_name": "Brown", "is_superuser": false, "organization_slug": "soylent", - "role": "member" + "role": "member", + "is_active": true }, { "email": "eve@initech.com", @@ -70,7 +94,8 @@ "last_name": "Davis", "is_superuser": false, "organization_slug": "initech", - "role": "admin" + "role": "admin", + "is_active": true }, { "email": "frank@umbrella.com", @@ -79,7 +104,18 @@ "last_name": "Miller", "is_superuser": false, "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", @@ -88,7 +124,8 @@ "last_name": "Hopper", "is_superuser": false, "organization_slug": null, - "role": null + "role": null, + "is_active": true }, { "email": "heidi@example.com", @@ -97,7 +134,18 @@ "last_name": "Klum", "is_superuser": false, "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 } ] } \ No newline at end of file diff --git a/backend/app/init_db.py b/backend/app/init_db.py index d29f66d..934cf11 100644 --- a/backend/app/init_db.py +++ b/backend/app/init_db.py @@ -8,6 +8,8 @@ Creates the first superuser if configured and doesn't already exist. import asyncio import json import logging +import random +from datetime import datetime, timedelta from pathlib import Path from sqlalchemy import select, text @@ -137,15 +139,38 @@ async def load_demo_data(session): session, email=user_data["email"] ) if not existing_user: + # Create user user_in = UserCreate( email=user_data["email"], password=user_data["password"], first_name=user_data["first_name"], last_name=user_data["last_name"], is_superuser=user_data["is_superuser"], + is_active=user_data.get("is_active", True), ) 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 org_slug = user_data.get("organization_slug") diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py index 1229954..c0e08ef 100755 --- a/backend/app/schemas/users.py +++ b/backend/app/schemas/users.py @@ -23,6 +23,7 @@ class UserBase(BaseModel): class UserCreate(UserBase): password: str is_superuser: bool = False + is_active: bool = True @field_validator("password") @classmethod diff --git a/frontend/src/components/charts/UserGrowthChart.tsx b/frontend/src/components/charts/UserGrowthChart.tsx index 508d159..1c0da5a 100644 --- a/frontend/src/components/charts/UserGrowthChart.tsx +++ b/frontend/src/components/charts/UserGrowthChart.tsx @@ -21,8 +21,8 @@ import { CHART_PALETTES } from '@/lib/chart-colors'; export interface UserGrowthData { date: string; - totalUsers: number; - activeUsers: number; + total_users: number; + active_users: number; } interface UserGrowthChartProps { @@ -41,8 +41,8 @@ function generateMockData(): UserGrowthData[] { const baseUsers = 100 + i * 3; data.push({ date: format(date, 'MMM d'), - totalUsers: baseUsers + Math.floor(Math.random() * 10), - activeUsers: Math.floor(baseUsers * 0.8) + Math.floor(Math.random() * 5), + total_users: baseUsers + Math.floor(Math.random() * 10), + active_users: Math.floor(baseUsers * 0.8) + Math.floor(Math.random() * 5), }); } @@ -89,7 +89,7 @@ export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps) />