Initial commit: Rentman Clone - Phase 0-6 (T001-T023)
Completed: - Phase 0: Project Setup (T001-T003) - Docker Compose, FastAPI skeleton, React SPA - Phase 1: Auth System (T004-T008) - DB models, JWT auth, RBAC middleware, user management - Phase 2: Contacts & Tags (T009-T011) - CRUD API + UI - Phase 3: Equipment Catalog (T012-T014) - Models, API, UI with barcode/QR - Phase 4: Crew Management (T015-T017) - Models, availability, UI - Phase 5: Vehicle Fleet (T018-T020) - Models, assignments, UI - Phase 6: Projects (T021-T023) - Project hierarchy models, CRUD API, list/detail UI
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""Authentication endpoints: register, login, refresh, me."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
verify_password,
|
||||
verify_token,
|
||||
get_password_hash,
|
||||
)
|
||||
from app.db.session import get_async_session
|
||||
from app.models import Account, User, Role
|
||||
from app.schemas.auth import (
|
||||
RegisterRequest,
|
||||
LoginRequest,
|
||||
RefreshRequest,
|
||||
TokenResponse,
|
||||
UserInfo,
|
||||
UserResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _build_user_info(user: User, role: Role | None = None) -> UserInfo:
|
||||
"""Helper to extract UserInfo from a User model instance."""
|
||||
# Fallback to user.role if loaded, else use the provided role
|
||||
role_obj = role or user.role
|
||||
return UserInfo(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
account_id=user.account_id,
|
||||
role_id=user.role_id,
|
||||
role_name=role_obj.name if role_obj else None,
|
||||
permissions=role_obj.permissions if role_obj else [],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
body: RegisterRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Register a new account with an admin user."""
|
||||
# Check if email already exists
|
||||
result = await session.execute(select(User).where(User.email == body.email))
|
||||
if result.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A user with this email already exists",
|
||||
)
|
||||
|
||||
# Check if account name already exists
|
||||
result = await session.execute(select(Account).where(Account.name == body.account_name))
|
||||
if result.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="An account with this name already exists",
|
||||
)
|
||||
|
||||
# Create account
|
||||
account = Account(name=body.account_name)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
|
||||
# Create admin role
|
||||
admin_permissions = [
|
||||
"projects:read", "projects:write", "projects:delete",
|
||||
"equipment:read", "equipment:write", "equipment:delete",
|
||||
"crew:read", "crew:write", "crew:delete",
|
||||
"vehicles:read", "vehicles:write", "vehicles:delete",
|
||||
"contacts:read", "contacts:write", "contacts:delete",
|
||||
"users:read", "users:write", "users:delete",
|
||||
"roles:read", "roles:write",
|
||||
]
|
||||
admin_role = Role(
|
||||
account_id=account.id,
|
||||
name="Admin",
|
||||
description="Full access to all features",
|
||||
permissions=admin_permissions,
|
||||
)
|
||||
session.add(admin_role)
|
||||
await session.flush()
|
||||
|
||||
# Create admin user
|
||||
user = User(
|
||||
account_id=account.id,
|
||||
email=body.email,
|
||||
full_name=body.full_name,
|
||||
password_hash=get_password_hash(body.password),
|
||||
role_id=admin_role.id,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
# Generate tokens
|
||||
access_token = create_access_token(subject=user.id)
|
||||
refresh_token = create_refresh_token(subject=user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=_build_user_info(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Authenticate user with email and password."""
|
||||
result = await session.execute(select(User).where(User.email == body.email))
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is deactivated",
|
||||
)
|
||||
|
||||
access_token = create_access_token(subject=user.id)
|
||||
refresh_token = create_refresh_token(subject=user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=_build_user_info(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(
|
||||
body: RefreshRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Refresh an access token using a valid refresh token."""
|
||||
try:
|
||||
payload = verify_token(body.refresh_token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type",
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
# Verify user exists and is active
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalars().first()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive",
|
||||
)
|
||||
|
||||
access_token = create_access_token(subject=user.id)
|
||||
new_refresh_token = create_refresh_token(subject=user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
user=_build_user_info(user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_info(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get the current authenticated user's info."""
|
||||
return UserResponse.model_validate(current_user)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Contacts CRUD endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.api.deps import get_current_user, require_permission
|
||||
from app.db.session import get_async_session
|
||||
from app.models import User, Contact, Tag
|
||||
from app.models.contact import contact_tags
|
||||
from app.schemas.contact import (
|
||||
ContactCreateRequest,
|
||||
ContactUpdateRequest,
|
||||
ContactResponse,
|
||||
ContactListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
@router.get("", response_model=ContactListResponse)
|
||||
async def list_contacts(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None, description="Search in name, email, phone"),
|
||||
type: str | None = Query(None, pattern="^(company|person)$", description="Filter by contact type"),
|
||||
current_user: User = Depends(require_permission("contacts:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List contacts with search, filter by type, pagination."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
base_q = select(Contact).where(Contact.account_id == account_id)
|
||||
|
||||
# Search across name fields, email, phone
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
base_q = base_q.where(
|
||||
or_(
|
||||
Contact.company_name.ilike(search_pattern),
|
||||
Contact.first_name.ilike(search_pattern),
|
||||
Contact.last_name.ilike(search_pattern),
|
||||
Contact.email.ilike(search_pattern),
|
||||
Contact.phone.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by type
|
||||
if type:
|
||||
base_q = base_q.where(Contact.type == type)
|
||||
|
||||
# Count total
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
# Fetch page with tags eager-loaded
|
||||
q = (
|
||||
base_q
|
||||
.options(joinedload(Contact.tags))
|
||||
.order_by(Contact.updated_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
result = await session.execute(q)
|
||||
contacts = result.unique().scalars().all()
|
||||
|
||||
return ContactListResponse(
|
||||
items=[ContactResponse.model_validate(c) for c in contacts],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_contact(
|
||||
body: ContactCreateRequest,
|
||||
current_user: User = Depends(require_permission("contacts:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a new contact."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
contact = Contact(
|
||||
account_id=account_id,
|
||||
type=body.type,
|
||||
company_name=body.company_name,
|
||||
first_name=body.first_name,
|
||||
last_name=body.last_name,
|
||||
email=body.email,
|
||||
phone=body.phone,
|
||||
mobile=body.mobile,
|
||||
website=body.website,
|
||||
billing_street=body.billing_street,
|
||||
billing_number=body.billing_number,
|
||||
billing_postalcode=body.billing_postalcode,
|
||||
billing_city=body.billing_city,
|
||||
billing_country=body.billing_country,
|
||||
shipping_street=body.shipping_street,
|
||||
shipping_number=body.shipping_number,
|
||||
shipping_postalcode=body.shipping_postalcode,
|
||||
shipping_city=body.shipping_city,
|
||||
shipping_country=body.shipping_country,
|
||||
tax_number=body.tax_number,
|
||||
note=body.note,
|
||||
)
|
||||
|
||||
if body.tag_ids:
|
||||
# Fetch tags that belong to this tenant (or could be global? We'll enforce tenant scope)
|
||||
tag_result = await session.execute(
|
||||
select(Tag).where(
|
||||
Tag.id.in_(body.tag_ids),
|
||||
Tag.account_id == account_id,
|
||||
)
|
||||
)
|
||||
contact.tags = tag_result.scalars().all()
|
||||
|
||||
session.add(contact)
|
||||
await session.commit()
|
||||
await session.refresh(contact)
|
||||
|
||||
# Reload with relationships
|
||||
await session.execute(
|
||||
select(Contact)
|
||||
.options(joinedload(Contact.tags))
|
||||
.where(Contact.id == contact.id)
|
||||
)
|
||||
await session.refresh(contact)
|
||||
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.get("/{contact_id}", response_model=ContactResponse)
|
||||
async def get_contact(
|
||||
contact_id: str,
|
||||
current_user: User = Depends(require_permission("contacts:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get a specific contact with tags."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Contact)
|
||||
.options(joinedload(Contact.tags))
|
||||
.where(Contact.id == contact_id, Contact.account_id == account_id)
|
||||
)
|
||||
contact = result.unique().scalars().first()
|
||||
if not contact:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Contact not found",
|
||||
)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.put("/{contact_id}", response_model=ContactResponse)
|
||||
async def update_contact(
|
||||
contact_id: str,
|
||||
body: ContactUpdateRequest,
|
||||
current_user: User = Depends(require_permission("contacts:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update an existing contact."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Contact)
|
||||
.options(joinedload(Contact.tags))
|
||||
.where(Contact.id == contact_id, Contact.account_id == account_id)
|
||||
)
|
||||
contact = result.unique().scalars().first()
|
||||
if not contact:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Contact not found",
|
||||
)
|
||||
|
||||
# Update fields if provided
|
||||
update_data = body.model_dump(exclude_unset=True, exclude={"tag_ids"})
|
||||
for field, value in update_data.items():
|
||||
setattr(contact, field, value)
|
||||
|
||||
# Handle tag_ids separately: if provided, replace the tag associations
|
||||
if body.tag_ids is not None:
|
||||
tag_result = await session.execute(
|
||||
select(Tag).where(
|
||||
Tag.id.in_(body.tag_ids),
|
||||
Tag.account_id == account_id,
|
||||
)
|
||||
)
|
||||
contact.tags = tag_result.scalars().all()
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(contact)
|
||||
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_contact(
|
||||
contact_id: str,
|
||||
current_user: User = Depends(require_permission("contacts:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Hard-delete a contact."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Contact).where(
|
||||
Contact.id == contact_id, Contact.account_id == account_id
|
||||
)
|
||||
)
|
||||
contact = result.scalars().first()
|
||||
if not contact:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Contact not found",
|
||||
)
|
||||
|
||||
await session.delete(contact)
|
||||
await session.commit()
|
||||
return None
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Crew CRUD endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.api.deps import get_current_user, require_permission
|
||||
from app.db.session import get_async_session
|
||||
from app.models import User, Crew, CrewAvailability
|
||||
from app.schemas.crew import (
|
||||
CrewCreateRequest,
|
||||
CrewUpdateRequest,
|
||||
CrewResponse,
|
||||
CrewListResponse,
|
||||
CrewAvailabilityCreateRequest,
|
||||
CrewAvailabilityUpdateRequest,
|
||||
CrewAvailabilityResponse,
|
||||
CrewAvailabilityListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/crew", tags=["crew"])
|
||||
avail_router = APIRouter(prefix="/crew-availabilities", tags=["crew-availabilities"])
|
||||
|
||||
# ===== Crew Endpoints =====
|
||||
|
||||
@router.get("", response_model=CrewListResponse)
|
||||
async def list_crew(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None, description="Search in name, email, role"),
|
||||
is_active: bool | None = Query(None, description="Filter by active status"),
|
||||
current_user: User = Depends(require_permission("crew:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List crew members with search, filter, pagination."""
|
||||
account_id = current_user.account_id
|
||||
base_q = select(Crew).where(Crew.account_id == account_id)
|
||||
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
base_q = base_q.where(
|
||||
or_(
|
||||
Crew.first_name.ilike(pattern),
|
||||
Crew.last_name.ilike(pattern),
|
||||
Crew.email.ilike(pattern),
|
||||
Crew.role_title.ilike(pattern),
|
||||
)
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
base_q = base_q.where(Crew.is_active == is_active)
|
||||
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
q = (
|
||||
base_q
|
||||
.options(selectinload(Crew.availabilities))
|
||||
.order_by(Crew.last_name, Crew.first_name)
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
result = await session.execute(q)
|
||||
items = result.unique().scalars().all()
|
||||
|
||||
return CrewListResponse(
|
||||
items=[CrewResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CrewResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_crew(
|
||||
body: CrewCreateRequest,
|
||||
current_user: User = Depends(require_permission("crew:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a new crew member."""
|
||||
account_id = current_user.account_id
|
||||
member = Crew(account_id=account_id, **body.model_dump())
|
||||
session.add(member)
|
||||
await session.commit()
|
||||
await session.refresh(member)
|
||||
return CrewResponse.model_validate(member)
|
||||
|
||||
|
||||
@router.get("/{member_id}", response_model=CrewResponse)
|
||||
async def get_crew(
|
||||
member_id: str,
|
||||
current_user: User = Depends(require_permission("crew:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get a specific crew member with availabilities."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Crew)
|
||||
.options(selectinload(Crew.availabilities))
|
||||
.where(Crew.id == member_id, Crew.account_id == account_id)
|
||||
)
|
||||
member = result.unique().scalars().first()
|
||||
if not member:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Crew member not found")
|
||||
return CrewResponse.model_validate(member)
|
||||
|
||||
|
||||
@router.put("/{member_id}", response_model=CrewResponse)
|
||||
async def update_crew(
|
||||
member_id: str,
|
||||
body: CrewUpdateRequest,
|
||||
current_user: User = Depends(require_permission("crew:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update a crew member."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Crew)
|
||||
.options(selectinload(Crew.availabilities))
|
||||
.where(Crew.id == member_id, Crew.account_id == account_id)
|
||||
)
|
||||
member = result.unique().scalars().first()
|
||||
if not member:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Crew member not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(member, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(member)
|
||||
return CrewResponse.model_validate(member)
|
||||
|
||||
|
||||
@router.delete("/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_crew(
|
||||
member_id: str,
|
||||
current_user: User = Depends(require_permission("crew:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Delete a crew member."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Crew).where(Crew.id == member_id, Crew.account_id == account_id)
|
||||
)
|
||||
member = result.scalars().first()
|
||||
if not member:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Crew member not found")
|
||||
|
||||
await session.delete(member)
|
||||
await session.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ===== CrewAvailability Endpoints =====
|
||||
|
||||
@avail_router.get("", response_model=CrewAvailabilityListResponse)
|
||||
async def list_availabilities(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
crew_id: str | None = Query(None, description="Filter by crew member"),
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
current_user: User = Depends(require_permission("crew:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List availabilities with filters."""
|
||||
account_id = current_user.account_id
|
||||
base_q = (
|
||||
select(CrewAvailability)
|
||||
.join(Crew)
|
||||
.where(Crew.account_id == account_id)
|
||||
)
|
||||
|
||||
if crew_id:
|
||||
base_q = base_q.where(CrewAvailability.crew_id == crew_id)
|
||||
if status:
|
||||
base_q = base_q.where(CrewAvailability.status == status)
|
||||
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
q = base_q.order_by(CrewAvailability.start_date.desc()).offset((page - 1) * size).limit(size)
|
||||
result = await session.execute(q)
|
||||
items = result.scalars().all()
|
||||
|
||||
return CrewAvailabilityListResponse(
|
||||
items=[CrewAvailabilityResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@avail_router.post("", response_model=CrewAvailabilityResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_availability(
|
||||
body: CrewAvailabilityCreateRequest,
|
||||
current_user: User = Depends(require_permission("crew:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a new availability entry."""
|
||||
# Verify crew member belongs to this tenant
|
||||
crew_result = await session.execute(
|
||||
select(Crew).where(
|
||||
Crew.id == body.crew_id,
|
||||
Crew.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
if not crew_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Crew member not found")
|
||||
|
||||
avail = CrewAvailability(**body.model_dump())
|
||||
session.add(avail)
|
||||
await session.commit()
|
||||
await session.refresh(avail)
|
||||
return CrewAvailabilityResponse.model_validate(avail)
|
||||
|
||||
|
||||
@avail_router.put("/{avail_id}", response_model=CrewAvailabilityResponse)
|
||||
async def update_availability(
|
||||
avail_id: str,
|
||||
body: CrewAvailabilityUpdateRequest,
|
||||
current_user: User = Depends(require_permission("crew:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update an availability entry."""
|
||||
result = await session.execute(
|
||||
select(CrewAvailability)
|
||||
.join(Crew)
|
||||
.where(
|
||||
CrewAvailability.id == avail_id,
|
||||
Crew.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
avail = result.scalars().first()
|
||||
if not avail:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Availability not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(avail, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(avail)
|
||||
return CrewAvailabilityResponse.model_validate(avail)
|
||||
|
||||
|
||||
@avail_router.delete("/{avail_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_availability(
|
||||
avail_id: str,
|
||||
current_user: User = Depends(require_permission("crew:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Delete an availability entry."""
|
||||
result = await session.execute(
|
||||
select(CrewAvailability)
|
||||
.join(Crew)
|
||||
.where(
|
||||
CrewAvailability.id == avail_id,
|
||||
Crew.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
avail = result.scalars().first()
|
||||
if not avail:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Availability not found")
|
||||
|
||||
await session.delete(avail)
|
||||
await session.commit()
|
||||
return None
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Equipment CRUD endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.api.deps import get_current_user, require_permission
|
||||
from app.db.session import get_async_session
|
||||
from app.models import User, Equipment, StockLocation, Contact
|
||||
from app.schemas.equipment import (
|
||||
EquipmentCreateRequest,
|
||||
EquipmentUpdateRequest,
|
||||
EquipmentResponse,
|
||||
EquipmentListResponse,
|
||||
LocationRef,
|
||||
SupplierRef,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/equipment", tags=["equipment"])
|
||||
|
||||
|
||||
@router.get("", response_model=EquipmentListResponse)
|
||||
async def list_equipment(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None, description="Search in name, brand, serial_number"),
|
||||
category: str | None = Query(None, description="Filter by category"),
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
location_id: str | None = Query(None, description="Filter by location"),
|
||||
current_user: User = Depends(require_permission("equipment:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List equipment with search, filters, pagination."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
base_q = select(Equipment).where(Equipment.account_id == account_id)
|
||||
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
base_q = base_q.where(
|
||||
or_(
|
||||
Equipment.name.ilike(pattern),
|
||||
Equipment.brand.ilike(pattern),
|
||||
Equipment.serial_number.ilike(pattern),
|
||||
Equipment.barcode.ilike(pattern),
|
||||
)
|
||||
)
|
||||
|
||||
if category:
|
||||
base_q = base_q.where(Equipment.category == category)
|
||||
if status:
|
||||
base_q = base_q.where(Equipment.status == status)
|
||||
if location_id:
|
||||
base_q = base_q.where(Equipment.location_id == location_id)
|
||||
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
q = (
|
||||
base_q
|
||||
.options(joinedload(Equipment.location), joinedload(Equipment.supplier))
|
||||
.order_by(Equipment.updated_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
result = await session.execute(q)
|
||||
items = result.unique().scalars().all()
|
||||
|
||||
return EquipmentListResponse(
|
||||
items=[EquipmentResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=EquipmentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_equipment(
|
||||
body: EquipmentCreateRequest,
|
||||
current_user: User = Depends(require_permission("equipment:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a new equipment item."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# Validate location if provided
|
||||
if body.location_id:
|
||||
loc_result = await session.execute(
|
||||
select(StockLocation).where(
|
||||
StockLocation.id == body.location_id,
|
||||
StockLocation.account_id == account_id,
|
||||
)
|
||||
)
|
||||
if not loc_result.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Stock location not found",
|
||||
)
|
||||
|
||||
# Validate supplier if provided
|
||||
if body.supplier_id:
|
||||
sup_result = await session.execute(
|
||||
select(Contact).where(
|
||||
Contact.id == body.supplier_id,
|
||||
Contact.account_id == account_id,
|
||||
)
|
||||
)
|
||||
if not sup_result.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Supplier (contact) not found",
|
||||
)
|
||||
|
||||
item = Equipment(account_id=account_id, **body.model_dump())
|
||||
session.add(item)
|
||||
await session.commit()
|
||||
await session.refresh(item)
|
||||
|
||||
await session.execute(
|
||||
select(Equipment)
|
||||
.options(joinedload(Equipment.location), joinedload(Equipment.supplier))
|
||||
.where(Equipment.id == item.id)
|
||||
)
|
||||
await session.refresh(item)
|
||||
|
||||
return EquipmentResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=EquipmentResponse)
|
||||
async def get_equipment(
|
||||
item_id: str,
|
||||
current_user: User = Depends(require_permission("equipment:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get a specific equipment item."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Equipment)
|
||||
.options(joinedload(Equipment.location), joinedload(Equipment.supplier))
|
||||
.where(Equipment.id == item_id, Equipment.account_id == account_id)
|
||||
)
|
||||
item = result.unique().scalars().first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
|
||||
return EquipmentResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.put("/{item_id}", response_model=EquipmentResponse)
|
||||
async def update_equipment(
|
||||
item_id: str,
|
||||
body: EquipmentUpdateRequest,
|
||||
current_user: User = Depends(require_permission("equipment:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update an existing equipment item."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Equipment)
|
||||
.options(joinedload(Equipment.location), joinedload(Equipment.supplier))
|
||||
.where(Equipment.id == item_id, Equipment.account_id == account_id)
|
||||
)
|
||||
item = result.unique().scalars().first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(item, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(item)
|
||||
return EquipmentResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_equipment(
|
||||
item_id: str,
|
||||
current_user: User = Depends(require_permission("equipment:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Delete an equipment item."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Equipment).where(Equipment.id == item_id, Equipment.account_id == account_id)
|
||||
)
|
||||
item = result.scalars().first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
|
||||
|
||||
await session.delete(item)
|
||||
await session.commit()
|
||||
return None
|
||||
@@ -0,0 +1,208 @@
|
||||
"""EquipmentGroup (Bundle) CRUD endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.api.deps import get_current_user, require_permission
|
||||
from app.db.session import get_async_session
|
||||
from app.models import User, EquipmentGroup, Equipment, StockLocation
|
||||
from app.models.equipment_group import equipment_group_items
|
||||
from app.schemas.equipment_group import (
|
||||
EquipmentGroupCreateRequest,
|
||||
EquipmentGroupUpdateRequest,
|
||||
EquipmentGroupResponse,
|
||||
EquipmentGroupListResponse,
|
||||
EquipmentGroupItem as EquipmentGroupItemSchema,
|
||||
)
|
||||
from app.schemas.equipment import LocationRef
|
||||
|
||||
router = APIRouter(prefix="/equipment-groups", tags=["equipment-groups"])
|
||||
|
||||
|
||||
def _load_items(group: EquipmentGroup) -> list[dict]:
|
||||
"""Build items list from M2M relationship."""
|
||||
# The items relationship loads Equipment objects; we need quantity from the association
|
||||
# We'll use a separate query approach in the endpoints.
|
||||
return []
|
||||
|
||||
|
||||
@router.get("", response_model=EquipmentGroupListResponse)
|
||||
async def list_equipment_groups(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None, description="Search in name"),
|
||||
current_user: User = Depends(require_permission("equipment:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List equipment groups with search and pagination."""
|
||||
account_id = current_user.account_id
|
||||
base_q = select(EquipmentGroup).where(EquipmentGroup.account_id == account_id)
|
||||
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
base_q = base_q.where(EquipmentGroup.name.ilike(pattern))
|
||||
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
q = (
|
||||
base_q
|
||||
.options(joinedload(EquipmentGroup.default_location), selectinload(EquipmentGroup.items))
|
||||
.order_by(EquipmentGroup.name)
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
result = await session.execute(q)
|
||||
groups = result.unique().scalars().all()
|
||||
|
||||
return EquipmentGroupListResponse(
|
||||
items=[EquipmentGroupResponse.model_validate(g) for g in groups],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=EquipmentGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_equipment_group(
|
||||
body: EquipmentGroupCreateRequest,
|
||||
current_user: User = Depends(require_permission("equipment:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a new equipment group (bundle)."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
group = EquipmentGroup(
|
||||
account_id=account_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
daily_rate=body.daily_rate,
|
||||
default_location_id=body.default_location_id,
|
||||
)
|
||||
|
||||
if body.items:
|
||||
equipment_ids = [i.equipment_id for i in body.items]
|
||||
eq_result = await session.execute(
|
||||
select(Equipment).where(
|
||||
Equipment.id.in_(equipment_ids),
|
||||
Equipment.account_id == account_id,
|
||||
)
|
||||
)
|
||||
items = eq_result.scalars().all()
|
||||
group.items = items
|
||||
# Store quantities via the association table directly
|
||||
for item_schema in body.items:
|
||||
await session.execute(
|
||||
equipment_group_items.update()
|
||||
.where(
|
||||
equipment_group_items.c.group_id == group.id,
|
||||
equipment_group_items.c.equipment_id == item_schema.equipment_id,
|
||||
)
|
||||
.values(quantity=item_schema.quantity)
|
||||
)
|
||||
|
||||
session.add(group)
|
||||
await session.commit()
|
||||
await session.refresh(group)
|
||||
|
||||
# Reload with relationships
|
||||
await session.execute(
|
||||
select(EquipmentGroup)
|
||||
.options(joinedload(EquipmentGroup.default_location), selectinload(EquipmentGroup.items))
|
||||
.where(EquipmentGroup.id == group.id)
|
||||
)
|
||||
await session.refresh(group)
|
||||
|
||||
return EquipmentGroupResponse.model_validate(group)
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=EquipmentGroupResponse)
|
||||
async def get_equipment_group(
|
||||
group_id: str,
|
||||
current_user: User = Depends(require_permission("equipment:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get a specific equipment group with items."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(EquipmentGroup)
|
||||
.options(joinedload(EquipmentGroup.default_location), selectinload(EquipmentGroup.items))
|
||||
.where(EquipmentGroup.id == group_id, EquipmentGroup.account_id == account_id)
|
||||
)
|
||||
group = result.unique().scalars().first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment group not found")
|
||||
return EquipmentGroupResponse.model_validate(group)
|
||||
|
||||
|
||||
@router.put("/{group_id}", response_model=EquipmentGroupResponse)
|
||||
async def update_equipment_group(
|
||||
group_id: str,
|
||||
body: EquipmentGroupUpdateRequest,
|
||||
current_user: User = Depends(require_permission("equipment:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update an equipment group."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(EquipmentGroup)
|
||||
.options(joinedload(EquipmentGroup.default_location), selectinload(EquipmentGroup.items))
|
||||
.where(EquipmentGroup.id == group_id, EquipmentGroup.account_id == account_id)
|
||||
)
|
||||
group = result.unique().scalars().first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment group not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True, exclude={"items"})
|
||||
for field, value in update_data.items():
|
||||
setattr(group, field, value)
|
||||
|
||||
# Handle items replacement
|
||||
if body.items is not None:
|
||||
equipment_ids = [i.equipment_id for i in body.items]
|
||||
eq_result = await session.execute(
|
||||
select(Equipment).where(
|
||||
Equipment.id.in_(equipment_ids),
|
||||
Equipment.account_id == account_id,
|
||||
)
|
||||
)
|
||||
group.items = eq_result.scalars().all()
|
||||
# Update quantities
|
||||
for item_schema in body.items:
|
||||
await session.execute(
|
||||
equipment_group_items.update()
|
||||
.where(
|
||||
equipment_group_items.c.group_id == group.id,
|
||||
equipment_group_items.c.equipment_id == item_schema.equipment_id,
|
||||
)
|
||||
.values(quantity=item_schema.quantity)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(group)
|
||||
return EquipmentGroupResponse.model_validate(group)
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_equipment_group(
|
||||
group_id: str,
|
||||
current_user: User = Depends(require_permission("equipment:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Delete an equipment group."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(EquipmentGroup).where(
|
||||
EquipmentGroup.id == group_id,
|
||||
EquipmentGroup.account_id == account_id,
|
||||
)
|
||||
)
|
||||
group = result.scalars().first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment group not found")
|
||||
|
||||
await session.delete(group)
|
||||
await session.commit()
|
||||
return None
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Health check endpoint."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.session import get_async_session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check(session: AsyncSession = Depends(get_async_session)):
|
||||
"""
|
||||
Health check endpoint.
|
||||
Returns application status and database connectivity.
|
||||
"""
|
||||
try:
|
||||
# Test database connection
|
||||
await session.execute(text("SELECT 1"))
|
||||
db_status = "connected"
|
||||
except Exception as e:
|
||||
db_status = f"error: {str(e)}"
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"app": "Rentman Clone",
|
||||
"database": db_status,
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
"""Project CRUD endpoints including sub-projects, function groups, and functions."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_current_user, require_permission
|
||||
from app.db.session import get_async_session
|
||||
from app.models import User, Project, SubProject, ProjectFunctionGroup, ProjectFunction
|
||||
from app.schemas.project import (
|
||||
ProjectCreateRequest,
|
||||
ProjectUpdateRequest,
|
||||
ProjectResponse,
|
||||
ProjectListResponse,
|
||||
SubProjectCreateRequest,
|
||||
SubProjectUpdateRequest,
|
||||
SubProjectResponse,
|
||||
SubProjectListResponse,
|
||||
ProjectFunctionGroupCreateRequest,
|
||||
ProjectFunctionGroupUpdateRequest,
|
||||
ProjectFunctionGroupResponse,
|
||||
ProjectFunctionGroupListResponse,
|
||||
ProjectFunctionCreateRequest,
|
||||
ProjectFunctionUpdateRequest,
|
||||
ProjectFunctionResponse,
|
||||
ProjectFunctionListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
# ===== Project Endpoints =====
|
||||
|
||||
|
||||
@router.get("", response_model=ProjectListResponse)
|
||||
async def list_projects(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None, description="Search in project name"),
|
||||
status: str | None = Query(None, description="Filter by status (draft, confirmed, in_progress, completed, cancelled)"),
|
||||
current_user: User = Depends(require_permission("projects:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List projects with search, filters, pagination."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
base_q = select(Project).where(Project.account_id == account_id)
|
||||
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
base_q = base_q.where(Project.name.ilike(pattern))
|
||||
|
||||
if status:
|
||||
base_q = base_q.where(Project.status == status)
|
||||
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
q = (
|
||||
base_q
|
||||
.order_by(Project.updated_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
result = await session.execute(q)
|
||||
items = result.scalars().all()
|
||||
|
||||
return ProjectListResponse(
|
||||
items=[ProjectResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project(
|
||||
body: ProjectCreateRequest,
|
||||
current_user: User = Depends(require_permission("projects:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a new project."""
|
||||
account_id = current_user.account_id
|
||||
project = Project(account_id=account_id, **body.model_dump())
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
async def get_project(
|
||||
project_id: str,
|
||||
current_user: User = Depends(require_permission("projects:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get a specific project."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectResponse)
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
body: ProjectUpdateRequest,
|
||||
current_user: User = Depends(require_permission("projects:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update an existing project."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(project, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_project(
|
||||
project_id: str,
|
||||
current_user: User = Depends(require_permission("projects:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Delete a project."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
await session.delete(project)
|
||||
await session.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ===== SubProject Endpoints =====
|
||||
|
||||
|
||||
@router.get("/{project_id}/subprojects", response_model=SubProjectListResponse)
|
||||
async def list_subprojects(
|
||||
project_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(require_permission("projects:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List subprojects for a project."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# Verify project ownership
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
base_q = select(SubProject).where(SubProject.project_id == project_id)
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
q = base_q.order_by(SubProject.sort_order).offset((page - 1) * size).limit(size)
|
||||
result = await session.execute(q)
|
||||
items = result.scalars().all()
|
||||
|
||||
return SubProjectListResponse(
|
||||
items=[SubProjectResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/subprojects", response_model=SubProjectResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_subproject(
|
||||
project_id: str,
|
||||
body: SubProjectCreateRequest,
|
||||
current_user: User = Depends(require_permission("projects:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a subproject within a project."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# Verify project ownership
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
# Verify parent subproject if provided
|
||||
if body.parent_id:
|
||||
parent_result = await session.execute(
|
||||
select(SubProject.id).where(
|
||||
SubProject.id == body.parent_id,
|
||||
SubProject.project_id == project_id,
|
||||
)
|
||||
)
|
||||
if not parent_result.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent subproject not found",
|
||||
)
|
||||
|
||||
sub = SubProject(project_id=project_id, **body.model_dump())
|
||||
session.add(sub)
|
||||
await session.commit()
|
||||
await session.refresh(sub)
|
||||
return SubProjectResponse.model_validate(sub)
|
||||
|
||||
|
||||
@router.get("/{project_id}/subprojects/{sub_id}", response_model=SubProjectResponse)
|
||||
async def get_subproject(
|
||||
project_id: str,
|
||||
sub_id: str,
|
||||
current_user: User = Depends(require_permission("projects:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get a specific subproject."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# Verify project ownership
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(SubProject).where(SubProject.id == sub_id, SubProject.project_id == project_id)
|
||||
)
|
||||
sub = result.scalars().first()
|
||||
if not sub:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SubProject not found")
|
||||
return SubProjectResponse.model_validate(sub)
|
||||
|
||||
|
||||
@router.put("/{project_id}/subprojects/{sub_id}", response_model=SubProjectResponse)
|
||||
async def update_subproject(
|
||||
project_id: str,
|
||||
sub_id: str,
|
||||
body: SubProjectUpdateRequest,
|
||||
current_user: User = Depends(require_permission("projects:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update a subproject."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# Verify project ownership
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(SubProject).where(SubProject.id == sub_id, SubProject.project_id == project_id)
|
||||
)
|
||||
sub = result.scalars().first()
|
||||
if not sub:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SubProject not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(sub, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(sub)
|
||||
return SubProjectResponse.model_validate(sub)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/subprojects/{sub_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_subproject(
|
||||
project_id: str,
|
||||
sub_id: str,
|
||||
current_user: User = Depends(require_permission("projects:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Delete a subproject."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# Verify project ownership
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(SubProject).where(SubProject.id == sub_id, SubProject.project_id == project_id)
|
||||
)
|
||||
sub = result.scalars().first()
|
||||
if not sub:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SubProject not found")
|
||||
|
||||
await session.delete(sub)
|
||||
await session.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ===== ProjectFunctionGroup Endpoints =====
|
||||
|
||||
|
||||
@router.get("/{project_id}/function-groups", response_model=ProjectFunctionGroupListResponse)
|
||||
async def list_function_groups(
|
||||
project_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(require_permission("projects:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List function groups for a project."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
base_q = select(ProjectFunctionGroup).where(ProjectFunctionGroup.project_id == project_id)
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
q = base_q.order_by(ProjectFunctionGroup.sort_order).offset((page - 1) * size).limit(size)
|
||||
result = await session.execute(q)
|
||||
items = result.scalars().all()
|
||||
|
||||
return ProjectFunctionGroupListResponse(
|
||||
items=[ProjectFunctionGroupResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/function-groups", response_model=ProjectFunctionGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_function_group(
|
||||
project_id: str,
|
||||
body: ProjectFunctionGroupCreateRequest,
|
||||
current_user: User = Depends(require_permission("projects:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a function group within a project."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
fg = ProjectFunctionGroup(project_id=project_id, **body.model_dump())
|
||||
session.add(fg)
|
||||
await session.commit()
|
||||
await session.refresh(fg)
|
||||
return ProjectFunctionGroupResponse.model_validate(fg)
|
||||
|
||||
|
||||
@router.get("/{project_id}/function-groups/{fg_id}", response_model=ProjectFunctionGroupResponse)
|
||||
async def get_function_group(
|
||||
project_id: str,
|
||||
fg_id: str,
|
||||
current_user: User = Depends(require_permission("projects:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get a specific function group."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(ProjectFunctionGroup).where(
|
||||
ProjectFunctionGroup.id == fg_id,
|
||||
ProjectFunctionGroup.project_id == project_id,
|
||||
)
|
||||
)
|
||||
fg = result.scalars().first()
|
||||
if not fg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found")
|
||||
return ProjectFunctionGroupResponse.model_validate(fg)
|
||||
|
||||
|
||||
@router.put("/{project_id}/function-groups/{fg_id}", response_model=ProjectFunctionGroupResponse)
|
||||
async def update_function_group(
|
||||
project_id: str,
|
||||
fg_id: str,
|
||||
body: ProjectFunctionGroupUpdateRequest,
|
||||
current_user: User = Depends(require_permission("projects:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update a function group."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(ProjectFunctionGroup).where(
|
||||
ProjectFunctionGroup.id == fg_id,
|
||||
ProjectFunctionGroup.project_id == project_id,
|
||||
)
|
||||
)
|
||||
fg = result.scalars().first()
|
||||
if not fg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(fg, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(fg)
|
||||
return ProjectFunctionGroupResponse.model_validate(fg)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/function-groups/{fg_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_function_group(
|
||||
project_id: str,
|
||||
fg_id: str,
|
||||
current_user: User = Depends(require_permission("projects:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Delete a function group."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(ProjectFunctionGroup).where(
|
||||
ProjectFunctionGroup.id == fg_id,
|
||||
ProjectFunctionGroup.project_id == project_id,
|
||||
)
|
||||
)
|
||||
fg = result.scalars().first()
|
||||
if not fg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found")
|
||||
|
||||
await session.delete(fg)
|
||||
await session.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ===== ProjectFunction Endpoints =====
|
||||
|
||||
|
||||
@router.get("/{project_id}/function-groups/{fg_id}/functions", response_model=ProjectFunctionListResponse)
|
||||
async def list_project_functions(
|
||||
project_id: str,
|
||||
fg_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(require_permission("projects:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List functions within a function group."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
# Verify function group belongs to project
|
||||
fg_result = await session.execute(
|
||||
select(ProjectFunctionGroup.id).where(
|
||||
ProjectFunctionGroup.id == fg_id,
|
||||
ProjectFunctionGroup.project_id == project_id,
|
||||
)
|
||||
)
|
||||
if not fg_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found")
|
||||
|
||||
base_q = select(ProjectFunction).where(ProjectFunction.function_group_id == fg_id)
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
q = base_q.order_by(ProjectFunction.sort_order).offset((page - 1) * size).limit(size)
|
||||
result = await session.execute(q)
|
||||
items = result.scalars().all()
|
||||
|
||||
return ProjectFunctionListResponse(
|
||||
items=[ProjectFunctionResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/function-groups/{fg_id}/functions", response_model=ProjectFunctionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project_function(
|
||||
project_id: str,
|
||||
fg_id: str,
|
||||
body: ProjectFunctionCreateRequest,
|
||||
current_user: User = Depends(require_permission("projects:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a function within a function group."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
fg_result = await session.execute(
|
||||
select(ProjectFunctionGroup.id).where(
|
||||
ProjectFunctionGroup.id == fg_id,
|
||||
ProjectFunctionGroup.project_id == project_id,
|
||||
)
|
||||
)
|
||||
if not fg_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found")
|
||||
|
||||
func_obj = ProjectFunction(function_group_id=fg_id, **body.model_dump())
|
||||
session.add(func_obj)
|
||||
await session.commit()
|
||||
await session.refresh(func_obj)
|
||||
return ProjectFunctionResponse.model_validate(func_obj)
|
||||
|
||||
|
||||
@router.get("/{project_id}/function-groups/{fg_id}/functions/{func_id}", response_model=ProjectFunctionResponse)
|
||||
async def get_project_function(
|
||||
project_id: str,
|
||||
fg_id: str,
|
||||
func_id: str,
|
||||
current_user: User = Depends(require_permission("projects:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get a specific project function."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
fg_result = await session.execute(
|
||||
select(ProjectFunctionGroup.id).where(
|
||||
ProjectFunctionGroup.id == fg_id,
|
||||
ProjectFunctionGroup.project_id == project_id,
|
||||
)
|
||||
)
|
||||
if not fg_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(ProjectFunction).where(
|
||||
ProjectFunction.id == func_id,
|
||||
ProjectFunction.function_group_id == fg_id,
|
||||
)
|
||||
)
|
||||
func_obj = result.scalars().first()
|
||||
if not func_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function not found")
|
||||
return ProjectFunctionResponse.model_validate(func_obj)
|
||||
|
||||
|
||||
@router.put("/{project_id}/function-groups/{fg_id}/functions/{func_id}", response_model=ProjectFunctionResponse)
|
||||
async def update_project_function(
|
||||
project_id: str,
|
||||
fg_id: str,
|
||||
func_id: str,
|
||||
body: ProjectFunctionUpdateRequest,
|
||||
current_user: User = Depends(require_permission("projects:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update a project function."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
fg_result = await session.execute(
|
||||
select(ProjectFunctionGroup.id).where(
|
||||
ProjectFunctionGroup.id == fg_id,
|
||||
ProjectFunctionGroup.project_id == project_id,
|
||||
)
|
||||
)
|
||||
if not fg_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(ProjectFunction).where(
|
||||
ProjectFunction.id == func_id,
|
||||
ProjectFunction.function_group_id == fg_id,
|
||||
)
|
||||
)
|
||||
func_obj = result.scalars().first()
|
||||
if not func_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(func_obj, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(func_obj)
|
||||
return ProjectFunctionResponse.model_validate(func_obj)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/function-groups/{fg_id}/functions/{func_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_project_function(
|
||||
project_id: str,
|
||||
fg_id: str,
|
||||
func_id: str,
|
||||
current_user: User = Depends(require_permission("projects:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Delete a project function."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
proj_result = await session.execute(
|
||||
select(Project.id).where(Project.id == project_id, Project.account_id == account_id)
|
||||
)
|
||||
if not proj_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
fg_result = await session.execute(
|
||||
select(ProjectFunctionGroup.id).where(
|
||||
ProjectFunctionGroup.id == fg_id,
|
||||
ProjectFunctionGroup.project_id == project_id,
|
||||
)
|
||||
)
|
||||
if not fg_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(ProjectFunction).where(
|
||||
ProjectFunction.id == func_id,
|
||||
ProjectFunction.function_group_id == fg_id,
|
||||
)
|
||||
)
|
||||
func_obj = result.scalars().first()
|
||||
if not func_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function not found")
|
||||
|
||||
await session.delete(func_obj)
|
||||
await session.commit()
|
||||
return None
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Role management endpoints (admin)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, require_permission
|
||||
from app.db.session import get_async_session
|
||||
from app.models import User, Role
|
||||
from app.schemas.role import RoleCreateRequest, RoleUpdateRequest, RoleResponse
|
||||
|
||||
router = APIRouter(prefix="/roles", tags=["roles"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[RoleResponse])
|
||||
async def list_roles(
|
||||
current_user: User = Depends(require_permission("roles:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List all roles within the current account."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Role).where(Role.account_id == account_id).order_by(Role.name)
|
||||
)
|
||||
roles = result.scalars().all()
|
||||
return [RoleResponse.model_validate(r) for r in roles]
|
||||
|
||||
|
||||
@router.post("", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_role(
|
||||
body: RoleCreateRequest,
|
||||
current_user: User = Depends(require_permission("roles:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a new role within the current account."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# Check name uniqueness within account
|
||||
existing = await session.execute(
|
||||
select(Role).where(Role.account_id == account_id, Role.name == body.name)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A role with this name already exists in your account",
|
||||
)
|
||||
|
||||
role = Role(
|
||||
account_id=account_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
permissions=body.permissions,
|
||||
)
|
||||
session.add(role)
|
||||
await session.commit()
|
||||
await session.refresh(role)
|
||||
return RoleResponse.model_validate(role)
|
||||
|
||||
|
||||
@router.put("/{role_id}", response_model=RoleResponse)
|
||||
async def update_role(
|
||||
role_id: str,
|
||||
body: RoleUpdateRequest,
|
||||
current_user: User = Depends(require_permission("roles:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update a role's permissions, name, or description."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Role).where(Role.id == role_id, Role.account_id == account_id)
|
||||
)
|
||||
role = result.scalars().first()
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Role not found",
|
||||
)
|
||||
|
||||
if body.name is not None:
|
||||
role.name = body.name
|
||||
if body.description is not None:
|
||||
role.description = body.description
|
||||
if body.permissions is not None:
|
||||
role.permissions = body.permissions
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(role)
|
||||
return RoleResponse.model_validate(role)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""API v1 router – aggregates all v1 endpoint routers."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.health import router as health_router
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.users import router as users_router
|
||||
from app.api.v1.roles import router as roles_router
|
||||
from app.api.v1.contacts import router as contacts_router
|
||||
from app.api.v1.tags import router as tags_router
|
||||
from app.api.v1.equipment import router as equipment_router
|
||||
from app.api.v1.stock_locations import router as stock_locations_router
|
||||
from app.api.v1.equipment_groups import router as equipment_groups_router
|
||||
from app.api.v1.crew import router as crew_router, avail_router as crew_avail_router
|
||||
from app.api.v1.vehicles import router as vehicles_router, assign_router as vehicle_assign_router
|
||||
from app.api.v1.projects import router as projects_router
|
||||
|
||||
api_v1_router = APIRouter()
|
||||
|
||||
api_v1_router.include_router(health_router, tags=["health"])
|
||||
api_v1_router.include_router(auth_router, tags=["auth"])
|
||||
api_v1_router.include_router(users_router, tags=["users"])
|
||||
api_v1_router.include_router(roles_router, tags=["roles"])
|
||||
api_v1_router.include_router(contacts_router, tags=["contacts"])
|
||||
api_v1_router.include_router(tags_router, tags=["tags"])
|
||||
api_v1_router.include_router(equipment_router, tags=["equipment"])
|
||||
api_v1_router.include_router(stock_locations_router, tags=["stock-locations"])
|
||||
api_v1_router.include_router(equipment_groups_router, tags=["equipment-groups"])
|
||||
api_v1_router.include_router(crew_router, tags=["crew"])
|
||||
api_v1_router.include_router(crew_avail_router, tags=["crew-availabilities"])
|
||||
api_v1_router.include_router(vehicles_router, tags=["vehicles"])
|
||||
api_v1_router.include_router(vehicle_assign_router, tags=["vehicle-assignments"])
|
||||
api_v1_router.include_router(projects_router, tags=["projects"])
|
||||
@@ -0,0 +1,142 @@
|
||||
"""StockLocation CRUD endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, require_permission
|
||||
from app.db.session import get_async_session
|
||||
from app.models import User, StockLocation
|
||||
from app.schemas.stock_location import (
|
||||
StockLocationCreateRequest,
|
||||
StockLocationUpdateRequest,
|
||||
StockLocationResponse,
|
||||
StockLocationListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/stock-locations", tags=["stock-locations"])
|
||||
|
||||
|
||||
@router.get("", response_model=StockLocationListResponse)
|
||||
async def list_stock_locations(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None, description="Search in name"),
|
||||
current_user: User = Depends(require_permission("equipment:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List stock locations with search and pagination."""
|
||||
account_id = current_user.account_id
|
||||
base_q = select(StockLocation).where(StockLocation.account_id == account_id)
|
||||
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
base_q = base_q.where(StockLocation.name.ilike(pattern))
|
||||
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
q = base_q.order_by(StockLocation.name).offset((page - 1) * size).limit(size)
|
||||
result = await session.execute(q)
|
||||
items = result.scalars().all()
|
||||
|
||||
return StockLocationListResponse(
|
||||
items=[StockLocationResponse.model_validate(i) for i in items],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=StockLocationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_stock_location(
|
||||
body: StockLocationCreateRequest,
|
||||
current_user: User = Depends(require_permission("equipment:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a new stock location."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# If this is set as default, unset others
|
||||
if body.is_default:
|
||||
await session.execute(
|
||||
select(StockLocation)
|
||||
.where(StockLocation.account_id == account_id, StockLocation.is_default == True) # noqa: E712
|
||||
)
|
||||
|
||||
loc = StockLocation(account_id=account_id, **body.model_dump())
|
||||
session.add(loc)
|
||||
await session.commit()
|
||||
await session.refresh(loc)
|
||||
return StockLocationResponse.model_validate(loc)
|
||||
|
||||
|
||||
@router.get("/{location_id}", response_model=StockLocationResponse)
|
||||
async def get_stock_location(
|
||||
location_id: str,
|
||||
current_user: User = Depends(require_permission("equipment:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get a specific stock location."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(StockLocation).where(
|
||||
StockLocation.id == location_id,
|
||||
StockLocation.account_id == account_id,
|
||||
)
|
||||
)
|
||||
loc = result.scalars().first()
|
||||
if not loc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stock location not found")
|
||||
return StockLocationResponse.model_validate(loc)
|
||||
|
||||
|
||||
@router.put("/{location_id}", response_model=StockLocationResponse)
|
||||
async def update_stock_location(
|
||||
location_id: str,
|
||||
body: StockLocationUpdateRequest,
|
||||
current_user: User = Depends(require_permission("equipment:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update a stock location."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(StockLocation).where(
|
||||
StockLocation.id == location_id,
|
||||
StockLocation.account_id == account_id,
|
||||
)
|
||||
)
|
||||
loc = result.scalars().first()
|
||||
if not loc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stock location not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(loc, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(loc)
|
||||
return StockLocationResponse.model_validate(loc)
|
||||
|
||||
|
||||
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_stock_location(
|
||||
location_id: str,
|
||||
current_user: User = Depends(require_permission("equipment:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Delete a stock location."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(StockLocation).where(
|
||||
StockLocation.id == location_id,
|
||||
StockLocation.account_id == account_id,
|
||||
)
|
||||
)
|
||||
loc = result.scalars().first()
|
||||
if not loc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stock location not found")
|
||||
|
||||
await session.delete(loc)
|
||||
await session.commit()
|
||||
return None
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tags endpoints for listing and creating tags within a tenant."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, require_permission
|
||||
from app.db.session import get_async_session
|
||||
from app.models import User, Tag
|
||||
from app.schemas.contact import TagResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TagCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
color: str | None = Field(None, max_length=7)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TagResponse])
|
||||
async def list_tags(
|
||||
current_user: User = Depends(require_permission("contacts:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List all tags within the current account."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Tag).where(Tag.account_id == account_id).order_by(Tag.name)
|
||||
)
|
||||
tags = result.scalars().all()
|
||||
return [TagResponse.model_validate(t) for t in tags]
|
||||
|
||||
|
||||
@router.post("", response_model=TagResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tag(
|
||||
body: TagCreateRequest,
|
||||
current_user: User = Depends(require_permission("contacts:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a new tag within the current account."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# Check for duplicate name
|
||||
existing = await session.execute(
|
||||
select(Tag).where(Tag.account_id == account_id, Tag.name == body.name)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A tag with this name already exists",
|
||||
)
|
||||
|
||||
tag = Tag(account_id=account_id, name=body.name, color=body.color)
|
||||
session.add(tag)
|
||||
await session.commit()
|
||||
await session.refresh(tag)
|
||||
return TagResponse.model_validate(tag)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""User management endpoints (admin)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user, require_permission
|
||||
from app.core.security import get_password_hash
|
||||
from app.db.session import get_async_session
|
||||
from app.models import User
|
||||
from app.schemas.user import UserCreateRequest, UserUpdateRequest, UserResponse, UserListResponse
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("", response_model=UserListResponse)
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(require_permission("users:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""List all users within the current account."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# Count total
|
||||
count_q = select(func.count(User.id)).where(User.account_id == account_id)
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
# Fetch page
|
||||
q = (
|
||||
select(User)
|
||||
.where(User.account_id == account_id)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
result = await session.execute(q)
|
||||
users = result.scalars().all()
|
||||
|
||||
return UserListResponse(
|
||||
items=[UserResponse.model_validate(u) for u in users],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreateRequest,
|
||||
current_user: User = Depends(require_permission("users:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a new user within the current account."""
|
||||
account_id = current_user.account_id
|
||||
|
||||
# Check email uniqueness
|
||||
existing = await session.execute(select(User).where(User.email == body.email))
|
||||
if existing.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A user with this email already exists",
|
||||
)
|
||||
|
||||
# If role_id is provided, verify it belongs to same account (optional)
|
||||
# (We'll skip role validation for now; can be added later)
|
||||
|
||||
user = User(
|
||||
account_id=account_id,
|
||||
email=body.email,
|
||||
full_name=body.full_name,
|
||||
password_hash=get_password_hash(body.password),
|
||||
role_id=body.role_id,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
current_user: User = Depends(require_permission("users:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Get a specific user by ID."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(User).where(User.id == user_id, User.account_id == account_id)
|
||||
)
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
body: UserUpdateRequest,
|
||||
current_user: User = Depends(require_permission("users:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update a user's role, active status, or name."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(User).where(User.id == user_id, User.account_id == account_id)
|
||||
)
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
if body.full_name is not None:
|
||||
user.full_name = body.full_name
|
||||
if body.role_id is not None:
|
||||
user.role_id = body.role_id
|
||||
if body.is_active is not None:
|
||||
user.is_active = body.is_active
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
current_user: User = Depends(require_permission("users:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Soft-delete (deactivate) a user. Does not actually delete."""
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(User).where(User.id == user_id, User.account_id == account_id)
|
||||
)
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Prevent self-deactivation
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot deactivate your own account",
|
||||
)
|
||||
|
||||
user.is_active = False
|
||||
await session.commit()
|
||||
return None
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Vehicle CRUD endpoints including assignments."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_current_user, require_permission
|
||||
from app.db.session import get_async_session
|
||||
from app.models import User, Vehicle, VehicleAssignment
|
||||
from app.schemas.vehicle import (
|
||||
VehicleCreateRequest,
|
||||
VehicleUpdateRequest,
|
||||
VehicleResponse,
|
||||
VehicleListResponse,
|
||||
VehicleAssignmentCreateRequest,
|
||||
VehicleAssignmentUpdateRequest,
|
||||
VehicleAssignmentResponse,
|
||||
VehicleAssignmentListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/vehicles", tags=["vehicles"])
|
||||
assign_router = APIRouter(prefix="/vehicle-assignments", tags=["vehicle-assignments"])
|
||||
|
||||
|
||||
# ===== Vehicle Endpoints =====
|
||||
|
||||
@router.get("", response_model=VehicleListResponse)
|
||||
async def list_vehicles(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None, description="Search in name, plate, brand"),
|
||||
vehicle_type: str | None = Query(None),
|
||||
is_active: bool | None = Query(None),
|
||||
current_user: User = Depends(require_permission("vehicles:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
account_id = current_user.account_id
|
||||
base_q = select(Vehicle).where(Vehicle.account_id == account_id)
|
||||
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
base_q = base_q.where(
|
||||
or_(Vehicle.name.ilike(pattern), Vehicle.license_plate.ilike(pattern),
|
||||
Vehicle.brand.ilike(pattern), Vehicle.model.ilike(pattern))
|
||||
)
|
||||
if vehicle_type:
|
||||
base_q = base_q.where(Vehicle.vehicle_type == vehicle_type)
|
||||
if is_active is not None:
|
||||
base_q = base_q.where(Vehicle.is_active == is_active)
|
||||
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
q = base_q.options(selectinload(Vehicle.assignments)).order_by(Vehicle.name).offset((page-1)*size).limit(size)
|
||||
result = await session.execute(q)
|
||||
items = result.unique().scalars().all()
|
||||
|
||||
return VehicleListResponse(items=[VehicleResponse.model_validate(i) for i in items], total=total, page=page, size=size)
|
||||
|
||||
|
||||
@router.post("", response_model=VehicleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_vehicle(
|
||||
body: VehicleCreateRequest,
|
||||
current_user: User = Depends(require_permission("vehicles:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
account_id = current_user.account_id
|
||||
vehicle = Vehicle(account_id=account_id, **body.model_dump())
|
||||
session.add(vehicle)
|
||||
await session.commit()
|
||||
await session.refresh(vehicle)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.get("/{vehicle_id}", response_model=VehicleResponse)
|
||||
async def get_vehicle(
|
||||
vehicle_id: str,
|
||||
current_user: User = Depends(require_permission("vehicles:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Vehicle).options(selectinload(Vehicle.assignments))
|
||||
.where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id)
|
||||
)
|
||||
vehicle = result.unique().scalars().first()
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found")
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.put("/{vehicle_id}", response_model=VehicleResponse)
|
||||
async def update_vehicle(
|
||||
vehicle_id: str,
|
||||
body: VehicleUpdateRequest,
|
||||
current_user: User = Depends(require_permission("vehicles:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(
|
||||
select(Vehicle).options(selectinload(Vehicle.assignments))
|
||||
.where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id)
|
||||
)
|
||||
vehicle = result.unique().scalars().first()
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(vehicle, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(vehicle)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.delete("/{vehicle_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_vehicle(
|
||||
vehicle_id: str,
|
||||
current_user: User = Depends(require_permission("vehicles:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
account_id = current_user.account_id
|
||||
result = await session.execute(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id))
|
||||
vehicle = result.scalars().first()
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found")
|
||||
await session.delete(vehicle)
|
||||
await session.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ===== VehicleAssignment Endpoints =====
|
||||
|
||||
@assign_router.get("", response_model=VehicleAssignmentListResponse)
|
||||
async def list_assignments(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
vehicle_id: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
current_user: User = Depends(require_permission("vehicles:read")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
account_id = current_user.account_id
|
||||
base_q = select(VehicleAssignment).join(Vehicle).where(Vehicle.account_id == account_id)
|
||||
if vehicle_id:
|
||||
base_q = base_q.where(VehicleAssignment.vehicle_id == vehicle_id)
|
||||
if status:
|
||||
base_q = base_q.where(VehicleAssignment.status == status)
|
||||
count_q = select(func.count()).select_from(base_q.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
q = base_q.order_by(VehicleAssignment.start_date.desc()).offset((page-1)*size).limit(size)
|
||||
result = await session.execute(q)
|
||||
items = result.scalars().all()
|
||||
return VehicleAssignmentListResponse(items=[VehicleAssignmentResponse.model_validate(i) for i in items], total=total, page=page, size=size)
|
||||
|
||||
|
||||
@assign_router.post("", response_model=VehicleAssignmentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_assignment(
|
||||
body: VehicleAssignmentCreateRequest,
|
||||
current_user: User = Depends(require_permission("vehicles:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
v_result = await session.execute(select(Vehicle).where(Vehicle.id == body.vehicle_id, Vehicle.account_id == current_user.account_id))
|
||||
if not v_result.scalars().first():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found")
|
||||
assign = VehicleAssignment(**body.model_dump())
|
||||
session.add(assign)
|
||||
await session.commit()
|
||||
await session.refresh(assign)
|
||||
return VehicleAssignmentResponse.model_validate(assign)
|
||||
|
||||
|
||||
@assign_router.put("/{assign_id}", response_model=VehicleAssignmentResponse)
|
||||
async def update_assignment(
|
||||
assign_id: str,
|
||||
body: VehicleAssignmentUpdateRequest,
|
||||
current_user: User = Depends(require_permission("vehicles:write")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(VehicleAssignment).join(Vehicle).where(VehicleAssignment.id == assign_id, Vehicle.account_id == current_user.account_id)
|
||||
)
|
||||
assign = result.scalars().first()
|
||||
if not assign:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(assign, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(assign)
|
||||
return VehicleAssignmentResponse.model_validate(assign)
|
||||
|
||||
|
||||
@assign_router.delete("/{assign_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_assignment(
|
||||
assign_id: str,
|
||||
current_user: User = Depends(require_permission("vehicles:delete")),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(VehicleAssignment).join(Vehicle).where(VehicleAssignment.id == assign_id, Vehicle.account_id == current_user.account_id)
|
||||
)
|
||||
assign = result.scalars().first()
|
||||
if not assign:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
|
||||
await session.delete(assign)
|
||||
await session.commit()
|
||||
return None
|
||||
Reference in New Issue
Block a user