landvex: Fixar och tester klara för alla komponenter

- Datafabrik: Dockerfile fix, agentorkestrering fungerar
- Vision: Identify-modell, FAISS, OCR alla testade
- API: Alla 7 integrationstester passerade
- Upplösare: Entitetsupplösning verifierad
This commit is contained in:
Bernt
2026-07-05 06:41:32 +00:00
parent f4f853d94b
commit aee0f09db8
19583 changed files with 1450867 additions and 1153 deletions
@@ -0,0 +1 @@
# Services package
@@ -0,0 +1,155 @@
"""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}"
@@ -0,0 +1,71 @@
"""Business logic för tenant-hantering."""
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 Tenant
from app.schemas import TenantCreate, TenantUpdate
class TenantService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, tenant_id: UUID) -> Optional[Tenant]:
result = await self.db.execute(select(Tenant).where(Tenant.id == tenant_id))
return result.scalar_one_or_none()
async def get_by_slug(self, slug: str) -> Optional[Tenant]:
result = await self.db.execute(select(Tenant).where(Tenant.slug == slug.lower().strip()))
return result.scalar_one_or_none()
async def list_tenants(self, skip: int = 0, limit: int = 100) -> tuple[list[Tenant], int]:
total_result = await self.db.execute(select(func.count()).select_from(Tenant))
total = total_result.scalar_one()
result = await self.db.execute(
select(Tenant).offset(skip).limit(limit).order_by(Tenant.created_at.desc())
)
return result.scalars().all(), total
async def create_tenant(self, data: TenantCreate) -> Tenant:
existing = await self.get_by_slug(data.slug)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Tenant with this slug already exists",
)
tenant = Tenant(
name=data.name.strip(),
slug=data.slug.lower().strip(),
description=data.description.strip() if data.description else None,
)
self.db.add(tenant)
await self.db.commit()
await self.db.refresh(tenant)
return tenant
async def update_tenant(self, tenant_id: UUID, data: TenantUpdate) -> Tenant:
tenant = await self.get_by_id(tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
if data.name is not None:
tenant.name = data.name.strip()
if data.description is not None:
tenant.description = data.description.strip() if data.description else None
await self.db.commit()
await self.db.refresh(tenant)
return tenant
async def delete_tenant(self, tenant_id: UUID) -> None:
tenant = await self.get_by_id(tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
await self.db.delete(tenant)
await self.db.commit()
@@ -0,0 +1,166 @@
"""Business logic för användarhantering."""
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 User, UserRole, UserStatus, Tenant
from app.schemas import UserCreate, UserUpdate
from app.security import get_password_hash, verify_password, can_manage
class UserService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, user_id: UUID) -> Optional[User]:
result = await self.db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def get_by_email(self, email: str) -> Optional[User]:
result = await self.db.execute(select(User).where(User.email == email.lower().strip()))
return result.scalar_one_or_none()
async def list_users(
self,
tenant_id: Optional[UUID] = None,
role: Optional[UserRole] = None,
status: Optional[UserStatus] = None,
skip: int = 0,
limit: int = 100,
) -> tuple[list[User], int]:
query = select(User)
count_query = select(func.count()).select_from(User)
if tenant_id:
query = query.where(User.tenant_id == tenant_id)
count_query = count_query.where(User.tenant_id == tenant_id)
if role:
query = query.where(User.role == role)
count_query = count_query.where(User.role == role)
if status:
query = query.where(User.status == status)
count_query = count_query.where(User.status == status)
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(User.created_at.desc()))
return result.scalars().all(), total
async def create_user(self, data: UserCreate, created_by: Optional[User] = None) -> User:
existing = await self.get_by_email(data.email)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email already exists",
)
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",
)
user = User(
email=data.email.lower().strip(),
first_name=data.first_name.strip(),
last_name=data.last_name.strip(),
role=data.role,
tenant_id=data.tenant_id,
status=UserStatus.ACTIVE if data.password else UserStatus.PENDING,
hashed_password=get_password_hash(data.password) if data.password else None,
)
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
return user
async def update_user(self, user_id: UUID, data: UserUpdate, actor: User) -> User:
user = await self.get_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if not actor.is_superuser and actor.id != user_id:
if not can_manage(actor.role, user.role):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify user with equal or higher role",
)
if data.email and data.email.lower().strip() != user.email:
existing = await self.get_by_email(data.email)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already in use",
)
user.email = data.email.lower().strip()
if data.first_name is not None:
user.first_name = data.first_name.strip()
if data.last_name is not None:
user.last_name = data.last_name.strip()
if data.role is not None:
if not actor.is_superuser and not can_manage(actor.role, data.role):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot assign role equal or higher than your own",
)
user.role = data.role
if data.status is not None:
user.status = data.status
if data.tenant_id is not None:
user.tenant_id = data.tenant_id
await self.db.commit()
await self.db.refresh(user)
return user
async def delete_user(self, user_id: UUID, actor: User) -> None:
user = await self.get_by_id(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if not actor.is_superuser:
if not can_manage(actor.role, user.role):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete user with equal or higher role",
)
if actor.id == user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete yourself",
)
await self.db.delete(user)
await self.db.commit()
async def authenticate(self, email: str, password: str) -> Optional[User]:
user = await self.get_by_email(email)
if not user or not user.hashed_password:
return None
if not verify_password(password, user.hashed_password):
return None
if user.status != UserStatus.ACTIVE:
return None
return user
async def set_password(self, user: User, password: str) -> None:
user.hashed_password = get_password_hash(password)
user.status = UserStatus.ACTIVE
await self.db.commit()
async def change_password(self, user: User, current_password: str, new_password: str) -> None:
if not verify_password(current_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
user.hashed_password = get_password_hash(new_password)
await self.db.commit()