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
+71
View File
@@ -0,0 +1,71 @@
"""
LandveX Admin Backend — Database Base Model
SQLAlchemy declarative base and mixins
"""
from datetime import datetime, timezone
from typing import Any, Dict
from uuid import uuid4
from sqlalchemy import Column, DateTime, String, event
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import DeclarativeBase, declared_attr
class Base(DeclarativeBase):
"""Base class for all database models."""
# Generate tablename automatically from class name
@declared_attr.directive
def __tablename__(cls) -> str:
return cls.__name__.lower() + "s"
# Common columns for all tables
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self) -> Dict[str, Any]:
"""Convert model instance to dictionary."""
result = {}
for column in self.__table__.columns:
value = getattr(self, column.name)
if value is not None:
if isinstance(value, datetime):
result[column.name] = value.isoformat()
else:
result[column.name] = value
return result
def __repr__(self) -> str:
return f"<{self.__class__.__name__}(id={self.id})>"
class AuditMixin:
"""Mixin to add audit fields to models."""
created_by = Column(String(255), nullable=True)
updated_by = Column(String(255), nullable=True)
deleted_at = Column(DateTime(timezone=True), nullable=True)
deleted_by = Column(String(255), nullable=True)
is_deleted = Column(String(1), default="N") # Soft delete flag
@property
def is_active(self) -> bool:
"""Check if record is active (not soft-deleted)."""
return self.is_deleted == "N" and self.deleted_at is None
class MetadataMixin:
"""Mixin to add JSON metadata field to models."""
metadata_json = Column("metadata", JSONB, default=dict)
def get_metadata(self) -> Dict[str, Any]:
"""Get metadata as dictionary."""
return self.metadata_json or {}
def set_metadata(self, key: str, value: Any) -> None:
"""Set a metadata value."""
if self.metadata_json is None:
self.metadata_json = {}
self.metadata_json[key] = value
+151
View File
@@ -0,0 +1,151 @@
"""
LandveX Admin Backend — Database Session Management
Async SQLAlchemy session handling with tenant context
"""
from contextlib import asynccontextmanager
from typing import AsyncGenerator, Optional
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy import text
from app.core.config import settings
from app.core.exceptions import TenantSchemaException
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True,
echo=settings.DEBUG,
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Dependency to get database session."""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
@asynccontextmanager
async def get_db_context() -> AsyncGenerator[AsyncSession, None]:
"""Context manager for database sessions."""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
class TenantContext:
"""Context manager for tenant schema isolation.
Uses PostgreSQL row-level security and schema search path
to ensure data isolation between tenants.
"""
def __init__(self, session: AsyncSession, tenant_schema: Optional[str] = None):
self.session = session
self.tenant_schema = tenant_schema or settings.DEFAULT_TENANT_SCHEMA
self._original_search_path = None
async def __aenter__(self):
"""Set tenant context on database connection."""
# Set search path to tenant schema first, then public
await self.session.execute(
text(f'SET search_path TO "{self.tenant_schema}", public')
)
# Set application context for RLS policies
await self.session.execute(
text("SELECT set_config('app.current_tenant', :tenant, true)"),
{"tenant": self.tenant_schema}
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Reset tenant context."""
# Reset search path
await self.session.execute(text('SET search_path TO public'))
# Clear application context
await self.session.execute(
text("SELECT set_config('app.current_tenant', '', true)")
)
return False
async def set_tenant_schema(session: AsyncSession, tenant_schema: str) -> None:
"""Set the tenant schema for the current session."""
try:
await session.execute(
text(f'SET search_path TO "{tenant_schema}", public')
)
await session.execute(
text("SELECT set_config('app.current_tenant', :tenant, true)"),
{"tenant": tenant_schema}
)
except Exception as e:
raise TenantSchemaException(
tenant_id=tenant_schema,
operation="set_schema",
original_error=str(e)
)
async def reset_tenant_schema(session: AsyncSession) -> None:
"""Reset schema to default."""
await session.execute(text('SET search_path TO public'))
await session.execute(
text("SELECT set_config('app.current_tenant', '', true)")
)
async def create_tenant_schema(schema_name: str) -> None:
"""Create a new schema for a tenant."""
async with engine.begin() as conn:
# Create schema if not exists
await conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema_name}"'))
# Grant usage
await conn.execute(text(f'GRANT USAGE ON SCHEMA "{schema_name}" TO PUBLIC'))
async def drop_tenant_schema(schema_name: str) -> None:
"""Drop a tenant schema."""
async with engine.begin() as conn:
await conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE'))
async def schema_exists(schema_name: str) -> bool:
"""Check if a schema exists."""
async with engine.connect() as conn:
result = await conn.execute(
text(
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = :schema)"
),
{"schema": schema_name}
)
return result.scalar()