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:
Agent Zero
2026-05-31 20:36:42 +00:00
commit 7f7da15965
135 changed files with 18980 additions and 0 deletions
View File
+99
View File
@@ -0,0 +1,99 @@
"""FastAPI dependencies for authentication, authorization, and tenant filtering."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, ExpiredSignatureError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.security import verify_token
from app.db.session import get_async_session
from app.models import User
security_scheme = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
session: AsyncSession = Depends(get_async_session),
) -> User:
"""Extract and validate JWT from Authorization header, return the current user."""
token = credentials.credentials
try:
payload = verify_token(token)
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
# Load user with role (and its permissions) eagerly
result = await session.execute(
select(User)
.options(joinedload(User.role))
.where(User.id == user_id)
)
user = result.unique().scalars().first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is deactivated",
)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""Return the current user if they are active (redundant check, kept for clarity)."""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is deactivated",
)
return current_user
def require_permission(permission: str):
"""Factory that returns a dependency checking for a specific permission."""
async def permission_checker(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No role assigned",
)
user_permissions: list[str] = current_user.role.permissions or []
if permission not in user_permissions:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permission: {permission}",
)
return current_user
return permission_checker
View File
+193
View File
@@ -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)
+220
View File
@@ -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
+269
View File
@@ -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
+193
View File
@@ -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
+208
View File
@@ -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
+29
View File
@@ -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,
}
+674
View File
@@ -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
+88
View File
@@ -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)
+33
View File
@@ -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"])
+142
View File
@@ -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
+59
View File
@@ -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)
+161
View File
@@ -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
+208
View File
@@ -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