aee0f09db8
- Datafabrik: Dockerfile fix, agentorkestrering fungerar - Vision: Identify-modell, FAISS, OCR alla testade - API: Alla 7 integrationstester passerade - Upplösare: Entitetsupplösning verifierad
156 lines
5.8 KiB
Python
156 lines
5.8 KiB
Python
"""Business logic för inbjudningsflödet."""
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func
|
|
from fastapi import HTTPException, status
|
|
|
|
from app.models import Invitation, User, UserRole, UserStatus, Tenant
|
|
from app.schemas import InvitationCreate, AcceptInvitationRequest
|
|
from app.config import get_settings
|
|
from app.security import get_password_hash, can_manage
|
|
from app.services.user_service import UserService
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
class InvitationService:
|
|
def __init__(self, db: AsyncSession):
|
|
self.db = db
|
|
self.user_service = UserService(db)
|
|
|
|
async def get_by_token(self, token: str) -> Optional[Invitation]:
|
|
result = await self.db.execute(select(Invitation).where(Invitation.token == token))
|
|
return result.scalar_one_or_none()
|
|
|
|
async def list_invitations(
|
|
self,
|
|
tenant_id: Optional[UUID] = None,
|
|
pending_only: bool = True,
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
) -> tuple[list[Invitation], int]:
|
|
query = select(Invitation)
|
|
count_query = select(func.count()).select_from(Invitation)
|
|
|
|
if tenant_id:
|
|
query = query.where(Invitation.tenant_id == tenant_id)
|
|
count_query = count_query.where(Invitation.tenant_id == tenant_id)
|
|
if pending_only:
|
|
query = query.where(Invitation.accepted_at.is_(None))
|
|
count_query = count_query.where(Invitation.accepted_at.is_(None))
|
|
|
|
total_result = await self.db.execute(count_query)
|
|
total = total_result.scalar_one()
|
|
|
|
result = await self.db.execute(query.offset(skip).limit(limit).order_by(Invitation.created_at.desc()))
|
|
return result.scalars().all(), total
|
|
|
|
async def create_invitation(self, data: InvitationCreate, invited_by: User) -> Invitation:
|
|
# Validera att inviteraren kan ge denna roll
|
|
if not invited_by.is_superuser and not can_manage(invited_by.role, data.role):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Cannot invite with role equal or higher than your own",
|
|
)
|
|
|
|
# Kolla om användaren redan finns
|
|
existing_user = await self.user_service.get_by_email(data.email)
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="User with this email already exists",
|
|
)
|
|
|
|
# Kolla om det redan finns en aktiv inbjudan
|
|
existing_invite = await self.db.execute(
|
|
select(Invitation).where(
|
|
Invitation.email == data.email.lower().strip(),
|
|
Invitation.accepted_at.is_(None),
|
|
Invitation.expires_at > datetime.utcnow(),
|
|
)
|
|
)
|
|
if existing_invite.scalar_one_or_none():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Active invitation already exists for this email",
|
|
)
|
|
|
|
if data.tenant_id:
|
|
tenant = await self.db.execute(select(Tenant).where(Tenant.id == data.tenant_id))
|
|
if tenant.scalar_one_or_none() is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Tenant not found",
|
|
)
|
|
|
|
token = secrets.token_urlsafe(48)
|
|
invitation = Invitation(
|
|
email=data.email.lower().strip(),
|
|
token=token,
|
|
role=data.role,
|
|
tenant_id=data.tenant_id,
|
|
invited_by_id=invited_by.id,
|
|
expires_at=datetime.utcnow() + timedelta(hours=settings.INVITATION_TOKEN_EXPIRE_HOURS),
|
|
)
|
|
self.db.add(invitation)
|
|
await self.db.commit()
|
|
await self.db.refresh(invitation)
|
|
return invitation
|
|
|
|
async def accept_invitation(self, data: AcceptInvitationRequest) -> User:
|
|
invitation = await self.get_by_token(data.token)
|
|
if invitation is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Invitation not found",
|
|
)
|
|
if invitation.accepted_at is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invitation already accepted",
|
|
)
|
|
if invitation.expires_at < datetime.utcnow():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_410_GONE,
|
|
detail="Invitation has expired",
|
|
)
|
|
|
|
# Skapa användaren
|
|
user = User(
|
|
email=invitation.email,
|
|
first_name=data.first_name.strip(),
|
|
last_name=data.last_name.strip(),
|
|
role=invitation.role,
|
|
tenant_id=invitation.tenant_id,
|
|
status=UserStatus.ACTIVE,
|
|
hashed_password=get_password_hash(data.password),
|
|
)
|
|
self.db.add(user)
|
|
|
|
invitation.accepted_at = datetime.utcnow()
|
|
await self.db.commit()
|
|
await self.db.refresh(user)
|
|
return user
|
|
|
|
async def revoke_invitation(self, invitation_id: UUID, actor: User) -> None:
|
|
invitation = await self.db.execute(select(Invitation).where(Invitation.id == invitation_id))
|
|
inv = invitation.scalar_one_or_none()
|
|
if inv is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invitation not found")
|
|
|
|
if not actor.is_superuser and inv.invited_by_id != actor.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Can only revoke invitations you sent",
|
|
)
|
|
|
|
await self.db.delete(inv)
|
|
await self.db.commit()
|
|
|
|
def get_invitation_link(self, token: str) -> str:
|
|
return f"{settings.FRONTEND_SET_PASSWORD_URL}?token={token}"
|