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:
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
Reference in New Issue
Block a user