Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 73 additions & 3 deletions app/api/v1/services.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
import logging
import uuid as uuid_module
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession
Expand All @@ -16,6 +16,7 @@
ServiceCreate,
ServiceUpdate,
ServiceResponse,
ServiceTemplatesUpdate,
ServiceFlavorCreate,
ServiceFlavorUpdate,
ServiceFlavorResponse,
Expand All @@ -29,6 +30,7 @@
ValidateFailoverRequest,
ValidateFailoverResponse,
)
from app.schemas.template import TemplateResponse
from app.schemas.common import ErrorResponse, PaginatedResponse

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -211,7 +213,8 @@ async def create_service(
async def list_services(
service_type: Optional[str] = Query(None, description="Filter by service type"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
organization_id: Optional[str] = Query(None, description="Visibility filter - returns global services + org-specific services"),
organization_id: Optional[str] = Query(None, description="Visibility filter - returns global services + services allowing this org"),
user_id: Optional[str] = Query(None, description="Visibility filter - also returns services allowing this user"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=100, description="Items per page"),
db: AsyncSession = Depends(get_db)
Expand All @@ -221,7 +224,8 @@ async def list_services(

- **service_type**: Filter by service type
- **is_active**: Filter by active status
- **organization_id**: Visibility filter - returns global services (no org) plus services matching this org ID
- **organization_id**: Visibility filter - returns global services (no scope) plus services whose allowed orgs include this ID
- **user_id**: Visibility filter - also returns services whose allowed users include this ID
- **page**: Page number (default: 1)
- **page_size**: Items per page (default: 50, max: 100)
"""
Expand All @@ -232,6 +236,7 @@ async def list_services(
service_type=service_type,
is_active=is_active,
organization_id=organization_id,
user_id=user_id,
skip=skip,
limit=page_size
)
Expand Down Expand Up @@ -336,6 +341,71 @@ async def delete_service(
detail=f"Failed to delete service: {str(e)}"
)


# =============================================================================
# Service <-> Document Template links
# =============================================================================

@router.get(
"/services/{service_id}/templates",
response_model=List[TemplateResponse],
responses={404: {"model": ErrorResponse}},
)
async def list_service_templates(
service_id: UUID,
organization_id: Optional[str] = Query(None, description="Visibility filter by organization"),
user_id: Optional[str] = Query(None, description="Visibility filter by user"),
db: AsyncSession = Depends(get_db),
) -> List[TemplateResponse]:
"""
List the document templates available for a service.

- If the service has explicitly linked templates, returns those (filtered by
the caller's org/user visibility when provided).
- If it has none, falls back to the global default template.
"""
try:
return await service_service.get_service_templates(
db, service_id, organization_id=organization_id, user_id=user_id
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing service templates: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list service templates: {str(e)}"
)


@router.put(
"/services/{service_id}/templates",
response_model=ServiceResponse,
responses={404: {"model": ErrorResponse}},
)
async def set_service_templates(
service_id: UUID,
request: ServiceTemplatesUpdate,
db: AsyncSession = Depends(get_db),
) -> ServiceResponse:
"""
Replace the set of document templates available for a service.

- **template_ids**: full list of template IDs to link (empty clears all links,
which makes the service fall back to the global default template).
"""
try:
return await service_service.set_service_templates(db, service_id, request.template_ids)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error setting service templates: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to set service templates: {str(e)}"
)


# Flavor CRUD endpoints

@router.post(
Expand Down
35 changes: 27 additions & 8 deletions app/api/v1/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,12 @@ async def upload_template(
name_en: Optional[str] = Form(None, max_length=255, description="English name"),
description_fr: Optional[str] = Form(None, description="French description"),
description_en: Optional[str] = Form(None, description="English description"),
# Using str instead of UUID for flexibility with external systems (MongoDB ObjectIds, etc.)
organization_id: Optional[str] = Form(None, max_length=100, description="Organization scope (null for system)"),
user_id: Optional[str] = Form(None, max_length=100, description="User scope (null for org/system)"),
# Multi-scope access lists (repeat the field to add several IDs). Both empty => system.
allowed_organization_ids: List[str] = Form(default=[], description="Organizations allowed to see the template"),
allowed_user_ids: List[str] = Form(default=[], description="Users allowed to see the template"),
# Deprecated single-scope aliases (folded into the lists). Free-form string IDs.
organization_id: Optional[str] = Form(None, max_length=100, description="Deprecated: single organization scope"),
user_id: Optional[str] = Form(None, max_length=100, description="Deprecated: single user scope"),
is_default: bool = Form(False, description="Set as default for scope"),
db: AsyncSession = Depends(get_db),
) -> TemplateResponse:
Expand All @@ -48,10 +51,10 @@ async def upload_template(
The template file must be a valid DOCX document. Placeholders in the format
{{placeholder_name}} will be automatically extracted.

Scope hierarchy:
- System templates: organization_id=null, user_id=null (visible to all)
- Organization templates: organization_id=X, user_id=null (visible to org X)
- User templates: organization_id=X, user_id=Y (visible only to user Y)
Scope (multi):
- System templates: both lists empty (visible to all)
- Organization templates: orgs listed in allowed_organization_ids
- User templates: users listed in allowed_user_ids

Maximum file size: 10 MB.
"""
Expand All @@ -63,6 +66,8 @@ async def upload_template(
name_en=name_en,
description_fr=description_fr,
description_en=description_en,
allowed_organization_ids=allowed_organization_ids,
allowed_user_ids=allowed_user_ids,
organization_id=organization_id,
user_id=user_id,
is_default=is_default,
Expand Down Expand Up @@ -147,15 +152,27 @@ async def update_template(
name_en: Optional[str] = Form(None, max_length=255, description="English name"),
description_fr: Optional[str] = Form(None, description="French description"),
description_en: Optional[str] = Form(None, description="English description"),
# Scope edit: pass either list to replace the template's audience.
allowed_organization_ids: Optional[List[str]] = Form(None, description="Replace organizations allowed to see the template"),
allowed_user_ids: Optional[List[str]] = Form(None, description="Replace users allowed to see the template"),
# When true, the access lists are replaced even if empty (clears scope to
# system). Needed because multipart cannot send an explicit empty list.
replace_scope: bool = Form(False, description="Replace scope with the given lists, even when empty"),
is_default: Optional[bool] = Form(None, description="Set as default"),
db: AsyncSession = Depends(get_db),
) -> TemplateResponse:
"""
Update template metadata and/or file.
Update template metadata, scope and/or file.

All fields are optional - only provided fields will be updated.
If a new file is provided, placeholders will be re-extracted.
Passing allowed_organization_ids / allowed_user_ids (or replace_scope=true)
replaces the template scope.
"""
if replace_scope:
# Coerce missing lists to empty so an empty audience (system) can be set.
allowed_organization_ids = allowed_organization_ids or []
allowed_user_ids = allowed_user_ids or []
try:
template = await document_template_service.update_template(
db=db,
Expand All @@ -165,6 +182,8 @@ async def update_template(
name_en=name_en,
description_fr=description_fr,
description_en=description_en,
allowed_organization_ids=allowed_organization_ids,
allowed_user_ids=allowed_user_ids,
is_default=is_default,
)
if not template:
Expand Down
2 changes: 1 addition & 1 deletion app/http_server/ingress.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title=pydantic_settings.app_name,
description=pydantic_settings.app_description,
version="1.0.0",
version="1.1.0",
docs_url=pydantic_settings.docs_url,
redoc_url="/redoc",
lifespan=lifespan,
Expand Down
131 changes: 131 additions & 0 deletions app/migrations/versions/008_multi_scope_and_service_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""multi_scope_and_service_templates - multi org/user scoping + service<->template links

Revision ID: 008
Revises: 007
Create Date: 2026-06-19 00:00:00.000000

Adds list-based scoping (allowed_organization_ids / allowed_user_ids) to both
`services` and `document_templates`, so a resource can be granted to several
organizations and/or several users instead of a single one. The legacy scalar
columns (services.organization_id, document_templates.organization_id/user_id)
are kept and back-compat-derived so existing clients (LinTO Studio) keep working.

Also adds the `service_document_templates` junction table letting admins choose
which document templates are available for which service (empty set => the
service falls back to the global default template).

The per-org unique constraints on services (uq_service_name_org / route) and the
document_templates check_user_requires_org constraint no longer fit the
multi-scope model and are dropped. DROP ... IF EXISTS keeps this idempotent.
"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = '008'
down_revision: Union[str, None] = '007'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

_ARRAY = postgresql.ARRAY(sa.String(100))


def upgrade() -> None:
# 1. Add multi-scope array columns (NOT NULL, default empty array).
for table in ("services", "document_templates"):
op.add_column(
table,
sa.Column(
"allowed_organization_ids", _ARRAY,
nullable=False, server_default="{}",
),
)
op.add_column(
table,
sa.Column(
"allowed_user_ids", _ARRAY,
nullable=False, server_default="{}",
),
)

# 2. Backfill existing single-scope values into the new lists.
op.execute(
"UPDATE services SET allowed_organization_ids = ARRAY[organization_id] "
"WHERE organization_id IS NOT NULL AND organization_id <> ''"
)
op.execute(
"UPDATE document_templates SET allowed_organization_ids = ARRAY[organization_id] "
"WHERE organization_id IS NOT NULL AND organization_id <> ''"
)
op.execute(
"UPDATE document_templates SET allowed_user_ids = ARRAY[user_id] "
"WHERE user_id IS NOT NULL AND user_id <> ''"
)

# 3. Drop constraints that no longer fit the multi-scope model.
op.execute("ALTER TABLE services DROP CONSTRAINT IF EXISTS uq_service_name_org")
op.execute("ALTER TABLE services DROP CONSTRAINT IF EXISTS uq_service_route_org")
op.execute("ALTER TABLE document_templates DROP CONSTRAINT IF EXISTS check_user_requires_org")

# 4. Indexes: plain index on services.name + GIN indexes for array membership.
op.create_index("idx_services_name", "services", ["name"])
op.create_index(
"idx_services_allowed_orgs", "services",
["allowed_organization_ids"], postgresql_using="gin",
)
op.create_index(
"idx_services_allowed_users", "services",
["allowed_user_ids"], postgresql_using="gin",
)
op.create_index(
"idx_templates_allowed_orgs", "document_templates",
["allowed_organization_ids"], postgresql_using="gin",
)
op.create_index(
"idx_templates_allowed_users", "document_templates",
["allowed_user_ids"], postgresql_using="gin",
)

# 5. Service <-> document template junction table.
op.create_table(
"service_document_templates",
sa.Column(
"service_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("services.id", ondelete="CASCADE"), primary_key=True,
),
sa.Column(
"document_template_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("document_templates.id", ondelete="CASCADE"), primary_key=True,
),
)
op.create_index(
"idx_service_document_templates_template",
"service_document_templates", ["document_template_id"],
)


def downgrade() -> None:
# Reverse order. Backfilled list data is lost; legacy scalar columns remain.
op.drop_index("idx_service_document_templates_template", table_name="service_document_templates")
op.drop_table("service_document_templates")

op.drop_index("idx_templates_allowed_users", table_name="document_templates")
op.drop_index("idx_templates_allowed_orgs", table_name="document_templates")
op.drop_index("idx_services_allowed_users", table_name="services")
op.drop_index("idx_services_allowed_orgs", table_name="services")
op.drop_index("idx_services_name", table_name="services")

# Re-add the dropped constraints (best-effort on downgrade).
op.execute(
"ALTER TABLE document_templates ADD CONSTRAINT check_user_requires_org "
"CHECK ((user_id IS NULL) OR (organization_id IS NOT NULL))"
)
op.create_unique_constraint("uq_service_route_org", "services", ["route", "organization_id"])
op.create_unique_constraint("uq_service_name_org", "services", ["name", "organization_id"])

for table in ("document_templates", "services"):
op.drop_column(table, "allowed_user_ids")
op.drop_column(table, "allowed_organization_ids")
2 changes: 2 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
from app.core.database import Base
from .associations import service_document_templates
from .organization import Organization
from .provider import Provider
from .model import Model
Expand All @@ -17,6 +18,7 @@

__all__ = [
"Base",
"service_document_templates",
"Organization",
"Provider",
"Model",
Expand Down
26 changes: 26 additions & 0 deletions app/models/associations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Association tables shared between models."""
from sqlalchemy import Column, ForeignKey, Table, Index
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base

# Many-to-many link between services and the document templates available for them.
# When a service has no rows here, the available set falls back to the global
# default template (see document_template_service.get_default_template).
service_document_templates = Table(
"service_document_templates",
Base.metadata,
Column(
"service_id",
UUID(as_uuid=True),
ForeignKey("services.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"document_template_id",
UUID(as_uuid=True),
ForeignKey("document_templates.id", ondelete="CASCADE"),
primary_key=True,
),
Index("idx_service_document_templates_template", "document_template_id"),
)
Loading
Loading