Compare commits

...

3 Commits

Author SHA1 Message Date
Felipe Cardoso
2e4700ae9b 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.
2025-11-21 14:15:05 +01:00
Felipe Cardoso
8c83e2a699 Add comprehensive demo data loading logic and .env.demo configuration
- Implemented `load_demo_data` to populate organizations, users, and relationships from `demo_data.json`.
- Refactored database initialization to handle demo-specific passwords and multi-entity creation in demo mode.
- Added `demo_data.json` with sample organizations and users for better demo showcase.
- Introduced `.env.demo` to simplify environment setup for demo scenarios.
- Updated `.gitignore` to include `.env.demo` while keeping other `.env` files excluded.
2025-11-21 08:39:07 +01:00
Felipe Cardoso
9b6356b0db Add comprehensive demo data loading logic and .env.demo configuration
- Implemented `load_demo_data` to populate organizations, users, and relationships from `demo_data.json`.
- Refactored database initialization to handle demo-specific passwords and multi-entity creation in demo mode.
- Added `demo_data.json` with sample organizations and users for better demo showcase.
- Introduced `.env.demo` to simplify environment setup for demo scenarios.
- Updated `.gitignore` to include `.env.demo` while keeping other `.env` files excluded.
2025-11-21 08:23:18 +01:00
12 changed files with 535 additions and 49 deletions

31
.env.demo Normal file
View File

@@ -0,0 +1,31 @@
# Common settings
PROJECT_NAME=App
VERSION=1.0.0
# Database settings
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=app
POSTGRES_HOST=db
POSTGRES_PORT=5432
DATABASE_URL=postgresql://postgres:postgres@db:5432/app
# Backend settings
BACKEND_PORT=8000
# CRITICAL: Generate a secure SECRET_KEY for production!
# Generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
# Must be at least 32 characters
SECRET_KEY=demo_secret_key_for_testing_only_do_not_use_in_prod
ENVIRONMENT=development
DEMO_MODE=true
DEBUG=true
BACKEND_CORS_ORIGINS=["http://localhost:3000"]
FIRST_SUPERUSER_EMAIL=admin@example.com
# IMPORTANT: Use a strong password (min 12 chars, mixed case, digits)
# Default weak passwords like 'Admin123' are rejected
FIRST_SUPERUSER_PASSWORD=Admin123!
# Frontend settings
FRONTEND_PORT=3000
NEXT_PUBLIC_API_URL=http://localhost:8000
NODE_ENV=development

1
.gitignore vendored
View File

@@ -268,6 +268,7 @@ celerybeat.pid
.env
.env.*
!.env.template
!.env.demo
.venv
env/
venv/

View File

