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
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
View File
+50
View File
@@ -0,0 +1,50 @@
"""Application configuration using pydantic-settings."""
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Application
APP_NAME: str = "Rentman Clone"
APP_ENV: str = "development"
APP_DEBUG: bool = True
API_V1_PREFIX: str = "/api/v1"
# Database
DATABASE_URL: str = "sqlite+aiosqlite:///./data/rentman.db"
# JWT Authentication
JWT_SECRET_KEY: str = "change-me-to-a-random-secret-at-least-32-chars"
JWT_ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_MINUTES: int = 1440
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
# MinIO
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_BUCKET_NAME: str = "rentman-files"
MINIO_SECURE: bool = False
# CORS
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
@property
def cors_origin_list(self) -> list[str]:
"""Return CORS origins as a list."""
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": True,
}
settings = Settings()
+46
View File
@@ -0,0 +1,46 @@
"""Security helpers: password hashing and JWT token management."""
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plaintext password against a bcrypt hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
def create_access_token(subject: str | Any, expires_delta: timedelta | None = None) -> str:
"""Create a short-lived JWT access token."""
if expires_delta is None:
expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
now = datetime.now(timezone.utc)
expire = now + expires_delta
to_encode = {"exp": expire, "sub": str(subject), "iat": now, "type": "access"}
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def create_refresh_token(subject: str | Any, expires_delta: timedelta | None = None) -> str:
"""Create a long-lived JWT refresh token."""
if expires_delta is None:
expires_delta = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
now = datetime.now(timezone.utc)
expire = now + expires_delta
to_encode = {"exp": expire, "sub": str(subject), "iat": now, "type": "refresh"}
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def verify_token(token: str) -> dict:
"""Decode and verify a JWT token. Returns the payload dict."""
return jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
View File
+8
View File
@@ -0,0 +1,8 @@
"""SQLAlchemy declarative base."""
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Base class for all database models."""
pass
+31
View File
@@ -0,0 +1,31 @@
"""Async SQLAlchemy engine and session factory."""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.core.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.APP_DEBUG,
poolclass=NullPool, # SQLite doesn't support connection pooling across threads
)
AsyncSessionFactory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_async_session() -> AsyncSession:
"""Dependency that provides an async database session."""
async with AsyncSessionFactory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+49
View File
@@ -0,0 +1,49 @@
"""FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.db.base import Base
from app.db.session import engine
from app.api.v1.router import api_v1_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
# On startup: create tables if they don't exist (for dev convenience)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# On shutdown: dispose engine
await engine.dispose()
app = FastAPI(
title=settings.APP_NAME,
version="0.1.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API v1 router
app.include_router(api_v1_router, prefix=settings.API_V1_PREFIX)
@app.get("/")
async def root():
"""Root endpoint."""
return {"message": "Rentman Clone API", "version": "0.1.0"}
+32
View File
@@ -0,0 +1,32 @@
"""Database models."""
from app.models.account import Account
from app.models.user import User
from app.models.role import Role
from app.models.contact import Contact
from app.models.tag import Tag
from app.models.equipment import Equipment
from app.models.stock_location import StockLocation
from app.models.equipment_group import EquipmentGroup
from app.models.crew import Crew, CrewAvailability
from app.models.vehicle import Vehicle, VehicleAssignment
from app.models.project import Project, SubProject, ProjectFunctionGroup, ProjectFunction
__all__ = [
"Account",
"User",
"Role",
"Contact",
"Tag",
"Equipment",
"StockLocation",
"EquipmentGroup",
"Crew",
"CrewAvailability",
"Vehicle",
"VehicleAssignment",
"Project",
"SubProject",
"ProjectFunctionGroup",
"ProjectFunction",
]
+34
View File
@@ -0,0 +1,34 @@
"""Account (Tenant) model."""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Account(Base):
"""Represents a tenant/company account."""
__tablename__ = "accounts"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Relationships
users: Mapped[list["User"]] = relationship("User", back_populates="account")
roles: Mapped[list["Role"]] = relationship("Role", back_populates="account")
contacts: Mapped[list["Contact"]] = relationship("Contact", back_populates="account")
tags: Mapped[list["Tag"]] = relationship("Tag", back_populates="account")
equipment: Mapped[list["Equipment"]] = relationship("Equipment", back_populates="account")
stock_locations: Mapped[list["StockLocation"]] = relationship("StockLocation", back_populates="account")
equipment_groups: Mapped[list["EquipmentGroup"]] = relationship("EquipmentGroup", back_populates="account")
crew_members: Mapped[list["Crew"]] = relationship("Crew", back_populates="account")
projects: Mapped[list["Project"]] = relationship("Project", back_populates="account")
+85
View File
@@ -0,0 +1,85 @@
"""Contact model for companies and persons."""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
# Many-to-many association table: contacts ↔ tags
contact_tags = Table(
"contact_tags",
Base.metadata,
Column(
"contact_id",
String(36),
ForeignKey("contacts.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"tag_id",
String(36),
ForeignKey("tags.id", ondelete="CASCADE"),
primary_key=True,
),
)
class Contact(Base):
"""Represents a contact (company or person) within a tenant account."""
__tablename__ = "contacts"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
account_id: Mapped[str] = mapped_column(
String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
type: Mapped[str] = mapped_column(
String(10), nullable=False
) # 'company' or 'person'
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Billing address
billing_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
billing_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
billing_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
billing_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
billing_country: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Shipping address
shipping_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
shipping_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
shipping_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
shipping_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
shipping_country: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Additional fields from design.md
tax_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
note: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="contacts")
tags: Mapped[list["Tag"]] = relationship(
"Tag", secondary=contact_tags, back_populates="contacts"
)
# projects_as_customer relationship will be added in Phase 3 when Project model is created
+88
View File
@@ -0,0 +1,88 @@
"""Crew and CrewAvailability models for managing personnel."""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func, Float, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Crew(Base):
"""Represents a crew member within a tenant account."""
__tablename__ = "crew"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
account_id: Mapped[str] = mapped_column(
String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
role_title: Mapped[str | None] = mapped_column(String(100), nullable=True)
hourly_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="crew_members")
availabilities: Mapped[list["CrewAvailability"]] = relationship(
"CrewAvailability", back_populates="crew_member", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Crew {self.first_name} {self.last_name}>"
class CrewAvailability(Base):
"""Tracks crew availability time periods."""
__tablename__ = "crew_availabilities"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
crew_id: Mapped[str] = mapped_column(
String(36), ForeignKey("crew.id", ondelete="CASCADE"), nullable=False
)
start_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
end_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="available"
) # available, booked, unavailable
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
crew_member: Mapped["Crew"] = relationship("Crew", back_populates="availabilities")
def __repr__(self) -> str:
return f"<CrewAvailability {self.crew_id}: {self.start_date}-{self.end_date}>"
+70
View File
@@ -0,0 +1,70 @@
"""Equipment model for the rental inventory catalog."""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func, Float, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Equipment(Base):
"""Represents a piece of rental equipment within a tenant account."""
__tablename__ = "equipment"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
account_id: Mapped[str] = mapped_column(
String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
brand: Mapped[str | None] = mapped_column(String(255), nullable=True)
serial_number: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True)
barcode: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True)
qr_code: Mapped[str | None] = mapped_column(String(500), nullable=True)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="available"
) # available, rented, maintenance, retired
purchase_price: Mapped[float | None] = mapped_column(Float, nullable=True)
current_value: Mapped[float | None] = mapped_column(Float, nullable=True)
weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
dimensions: Mapped[str | None] = mapped_column(String(255), nullable=True)
power_watt: Mapped[float | None] = mapped_column(Float, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
custom_fields: Mapped[str | None] = mapped_column(
Text, nullable=True
) # JSON string for flexible custom fields
# Location reference
location_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("stock_locations.id", ondelete="SET NULL"), nullable=True
)
# Supplier reference (optional contact)
supplier_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("contacts.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="equipment")
location: Mapped["StockLocation"] = relationship(
"StockLocation", back_populates="equipment_items"
)
supplier: Mapped["Contact"] = relationship("Contact", foreign_keys=[supplier_id])
def __repr__(self) -> str:
return f"<Equipment {self.name} ({self.status})>"
+74
View File
@@ -0,0 +1,74 @@
"""EquipmentGroup (Bundle) model for grouping equipment items."""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func, Float, Text, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
# Many-to-many association table: equipment_groups ↔ equipment
equipment_group_items = Table(
"equipment_group_items",
Base.metadata,
Column(
"group_id",
String(36),
ForeignKey("equipment_groups.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"equipment_id",
String(36),
ForeignKey("equipment.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"quantity",
Float,
nullable=False,
default=1.0,
),
)
class EquipmentGroup(Base):
"""Represents a bundle/group of equipment items."""
__tablename__ = "equipment_groups"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
account_id: Mapped[str] = mapped_column(
String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
daily_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
default_location_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("stock_locations.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="equipment_groups")
default_location: Mapped["StockLocation"] = relationship(
"StockLocation", back_populates="equipment_groups"
)
items: Mapped[list["Equipment"]] = relationship(
"Equipment", secondary=equipment_group_items, backref="groups"
)
def __repr__(self) -> str:
return f"<EquipmentGroup {self.name}>"
+149
View File
@@ -0,0 +1,149 @@
"""Project models for event/rental project management."""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, func, Float, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Project(Base):
"""Represents a rental/event project within a tenant account."""
__tablename__ = "projects"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
account_id: Mapped[str] = mapped_column(
String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="draft"
) # draft, confirmed, in_progress, completed, cancelled
start_date: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
end_date: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
budget: Mapped[float | None] = mapped_column(Float, nullable=True)
total_costs: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
custom_fields: Mapped[str | None] = mapped_column(
Text, nullable=True
) # JSON string for flexible custom fields
custom_field_defs: Mapped[str | None] = mapped_column(
Text, nullable=True
) # JSON string for custom field definitions
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="projects")
sub_projects: Mapped[list["SubProject"]] = relationship(
"SubProject", back_populates="project", cascade="all, delete-orphan"
)
function_groups: Mapped[list["ProjectFunctionGroup"]] = relationship(
"ProjectFunctionGroup", back_populates="project", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Project {self.name} ({self.status})>"
class SubProject(Base):
"""Represents a sub-project with self-referential parent hierarchy."""
__tablename__ = "sub_projects"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
project_id: Mapped[str] = mapped_column(
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
parent_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("sub_projects.id", ondelete="CASCADE"), nullable=True
)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships
project: Mapped["Project"] = relationship("Project", back_populates="sub_projects")
parent: Mapped[Optional["SubProject"]] = relationship(
"SubProject", remote_side="SubProject.id", back_populates="children"
)
children: Mapped[list["SubProject"]] = relationship(
"SubProject", back_populates="parent", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<SubProject {self.name}>"
class ProjectFunctionGroup(Base):
"""Groups project functions under a project."""
__tablename__ = "project_function_groups"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
project_id: Mapped[str] = mapped_column(
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships
project: Mapped["Project"] = relationship(
"Project", back_populates="function_groups"
)
functions: Mapped[list["ProjectFunction"]] = relationship(
"ProjectFunction", back_populates="function_group", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<ProjectFunctionGroup {self.name}>"
class ProjectFunction(Base):
"""Individual function/line-item within a function group."""
__tablename__ = "project_functions"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
function_group_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("project_function_groups.id", ondelete="CASCADE"),
nullable=False,
)
quantity: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
daily_costs: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
total_costs: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships
function_group: Mapped["ProjectFunctionGroup"] = relationship(
"ProjectFunctionGroup", back_populates="functions"
)
def __repr__(self) -> str:
return f"<ProjectFunction {self.name} x{self.quantity}>"
+28
View File
@@ -0,0 +1,28 @@
"""Role model for RBAC."""
import uuid
from sqlalchemy import String, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Role(Base):
"""Represents a role with permissions within an account."""
__tablename__ = "roles"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
account_id: Mapped[str] = mapped_column(
String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
permissions: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="roles")
users: Mapped[list["User"]] = relationship("User", back_populates="role")
+47
View File
@@ -0,0 +1,47 @@
"""Stock location model for equipment storage locations."""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class StockLocation(Base):
"""Represents a physical storage location for equipment."""
__tablename__ = "stock_locations"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
account_id: Mapped[str] = mapped_column(
String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
address: Mapped[str | None] = mapped_column(String(500), nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="stock_locations")
equipment_items: Mapped[list["Equipment"]] = relationship(
"Equipment", back_populates="location"
)
equipment_groups: Mapped[list["EquipmentGroup"]] = relationship(
"EquipmentGroup", back_populates="default_location"
)
def __repr__(self) -> str:
return f"<StockLocation {self.name}>"
+34
View File
@@ -0,0 +1,34 @@
"""Tag model for categorizing entities."""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.contact import contact_tags
class Tag(Base):
"""Represents a tag within a tenant account for categorizing entities."""
__tablename__ = "tags"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
account_id: Mapped[str] = mapped_column(
String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # hex color
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="tags")
contacts: Mapped[list["Contact"]] = relationship(
"Contact", secondary=contact_tags, back_populates="tags"
)
+39
View File
@@ -0,0 +1,39 @@
"""User model."""
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class User(Base):
"""Represents a user within a tenant account."""
__tablename__ = "users"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
account_id: Mapped[str] = mapped_column(
String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("roles.id", ondelete="SET NULL"), nullable=True
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="users")
role: Mapped["Role | None"] = relationship("Role", back_populates="users")
+95
View File
@@ -0,0 +1,95 @@
"""Vehicle and VehicleAssignment models for fleet management."""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func, Float, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Vehicle(Base):
"""Represents a vehicle within a tenant account."""
__tablename__ = "vehicles"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
account_id: Mapped[str] = mapped_column(
String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
license_plate: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True)
brand: Mapped[str | None] = mapped_column(String(100), nullable=True)
model: Mapped[str | None] = mapped_column(String(100), nullable=True)
year: Mapped[int | None] = mapped_column(nullable=True)
color: Mapped[str | None] = mapped_column(String(50), nullable=True)
vehicle_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
payload_capacity_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
load_volume_m3: Mapped[float | None] = mapped_column(Float, nullable=True)
fuel_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="vehicles")
assignments: Mapped[list["VehicleAssignment"]] = relationship(
"VehicleAssignment", back_populates="vehicle", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Vehicle {self.name} ({self.license_plate or 'no plate'})>"
class VehicleAssignment(Base):
"""Tracks vehicle assignment to projects/events."""
__tablename__ = "vehicle_assignments"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
vehicle_id: Mapped[str] = mapped_column(
String(36), ForeignKey("vehicles.id", ondelete="CASCADE"), nullable=False
)
project_id: Mapped[str | None] = mapped_column(
String(36), nullable=True
)
start_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
end_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="assigned"
) # assigned, in_use, returned
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Relationships
vehicle: Mapped["Vehicle"] = relationship("Vehicle", back_populates="assignments")
def __repr__(self) -> str:
return f"<VehicleAssignment {self.vehicle_id}: {self.start_date}-{self.end_date}>"
+52
View File
@@ -0,0 +1,52 @@
"""Pydantic schemas for authentication endpoints."""
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
"""Request body for self-service registration."""
account_name: str = Field(..., min_length=2, max_length=255, description="Company/account name")
full_name: str = Field(..., min_length=2, max_length=255, description="Admin user's full name")
email: EmailStr = Field(..., description="Admin user's email address")
password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 characters)")
class LoginRequest(BaseModel):
"""Request body for login."""
email: EmailStr
password: str
class RefreshRequest(BaseModel):
"""Request body for token refresh."""
refresh_token: str
class UserInfo(BaseModel):
"""Basic user info returned with tokens (no password hash)."""
id: str
email: str
full_name: str
account_id: str
role_id: str | None = None
role_name: str | None = None
permissions: list[str] = []
class TokenResponse(BaseModel):
"""Response with access and refresh tokens, plus user info."""
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserInfo
class UserResponse(BaseModel):
"""Public user representation (no password hash)."""
id: str
email: str
full_name: str
is_active: bool
role_id: str | None = None
model_config = {"from_attributes": True}
+101
View File
@@ -0,0 +1,101 @@
"""Pydantic schemas for contacts and tags."""
from pydantic import BaseModel, Field, EmailStr
class TagResponse(BaseModel):
"""Public tag representation."""
id: str
name: str
color: str | None = None
model_config = {"from_attributes": True}
class ContactCreateRequest(BaseModel):
"""Request body for creating a new contact."""
type: str = Field(..., pattern="^(company|person)$")
company_name: str | None = Field(None, max_length=255)
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
email: str | None = Field(None, max_length=255)
phone: str | None = Field(None, max_length=50)
mobile: str | None = Field(None, max_length=50)
website: str | None = Field(None, max_length=500)
billing_street: str | None = Field(None, max_length=255)
billing_number: str | None = Field(None, max_length=20)
billing_postalcode: str | None = Field(None, max_length=20)
billing_city: str | None = Field(None, max_length=100)
billing_country: str | None = Field(None, max_length=100)
shipping_street: str | None = Field(None, max_length=255)
shipping_number: str | None = Field(None, max_length=20)
shipping_postalcode: str | None = Field(None, max_length=20)
shipping_city: str | None = Field(None, max_length=100)
shipping_country: str | None = Field(None, max_length=100)
tax_number: str | None = Field(None, max_length=50)
note: str | None = None
tag_ids: list[str] = []
class ContactUpdateRequest(BaseModel):
"""Request body for updating an existing contact."""
type: str | None = Field(None, pattern="^(company|person)$")
company_name: str | None = Field(None, max_length=255)
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
email: str | None = Field(None, max_length=255)
phone: str | None = Field(None, max_length=50)
mobile: str | None = Field(None, max_length=50)
website: str | None = Field(None, max_length=500)
billing_street: str | None = Field(None, max_length=255)
billing_number: str | None = Field(None, max_length=20)
billing_postalcode: str | None = Field(None, max_length=20)
billing_city: str | None = Field(None, max_length=100)
billing_country: str | None = Field(None, max_length=100)
shipping_street: str | None = Field(None, max_length=255)
shipping_number: str | None = Field(None, max_length=20)
shipping_postalcode: str | None = Field(None, max_length=20)
shipping_city: str | None = Field(None, max_length=100)
shipping_country: str | None = Field(None, max_length=100)
tax_number: str | None = Field(None, max_length=50)
note: str | None = None
tag_ids: list[str] | None = None
class ContactResponse(BaseModel):
"""Public contact representation with tags."""
id: str
account_id: str
type: str
company_name: str | None = None
first_name: str | None = None
last_name: str | None = None
email: str | None = None
phone: str | None = None
mobile: str | None = None
website: str | None = None
billing_street: str | None = None
billing_number: str | None = None
billing_postalcode: str | None = None
billing_city: str | None = None
billing_country: str | None = None
shipping_street: str | None = None
shipping_number: str | None = None
shipping_postalcode: str | None = None
shipping_city: str | None = None
shipping_country: str | None = None
tax_number: str | None = None
note: str | None = None
created_at: str
updated_at: str
tags: list[TagResponse] = []
model_config = {"from_attributes": True}
class ContactListResponse(BaseModel):
"""Paginated list of contacts."""
items: list[ContactResponse]
total: int
page: int
size: int
+94
View File
@@ -0,0 +1,94 @@
"""Pydantic schemas for Crew and CrewAvailability."""
from datetime import datetime
from pydantic import BaseModel, Field
class CrewAvailabilityResponse(BaseModel):
"""Public crew availability representation."""
id: str
crew_id: str
start_date: str
end_date: str
status: str
notes: str | None = None
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class CrewCreateRequest(BaseModel):
"""Request body for creating a new crew member."""
first_name: str = Field(..., max_length=100)
last_name: str = Field(..., max_length=100)
email: str | None = Field(None, max_length=255)
phone: str | None = Field(None, max_length=50)
role_title: str | None = Field(None, max_length=100)
hourly_rate: float | None = None
is_active: bool = True
notes: str | None = None
class CrewUpdateRequest(BaseModel):
"""Request body for updating a crew member."""
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
email: str | None = Field(None, max_length=255)
phone: str | None = Field(None, max_length=50)
role_title: str | None = Field(None, max_length=100)
hourly_rate: float | None = None
is_active: bool | None = None
notes: str | None = None
class CrewResponse(BaseModel):
"""Public crew member representation."""
id: str
account_id: str
first_name: str
last_name: str
email: str | None = None
phone: str | None = None
role_title: str | None = None
hourly_rate: float | None = None
is_active: bool
notes: str | None = None
created_at: str
updated_at: str
availabilities: list[CrewAvailabilityResponse] = []
model_config = {"from_attributes": True}
class CrewListResponse(BaseModel):
"""Paginated list of crew members."""
items: list[CrewResponse]
total: int
page: int
size: int
class CrewAvailabilityCreateRequest(BaseModel):
"""Request body for creating crew availability."""
crew_id: str
start_date: str = Field(..., description="ISO datetime with timezone")
end_date: str = Field(..., description="ISO datetime with timezone")
status: str = Field("available", max_length=20)
notes: str | None = None
class CrewAvailabilityUpdateRequest(BaseModel):
"""Request body for updating crew availability."""
start_date: str | None = None
end_date: str | None = None
status: str | None = Field(None, max_length=20)
notes: str | None = None
class CrewAvailabilityListResponse(BaseModel):
"""Paginated list of crew availabilities."""
items: list[CrewAvailabilityResponse]
total: int
page: int
size: int
+95
View File
@@ -0,0 +1,95 @@
"""Pydantic schemas for Equipment (inventory catalog)."""
from datetime import datetime
from pydantic import BaseModel, Field
class EquipmentCreateRequest(BaseModel):
"""Request body for creating new equipment."""
name: str = Field(..., max_length=255)
category: str | None = Field(None, max_length=100)
brand: str | None = Field(None, max_length=255)
serial_number: str | None = Field(None, max_length=100)
barcode: str | None = Field(None, max_length=100)
qr_code: str | None = Field(None, max_length=500)
status: str = Field("available", max_length=20)
purchase_price: float | None = None
current_value: float | None = None
weight_kg: float | None = None
dimensions: str | None = Field(None, max_length=255)
power_watt: float | None = None
notes: str | None = None
custom_fields: str | None = None
location_id: str | None = None
supplier_id: str | None = None
class EquipmentUpdateRequest(BaseModel):
"""Request body for updating existing equipment."""
name: str | None = Field(None, max_length=255)
category: str | None = Field(None, max_length=100)
brand: str | None = Field(None, max_length=255)
serial_number: str | None = Field(None, max_length=100)
barcode: str | None = Field(None, max_length=100)
qr_code: str | None = Field(None, max_length=500)
status: str | None = Field(None, max_length=20)
purchase_price: float | None = None
current_value: float | None = None
weight_kg: float | None = None
dimensions: str | None = Field(None, max_length=255)
power_watt: float | None = None
notes: str | None = None
custom_fields: str | None = None
location_id: str | None = None
supplier_id: str | None = None
class LocationRef(BaseModel):
"""Minimal stock location reference."""
id: str
name: str
model_config = {"from_attributes": True}
class SupplierRef(BaseModel):
"""Minimal supplier (contact) reference."""
id: str
name: str | None = None
company_name: str | None = None
model_config = {"from_attributes": True}
class EquipmentResponse(BaseModel):
"""Public equipment representation."""
id: str
account_id: str
name: str
category: str | None = None
brand: str | None = None
serial_number: str | None = None
barcode: str | None = None
qr_code: str | None = None
status: str
purchase_price: float | None = None
current_value: float | None = None
weight_kg: float | None = None
dimensions: str | None = None
power_watt: float | None = None
notes: str | None = None
custom_fields: str | None = None
location: LocationRef | None = None
supplier: SupplierRef | None = None
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class EquipmentListResponse(BaseModel):
"""Paginated list of equipment."""
items: list[EquipmentResponse]
total: int
page: int
size: int
+58
View File
@@ -0,0 +1,58 @@
"""Pydantic schemas for EquipmentGroup (Bundles)."""
from datetime import datetime
from pydantic import BaseModel, Field
class EquipmentGroupItem(BaseModel):
"""An item inside a bundle with quantity."""
equipment_id: str
name: str | None = None
quantity: float = 1.0
model_config = {"from_attributes": True}
class EquipmentGroupCreateRequest(BaseModel):
"""Request body for creating a new equipment group/bundle."""
name: str = Field(..., max_length=255)
description: str | None = None
daily_rate: float | None = None
default_location_id: str | None = None
items: list[EquipmentGroupItem] = []
class EquipmentGroupUpdateRequest(BaseModel):
"""Request body for updating an equipment group."""
name: str | None = Field(None, max_length=255)
description: str | None = None
daily_rate: float | None = None
default_location_id: str | None = None
items: list[EquipmentGroupItem] | None = None
class EquipmentGroupResponse(BaseModel):
"""Public equipment group representation."""
id: str
account_id: str
name: str
description: str | None = None
daily_rate: float | None = None
default_location: LocationRef | None = None
items: list[EquipmentGroupItem] = []
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class EquipmentGroupListResponse(BaseModel):
"""Paginated list of equipment groups."""
items: list[EquipmentGroupResponse]
total: int
page: int
size: int
# Need LocationRef for EquipmentGroupResponse
from app.schemas.equipment import LocationRef # noqa: E402, F811
+170
View File
@@ -0,0 +1,170 @@
"""Pydantic schemas for Project, SubProject, ProjectFunctionGroup, ProjectFunction."""
from datetime import datetime
from pydantic import BaseModel, Field
# ===== Project Schemas =====
class ProjectCreateRequest(BaseModel):
"""Request body for creating a new project."""
name: str = Field(..., max_length=255)
description: str | None = None
status: str = Field("draft", max_length=20)
start_date: datetime | None = None
end_date: datetime | None = None
budget: float | None = None
total_costs: float = 0.0
notes: str | None = None
custom_fields: str | None = None
custom_field_defs: str | None = None
class ProjectUpdateRequest(BaseModel):
"""Request body for updating an existing project."""
name: str | None = Field(None, max_length=255)
description: str | None = None
status: str | None = Field(None, max_length=20)
start_date: datetime | None = None
end_date: datetime | None = None
budget: float | None = None
total_costs: float | None = None
notes: str | None = None
custom_fields: str | None = None
custom_field_defs: str | None = None
class ProjectResponse(BaseModel):
"""Public project representation."""
id: str
account_id: str
name: str
description: str | None = None
status: str
start_date: datetime | None = None
end_date: datetime | None = None
budget: float | None = None
total_costs: float = 0.0
notes: str | None = None
custom_fields: str | None = None
custom_field_defs: str | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ProjectListResponse(BaseModel):
"""Paginated list of projects."""
items: list[ProjectResponse]
total: int
page: int
size: int
# ===== SubProject Schemas =====
class SubProjectCreateRequest(BaseModel):
"""Request body for creating a subproject."""
name: str = Field(..., max_length=255)
parent_id: str | None = None
sort_order: int = 0
class SubProjectUpdateRequest(BaseModel):
"""Request body for updating a subproject."""
name: str | None = Field(None, max_length=255)
parent_id: str | None = None
sort_order: int | None = None
class SubProjectResponse(BaseModel):
"""Public subproject representation."""
id: str
name: str
project_id: str
parent_id: str | None = None
sort_order: int = 0
model_config = {"from_attributes": True}
class SubProjectListResponse(BaseModel):
"""Paginated list of subprojects."""
items: list[SubProjectResponse]
total: int
page: int
size: int
# ===== ProjectFunctionGroup Schemas =====
class ProjectFunctionGroupCreateRequest(BaseModel):
"""Request body for creating a function group."""
name: str = Field(..., max_length=255)
sort_order: int = 0
class ProjectFunctionGroupUpdateRequest(BaseModel):
"""Request body for updating a function group."""
name: str | None = Field(None, max_length=255)
sort_order: int | None = None
class ProjectFunctionGroupResponse(BaseModel):
"""Public function group representation."""
id: str
name: str
project_id: str
sort_order: int = 0
model_config = {"from_attributes": True}
class ProjectFunctionGroupListResponse(BaseModel):
"""Paginated list of function groups."""
items: list[ProjectFunctionGroupResponse]
total: int
page: int
size: int
# ===== ProjectFunction Schemas =====
class ProjectFunctionCreateRequest(BaseModel):
"""Request body for creating a project function."""
name: str = Field(..., max_length=255)
quantity: float = 1.0
daily_costs: float = 0.0
total_costs: float = 0.0
sort_order: int = 0
class ProjectFunctionUpdateRequest(BaseModel):
"""Request body for updating a project function."""
name: str | None = Field(None, max_length=255)
quantity: float | None = None
daily_costs: float | None = None
total_costs: float | None = None
sort_order: int | None = None
class ProjectFunctionResponse(BaseModel):
"""Public project function representation."""
id: str
name: str
function_group_id: str
quantity: float = 1.0
daily_costs: float = 0.0
total_costs: float = 0.0
sort_order: int = 0
model_config = {"from_attributes": True}
class ProjectFunctionListResponse(BaseModel):
"""Paginated list of project functions."""
items: list[ProjectFunctionResponse]
total: int
page: int
size: int
+28
View File
@@ -0,0 +1,28 @@
"""Pydantic schemas for role management."""
from pydantic import BaseModel, Field
class RoleCreateRequest(BaseModel):
"""Request body for creating a new role."""
name: str = Field(..., min_length=2, max_length=100)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
class RoleUpdateRequest(BaseModel):
"""Request body for updating an existing role."""
name: str | None = Field(None, min_length=2, max_length=100)
description: str | None = None
permissions: list[str] | None = None
class RoleResponse(BaseModel):
"""Public role representation."""
id: str
account_id: str
name: str
description: str | None = None
permissions: list[str]
model_config = {"from_attributes": True}
+39
View File
@@ -0,0 +1,39 @@
"""Pydantic schemas for StockLocation."""
from datetime import datetime
from pydantic import BaseModel, Field
class StockLocationCreateRequest(BaseModel):
"""Request body for creating a new stock location."""
name: str = Field(..., max_length=255)
address: str | None = Field(None, max_length=500)
is_default: bool = False
class StockLocationUpdateRequest(BaseModel):
"""Request body for updating a stock location."""
name: str | None = Field(None, max_length=255)
address: str | None = Field(None, max_length=500)
is_default: bool | None = None
class StockLocationResponse(BaseModel):
"""Public stock location representation."""
id: str
account_id: str
name: str
address: str | None = None
is_default: bool
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class StockLocationListResponse(BaseModel):
"""Paginated list of stock locations."""
items: list[StockLocationResponse]
total: int
page: int
size: int
+40
View File
@@ -0,0 +1,40 @@
"""Pydantic schemas for user management."""
from pydantic import BaseModel, EmailStr, Field
class UserCreateRequest(BaseModel):
"""Request body for creating a new user (invite)."""
email: EmailStr
full_name: str = Field(..., min_length=2, max_length=255)
password: str = Field(..., min_length=8, max_length=128)
role_id: str | None = None
class UserUpdateRequest(BaseModel):
"""Request body for updating an existing user."""
full_name: str | None = Field(None, min_length=2, max_length=255)
role_id: str | None = None
is_active: bool | None = None
class UserResponse(BaseModel):
"""Public user representation (no password hash)."""
id: str
account_id: str
email: str
full_name: str
is_active: bool
role_id: str | None = None
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class UserListResponse(BaseModel):
"""Paginated list of users."""
items: list[UserResponse]
total: int
page: int
size: int
+107
View File
@@ -0,0 +1,107 @@
"""Pydantic schemas for Vehicles and VehicleAssignments."""
from pydantic import BaseModel, Field
class VehicleAssignmentResponse(BaseModel):
"""Public vehicle assignment representation."""
id: str
vehicle_id: str
project_id: str | None = None
start_date: str
end_date: str
status: str
notes: str | None = None
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class VehicleCreateRequest(BaseModel):
"""Request body for creating a new vehicle."""
name: str = Field(..., max_length=255)
license_plate: str | None = Field(None, max_length=50)
brand: str | None = Field(None, max_length=100)
model: str | None = Field(None, max_length=100)
year: int | None = None
color: str | None = Field(None, max_length=50)
vehicle_type: str | None = Field(None, max_length=50)
payload_capacity_kg: float | None = None
load_volume_m3: float | None = None
fuel_type: str | None = Field(None, max_length=30)
is_active: bool = True
notes: str | None = None
class VehicleUpdateRequest(BaseModel):
"""Request body for updating a vehicle."""
name: str | None = Field(None, max_length=255)
license_plate: str | None = Field(None, max_length=50)
brand: str | None = Field(None, max_length=100)
model: str | None = Field(None, max_length=100)
year: int | None = None
color: str | None = Field(None, max_length=50)
vehicle_type: str | None = Field(None, max_length=50)
payload_capacity_kg: float | None = None
load_volume_m3: float | None = None
fuel_type: str | None = Field(None, max_length=30)
is_active: bool | None = None
notes: str | None = None
class VehicleResponse(BaseModel):
"""Public vehicle representation."""
id: str
account_id: str
name: str
license_plate: str | None = None
brand: str | None = None
model: str | None = None
year: int | None = None
color: str | None = None
vehicle_type: str | None = None
payload_capacity_kg: float | None = None
load_volume_m3: float | None = None
fuel_type: str | None = None
is_active: bool
notes: str | None = None
assignments: list[VehicleAssignmentResponse] = []
created_at: str
updated_at: str
model_config = {"from_attributes": True}
class VehicleListResponse(BaseModel):
"""Paginated list of vehicles."""
items: list[VehicleResponse]
total: int
page: int
size: int
class VehicleAssignmentCreateRequest(BaseModel):
"""Request body for creating a vehicle assignment."""
vehicle_id: str
project_id: str | None = None
start_date: str = Field(..., description="ISO datetime")
end_date: str = Field(..., description="ISO datetime")
status: str = Field("assigned", max_length=20)
notes: str | None = None
class VehicleAssignmentUpdateRequest(BaseModel):
"""Request body for updating a vehicle assignment."""
start_date: str | None = None
end_date: str | None = None
status: str | None = Field(None, max_length=20)
notes: str | None = None
class VehicleAssignmentListResponse(BaseModel):
"""Paginated list of vehicle assignments."""
items: list[VehicleAssignmentResponse]
total: int
page: int
size: int