Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/api/core/dependencies/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,13 @@ async def get_current_active_user(
detail="Inactive user"
)
return current_user

async def get_current_admin_user(
current_user: User = Depends(get_current_active_user)
) -> User:
if current_user.role.value != "ADMIN":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required"
)
return current_user
18 changes: 16 additions & 2 deletions apps/api/main.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(title="TicketFlow API", version="1.0.0")

# TODO: Add CORS middleware
# TODO: Include routers from modules
# Configure CORS for potential direct frontend-to-backend communication
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# TODO: Add exception handlers

# pyrefly: ignore [missing-import]
from apps.api.modules.auth.router import router as auth_router
# pyrefly: ignore [missing-import]
from apps.api.modules.tickets.router import router as tickets_router
# pyrefly: ignore [missing-import]
from apps.api.modules.dashboard.router import router as dashboard_router
# pyrefly: ignore [missing-import]
from apps.api.modules.users.router import router as users_router

app.include_router(auth_router, prefix="/api/v1/auth")
app.include_router(tickets_router, prefix="/api/v1/tickets")
app.include_router(dashboard_router, prefix="/api/v1/dashboard")
app.include_router(users_router, prefix="/api/v1/users")

@app.get("/")
def read_root():
Expand Down
123 changes: 120 additions & 3 deletions apps/api/modules/users/router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,122 @@
from fastapi import APIRouter
import uuid
from typing import Optional

router = APIRouter(prefix="/users", tags=["users"])
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession

# TODO: Implement users endpoints
from apps.api.infrastructure.db.session import get_db
from apps.api.core.dependencies.auth import get_current_admin_user
from apps.api.modules.users.models import User, Role
from apps.api.modules.users.schemas import (
UserCreate, UserUpdate, UserRead, UserListResponse
)
from apps.api.modules.users.service import UserService

router = APIRouter(tags=["Users"])

@router.get("", response_model=UserListResponse)
async def list_users(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
role: Optional[Role] = None,
user_status: Optional[bool] = Query(None, alias="status"),
search: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
users, total = await UserService.list_users(
db=db, skip=skip, limit=limit, role=role, status=user_status, search=search
)
return UserListResponse(data=users, total=total)

@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(
user_in: UserCreate,
db: AsyncSession = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
existing_user = await UserService.get_user_by_email(db, email=user_in.email)
if existing_user:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
return await UserService.create_user(db=db, user_in=user_in)

@router.get("/{id}", response_model=UserRead)
async def get_user(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
user = await UserService.get_user(db, id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user

@router.patch("/{id}", response_model=UserRead)
async def update_user(
id: uuid.UUID,
user_in: UserUpdate,
db: AsyncSession = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
try:
return await UserService.update_user(db, id, user_in, current_admin.id)
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))

@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
try:
await UserService.delete_user(db, id, current_admin.id)
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))

@router.patch("/{id}/activate", response_model=UserRead)
async def activate_user(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
update_schema = UserUpdate(is_active=True)
try:
return await UserService.update_user(db, id, update_schema, current_admin.id)
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))

@router.patch("/{id}/deactivate", response_model=UserRead)
async def deactivate_user(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
update_schema = UserUpdate(is_active=False)
try:
return await UserService.update_user(db, id, update_schema, current_admin.id)
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))

@router.patch("/{id}/role", response_model=UserRead)
async def update_user_role(
id: uuid.UUID,
role: Role = Query(..., description="The new role to assign"),
db: AsyncSession = Depends(get_db),
current_admin: User = Depends(get_current_admin_user)
):
update_schema = UserUpdate(role=role)
try:
return await UserService.update_user(db, id, update_schema, current_admin.id)
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
5 changes: 5 additions & 0 deletions apps/api/modules/users/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ class UserUpdate(BaseModel):
role: Role | None = None
is_active: bool | None = None
password: str | None = Field(None, min_length=8)

class UserListResponse(BaseModel):
data: list[UserRead]
total: int

130 changes: 129 additions & 1 deletion apps/api/modules/users/service.py
Original file line number Diff line number Diff line change
@@ -1 +1,129 @@
# TODO: Implement users business logic
import uuid
from typing import List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, func

from apps.api.modules.users.models import User, Role
from apps.api.modules.users.schemas import UserCreate, UserUpdate
from apps.api.modules.auth.auth_service import AuthService