@@ -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,
@@ -80,6 +83,131 @@ class BulkActionResult(BaseModel):
# ===== User Management Endpoints =====
class UserGrowthData(BaseModel):
date: str
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]
user_status: list[UserStatusData]
@router.get(
"/stats",
response_model=AdminStatsResponse,
summary="Admin: Get Dashboard Stats",
description="Get aggregated statistics for the admin dashboard (admin only)",
operation_id="admin_get_stats",
)
async def admin_get_stats(
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db),
) -> Any:
"""Get admin dashboard statistics."""
# 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)
)
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)
)
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:
date_str = user.created_at.strftime("%b %d")
if date_str not in daily_counts:
daily_counts[date_str] = {"total": 0, "active": 0}
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
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
org_query = (
select(Organization.name, func.count(UserOrganization.user_id).label("count"))
.join(UserOrganization, Organization.id == UserOrganization.organization_id)
.group_by(Organization.name)
.order_by(func.count(UserOrganization.user_id).desc())
.limit(5)
)
result = await db.execute(org_query)
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)
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),
]
return AdminStatsResponse(
user_growth=user_growth,
organization_distribution=org_dist,
user_status=user_status,
)
# ===== User Management Endpoints =====
@router.get(
"/users",
response_model=PaginatedResponse[UserResponse],

View File

@@ -0,0 +1,151 @@
{
"organizations": [
{
"name": "Acme Corp",
"slug": "acme-corp",
"description": "A leading provider of coyote-catching equipment."
},
{
"name": "Globex Corporation",
"slug": "globex",
"description": "We own the East Coast."
},
{
"name": "Soylent Corp",
"slug": "soylent",
"description": "Making food for the future."
},
{
"name": "Initech",
"slug": "initech",
"description": "Software for the soul."
},
{
"name": "Umbrella Corporation",
"slug": "umbrella",
"description": "Our business is life itself."
}
],
"users": [
{
"email": "alice@acme.com",
"password": "Demo123!",
"first_name": "Alice",
"last_name": "Smith",
"is_superuser": false,
"organization_slug": "acme-corp",
"role": "admin",
"is_active": true
},
{
"email": "bob@acme.com",
"password": "Demo123!",
"first_name": "Bob",
"last_name": "Jones",
"is_superuser": false,
"organization_slug": "acme-corp",
"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",
"password": "Demo123!",
"first_name": "Carol",
"last_name": "Williams",
"is_superuser": false,
"organization_slug": "globex",
"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",
"password": "Demo123!",
"first_name": "Dave",
"last_name": "Brown",
"is_superuser": false,
"organization_slug": "soylent",
"role": "member",
"is_active": true
},
{
"email": "eve@initech.com",
"password": "Demo123!",
"first_name": "Eve",
"last_name": "Davis",
"is_superuser": false,
"organization_slug": "initech",
"role": "admin",
"is_active": true
},
{
"email": "frank@umbrella.com",
"password": "Demo123!",
"first_name": "Frank",
"last_name": "Miller",
"is_superuser": false,
"organization_slug": "umbrella",
"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",
"password": "Demo123!",
"first_name": "Grace",
"last_name": "Hopper",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
},
{
"email": "heidi@example.com",
"password": "Demo123!",
"first_name": "Heidi",
"last_name": "Klum",
"is_superuser": false,
"organization_slug": 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

@@ -6,12 +6,20 @@ 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
from app.core.config import settings
from app.core.database import SessionLocal, engine
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 UserOrganization
from app.schemas.users import UserCreate
logger = logging.getLogger(__name__)
@@ -26,7 +34,12 @@ async def init_db() -> User | None:
"""
# Use default values if not set in environment variables
superuser_email = settings.FIRST_SUPERUSER_EMAIL or "admin@example.com"
superuser_password = settings.FIRST_SUPERUSER_PASSWORD or "AdminPassword123!"
default_password = "AdminPassword123!"
if settings.DEMO_MODE:
default_password = "Admin123!"
superuser_password = settings.FIRST_SUPERUSER_PASSWORD or default_password
if not settings.FIRST_SUPERUSER_EMAIL or not settings.FIRST_SUPERUSER_PASSWORD:
logger.warning(
@@ -58,25 +71,9 @@ async def init_db() -> User | None:
logger.info(f"Created first superuser: {user.email}")
# Create demo user if in demo mode
# Create demo data if in demo mode
if settings.DEMO_MODE:
demo_email = "demo@example.com"
demo_password = "Demo123!"
existing_demo_user = await user_crud.get_by_email(session, email=demo_email)
if not existing_demo_user:
demo_user_in = UserCreate(
email=demo_email,
password=demo_password,
first_name="Demo",
last_name="User",
is_superuser=False,
)
demo_user = await user_crud.create(session, obj_in=demo_user_in)
await session.commit()
logger.info(f"Created demo user: {demo_user.email}")
else:
logger.info(f"Demo user already exists: {existing_demo_user.email}")
await load_demo_data(session)
return user
@@ -86,6 +83,117 @@ async def init_db() -> User | None:
raise
def _load_json_file(path: Path):
with open(path) as f:
return json.load(f)
async def load_demo_data(session):
"""Load demo data from JSON file."""
demo_data_path = Path(__file__).parent / "core" / "demo_data.json"
if not demo_data_path.exists():
logger.warning(f"Demo data file not found: {demo_data_path}")
return
try:
# Use asyncio.to_thread to avoid blocking the event loop
data = await asyncio.to_thread(_load_json_file, demo_data_path)
# Create Organizations
org_map = {}
for org_data in data.get("organizations", []):
# Check if org exists
result = await session.execute(
text("SELECT * FROM organizations WHERE slug = :slug"),
{"slug": org_data["slug"]},
)
existing_org = result.first()
if not existing_org:
org = Organization(
name=org_data["name"],
slug=org_data["slug"],
description=org_data.get("description"),
is_active=True,
)
session.add(org)
await session.flush() # Flush to get ID
org_map[org.slug] = org
logger.info(f"Created demo organization: {org.name}")
else:
# We can't easily get the ORM object from raw SQL result for map without querying again or mapping
# So let's just query it properly if we need it for relationships
# But for simplicity in this script, let's just assume we created it or it exists.
# To properly map for users, we need the ID.
# Let's use a simpler approach: just try to create, if slug conflict, skip.
pass
# Re-query all orgs to build map for users
result = await session.execute(select(Organization))
orgs = result.scalars().all()
org_map = {org.slug: org for org in orgs}
# Create Users
for user_data in data.get("users", []):
existing_user = await user_crud.get_by_email(
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)
# 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")
role = user_data.get("role")
if org_slug and org_slug in org_map and role:
org = org_map[org_slug]
# Check if membership exists (it shouldn't for new user)
member = UserOrganization(
user_id=user.id, organization_id=org.id, role=role
)
session.add(member)
logger.info(f"Added {user.email} to {org.name} as {role}")
else:
logger.info(f"Demo user already exists: {existing_user.email}")
await session.commit()
logger.info("Demo data loaded successfully")
except Exception as e:
logger.error(f"Error loading demo data: {e}")
raise
async def main():
"""Main entry point for database initialization."""
# Configure logging to show info logs

View File

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

1
frontend/.gitignore vendored
View File

@@ -33,6 +33,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
.
# vercel
.vercel

View File

@@ -4,6 +4,8 @@
* Protected by AuthGuard in layout with requireAdmin=true
*/
'use client';
import { Link } from '@/lib/i18n/routing';
import { DashboardStats } from '@/components/admin';
import {
@@ -13,11 +15,18 @@ import {
UserStatusChart,
} from '@/components/charts';
import { Users, Building2, Settings } from 'lucide-react';
// Re-export server-only metadata from separate, ignored file
export { metadata } from './metadata';
import { useQuery } from '@tanstack/react-query';
import { getAdminStats } from '@/lib/api/admin';
export default function AdminPage() {
const { data: stats, isLoading, error } = useQuery({
queryKey: ['admin', 'stats'],
queryFn: async () => {
const response = await getAdminStats();
return response.data;
},
});
return (
<div className="container mx-auto px-6 py-8">
<div className="space-y-8">
@@ -76,10 +85,22 @@ export default function AdminPage() {
<div>
<h2 className="text-xl font-semibold mb-4">Analytics Overview</h2>
<div className="grid gap-6 md:grid-cols-2">
<UserGrowthChart />
<UserGrowthChart
data={stats?.user_growth}
loading={isLoading}
error={error ? (error as Error).message : null}
/>
<SessionActivityChart />
<OrganizationDistributionChart />
<UserStatusChart />
<OrganizationDistributionChart
data={stats?.organization_distribution}
loading={isLoading}
error={error ? (error as Error).message : null}
/>
<UserStatusChart
data={stats?.user_status}
loading={isLoading}
error={error ? (error as Error).message : null}
/>
</div>
</div>
</div>

View File

@@ -20,8 +20,7 @@ import { CHART_PALETTES } from '@/lib/chart-colors';
export interface OrganizationDistributionData {
name: string;
members: number;
activeMembers: number;
value: number;
}
interface OrganizationDistributionChartProps {
@@ -33,12 +32,12 @@ interface OrganizationDistributionChartProps {
// Generate mock data for development/demo
function generateMockData(): OrganizationDistributionData[] {
return [
{ name: 'Engineering', members: 45, activeMembers: 42 },
{ name: 'Marketing', members: 28, activeMembers: 25 },
{ name: 'Sales', members: 35, activeMembers: 33 },
{ name: 'Operations', members: 22, activeMembers: 20 },
{ name: 'HR', members: 15, activeMembers: 14 },
{ name: 'Finance', members: 18, activeMembers: 17 },
{ name: 'Engineering', value: 45 },
{ name: 'Marketing', value: 28 },
{ name: 'Sales', value: 35 },
{ name: 'Operations', value: 22 },
{ name: 'HR', value: 15 },
{ name: 'Finance', value: 18 },
];
}
@@ -85,17 +84,11 @@ export function OrganizationDistributionChart({
}}
/>
<Bar
dataKey="members"
dataKey="value"
name="Total Members"
fill={CHART_PALETTES.bar[0]}
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="activeMembers"
name="Active Members"
fill={CHART_PALETTES.bar[1]}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</ChartCard>

View File

@@ -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)
/>
<Line
type="monotone"
dataKey="totalUsers"
dataKey="total_users"
name="Total Users"
stroke={CHART_PALETTES.line[0]}
strokeWidth={2}
@@ -98,7 +98,7 @@ export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps)
/>
<Line
type="monotone"
dataKey="activeUsers"
dataKey="active_users"
name="Active Users"
stroke={CHART_PALETTES.line[1]}
strokeWidth={2}

View File

@@ -12,7 +12,7 @@ import { CHART_PALETTES } from '@/lib/chart-colors';
export interface UserStatusData {
name: string;
value: number;
color: string;
color?: string;
}
interface UserStatusChartProps {
@@ -38,7 +38,13 @@ const renderLabel = (entry: { percent: number; name: string }) => {
};
export function UserStatusChart({ data, loading, error }: UserStatusChartProps) {
const chartData = data || generateMockData();
const rawData = data || generateMockData();
// Assign colors if missing
const chartData = rawData.map((item, index) => ({
...item,
color: item.color || CHART_PALETTES.pie[index % CHART_PALETTES.pie.length],
}));
return (
<ChartCard

View File

@@ -0,0 +1,45 @@
import { apiClient } from './client';
import type { Options } from './generated/sdk.gen';
export interface UserGrowthData {
date: string;
total_users: number;
active_users: number;
}
export interface OrgDistributionData {
name: string;
value: number;
}
export interface UserStatusData {
name: string;
value: number;
}
export interface AdminStatsResponse {
user_growth: UserGrowthData[];
organization_distribution: OrgDistributionData[];
user_status: UserStatusData[];
}
/**
* Admin: Get Dashboard Stats
*
* Get aggregated statistics for the admin dashboard (admin only)
*/
export const getAdminStats = <ThrowOnError extends boolean = false>(
options?: Options<any, ThrowOnError>
) => {
return (options?.client ?? apiClient).get<AdminStatsResponse, any, ThrowOnError>({
responseType: 'json',
security: [
{
scheme: 'bearer',
type: 'http',
},
],
url: '/api/v1/admin/stats',
...options,
});
};