Files
leocrm/app/routes/guests.py
T

205 lines
6.4 KiB
Python
Raw Normal View History

"""Guest management routes — invite, list, delete guests (admin only)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.auth import get_redis, hash_password
from app.core.db import get_db
from app.deps import get_current_user, require_admin
from app.models.guest_user import GuestUser
router = APIRouter(prefix="/api/v1/guests", tags=["guests"])
settings = get_settings()
@router.post("/invite")
async def invite_guest(
request: Request,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Invite a guest user. Admin only."""
email = body.get("email", "")
name = body.get("name", "")
expires_in_hours = body.get("expires_in_hours", 72) # Default 3 days
if not email or not name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Email and name required", "code": "missing_fields"},
)
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Check if guest already exists for this tenant
existing_q = await db.execute(
select(GuestUser)
.where(GuestUser.email == email)
.where(GuestUser.tenant_id == tenant_id)
)
existing = existing_q.scalar_one_or_none()
if existing:
if existing.status == "active":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"detail": "Guest already active", "code": "guest_exists"},
)
# Re-invite: update existing record
existing.name = name
existing.status = "invited"
existing.invited_by = user_id
existing.expires_at = datetime.now(UTC) + timedelta(hours=expires_in_hours)
existing.password_hash = None
await db.commit()
await db.refresh(existing)
return {
"id": str(existing.id),
"email": existing.email,
"name": existing.name,
"status": existing.status,
"expires_at": existing.expires_at.isoformat() if existing.expires_at else None,
}
expires_at = datetime.now(UTC) + timedelta(hours=expires_in_hours)
guest = GuestUser(
email=email,
name=name,
tenant_id=tenant_id,
invited_by=user_id,
status="invited",
expires_at=expires_at,
)
db.add(guest)
await db.commit()
await db.refresh(guest)
return {
"id": str(guest.id),
"email": guest.email,
"name": guest.name,
"status": guest.status,
"expires_at": guest.expires_at.isoformat() if guest.expires_at else None,
}
@router.post("/accept/{token}")
async def accept_invitation(
token: str,
body: dict,
db: AsyncSession = Depends(get_db),
):
"""Guest accepts invitation and sets password."""
password = body.get("password", "")
if not password or len(password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Password must be at least 8 characters", "code": "weak_password"},
)
# Token is the guest user ID
try:
guest_id = uuid.UUID(token)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Invalid token", "code": "invalid_token"},
)
guest_q = await db.execute(
select(GuestUser).where(GuestUser.id == guest_id).where(GuestUser.status == "invited")
)
guest = guest_q.scalar_one_or_none()
if not guest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"detail": "Invitation not found or already accepted", "code": "invitation_not_found"},
)
# Check expiration
if guest.expires_at and guest.expires_at < datetime.now(UTC):
guest.status = "expired"
await db.commit()
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail={"detail": "Invitation expired", "code": "invitation_expired"},
)
guest.password_hash = hash_password(password)
guest.status = "active"
await db.commit()
return {"message": "Invitation accepted", "status": "active"}
@router.get("")
async def list_guests(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""List all guest users for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await db.execute(
select(GuestUser)
.where(GuestUser.tenant_id == tenant_id)
.order_by(GuestUser.created_at.desc())
)
guests = result.scalars().all()
return [
{
"id": str(g.id),
"email": g.email,
"name": g.name,
"status": g.status,
"expires_at": g.expires_at.isoformat() if g.expires_at else None,
"created_at": g.created_at.isoformat() if g.created_at else None,
}
for g in guests
]
@router.delete("/{guest_id}")
async def delete_guest(
guest_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Delete/revoke a guest user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
gid = uuid.UUID(guest_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Invalid guest ID", "code": "invalid_id"},
)
guest_q = await db.execute(
select(GuestUser).where(GuestUser.id == gid).where(GuestUser.tenant_id == tenant_id)
)
guest = guest_q.scalar_one_or_none()
if not guest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"detail": "Guest not found", "code": "not_found"},
)
# Revoke: mark as revoked and clear password
guest.status = "revoked"
guest.password_hash = None
await db.commit()
# Invalidate any active guest sessions
redis = get_redis()
# We can't easily find all sessions for this guest, but they'll expire naturally
return {"message": "Guest revoked", "status": "revoked"}