class UserService:
@staticmethod
async def get_user(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()

@staticmethod
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()

@staticmethod
async def list_users(
db: AsyncSession,
skip: int = 0,
limit: int = 100,
role: Optional[Role] = None,
status: Optional[bool] = None,
search: Optional[str] = None
) -> Tuple[List[User], int]:
query = select(User)

if role:
query = query.where(User.role == role)
if status is not None:
query = query.where(User.is_active == status)
if search:
search_pattern = f"%{search}%"
query = query.where(
or_(
User.full_name.ilike(search_pattern),
User.email.ilike(search_pattern)
)
)

# Get total count
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar_one()

query = query.order_by(User.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
return list(result.scalars().all()), total

@staticmethod
async def create_user(db: AsyncSession, user_in: UserCreate) -> User:
hashed_password = AuthService.hash_new_password(user_in.password)
new_user = User(
full_name=user_in.full_name,
email=user_in.email,
password_hash=hashed_password,
role=user_in.role,
is_active=user_in.is_active
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return new_user

@staticmethod
async def update_user(
db: AsyncSession,
user_id: uuid.UUID,
user_in: UserUpdate,
current_user_id: uuid.UUID
) -> User:
user = await UserService.get_user(db, user_id)
if not user:
raise ValueError("User not found")

update_data = user_in.model_dump(exclude_unset=True)

# Safety checks
if "role" in update_data and update_data["role"] != Role.ADMIN and user.id == current_user_id:
raise ValueError("Cannot demote yourself from ADMIN role")

if "is_active" in update_data and update_data["is_active"] is False and user.id == current_user_id:
raise ValueError("Cannot deactivate yourself")

if ("role" in update_data and update_data["role"] != Role.ADMIN) or \
("is_active" in update_data and update_data["is_active"] is False):
# Check if this is the last admin
if user.role == Role.ADMIN:
admin_count = await UserService._get_active_admin_count(db)
if admin_count <= 1:
raise ValueError("Cannot modify or deactivate the last active ADMIN user")

if "password" in update_data and update_data["password"]:
update_data["password_hash"] = AuthService.hash_new_password(update_data.pop("password"))

for key, value in update_data.items():
setattr(user, key, value)

await db.commit()
await db.refresh(user)
return user

@staticmethod
async def delete_user(db: AsyncSession, user_id: uuid.UUID, current_user_id: uuid.UUID) -> bool:
user = await UserService.get_user(db, user_id)
if not user:
raise ValueError("User not found")

if user.id == current_user_id:
raise ValueError("Cannot delete yourself")

if user.role == Role.ADMIN:
admin_count = await UserService._get_active_admin_count(db)
if admin_count <= 1:
raise ValueError("Cannot delete the last active ADMIN user")

await db.delete(user)
await db.commit()
return True

@staticmethod
async def _get_active_admin_count(db: AsyncSession) -> int:
query = select(func.count()).select_from(User).where(User.role == Role.ADMIN, User.is_active == True)
result = await db.execute(query)
return result.scalar_one()
52 changes: 52 additions & 0 deletions apps/api/scripts/seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import asyncio
import sys
import os

# Add the project root to the python path so imports work correctly when run directly
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))))

from sqlalchemy import select
from apps.api.infrastructure.db.session import SessionLocal
from apps.api.modules.users.models import User, Role
from apps.api.modules.auth.auth_service import AuthService

async def seed_users():
async with SessionLocal() as session:
# Default Admin User
admin_email = "admin@ticketflow.com"
admin_exists = await session.execute(select(User).where(User.email == admin_email))
if not admin_exists.scalar_one_or_none():
admin_user = User(
full_name="System Admin",
email=admin_email,
password_hash=AuthService.hash_new_password("Admin@123456"),
role=Role.ADMIN,
is_active=True
)
session.add(admin_user)
print(f"Created Admin user: {admin_email}")
else:
print(f"Admin user already exists: {admin_email}")

# Default Regular User (Mapped to EMPLOYEE role)
regular_email = "user@ticketflow.com"
regular_exists = await session.execute(select(User).where(User.email == regular_email))
if not regular_exists.scalar_one_or_none():
regular_user = User(
full_name="Regular User",
email=regular_email,
password_hash=AuthService.hash_new_password("User@123456"),
role=Role.EMPLOYEE,
is_active=True
)
session.add(regular_user)
print(f"Created Regular user: {regular_email}")
else:
print(f"Regular user already exists: {regular_email}")

await session.commit()
print("Database seed completed successfully.")

if __name__ == "__main__":
print("Starting database seed...")
asyncio.run(seed_users())
2 changes: 1 addition & 1 deletion apps/web/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LoginForm } from '@/components/auth/LoginForm';
import { LoginForm } from '@/features/auth';

export const metadata = {
title: 'Login - TicketFlow',
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AuthGuard } from '@/components/auth/AuthGuard';
import { AuthGuard } from '@/features/auth';

export default function DashboardLayout({
children,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { dashboardService } from '@/services/dashboard.service';
import { dashboardService } from '@/features/dashboard';
import {
DashboardHeaderWrapper,
SummaryCards,
DashboardCharts,
RecentActivity,
} from '@/components/dashboard';
} from '@/features/dashboard';

export const metadata = {
title: 'Dashboard - TicketFlow',
Expand Down
Loading
Loading