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
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Run migrations and start
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+145
View File
@@ -0,0 +1,145 @@
"""
quiXzoom Academy API endpoints for LandveX Admin
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from app.core.security import get_current_user
from app.models.user import User
router = APIRouter(prefix="/academy", tags=["academy"])
# Models
class LessonCompleteRequest(BaseModel):
lesson_id: str
module_id: str
score: Optional[int] = None
class ProgressResponse(BaseModel):
user_id: str
completed_lessons: List[str]
current_module: int
total_progress: float
certificate_earned: bool
certificate_date: Optional[str] = None
class CertificateResponse(BaseModel):
pdf_url: str
verification_code: str
issue_date: str
# In-memory storage (replace with DB in production)
academy_progress = {}
LESSONS_PER_MODULE = {
"module1": 4,
"module2": 4,
"module3": 3,
"module4": 3,
"module5": 3,
"module6": 4
}
TOTAL_LESSONS = sum(LESSONS_PER_MODULE.values())
@router.get("/progress", response_model=ProgressResponse)
async def get_progress(current_user: User = Depends(get_current_user)):
"""Get user's academy progress"""
user_id = str(current_user.id)
progress = academy_progress.get(user_id, {
"completed_lessons": [],
"current_module": 1
})
completed = len(progress["completed_lessons"])
total_progress = (completed / TOTAL_LESSONS) * 100
certificate_earned = completed >= TOTAL_LESSONS
return ProgressResponse(
user_id=user_id,
completed_lessons=progress["completed_lessons"],
current_module=progress["current_module"],
total_progress=round(total_progress, 1),
certificate_earned=certificate_earned,
certificate_date=datetime.utcnow().isoformat() if certificate_earned else None
)
@router.post("/complete", status_code=status.HTTP_200_OK)
async def complete_lesson(
request: LessonCompleteRequest,
current_user: User = Depends(get_current_user)
):
"""Mark a lesson as completed"""
user_id = str(current_user.id)
if user_id not in academy_progress:
academy_progress[user_id] = {
"completed_lessons": [],
"current_module": 1
}
progress = academy_progress[user_id]
if request.lesson_id not in progress["completed_lessons"]:
progress["completed_lessons"].append(request.lesson_id)
# Update current module
module_num = int(request.module_id.replace("module", ""))
module_lessons = [f"m{module_num}l{i}" for i in range(1, LESSONS_PER_MODULE[request.module_id] + 1)]
if all(l in progress["completed_lessons"] for l in module_lessons):
progress["current_module"] = min(module_num + 1, 6)
return {
"success": True,
"lesson_id": request.lesson_id,
"progress": len(progress["completed_lessons"]) / TOTAL_LESSONS * 100
}
@router.get("/certificate", response_model=CertificateResponse)
async def get_certificate(current_user: User = Depends(get_current_user)):
"""Generate certificate PDF"""
user_id = str(current_user.id)
progress = academy_progress.get(user_id, {"completed_lessons": []})
if len(progress["completed_lessons"]) < TOTAL_LESSONS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Certificate not yet earned. Complete all lessons first."
)
# Generate verification code
verification = f"QX-{user_id[:8].upper()}-{datetime.utcnow().strftime('%Y%m%d')}"
return CertificateResponse(
pdf_url=f"/api/academy/certificate/{user_id}/download",
verification_code=verification,
issue_date=datetime.utcnow().isoformat()
)
@router.get("/stats")
async def get_academy_stats(current_user: User = Depends(get_current_user)):
"""Get academy statistics (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
total_users = len(academy_progress)
completed_users = sum(
1 for p in academy_progress.values()
if len(p["completed_lessons"]) >= TOTAL_LESSONS
)
return {
"total_enrolled": total_users,
"completed_all": completed_users,
"completion_rate": round(completed_users / total_users * 100, 1) if total_users > 0 else 0,
"average_progress": round(
sum(len(p["completed_lessons"]) for p in academy_progress.values()) / total_users / TOTAL_LESSONS * 100, 1
) if total_users > 0 else 0
}
+83
View File
@@ -0,0 +1,83 @@
"""API dependencies for authentication, authorization, and tenant resolution."""
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, Header, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.database import get_db
from app.core.security import decode_token
from app.models.user import User
from app.models.tenant import Tenant
from app.core.permissions import has_permission
security = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
db: Session = Depends(get_db),
) -> User:
"""Validate JWT and return current user."""
try:
payload = decode_token(credentials.credentials)
user_id = UUID(payload["sub"])
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.id == user_id).first()
if not user or user.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
return user
async def get_current_tenant(
x_tenant_id: Annotated[UUID, Header(..., alias="X-Tenant-ID")],
user: User = Depends(get_current_user),
) -> Tenant:
"""Validate user has access to the specified tenant."""
if user.tenant_id != x_tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant",
)
return user.tenant
class RequirePermission:
"""Dependency factory for permission checking."""
def __init__(self, permission: str):
self.permission = permission
async def __call__(self, user: User = Depends(get_current_user)) -> User:
user_perms = set()
for role in user.roles:
user_perms.update(role.permissions)
if not has_permission(user_perms, self.permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission: {self.permission}",
)
return user
# Common permission dependencies
RequireAdmin = Depends(RequirePermission("users.update"))
RequireUserRead = Depends(RequirePermission("users.read"))
RequireTenantManage = Depends(RequirePermission("tenants.manage"))
RequireBillingRead = Depends(RequirePermission("billing.read"))
RequireAuditRead = Depends(RequirePermission("audit.read"))
RequireReportCreate = Depends(RequirePermission("reports.create"))
RequireApiKeyCreate = Depends(RequirePermission("api_keys.create"))
@@ -0,0 +1,138 @@
"""RBAC permission system."""
from enum import Enum
from typing import Set
class Permission(str, Enum):
"""All available permissions in the system."""
# Users
USERS_CREATE = "users.create"
USERS_READ = "users.read"
USERS_UPDATE = "users.update"
USERS_DELETE = "users.delete"
USERS_IMPERSONATE = "users.impersonate"
# Tenants
TENANTS_CREATE = "tenants.create"
TENANTS_READ = "tenants.read"
TENANTS_UPDATE = "tenants.update"
TENANTS_DELETE = "tenants.delete"
TENANTS_MANAGE = "tenants.manage"
# Roles
ROLES_CREATE = "roles.create"
ROLES_READ = "roles.read"
ROLES_UPDATE = "roles.update"
ROLES_DELETE = "roles.delete"
ROLES_ASSIGN = "roles.assign"
# API Keys
API_KEYS_CREATE = "api_keys.create"
API_KEYS_READ = "api_keys.read"
API_KEYS_REVOKE = "api_keys.revoke"
API_KEYS_ROTATE = "api_keys.rotate"
# Billing
BILLING_READ = "billing.read"
BILLING_MANAGE = "billing.manage"
BILLING_INVOICES = "billing.invoices"
BILLING_SUBSCRIBE = "billing.subscribe"
# Audit Logs
AUDIT_READ = "audit.read"
AUDIT_EXPORT = "audit.export"
# Reports
REPORTS_CREATE = "reports.create"
REPORTS_READ = "reports.read"
REPORTS_RUN = "reports.run"
REPORTS_EXPORT = "reports.export"
REPORTS_DELETE = "reports.delete"
# Plugins
PLUGINS_INSTALL = "plugins.install"
PLUGINS_CONFIGURE = "plugins.configure"
PLUGINS_UNINSTALL = "plugins.uninstall"
PLUGINS_ENABLE = "plugins.enable"
# Settings
SETTINGS_READ = "settings.read"
SETTINGS_UPDATE = "settings.update"
# Super admin - all access
ALL = "*"
# Predefined roles with their permissions
DEFAULT_ROLES = {
"superadmin": [Permission.ALL],
"admin": [
Permission.USERS_CREATE,
Permission.USERS_READ,
Permission.USERS_UPDATE,
Permission.USERS_DELETE,
Permission.ROLES_READ,
Permission.ROLES_ASSIGN,
Permission.API_KEYS_CREATE,
Permission.API_KEYS_READ,
Permission.API_KEYS_REVOKE,
Permission.BILLING_READ,
Permission.BILLING_INVOICES,
Permission.AUDIT_READ,
Permission.AUDIT_EXPORT,
Permission.REPORTS_CREATE,
Permission.REPORTS_READ,
Permission.REPORTS_RUN,
Permission.REPORTS_EXPORT,
Permission.PLUGINS_CONFIGURE,
Permission.PLUGINS_ENABLE,
Permission.SETTINGS_READ,
Permission.SETTINGS_UPDATE,
],
"editor": [
Permission.USERS_READ,
Permission.USERS_UPDATE,
Permission.REPORTS_CREATE,
Permission.REPORTS_READ,
Permission.REPORTS_RUN,
Permission.REPORTS_EXPORT,
Permission.SETTINGS_READ,
],
"viewer": [
Permission.USERS_READ,
Permission.REPORTS_READ,
Permission.SETTINGS_READ,
],
"api": [
Permission.API_KEYS_READ,
Permission.API_KEYS_CREATE,
],
}
def has_permission(user_permissions: Set[str], required: str) -> bool:
"""Check if user has the required permission.
Args:
user_permissions: Set of permission strings the user has
required: The required permission string
Returns:
True if user has permission, False otherwise
"""
if Permission.ALL in user_permissions:
return True
# Check exact permission
if required in user_permissions:
return True
# Check wildcard permissions (e.g., "users.*" matches "users.read")
parts = required.split(".")
for i in range(1, len(parts)):
wildcard = ".".join(parts[:i]) + ".*"
if wildcard in user_permissions:
return True
return False
+103
View File
@@ -0,0 +1,103 @@
"""Security utilities for authentication and authorization."""
from datetime import datetime, timedelta
from typing import Any
from uuid import UUID
import jwt
from passlib.context import CryptContext
from app.config import settings
# Password hashing with Argon2id
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password using Argon2id."""
return pwd_context.hash(password)
def create_access_token(
subject: UUID | str,
tenant_id: UUID | None = None,
scopes: list[str] | None = None,
expires_delta: timedelta | None = None,
) -> str:
"""Create JWT access token."""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode: dict[str, Any] = {
"sub": str(subject),
"exp": expire,
"iat": datetime.utcnow(),
"type": "access",
}
if tenant_id:
to_encode["tenant_id"] = str(tenant_id)
if scopes:
to_encode["scopes"] = scopes
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM
)
return encoded_jwt
def create_refresh_token(subject: UUID | str) -> str:
"""Create JWT refresh token."""
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode: dict[str, Any] = {
"sub": str(subject),
"exp": expire,
"iat": datetime.utcnow(),
"type": "refresh",
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM
)
return encoded_jwt
def decode_token(token: str) -> dict[str, Any]:
"""Decode and validate JWT token."""
return jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
)
def generate_api_key() -> tuple[str, str]:
"""Generate a new API key and its hash.
Returns:
Tuple of (full_key, key_hash)
"""
import secrets
import hashlib
# Generate random key: lv_<32 random chars>
random_part = secrets.token_urlsafe(32)
full_key = f"lv_{random_part}"
# Hash for storage
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
return full_key, key_hash
def verify_api_key(plain_key: str, key_hash: str) -> bool:
"""Verify an API key against its hash."""
import hashlib
computed_hash = hashlib.sha256(plain_key.encode()).hexdigest()
return computed_hash == key_hash
+66
View File
@@ -0,0 +1,66 @@
"""FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from app.config import settings
from app.api.v1 import auth, tenants, users, roles, api_keys, billing, audit, reports, plugins, dashboard
from app.database import engine, Base
from app.core.exceptions import setup_exception_handlers
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
# Startup
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown
await engine.dispose()
app = FastAPI(
title="LandveX Admin API",
description="Admin backend API for LandveX Enterprise Platform",
version="1.0.0",
lifespan=lifespan,
)
# Security middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
expose_headers=["X-Request-ID"],
)
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=settings.ALLOWED_HOSTS,
)
# Exception handlers
setup_exception_handlers(app)
# API routes
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Auth"])
app.include_router(tenants.router, prefix="/api/v1/tenants", tags=["Tenants"])
app.include_router(users.router, prefix="/api/v1/users", tags=["Users"])
app.include_router(roles.router, prefix="/api/v1/roles", tags=["Roles"])
app.include_router(api_keys.router, prefix="/api/v1/api-keys", tags=["API Keys"])
app.include_router(billing.router, prefix="/api/v1/billing", tags=["Billing"])
app.include_router(audit.router, prefix="/api/v1/audit-logs", tags=["Audit Logs"])
app.include_router(reports.router, prefix="/api/v1/reports", tags=["Reports"])
app.include_router(plugins.router, prefix="/api/v1/plugins", tags=["Plugins"])
app.include_router(dashboard.router, prefix="/api/v1/dashboard", tags=["Dashboard"])
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "version": "1.0.0"}
@@ -0,0 +1,49 @@
"""API key model for service authentication."""
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, Integer, DateTime, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ApiKeyStatus(str, Enum):
ACTIVE = "active"
REVOKED = "revoked"
EXPIRED = "expired"
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
key_hash: Mapped[str] = mapped_column(String(255), nullable=False)
key_prefix: Mapped[str] = mapped_column(String(8), nullable=False)
scopes: Mapped[list[str]] = mapped_column(JSON, default=list)
rate_limit: Mapped[int] = mapped_column(Integer, default=1000)
expires_at: Mapped[datetime | None]
last_used_at: Mapped[datetime | None]
created_by: Mapped[UUID | None] = mapped_column(ForeignKey("users.id"))
status: Mapped[ApiKeyStatus] = mapped_column(default=ApiKeyStatus.ACTIVE)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant: Mapped["Tenant"] = relationship(back_populates="api_keys")
creator: Mapped["User | None"] = relationship(back_populates="api_keys_created")
def __repr__(self) -> str:
return f"<ApiKey {self.key_prefix}...>"
def is_valid(self) -> bool:
"""Check if key is active and not expired."""
if self.status != ApiKeyStatus.ACTIVE:
return False
if self.expires_at and self.expires_at < datetime.utcnow():
return False
return True
+38
View File
@@ -0,0 +1,38 @@
"""Audit log model for compliance and security tracking."""
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, JSON, INET, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class AuditSeverity(str, Enum):
INFO = "info"
WARNING = "warning"
CRITICAL = "critical"
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID | None] = mapped_column(ForeignKey("tenants.id"))
user_id: Mapped[UUID | None] = mapped_column(ForeignKey("users.id"))
action: Mapped[str] = mapped_column(String(50), nullable=False)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
resource_id: Mapped[UUID | None]
details: Mapped[dict] = mapped_column(JSON, default=dict)
ip_address: Mapped[str | None] = mapped_column(INET)
user_agent: Mapped[str | None] = mapped_column(Text)
severity: Mapped[AuditSeverity] = mapped_column(default=AuditSeverity.INFO)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant: Mapped["Tenant | None"] = relationship(back_populates="audit_logs")
user: Mapped["User | None"] = relationship(back_populates="audit_logs")
def __repr__(self) -> str:
return f"<AuditLog {self.action} on {self.resource_type}>"
@@ -0,0 +1,90 @@
"""Billing and subscription models."""
from datetime import datetime, date
from decimal import Decimal
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, Date, Numeric, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SubscriptionPlan(Base):
__tablename__ = "subscription_plans"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
description: Mapped[str | None]
price_monthly: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
price_yearly: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
features: Mapped[dict] = mapped_column(JSON, default=dict)
limits: Mapped[dict] = mapped_column(JSON, default=dict)
is_public: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenants: Mapped[list["Tenant"]] = relationship(back_populates="plan")
def __repr__(self) -> str:
return f"<SubscriptionPlan {self.slug}>"
class InvoiceStatus(str, Enum):
DRAFT = "draft"
SENT = "sent"
PAID = "paid"
OVERDUE = "overdue"
CANCELLED = "cancelled"
class Invoice(Base):
__tablename__ = "invoices"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
invoice_number: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
period_start: Mapped[date] = mapped_column(Date, nullable=False)
period_end: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
tax_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
total_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="SEK")
status: Mapped[InvoiceStatus] = mapped_column(default=InvoiceStatus.DRAFT)
paid_at: Mapped[datetime | None]
stripe_invoice_id: Mapped[str | None] = mapped_column(String(100))
pdf_url: Mapped[str | None]
due_date: Mapped[date] = mapped_column(Date, nullable=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant: Mapped["Tenant"] = relationship(back_populates="invoices")
items: Mapped[list["InvoiceItem"]] = relationship(
back_populates="invoice", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Invoice {self.invoice_number}>"
class InvoiceItem(Base):
__tablename__ = "invoice_items"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
invoice_id: Mapped[UUID] = mapped_column(
ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False
)
description: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
metadata: Mapped[dict] = mapped_column(JSON, default=dict)
# Relationships
invoice: Mapped["Invoice"] = relationship(back_populates="items")
def __repr__(self) -> str:
return f"<InvoiceItem {self.description}>"
@@ -0,0 +1,60 @@
"""Plugin management models."""
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, JSON, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class PluginStatus(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
DEPRECATED = "deprecated"
class Plugin(Base):
__tablename__ = "plugins"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
description: Mapped[str | None]
version: Mapped[str] = mapped_column(String(20), nullable=False)
author: Mapped[str | None] = mapped_column(String(100))
config_schema: Mapped[dict] = mapped_column(JSON, default=dict)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[PluginStatus] = mapped_column(default=PluginStatus.ACTIVE)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant_installations: Mapped[list["TenantPlugin"]] = relationship(
back_populates="plugin"
)
def __repr__(self) -> str:
return f"<Plugin {self.slug}@{self.version}>"
class TenantPlugin(Base):
__tablename__ = "tenant_plugins"
tenant_id: Mapped[UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
)
plugin_id: Mapped[UUID] = mapped_column(
ForeignKey("plugins.id", ondelete="CASCADE"), primary_key=True
)
config: Mapped[dict] = mapped_column(JSON, default=dict)
installed_by: Mapped[UUID | None] = mapped_column(ForeignKey("users.id"))
installed_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
status: Mapped[str] = mapped_column(default="active")
# Relationships
tenant: Mapped["Tenant"] = relationship(back_populates="installed_plugins")
plugin: Mapped["Plugin"] = relationship(back_populates="tenant_installations")
def __repr__(self) -> str:
return f"<TenantPlugin {self.tenant_id}:{self.plugin_id}>"
@@ -0,0 +1,60 @@
"""Report and analytics models."""
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, Text, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Report(Base):
__tablename__ = "reports"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
type: Mapped[str] = mapped_column(String(50), nullable=False)
config: Mapped[dict] = mapped_column(JSON, default=dict)
schedule: Mapped[str | None] # Cron expression or null for one-time
last_run_at: Mapped[datetime | None]
next_run_at: Mapped[datetime | None]
created_by: Mapped[UUID | None] = mapped_column(ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant: Mapped["Tenant"] = relationship(back_populates="reports")
runs: Mapped[list["ReportRun"]] = relationship(
back_populates="report", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Report {self.name}>"
class ReportRunStatus(str, Enum):
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class ReportRun(Base):
__tablename__ = "report_runs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
report_id: Mapped[UUID] = mapped_column(
ForeignKey("reports.id", ondelete="CASCADE"), nullable=False
)
status: Mapped[ReportRunStatus] = mapped_column(default=ReportRunStatus.RUNNING)
result_url: Mapped[str | None] = mapped_column(Text)
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
completed_at: Mapped[datetime | None]
# Relationships
report: Mapped["Report"] = relationship(back_populates="runs")
def __repr__(self) -> str:
return f"<ReportRun {self.status}>"
@@ -0,0 +1,53 @@
"""Tenant model for multi-tenant isolation."""
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, JSON, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class TenantType(str, Enum):
MUNICIPALITY = "municipality"
ENTERPRISE = "enterprise"
PARTNER = "partner"
class TenantStatus(str, Enum):
ACTIVE = "active"
SUSPENDED = "suspended"
CANCELLED = "cancelled"
TRIAL = "trial"
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
type: Mapped[TenantType] = mapped_column(nullable=False)
org_number: Mapped[str | None] = mapped_column(String(20), unique=True)
billing_email: Mapped[str] = mapped_column(String(255), nullable=False)
plan_id: Mapped[UUID | None] = mapped_column(ForeignKey("subscription_plans.id"))
status: Mapped[TenantStatus] = mapped_column(default=TenantStatus.ACTIVE)
settings: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
default=datetime.utcnow, onupdate=datetime.utcnow
)
# Relationships
users: Mapped[list["User"]] = relationship(back_populates="tenant")
api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="tenant")
invoices: Mapped[list["Invoice"]] = relationship(back_populates="tenant")
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="tenant")
reports: Mapped[list["Report"]] = relationship(back_populates="tenant")
installed_plugins: Mapped[list["TenantPlugin"]] = relationship(
back_populates="tenant"
)
def __repr__(self) -> str:
return f"<Tenant {self.slug}>"
+105
View File
@@ -0,0 +1,105 @@
"""User model with RBAC support."""
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, Integer, DateTime, Boolean, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class UserStatus(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
PENDING = "pending"
LOCKED = "locked"
# Association table for user roles
user_roles = Table(
"user_roles",
Base.metadata,
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("assigned_by", ForeignKey("users.id")),
Column("assigned_at", DateTime, default=datetime.utcnow),
)
class User(Base):
__tablename__ = "users"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
first_name: Mapped[str | None] = mapped_column(String(100))
last_name: Mapped[str | None] = mapped_column(String(100))
phone: Mapped[str | None] = mapped_column(String(50))
avatar_url: Mapped[str | None] = mapped_column(String(500))
status: Mapped[UserStatus] = mapped_column(default=UserStatus.ACTIVE)
last_login_at: Mapped[datetime | None]
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[datetime | None]
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
mfa_secret: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
default=datetime.utcnow, onupdate=datetime.utcnow
)
# Relationships
tenant: Mapped["Tenant"] = relationship(back_populates="users")
roles: Mapped[list["Role"]] = relationship(
secondary=user_roles, back_populates="users"
)
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
api_keys_created: Mapped[list["ApiKey"]] = relationship(
back_populates="created_by"
)
def __repr__(self) -> str:
return f"<User {self.email}>"
def has_permission(self, permission: str) -> bool:
"""Check if user has a specific permission through any role."""
for role in self.roles:
if permission in role.permissions or "*" in role.permissions:
return True
return False
def has_access_to(self, tenant_id: UUID) -> bool:
"""Check if user belongs to the specified tenant."""
return self.tenant_id == tenant_id
class Role(Base):
__tablename__ = "roles"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID | None] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None]
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant: Mapped["Tenant | None"] = relationship()
users: Mapped[list["User"]] = relationship(
secondary=user_roles, back_populates="roles"
)
__table_args__ = (
# Unique constraint on tenant_id + slug
{"sqlite_autoincrement": True}, # Placeholder for unique constraint
)
def __repr__(self) -> str:
return f"<Role {self.slug}>"
+19
View File
@@ -0,0 +1,19 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy[asyncio]==2.0.25
asyncpg==0.29.0
alembic==1.13.1
pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[argon2]==1.7.4
python-multipart==0.0.6
redis==5.0.1
celery==5.3.6
stripe==7.10.0
prometheus-client==0.19.0
structlog==24.1.0
httpx==0.26.0
pytest==7.4.4
pytest-asyncio==0.23.3
pytest-cov==4.1.0