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,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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user