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,180 @@
"""
Audit Service för LandveX Admin Backend.
Hanterar audit-logging för alla systemändringar.
"""
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import AuditLog, AuditAction
settings = get_settings()
class AuditService:
"""Service för att hantera audit logs."""
def __init__(self, session: AsyncSession):
self.session = session
async def log(
self,
action: AuditAction,
entity_type: str,
entity_id: str,
actor_id: Optional[str] = None,
actor_type: str = "user",
actor_ip: Optional[str] = None,
actor_user_agent: Optional[str] = None,
previous_values: Optional[Dict[str, Any]] = None,
new_values: Optional[Dict[str, Any]] = None,
changed_fields: Optional[List[str]] = None,
request_id: Optional[str] = None,
request_method: Optional[str] = None,
request_path: Optional[str] = None,
request_body: Optional[str] = None,
success: bool = True,
error_message: Optional[str] = None,
tenant_id: Optional[str] = None,
tenant_schema: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> AuditLog:
"""Skapa en audit log entry."""
sanitized_body = None
if request_body:
sanitized_body = self._sanitize_request_body(request_body)
audit_entry = AuditLog(
action=action,
entity_type=entity_type,
entity_id=entity_id,
actor_id=actor_id or "system",
actor_type=actor_type,
actor_ip=actor_ip,
actor_user_agent=actor_user_agent,
previous_values=previous_values,
new_values=new_values,
changed_fields=changed_fields,
request_id=request_id,
request_method=request_method,
request_path=request_path,
request_body=sanitized_body,
success="Y" if success else "N",
error_message=error_message,
tenant_id=tenant_id,
tenant_schema=tenant_schema,
metadata_json=metadata,
)
self.session.add(audit_entry)
await self.session.flush()
return audit_entry
async def get_by_entity(
self, entity_type: str, entity_id: str, limit: int = 100, offset: int = 0
) -> tuple[List[AuditLog], int]:
query = (
select(AuditLog)
.where(AuditLog.entity_type == entity_type)
.where(AuditLog.entity_id == entity_id)
.order_by(desc(AuditLog.timestamp))
.offset(offset)
.limit(limit)
)
count_query = (
select(func.count(AuditLog.id))
.where(AuditLog.entity_type == entity_type)
.where(AuditLog.entity_id == entity_id)
)
result = await self.session.execute(query)
count_result = await self.session.execute(count_query)
return list(result.scalars().all()), count_result.scalar()
async def get_by_tenant(
self, tenant_id: str, limit: int = 100, offset: int = 0
) -> tuple[List[AuditLog], int]:
query = (
select(AuditLog)
.where(AuditLog.tenant_id == tenant_id)
.order_by(desc(AuditLog.timestamp))
.offset(offset)
.limit(limit)
)
count_query = (
select(func.count(AuditLog.id))
.where(AuditLog.tenant_id == tenant_id)
)
result = await self.session.execute(query)
count_result = await self.session.execute(count_query)
return list(result.scalars().all()), count_result.scalar()
async def get_by_actor(
self, actor_id: str, limit: int = 100, offset: int = 0
) -> tuple[List[AuditLog], int]:
query = (
select(AuditLog)
.where(AuditLog.actor_id == actor_id)
.order_by(desc(AuditLog.timestamp))
.offset(offset)
.limit(limit)
)
count_query = (
select(func.count(AuditLog.id))
.where(AuditLog.actor_id == actor_id)
)
result = await self.session.execute(query)
count_result = await self.session.execute(count_query)
return list(result.scalars().all()), count_result.scalar()
async def get_recent(self, limit: int = 50, action: Optional[AuditAction] = None) -> List[AuditLog]:
query = select(AuditLog).order_by(desc(AuditLog.timestamp))
if action:
query = query.where(AuditLog.action == action)
query = query.limit(limit)
result = await self.session.execute(query)
return list(result.scalars().all())
async def cleanup_old_logs(self, retention_days: Optional[int] = None) -> int:
retention = retention_days or settings.AUDIT_RETENTION_DAYS
cutoff = datetime.now(timezone.utc) - __import__('datetime').timedelta(days=retention)
count_query = select(func.count(AuditLog.id)).where(AuditLog.timestamp < cutoff)
result = await self.session.execute(count_query)
return result.scalar()
def _sanitize_request_body(self, body: str) -> Optional[str]:
import json
sensitive = {
"password", "secret", "token", "api_key", "apikey",
"authorization", "credit_card", "ssn", "personal_number"
}
try:
data = json.loads(body)
if isinstance(data, dict):
sanitized = self._redact_sensitive(data, sensitive)
return json.dumps(sanitized)
return body[:1000]
except json.JSONDecodeError:
return body[:1000]
def _redact_sensitive(self, data: Dict[str, Any], sensitive: set) -> Dict[str, Any]:
result = {}
for key, value in data.items():
if any(field in key.lower() for field in sensitive):
result[key] = "***REDACTED***"
elif isinstance(value, dict):
result[key] = self._redact_sensitive(value, sensitive)
elif isinstance(value, list):
result[key] = [
self._redact_sensitive(item, sensitive) if isinstance(item, dict) else item
for item in value
]
else:
result[key] = value
return result
@@ -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,483 @@
"""
Business logic för tenant-hantering med audit-logging och schema-isolering.
"""
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any, List
from uuid import UUID
from sqlalchemy import select, func, and_, or_, desc, asc
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import HTTPException, status
from app.config import get_settings
from app.database import create_tenant_schema, schema_exists
from app.models import Tenant, TenantType, TenantStatus, AuditAction
from app.schemas import (
TenantCreate, TenantUpdate, TenantListParams,
TenantActivateRequest, TenantSuspendRequest
)
from app.services.audit_service import AuditService
settings = get_settings()
class TenantService:
def __init__(self, db: AsyncSession):
self.db = db
self.audit = AuditService(db)
# ─── READ ─────────────────────────────────────────────────────────────────
async def get_by_id(self, tenant_id: UUID, include_deleted: bool = False) -> Optional[Tenant]:
query = select(Tenant).where(Tenant.id == tenant_id)
if not include_deleted:
query = query.where(Tenant.is_deleted == "N")
result = await self.db.execute(query)
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()).where(Tenant.is_deleted == "N")
)
return result.scalar_one_or_none()
async def get_by_org_number(self, org_number: str) -> Optional[Tenant]:
cleaned = org_number.replace(" ", "").replace("-", "")
result = await self.db.execute(
select(Tenant).where(Tenant.org_number == cleaned).where(Tenant.is_deleted == "N")
)
return result.scalar_one_or_none()
async def list_tenants(self, params: TenantListParams) -> tuple[List[Tenant], int]:
query = select(Tenant).where(Tenant.is_deleted == "N")
count_query = select(func.count(Tenant.id)).where(Tenant.is_deleted == "N")
if params.status:
query = query.where(Tenant.status == params.status)
count_query = count_query.where(Tenant.status == params.status)
if params.tenant_type:
query = query.where(Tenant.tenant_type == params.tenant_type)
count_query = count_query.where(Tenant.tenant_type == params.tenant_type)
if params.country:
query = query.where(Tenant.country == params.country)
count_query = count_query.where(Tenant.country == params.country)
if params.city:
query = query.where(Tenant.city.ilike(f"%{params.city}%"))
count_query = count_query.where(Tenant.city.ilike(f"%{params.city}%"))
if params.is_active is not None:
if params.is_active:
query = query.where(Tenant.status == TenantStatus.ACTIVE)
count_query = count_query.where(Tenant.status == TenantStatus.ACTIVE)
else:
query = query.where(Tenant.status != TenantStatus.ACTIVE)
count_query = count_query.where(Tenant.status != TenantStatus.ACTIVE)
if params.search:
search_filter = or_(
Tenant.name.ilike(f"%{params.search}%"),
Tenant.slug.ilike(f"%{params.search}%"),
Tenant.city.ilike(f"%{params.search}%"),
Tenant.org_number.ilike(f"%{params.search}%"),
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
sort_column = getattr(Tenant, params.sort_by, Tenant.created_at)
if params.sort_order == "desc":
query = query.order_by(desc(sort_column))
else:
query = query.order_by(asc(sort_column))
offset = (params.page - 1) * params.page_size
query = query.offset(offset).limit(params.page_size)
result = await self.db.execute(query)
tenants = result.scalars().all()
count_result = await self.db.execute(count_query)
total = count_result.scalar()
return list(tenants), total
# ─── CREATE ───────────────────────────────────────────────────────────────
async def create_tenant(
self,
data: TenantCreate,
actor_id: Optional[str] = None,
request_context: Optional[Dict[str, Any]] = None
) -> Tenant:
# Check slug uniqueness
existing = await self.get_by_slug(data.slug)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Tenant with slug '{data.slug}' already exists",
)
# Check org_number uniqueness
if data.org_number:
existing_org = await self.get_by_org_number(data.org_number)
if existing_org:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Tenant with org number '{data.org_number}' already exists",
)
schema_name = f"{settings.TENANT_SCHEMA_PREFIX}{data.slug}"
if await schema_exists(schema_name):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Schema '{schema_name}' already exists in database",
)
tenant = Tenant(
name=data.name,
slug=data.slug.lower().strip(),
org_number=data.org_number,
tenant_type=data.tenant_type,
status=TenantStatus.PENDING,
schema_name=schema_name,
contact_email=data.contact_email,
contact_phone=data.contact_phone,
billing_email=data.billing_email,
address=data.address,
postal_code=data.postal_code,
city=data.city,
country=data.country,
municipality_code=data.municipality_code,
county_code=data.county_code,
latitude=data.latitude,
longitude=data.longitude,
plan=data.plan,
is_trial=data.is_trial,
max_users=data.max_users,
max_projects=data.max_projects,
storage_quota_mb=data.storage_quota_mb,
features=data.features or {},
settings=data.settings or {},
branding=data.branding or {},
description=data.description,
website=data.website,
created_by=actor_id,
)
if data.is_trial and data.trial_days:
tenant.trial_ends_at = datetime.now(timezone.utc) + timedelta(days=data.trial_days)
tenant.status = TenantStatus.TRIAL
self.db.add(tenant)
await self.db.flush()
await self.db.refresh(tenant)
# Create schema
try:
await create_tenant_schema(schema_name)
except Exception as e:
await self.db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create tenant schema: {str(e)}",
)
# Audit log
rc = request_context or {}
await self.audit.log(
action=AuditAction.CREATE,
entity_type="tenant",
entity_id=str(tenant.id),
actor_id=actor_id,
new_values=tenant.to_dict(include_sensitive=True),
tenant_id=str(tenant.id),
tenant_schema=schema_name,
**rc
)
await self.audit.log(
action=AuditAction.SCHEMA_CREATE,
entity_type="tenant_schema",
entity_id=str(tenant.id),
actor_id=actor_id,
new_values={"schema_name": schema_name},
tenant_id=str(tenant.id),
tenant_schema=schema_name,
**rc
)
return tenant
# ─── UPDATE ───────────────────────────────────────────────────────────────
async def update_tenant(
self,
tenant_id: UUID,
data: TenantUpdate,
actor_id: Optional[str] = None,
request_context: Optional[Dict[str, Any]] = None
) -> 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")
previous_values = tenant.to_dict(include_sensitive=True)
changed_fields = []
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if hasattr(tenant, field) and getattr(tenant, field) != value:
setattr(tenant, field, value)
changed_fields.append(field)
if not changed_fields:
return tenant
tenant.updated_by = actor_id
await self.db.flush()
await self.db.refresh(tenant)
rc = request_context or {}
await self.audit.log(
action=AuditAction.UPDATE,
entity_type="tenant",
entity_id=str(tenant.id),
actor_id=actor_id,
previous_values=previous_values,
new_values=tenant.to_dict(include_sensitive=True),
changed_fields=changed_fields,
tenant_id=str(tenant.id),
tenant_schema=tenant.schema_name,
**rc
)
return tenant
# ─── DELETE ───────────────────────────────────────────────────────────────
async def delete_tenant(
self,
tenant_id: UUID,
actor_id: Optional[str] = None,
request_context: Optional[Dict[str, Any]] = None
) -> 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 tenant.is_deleted == "Y":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already deleted")
previous_values = tenant.to_dict(include_sensitive=True)
tenant.is_deleted = "Y"
tenant.deleted_at = datetime.now(timezone.utc)
tenant.deleted_by = actor_id
tenant.status = TenantStatus.CANCELLED
await self.db.flush()
rc = request_context or {}
await self.audit.log(
action=AuditAction.DELETE,
entity_type="tenant",
entity_id=str(tenant.id),
actor_id=actor_id,
previous_values=previous_values,
new_values={"is_deleted": "Y", "deleted_at": tenant.deleted_at.isoformat()},
changed_fields=["is_deleted", "deleted_at", "status"],
tenant_id=str(tenant.id),
tenant_schema=tenant.schema_name,
**rc
)
return tenant
async def hard_delete_tenant(
self,
tenant_id: UUID,
actor_id: Optional[str] = None,
request_context: Optional[Dict[str, Any]] = None
) -> None:
tenant = await self.get_by_id(tenant_id, include_deleted=True)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
rc = request_context or {}
await self.audit.log(
action=AuditAction.DELETE,
entity_type="tenant",
entity_id=str(tenant.id),
actor_id=actor_id,
previous_values=tenant.to_dict(include_sensitive=True),
tenant_id=str(tenant.id),
tenant_schema=tenant.schema_name,
**rc
)
await self.db.delete(tenant)
await self.db.flush()
# ─── STATUS MANAGEMENT ────────────────────────────────────────────────────
async def activate_tenant(
self,
tenant_id: UUID,
data: TenantActivateRequest,
actor_id: Optional[str] = None,
request_context: Optional[Dict[str, Any]] = None
) -> 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 tenant.status == TenantStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already active")
if tenant.status == TenantStatus.CANCELLED:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot activate a cancelled tenant")
previous_values = tenant.to_dict(include_sensitive=True)
tenant.activate(activated_by=actor_id)
tenant.plan_started_at = datetime.now(timezone.utc)
await self.db.flush()
await self.db.refresh(tenant)
rc = request_context or {}
await self.audit.log(
action=AuditAction.ACTIVATE,
entity_type="tenant",
entity_id=str(tenant.id),
actor_id=actor_id,
previous_values=previous_values,
new_values=tenant.to_dict(include_sensitive=True),
changed_fields=["status", "activated_at", "activated_by", "plan_started_at"],
tenant_id=str(tenant.id),
tenant_schema=tenant.schema_name,
**rc
)
return tenant
async def suspend_tenant(
self,
tenant_id: UUID,
data: TenantSuspendRequest,
actor_id: Optional[str] = None,
request_context: Optional[Dict[str, Any]] = None
) -> 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 tenant.status == TenantStatus.SUSPENDED:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already suspended")
if tenant.status == TenantStatus.CANCELLED:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot suspend a cancelled tenant")
previous_values = tenant.to_dict(include_sensitive=True)
tenant.suspend(reason=data.reason, suspended_by=actor_id or "system")
await self.db.flush()
await self.db.refresh(tenant)
rc = request_context or {}
await self.audit.log(
action=AuditAction.SUSPEND,
entity_type="tenant",
entity_id=str(tenant.id),
actor_id=actor_id,
previous_values=previous_values,
new_values=tenant.to_dict(include_sensitive=True),
changed_fields=["status", "suspended_at", "suspended_reason", "suspended_by"],
tenant_id=str(tenant.id),
tenant_schema=tenant.schema_name,
**rc
)
return tenant
async def cancel_tenant(
self,
tenant_id: UUID,
reason: Optional[str] = None,
actor_id: Optional[str] = None,
request_context: Optional[Dict[str, Any]] = None
) -> 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 tenant.status == TenantStatus.CANCELLED:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already cancelled")
previous_values = tenant.to_dict(include_sensitive=True)
tenant.cancel(reason=reason)
await self.db.flush()
await self.db.refresh(tenant)
rc = request_context or {}
await self.audit.log(
action=AuditAction.CANCEL,
entity_type="tenant",
entity_id=str(tenant.id),
actor_id=actor_id,
previous_values=previous_values,
new_values=tenant.to_dict(include_sensitive=True),
changed_fields=["status", "cancelled_at", "cancellation_reason"],
tenant_id=str(tenant.id),
tenant_schema=tenant.schema_name,
**rc
)
return tenant
# ─── STATISTICS ───────────────────────────────────────────────────────────
async def get_statistics(self) -> Dict[str, Any]:
status_counts = {}
for st in TenantStatus:
result = await self.db.execute(
select(func.count(Tenant.id))
.where(Tenant.status == st)
.where(Tenant.is_deleted == "N")
)
status_counts[st.value] = result.scalar()
type_counts = {}
for tt in TenantType:
result = await self.db.execute(
select(func.count(Tenant.id))
.where(Tenant.tenant_type == tt)
.where(Tenant.is_deleted == "N")
)
type_counts[tt.value] = result.scalar()
plan_result = await self.db.execute(
select(Tenant.plan, func.count(Tenant.id))
.where(Tenant.is_deleted == "N")
.group_by(Tenant.plan)
)
plan_counts = {plan: count for plan, count in plan_result.all()}
total_result = await self.db.execute(
select(func.count(Tenant.id)).where(Tenant.is_deleted == "N")
)
total = total_result.scalar()
return {
"total_tenants": total,
"active_tenants": status_counts.get("active", 0),
"pending_tenants": status_counts.get("pending", 0),
"suspended_tenants": status_counts.get("suspended", 0),
"trial_tenants": status_counts.get("trial", 0),
"cancelled_tenants": status_counts.get("cancelled", 0),
"by_type": type_counts,
"by_plan": plan_counts,
}
@@ -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()