Add PostgreSQL database support with Alembic migrations
Backend: - Add PostgreSQL service to docker-compose with health checks - Add SQLAlchemy async models for all entities (Agency, User, Campaign, Proof, ProofVersion, FlaggedItem, ResolvedItem, ErrorItem) - Add Alembic migration framework with initial schema migration - Add repository layer for CRUD operations - Add REST API endpoints for campaigns, proofs, and audit items - Add file storage service for proof uploads - Update WebSocket handler to optionally persist analysis results Frontend: - Add apiService.ts for REST API communication - Update geminiService.ts to support database persistence options Deployment: - Update deploy.sh to handle database migrations (6-step process) - Update Dockerfile to include alembic configuration - Add PostgreSQL environment variables to .env templates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
321a9ca820
commit
99af0164e6
26 changed files with 2534 additions and 44 deletions
|
|
@ -14,6 +14,15 @@ COMPOSE_PROJECT_NAME=modcomms-prod
|
|||
# Examples: 8000 for prod, 8001 for dev
|
||||
BACKEND_PORT=8000
|
||||
|
||||
# PostgreSQL port (must be unique per instance)
|
||||
# Examples: 5432 for prod, 5433 for dev
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# PostgreSQL credentials (change in production!)
|
||||
POSTGRES_USER=modcomms
|
||||
POSTGRES_PASSWORD=change_this_in_production
|
||||
POSTGRES_DB=modcomms
|
||||
|
||||
# Frontend deployment directory (Apache document root)
|
||||
# Examples: /var/www/modcomms-prod, /var/www/modcomms-dev
|
||||
FRONTEND_DEPLOY_DIR=/var/www/html/modcomms
|
||||
|
|
|
|||
|
|
@ -21,3 +21,11 @@ AZURE_CLIENT_ID=your_azure_client_id_here
|
|||
|
||||
# Development only - set to "true" to disable authentication (NOT for production)
|
||||
DISABLE_AUTH=false
|
||||
|
||||
# Database Configuration (PostgreSQL)
|
||||
# Format: postgresql+asyncpg://user:password@host:port/database
|
||||
DATABASE_URL=postgresql+asyncpg://modcomms:modcomms_dev@localhost:5432/modcomms
|
||||
|
||||
# File Storage Path (for uploaded proofs)
|
||||
# Defaults to ../storage relative to backend/
|
||||
# FILE_STORAGE_PATH=/path/to/storage
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
# Copy application code
|
||||
COPY backend/app/ ./app/
|
||||
|
||||
# Copy alembic configuration for migrations
|
||||
COPY backend/alembic.ini .
|
||||
COPY backend/alembic/ ./alembic/
|
||||
|
||||
# Copy reference docs into the image
|
||||
COPY reference_docs/ ./reference_docs/
|
||||
|
||||
|
|
|
|||
42
backend/alembic.ini
Normal file
42
backend/alembic.ini
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
74
backend/alembic/env.py
Normal file
74
backend/alembic/env.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.config import settings
|
||||
from app.models.database import Base
|
||||
from app.models.models import (
|
||||
Agency, User, Campaign, Proof, ProofVersion,
|
||||
FlaggedItem, ResolvedItem, ErrorItem, DropdownOption
|
||||
)
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url():
|
||||
return settings.DATABASE_URL
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = get_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in 'online' mode with async engine."""
|
||||
configuration = config.get_section(config.config_ini_section)
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
160
backend/alembic/versions/001_initial_schema.py
Normal file
160
backend/alembic/versions/001_initial_schema.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Initial schema
|
||||
|
||||
Revision ID: 001_initial
|
||||
Revises:
|
||||
Create Date: 2024-12-16
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001_initial'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create agencies table
|
||||
op.create_table(
|
||||
'agencies',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('name', sa.String(255), nullable=False, unique=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Create users table
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('azure_ad_oid', sa.String(255), nullable=False, unique=True),
|
||||
sa.Column('email', sa.String(255), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('role', sa.String(50), nullable=False, server_default='basic_user'),
|
||||
sa.Column('agency_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agencies.id'), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Create campaigns table
|
||||
op.create_table(
|
||||
'campaigns',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('workfront_id', sa.String(100), nullable=True),
|
||||
sa.Column('client_lead', sa.String(255), nullable=True),
|
||||
sa.Column('agency_lead', sa.String(255), nullable=True),
|
||||
sa.Column('brand_guidelines', sa.String(50), nullable=True),
|
||||
sa.Column('status', sa.String(50), server_default='In Progress'),
|
||||
sa.Column('agency_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agencies.id'), nullable=True),
|
||||
sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Create proofs table
|
||||
op.create_table(
|
||||
'proofs',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('campaign_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('campaigns.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('proof_name', sa.String(255), nullable=False),
|
||||
sa.Column('channel', sa.String(100), nullable=True),
|
||||
sa.Column('sub_channel', sa.String(100), nullable=True),
|
||||
sa.Column('proof_type', sa.String(100), nullable=True),
|
||||
sa.Column('workfront_id', sa.String(100), nullable=True),
|
||||
sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('campaign_id', 'proof_name', name='uq_campaign_proof_name'),
|
||||
)
|
||||
|
||||
# Create proof_versions table
|
||||
op.create_table(
|
||||
'proof_versions',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('proof_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('proofs.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('version', sa.Integer, nullable=False),
|
||||
sa.Column('file_storage_key', sa.String(500), nullable=True),
|
||||
sa.Column('thumbnail_url', sa.Text, nullable=True),
|
||||
sa.Column('agent_review', postgresql.JSONB, nullable=True),
|
||||
sa.Column('overall_status', sa.String(50), nullable=True),
|
||||
sa.Column('workfront_id', sa.String(100), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('proof_id', 'version', name='uq_proof_version'),
|
||||
)
|
||||
|
||||
# Create flagged_items table
|
||||
op.create_table(
|
||||
'flagged_items',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('proof_version_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('proof_versions.id'), nullable=False),
|
||||
sa.Column('agent_flagged', sa.String(100), nullable=False),
|
||||
sa.Column('comments', sa.Text, nullable=True),
|
||||
sa.Column('submitter_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Create resolved_items table
|
||||
op.create_table(
|
||||
'resolved_items',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('proof_version_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('proof_versions.id'), nullable=False),
|
||||
sa.Column('agent', sa.String(100), nullable=False),
|
||||
sa.Column('issue', sa.Text, nullable=True),
|
||||
sa.Column('resolution', sa.Text, nullable=True),
|
||||
sa.Column('submitter_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Create error_items table
|
||||
op.create_table(
|
||||
'error_items',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('proof_version_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('proof_versions.id'), nullable=False),
|
||||
sa.Column('error_summary', sa.Text, nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Create dropdown_options table for configurable options
|
||||
op.create_table(
|
||||
'dropdown_options',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('option_type', sa.String(50), nullable=False),
|
||||
sa.Column('parent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('dropdown_options.id'), nullable=True),
|
||||
sa.Column('value', sa.String(255), nullable=False),
|
||||
sa.Column('display_order', sa.Integer, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# Create indexes for common queries
|
||||
op.create_index('idx_campaigns_agency', 'campaigns', ['agency_id'])
|
||||
op.create_index('idx_proofs_campaign', 'proofs', ['campaign_id'])
|
||||
op.create_index('idx_proof_versions_proof', 'proof_versions', ['proof_id'])
|
||||
op.create_index('idx_proof_versions_status', 'proof_versions', ['overall_status'])
|
||||
op.create_index('idx_users_agency', 'users', ['agency_id'])
|
||||
op.create_index('idx_flagged_items_version', 'flagged_items', ['proof_version_id'])
|
||||
op.create_index('idx_resolved_items_version', 'resolved_items', ['proof_version_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes
|
||||
op.drop_index('idx_resolved_items_version')
|
||||
op.drop_index('idx_flagged_items_version')
|
||||
op.drop_index('idx_users_agency')
|
||||
op.drop_index('idx_proof_versions_status')
|
||||
op.drop_index('idx_proof_versions_proof')
|
||||
op.drop_index('idx_proofs_campaign')
|
||||
op.drop_index('idx_campaigns_agency')
|
||||
|
||||
# Drop tables in reverse order
|
||||
op.drop_table('dropdown_options')
|
||||
op.drop_table('error_items')
|
||||
op.drop_table('resolved_items')
|
||||
op.drop_table('flagged_items')
|
||||
op.drop_table('proof_versions')
|
||||
op.drop_table('proofs')
|
||||
op.drop_table('campaigns')
|
||||
op.drop_table('users')
|
||||
op.drop_table('agencies')
|
||||
3
backend/app/api/__init__.py
Normal file
3
backend/app/api/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.api.routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
488
backend/app/api/routes.py
Normal file
488
backend/app/api/routes.py
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
"""REST API routes for campaigns, proofs, and audit items."""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.schemas import (
|
||||
CampaignCreate,
|
||||
CampaignUpdate,
|
||||
CampaignResponse,
|
||||
ProofCreate,
|
||||
ProofResponse,
|
||||
ProofVersionResponse,
|
||||
FlaggedItemCreate,
|
||||
FlaggedItemResponse,
|
||||
ResolvedItemCreate,
|
||||
ResolvedItemResponse,
|
||||
ErrorItemResponse,
|
||||
AnalyticsResponse,
|
||||
DropdownOptionsResponse,
|
||||
UserResponse,
|
||||
)
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.models.database import get_db
|
||||
from app.repositories import (
|
||||
CampaignRepository,
|
||||
ProofRepository,
|
||||
UserRepository,
|
||||
AuditRepository,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Helper to get user from DB based on Azure claims
|
||||
async def get_db_user(
|
||||
session: AsyncSession,
|
||||
user_claims: dict,
|
||||
) -> Optional[uuid.UUID]:
|
||||
"""Get or create user from Azure AD claims and return user ID."""
|
||||
user_repo = UserRepository(session)
|
||||
azure_oid = user_claims.get("oid") or user_claims.get("sub")
|
||||
if not azure_oid:
|
||||
return None
|
||||
|
||||
user = await user_repo.get_or_create_from_azure(
|
||||
azure_ad_oid=azure_oid,
|
||||
email=user_claims.get("email", user_claims.get("preferred_username", "")),
|
||||
name=user_claims.get("name", "Unknown"),
|
||||
)
|
||||
return user.id
|
||||
|
||||
|
||||
# Campaign endpoints
|
||||
@router.get("/campaigns", response_model=list[CampaignResponse])
|
||||
async def list_campaigns(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List all campaigns."""
|
||||
repo = CampaignRepository(db)
|
||||
campaigns_with_counts = await repo.get_with_proof_counts()
|
||||
|
||||
return [
|
||||
CampaignResponse(
|
||||
id=item["campaign"].id,
|
||||
name=item["campaign"].name,
|
||||
workfront_id=item["campaign"].workfront_id,
|
||||
client_lead=item["campaign"].client_lead,
|
||||
agency_lead=item["campaign"].agency_lead,
|
||||
brand_guidelines=item["campaign"].brand_guidelines,
|
||||
status=item["campaign"].status,
|
||||
agency=item["campaign"].agency.name if item["campaign"].agency else None,
|
||||
created_at=item["campaign"].created_at,
|
||||
updated_at=item["campaign"].updated_at,
|
||||
proofs=item["proof_count"],
|
||||
)
|
||||
for item in campaigns_with_counts
|
||||
]
|
||||
|
||||
|
||||
@router.post("/campaigns", response_model=CampaignResponse, status_code=201)
|
||||
async def create_campaign(
|
||||
data: CampaignCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new campaign."""
|
||||
user_id = await get_db_user(db, user)
|
||||
repo = CampaignRepository(db)
|
||||
|
||||
# Check if campaign name already exists
|
||||
existing = await repo.get_by_name(data.name)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Campaign with this name already exists")
|
||||
|
||||
campaign = await repo.create(
|
||||
name=data.name,
|
||||
workfront_id=data.workfront_id,
|
||||
client_lead=data.client_lead,
|
||||
agency_lead=data.agency_lead,
|
||||
brand_guidelines=data.brand_guidelines,
|
||||
created_by=user_id,
|
||||
)
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
workfront_id=campaign.workfront_id,
|
||||
client_lead=campaign.client_lead,
|
||||
agency_lead=campaign.agency_lead,
|
||||
brand_guidelines=campaign.brand_guidelines,
|
||||
status=campaign.status,
|
||||
agency=None,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
proofs=0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}", response_model=CampaignResponse)
|
||||
async def get_campaign(
|
||||
campaign_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a campaign by ID."""
|
||||
repo = CampaignRepository(db)
|
||||
campaign = await repo.get_by_id(campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
workfront_id=campaign.workfront_id,
|
||||
client_lead=campaign.client_lead,
|
||||
agency_lead=campaign.agency_lead,
|
||||
brand_guidelines=campaign.brand_guidelines,
|
||||
status=campaign.status,
|
||||
agency=campaign.agency.name if campaign.agency else None,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
proofs=len(campaign.proofs),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/campaigns/{campaign_id}", response_model=CampaignResponse)
|
||||
async def update_campaign(
|
||||
campaign_id: uuid.UUID,
|
||||
data: CampaignUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update a campaign."""
|
||||
repo = CampaignRepository(db)
|
||||
campaign = await repo.update(
|
||||
campaign_id,
|
||||
name=data.name,
|
||||
workfront_id=data.workfront_id,
|
||||
client_lead=data.client_lead,
|
||||
agency_lead=data.agency_lead,
|
||||
brand_guidelines=data.brand_guidelines,
|
||||
status=data.status,
|
||||
)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
workfront_id=campaign.workfront_id,
|
||||
client_lead=campaign.client_lead,
|
||||
agency_lead=campaign.agency_lead,
|
||||
brand_guidelines=campaign.brand_guidelines,
|
||||
status=campaign.status,
|
||||
agency=campaign.agency.name if campaign.agency else None,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
proofs=len(campaign.proofs) if campaign.proofs else 0,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/campaigns/{campaign_id}", status_code=204)
|
||||
async def delete_campaign(
|
||||
campaign_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a campaign."""
|
||||
repo = CampaignRepository(db)
|
||||
success = await repo.delete(campaign_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
|
||||
# Proof endpoints
|
||||
@router.get("/campaigns/{campaign_id}/proofs", response_model=list[ProofResponse])
|
||||
async def list_proofs(
|
||||
campaign_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all proofs for a campaign."""
|
||||
repo = ProofRepository(db)
|
||||
proofs = await repo.list_by_campaign(campaign_id)
|
||||
|
||||
return [
|
||||
ProofResponse(
|
||||
id=proof.id,
|
||||
proof_name=proof.proof_name,
|
||||
channel=proof.channel,
|
||||
sub_channel=proof.sub_channel,
|
||||
proof_type=proof.proof_type,
|
||||
workfront_id=proof.workfront_id,
|
||||
created_at=proof.created_at,
|
||||
versions=[
|
||||
ProofVersionResponse(
|
||||
id=v.id,
|
||||
version=v.version,
|
||||
file_storage_key=v.file_storage_key,
|
||||
thumbnail_url=v.thumbnail_url,
|
||||
agent_review=v.agent_review,
|
||||
overall_status=v.overall_status,
|
||||
workfront_id=v.workfront_id,
|
||||
created_at=v.created_at,
|
||||
)
|
||||
for v in proof.versions
|
||||
],
|
||||
)
|
||||
for proof in proofs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/proofs/{proof_id}", response_model=ProofResponse)
|
||||
async def get_proof(
|
||||
proof_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a proof by ID."""
|
||||
repo = ProofRepository(db)
|
||||
proof = await repo.get_by_id(proof_id)
|
||||
if not proof:
|
||||
raise HTTPException(status_code=404, detail="Proof not found")
|
||||
|
||||
return ProofResponse(
|
||||
id=proof.id,
|
||||
proof_name=proof.proof_name,
|
||||
channel=proof.channel,
|
||||
sub_channel=proof.sub_channel,
|
||||
proof_type=proof.proof_type,
|
||||
workfront_id=proof.workfront_id,
|
||||
created_at=proof.created_at,
|
||||
versions=[
|
||||
ProofVersionResponse(
|
||||
id=v.id,
|
||||
version=v.version,
|
||||
file_storage_key=v.file_storage_key,
|
||||
thumbnail_url=v.thumbnail_url,
|
||||
agent_review=v.agent_review,
|
||||
overall_status=v.overall_status,
|
||||
workfront_id=v.workfront_id,
|
||||
created_at=v.created_at,
|
||||
)
|
||||
for v in proof.versions
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/proofs/{proof_id}", status_code=204)
|
||||
async def delete_proof(
|
||||
proof_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a proof."""
|
||||
repo = ProofRepository(db)
|
||||
success = await repo.delete(proof_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Proof not found")
|
||||
|
||||
|
||||
# Audit endpoints
|
||||
@router.post("/proofs/{proof_id}/versions/{version}/flag", response_model=FlaggedItemResponse, status_code=201)
|
||||
async def flag_proof_version(
|
||||
proof_id: uuid.UUID,
|
||||
version: int,
|
||||
data: FlaggedItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Flag an issue on a proof version."""
|
||||
user_id = await get_db_user(db, user)
|
||||
proof_repo = ProofRepository(db)
|
||||
audit_repo = AuditRepository(db)
|
||||
|
||||
# Get the proof version
|
||||
proof_version = await proof_repo.get_version(proof_id, version)
|
||||
if not proof_version:
|
||||
raise HTTPException(status_code=404, detail="Proof version not found")
|
||||
|
||||
flagged = await audit_repo.create_flagged_item(
|
||||
proof_version_id=proof_version.id,
|
||||
agent_flagged=data.agent_flagged,
|
||||
comments=data.comments,
|
||||
submitter_id=user_id,
|
||||
)
|
||||
|
||||
# Get related data for response
|
||||
proof = await proof_repo.get_by_id(proof_id)
|
||||
|
||||
return FlaggedItemResponse(
|
||||
id=flagged.id,
|
||||
proof_version_id=flagged.proof_version_id,
|
||||
agent_flagged=flagged.agent_flagged,
|
||||
comments=flagged.comments,
|
||||
submitter_name=user.get("name"),
|
||||
submitter_agency=None,
|
||||
campaign_name=proof.campaign.name if proof and proof.campaign else None,
|
||||
proof_name=proof.proof_name if proof else None,
|
||||
version=version,
|
||||
created_at=flagged.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/proofs/{proof_id}/versions/{version}/resolve", response_model=ResolvedItemResponse, status_code=201)
|
||||
async def resolve_proof_version(
|
||||
proof_id: uuid.UUID,
|
||||
version: int,
|
||||
data: ResolvedItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Resolve an issue on a proof version."""
|
||||
user_id = await get_db_user(db, user)
|
||||
proof_repo = ProofRepository(db)
|
||||
audit_repo = AuditRepository(db)
|
||||
|
||||
# Get the proof version
|
||||
proof_version = await proof_repo.get_version(proof_id, version)
|
||||
if not proof_version:
|
||||
raise HTTPException(status_code=404, detail="Proof version not found")
|
||||
|
||||
resolved = await audit_repo.create_resolved_item(
|
||||
proof_version_id=proof_version.id,
|
||||
agent=data.agent,
|
||||
issue=data.issue,
|
||||
resolution=data.resolution,
|
||||
submitter_id=user_id,
|
||||
)
|
||||
|
||||
# Get related data for response
|
||||
proof = await proof_repo.get_by_id(proof_id)
|
||||
|
||||
return ResolvedItemResponse(
|
||||
id=resolved.id,
|
||||
proof_version_id=resolved.proof_version_id,
|
||||
agent=resolved.agent,
|
||||
issue=resolved.issue,
|
||||
resolution=resolved.resolution,
|
||||
submitter_name=user.get("name"),
|
||||
submitter_agency=None,
|
||||
campaign_name=proof.campaign.name if proof and proof.campaign else None,
|
||||
proof_name=proof.proof_name if proof else None,
|
||||
version=version,
|
||||
created_at=resolved.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit/flagged", response_model=list[FlaggedItemResponse])
|
||||
async def list_flagged_items(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List all flagged items."""
|
||||
audit_repo = AuditRepository(db)
|
||||
flagged_items = await audit_repo.get_flagged_items(limit=limit, offset=offset)
|
||||
|
||||
return [
|
||||
FlaggedItemResponse(
|
||||
id=item.id,
|
||||
proof_version_id=item.proof_version_id,
|
||||
agent_flagged=item.agent_flagged,
|
||||
comments=item.comments,
|
||||
submitter_name=item.submitter.name if item.submitter else None,
|
||||
submitter_agency=item.submitter.agency.name if item.submitter and item.submitter.agency else None,
|
||||
campaign_name=item.proof_version.proof.campaign.name if item.proof_version and item.proof_version.proof and item.proof_version.proof.campaign else None,
|
||||
proof_name=item.proof_version.proof.proof_name if item.proof_version and item.proof_version.proof else None,
|
||||
version=item.proof_version.version if item.proof_version else None,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
for item in flagged_items
|
||||
]
|
||||
|
||||
|
||||
@router.get("/audit/resolved", response_model=list[ResolvedItemResponse])
|
||||
async def list_resolved_items(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List all resolved items."""
|
||||
audit_repo = AuditRepository(db)
|
||||
resolved_items = await audit_repo.get_resolved_items(limit=limit, offset=offset)
|
||||
|
||||
return [
|
||||
ResolvedItemResponse(
|
||||
id=item.id,
|
||||
proof_version_id=item.proof_version_id,
|
||||
agent=item.agent,
|
||||
issue=item.issue,
|
||||
resolution=item.resolution,
|
||||
submitter_name=item.submitter.name if item.submitter else None,
|
||||
submitter_agency=item.submitter.agency.name if item.submitter and item.submitter.agency else None,
|
||||
campaign_name=item.proof_version.proof.campaign.name if item.proof_version and item.proof_version.proof and item.proof_version.proof.campaign else None,
|
||||
proof_name=item.proof_version.proof.proof_name if item.proof_version and item.proof_version.proof else None,
|
||||
version=item.proof_version.version if item.proof_version else None,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
for item in resolved_items
|
||||
]
|
||||
|
||||
|
||||
@router.get("/audit/errors", response_model=list[ErrorItemResponse])
|
||||
async def list_error_items(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List all error items."""
|
||||
audit_repo = AuditRepository(db)
|
||||
error_items = await audit_repo.get_error_items(limit=limit, offset=offset)
|
||||
|
||||
return [
|
||||
ErrorItemResponse(
|
||||
id=item.id,
|
||||
proof_version_id=item.proof_version_id,
|
||||
error_summary=item.error_summary,
|
||||
campaign_name=item.proof_version.proof.campaign.name if item.proof_version and item.proof_version.proof and item.proof_version.proof.campaign else None,
|
||||
proof_name=item.proof_version.proof.proof_name if item.proof_version and item.proof_version.proof else None,
|
||||
version=item.proof_version.version if item.proof_version else None,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
for item in error_items
|
||||
]
|
||||
|
||||
|
||||
# Analytics endpoint
|
||||
@router.get("/analytics", response_model=AnalyticsResponse)
|
||||
async def get_analytics(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get analytics data."""
|
||||
repo = CampaignRepository(db)
|
||||
analytics = await repo.get_analytics()
|
||||
return AnalyticsResponse(**analytics)
|
||||
|
||||
|
||||
# Users endpoint (admin only)
|
||||
@router.get("/users", response_model=list[UserResponse])
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List all users (admin only)."""
|
||||
user_repo = UserRepository(db)
|
||||
users = await user_repo.list_all()
|
||||
|
||||
return [
|
||||
UserResponse(
|
||||
id=u.id,
|
||||
email=u.email,
|
||||
name=u.name,
|
||||
role=u.role,
|
||||
agency=u.agency.name if u.agency else None,
|
||||
created_at=u.created_at,
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
165
backend/app/api/schemas.py
Normal file
165
backend/app/api/schemas.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""Pydantic schemas for API request/response validation."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# Campaign schemas
|
||||
class CampaignCreate(BaseModel):
|
||||
name: str
|
||||
workfront_id: Optional[str] = None
|
||||
client_lead: Optional[str] = None
|
||||
agency_lead: Optional[str] = None
|
||||
brand_guidelines: Optional[str] = None
|
||||
|
||||
|
||||
class CampaignUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
workfront_id: Optional[str] = None
|
||||
client_lead: Optional[str] = None
|
||||
agency_lead: Optional[str] = None
|
||||
brand_guidelines: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class CampaignResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
workfront_id: Optional[str]
|
||||
client_lead: Optional[str]
|
||||
agency_lead: Optional[str]
|
||||
brand_guidelines: Optional[str]
|
||||
status: str
|
||||
agency: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
proofs: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Proof schemas
|
||||
class ProofCreate(BaseModel):
|
||||
proof_name: str
|
||||
channel: Optional[str] = None
|
||||
sub_channel: Optional[str] = None
|
||||
proof_type: Optional[str] = None
|
||||
|
||||
|
||||
class ProofVersionResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
version: int
|
||||
file_storage_key: Optional[str]
|
||||
thumbnail_url: Optional[str]
|
||||
agent_review: Optional[dict]
|
||||
overall_status: Optional[str]
|
||||
workfront_id: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProofResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
proof_name: str
|
||||
channel: Optional[str]
|
||||
sub_channel: Optional[str]
|
||||
proof_type: Optional[str]
|
||||
workfront_id: Optional[str]
|
||||
created_at: datetime
|
||||
versions: list[ProofVersionResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Audit schemas
|
||||
class FlaggedItemCreate(BaseModel):
|
||||
proof_version_id: uuid.UUID
|
||||
agent_flagged: str
|
||||
comments: Optional[str] = None
|
||||
|
||||
|
||||
class FlaggedItemResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
proof_version_id: uuid.UUID
|
||||
agent_flagged: str
|
||||
comments: Optional[str]
|
||||
submitter_name: Optional[str]
|
||||
submitter_agency: Optional[str]
|
||||
campaign_name: Optional[str]
|
||||
proof_name: Optional[str]
|
||||
version: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResolvedItemCreate(BaseModel):
|
||||
proof_version_id: uuid.UUID
|
||||
agent: str
|
||||
issue: Optional[str] = None
|
||||
resolution: Optional[str] = None
|
||||
|
||||
|
||||
class ResolvedItemResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
proof_version_id: uuid.UUID
|
||||
agent: str
|
||||
issue: Optional[str]
|
||||
resolution: Optional[str]
|
||||
submitter_name: Optional[str]
|
||||
submitter_agency: Optional[str]
|
||||
campaign_name: Optional[str]
|
||||
proof_name: Optional[str]
|
||||
version: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ErrorItemResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
proof_version_id: uuid.UUID
|
||||
error_summary: Optional[str]
|
||||
campaign_name: Optional[str]
|
||||
proof_name: Optional[str]
|
||||
version: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Analytics schemas
|
||||
class AnalyticsResponse(BaseModel):
|
||||
total_reviews: int
|
||||
passed: int
|
||||
failed: int
|
||||
errors: int
|
||||
legal_review: int
|
||||
|
||||
|
||||
# Dropdown options schemas
|
||||
class DropdownOptionsResponse(BaseModel):
|
||||
campaigns: list[str]
|
||||
channels: dict[str, dict[str, list[str]]]
|
||||
|
||||
|
||||
# User schemas
|
||||
class UserResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
agency: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -25,6 +25,16 @@ class Settings:
|
|||
# Auth bypass for development (set to "true" to skip auth)
|
||||
DISABLE_AUTH: bool = os.getenv("DISABLE_AUTH", "false").lower() == "true"
|
||||
|
||||
# Database configuration
|
||||
DATABASE_URL: str = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://modcomms:modcomms_dev@localhost:5432/modcomms"
|
||||
)
|
||||
|
||||
# File storage path for uploaded proofs
|
||||
_default_storage = Path(__file__).parent.parent.parent / "storage"
|
||||
FILE_STORAGE_PATH: str = os.getenv("FILE_STORAGE_PATH", str(_default_storage))
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate required settings are present."""
|
||||
if not self.GEMINI_API_KEY:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from app.config import settings
|
||||
from app.services.auth_service import verify_access_token
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.models.database import init_db, close_db
|
||||
from app.api import router as api_router
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
|
|
@ -40,6 +42,15 @@ async def lifespan(app: FastAPI):
|
|||
# Validate settings
|
||||
settings.validate()
|
||||
|
||||
# Initialize database
|
||||
print("Initializing database connection...")
|
||||
try:
|
||||
await init_db()
|
||||
print("Database initialized successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Database initialization failed (may not be available): {e}")
|
||||
print(f"Warning: Database not available - running in stateless mode")
|
||||
|
||||
# Initialize services
|
||||
print("Loading reference documents...")
|
||||
reference_docs = ReferenceDocsService(settings.REFERENCE_DOCS_PATH)
|
||||
|
|
@ -59,8 +70,9 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
yield
|
||||
|
||||
# Cleanup on shutdown (if needed)
|
||||
# Cleanup on shutdown
|
||||
print("Shutting down...")
|
||||
await close_db()
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
|
|
@ -80,6 +92,9 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
|
|
|
|||
50
backend/app/models/database.py
Normal file
50
backend/app/models/database.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
pass
|
||||
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""FastAPI dependency to get database session."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialize database tables."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close database connections."""
|
||||
await engine.dispose()
|
||||
175
backend/app/models/models.py
Normal file
175
backend/app/models/models.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.database import Base
|
||||
|
||||
|
||||
class Agency(Base):
|
||||
"""Agency/organization that users belong to."""
|
||||
__tablename__ = "agencies"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="agency")
|
||||
campaigns: Mapped[list["Campaign"]] = relationship("Campaign", back_populates="agency")
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User account linked to Azure AD."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
azure_ad_oid: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="basic_user")
|
||||
agency_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("agencies.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
agency: Mapped[Optional["Agency"]] = relationship("Agency", back_populates="users")
|
||||
campaigns: Mapped[list["Campaign"]] = relationship("Campaign", back_populates="created_by_user")
|
||||
proofs: Mapped[list["Proof"]] = relationship("Proof", back_populates="created_by_user")
|
||||
flagged_items: Mapped[list["FlaggedItem"]] = relationship("FlaggedItem", back_populates="submitter")
|
||||
resolved_items: Mapped[list["ResolvedItem"]] = relationship("ResolvedItem", back_populates="submitter")
|
||||
|
||||
|
||||
class Campaign(Base):
|
||||
"""Marketing campaign containing proofs."""
|
||||
__tablename__ = "campaigns"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
workfront_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
client_lead: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
agency_lead: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
brand_guidelines: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(50), default="In Progress")
|
||||
agency_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("agencies.id"), nullable=True)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
agency: Mapped[Optional["Agency"]] = relationship("Agency", back_populates="campaigns")
|
||||
created_by_user: Mapped[Optional["User"]] = relationship("User", back_populates="campaigns")
|
||||
proofs: Mapped[list["Proof"]] = relationship("Proof", back_populates="campaign", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Proof(Base):
|
||||
"""Marketing proof/asset to be reviewed."""
|
||||
__tablename__ = "proofs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
campaign_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False)
|
||||
proof_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
channel: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
sub_channel: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
proof_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
workfront_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="proofs")
|
||||
created_by_user: Mapped[Optional["User"]] = relationship("User", back_populates="proofs")
|
||||
versions: Mapped[list["ProofVersion"]] = relationship("ProofVersion", back_populates="proof", cascade="all, delete-orphan", order_by="desc(ProofVersion.version)")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("campaign_id", "proof_name", name="uq_campaign_proof_name"),
|
||||
)
|
||||
|
||||
|
||||
class ProofVersion(Base):
|
||||
"""Version of a proof with analysis results."""
|
||||
__tablename__ = "proof_versions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
proof_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("proofs.id", ondelete="CASCADE"), nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
file_storage_key: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
thumbnail_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
agent_review: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
overall_status: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
workfront_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
proof: Mapped["Proof"] = relationship("Proof", back_populates="versions")
|
||||
flagged_items: Mapped[list["FlaggedItem"]] = relationship("FlaggedItem", back_populates="proof_version")
|
||||
resolved_items: Mapped[list["ResolvedItem"]] = relationship("ResolvedItem", back_populates="proof_version")
|
||||
error_items: Mapped[list["ErrorItem"]] = relationship("ErrorItem", back_populates="proof_version")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("proof_id", "version", name="uq_proof_version"),
|
||||
)
|
||||
|
||||
|
||||
class FlaggedItem(Base):
|
||||
"""Record of a flagged issue on a proof version."""
|
||||
__tablename__ = "flagged_items"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
proof_version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("proof_versions.id"), nullable=False)
|
||||
agent_flagged: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
comments: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
submitter_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
proof_version: Mapped["ProofVersion"] = relationship("ProofVersion", back_populates="flagged_items")
|
||||
submitter: Mapped[Optional["User"]] = relationship("User", back_populates="flagged_items")
|
||||
|
||||
|
||||
class ResolvedItem(Base):
|
||||
"""Record of a resolved issue on a proof version."""
|
||||
__tablename__ = "resolved_items"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
proof_version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("proof_versions.id"), nullable=False)
|
||||
agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
issue: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
resolution: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
submitter_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
proof_version: Mapped["ProofVersion"] = relationship("ProofVersion", back_populates="resolved_items")
|
||||
submitter: Mapped[Optional["User"]] = relationship("User", back_populates="resolved_items")
|
||||
|
||||
|
||||
class ErrorItem(Base):
|
||||
"""Record of an analysis error on a proof version."""
|
||||
__tablename__ = "error_items"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
proof_version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("proof_versions.id"), nullable=False)
|
||||
error_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
proof_version: Mapped["ProofVersion"] = relationship("ProofVersion", back_populates="error_items")
|
||||
|
||||
|
||||
class DropdownOption(Base):
|
||||
"""Configurable dropdown options for channels/sub-channels/proof types."""
|
||||
__tablename__ = "dropdown_options"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
option_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'channel', 'sub_channel', 'proof_type'
|
||||
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("dropdown_options.id"), nullable=True)
|
||||
value: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
display_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Self-referential relationship for hierarchy
|
||||
parent: Mapped[Optional["DropdownOption"]] = relationship("DropdownOption", remote_side=[id], back_populates="children")
|
||||
children: Mapped[list["DropdownOption"]] = relationship("DropdownOption", back_populates="parent")
|
||||
11
backend/app/repositories/__init__.py
Normal file
11
backend/app/repositories/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from app.repositories.campaign_repository import CampaignRepository
|
||||
from app.repositories.proof_repository import ProofRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.repositories.audit_repository import AuditRepository
|
||||
|
||||
__all__ = [
|
||||
"CampaignRepository",
|
||||
"ProofRepository",
|
||||
"UserRepository",
|
||||
"AuditRepository",
|
||||
]
|
||||
163
backend/app/repositories/audit_repository.py
Normal file
163
backend/app/repositories/audit_repository.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.models import FlaggedItem, ResolvedItem, ErrorItem, ProofVersion, Proof, Campaign
|
||||
|
||||
|
||||
class AuditRepository:
|
||||
"""Repository for audit-related database operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
# Flagged Items
|
||||
async def create_flagged_item(
|
||||
self,
|
||||
proof_version_id: uuid.UUID,
|
||||
agent_flagged: str,
|
||||
comments: Optional[str] = None,
|
||||
submitter_id: Optional[uuid.UUID] = None,
|
||||
) -> FlaggedItem:
|
||||
"""Create a new flagged item."""
|
||||
flagged = FlaggedItem(
|
||||
proof_version_id=proof_version_id,
|
||||
agent_flagged=agent_flagged,
|
||||
comments=comments,
|
||||
submitter_id=submitter_id,
|
||||
)
|
||||
self.session.add(flagged)
|
||||
await self.session.flush()
|
||||
return flagged
|
||||
|
||||
async def get_flagged_items(
|
||||
self,
|
||||
agency_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[FlaggedItem]:
|
||||
"""Get all flagged items, optionally filtered by agency."""
|
||||
query = (
|
||||
select(FlaggedItem)
|
||||
.options(
|
||||
selectinload(FlaggedItem.proof_version)
|
||||
.selectinload(ProofVersion.proof)
|
||||
.selectinload(Proof.campaign),
|
||||
selectinload(FlaggedItem.submitter),
|
||||
)
|
||||
.join(ProofVersion)
|
||||
.join(Proof)
|
||||
.join(Campaign)
|
||||
)
|
||||
if agency_id:
|
||||
query = query.where(Campaign.agency_id == agency_id)
|
||||
query = query.order_by(FlaggedItem.created_at.desc()).limit(limit).offset(offset)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
# Resolved Items
|
||||
async def create_resolved_item(
|
||||
self,
|
||||
proof_version_id: uuid.UUID,
|
||||
agent: str,
|
||||
issue: Optional[str] = None,
|
||||
resolution: Optional[str] = None,
|
||||
submitter_id: Optional[uuid.UUID] = None,
|
||||
) -> ResolvedItem:
|
||||
"""Create a new resolved item."""
|
||||
resolved = ResolvedItem(
|
||||
proof_version_id=proof_version_id,
|
||||
agent=agent,
|
||||
issue=issue,
|
||||
resolution=resolution,
|
||||
submitter_id=submitter_id,
|
||||
)
|
||||
self.session.add(resolved)
|
||||
await self.session.flush()
|
||||
return resolved
|
||||
|
||||
async def get_resolved_items(
|
||||
self,
|
||||
agency_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[ResolvedItem]:
|
||||
"""Get all resolved items, optionally filtered by agency."""
|
||||
query = (
|
||||
select(ResolvedItem)
|
||||
.options(
|
||||
selectinload(ResolvedItem.proof_version)
|
||||
.selectinload(ProofVersion.proof)
|
||||
.selectinload(Proof.campaign),
|
||||
selectinload(ResolvedItem.submitter),
|
||||
)
|
||||
.join(ProofVersion)
|
||||
.join(Proof)
|
||||
.join(Campaign)
|
||||
)
|
||||
if agency_id:
|
||||
query = query.where(Campaign.agency_id == agency_id)
|
||||
query = query.order_by(ResolvedItem.created_at.desc()).limit(limit).offset(offset)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
# Error Items
|
||||
async def create_error_item(
|
||||
self,
|
||||
proof_version_id: uuid.UUID,
|
||||
error_summary: Optional[str] = None,
|
||||
) -> ErrorItem:
|
||||
"""Create a new error item."""
|
||||
error = ErrorItem(
|
||||
proof_version_id=proof_version_id,
|
||||
error_summary=error_summary,
|
||||
)
|
||||
self.session.add(error)
|
||||
await self.session.flush()
|
||||
return error
|
||||
|
||||
async def get_error_items(
|
||||
self,
|
||||
agency_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[ErrorItem]:
|
||||
"""Get all error items, optionally filtered by agency."""
|
||||
query = (
|
||||
select(ErrorItem)
|
||||
.options(
|
||||
selectinload(ErrorItem.proof_version)
|
||||
.selectinload(ProofVersion.proof)
|
||||
.selectinload(Proof.campaign),
|
||||
)
|
||||
.join(ProofVersion)
|
||||
.join(Proof)
|
||||
.join(Campaign)
|
||||
)
|
||||
if agency_id:
|
||||
query = query.where(Campaign.agency_id == agency_id)
|
||||
query = query.order_by(ErrorItem.created_at.desc()).limit(limit).offset(offset)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_audit_log(
|
||||
self,
|
||||
agency_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 100,
|
||||
) -> dict:
|
||||
"""Get combined audit log with flagged, resolved, and error items."""
|
||||
flagged = await self.get_flagged_items(agency_id=agency_id, limit=limit)
|
||||
resolved = await self.get_resolved_items(agency_id=agency_id, limit=limit)
|
||||
errors = await self.get_error_items(agency_id=agency_id, limit=limit)
|
||||
|
||||
return {
|
||||
"flagged_items": flagged,
|
||||
"resolved_items": resolved,
|
||||
"error_items": errors,
|
||||
}
|
||||
165
backend/app/repositories/campaign_repository.py
Normal file
165
backend/app/repositories/campaign_repository.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.models import Campaign, Proof, ProofVersion
|
||||
|
||||
|
||||
class CampaignRepository:
|
||||
"""Repository for campaign-related database operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
name: str,
|
||||
workfront_id: Optional[str] = None,
|
||||
client_lead: Optional[str] = None,
|
||||
agency_lead: Optional[str] = None,
|
||||
brand_guidelines: Optional[str] = None,
|
||||
agency_id: Optional[uuid.UUID] = None,
|
||||
created_by: Optional[uuid.UUID] = None,
|
||||
) -> Campaign:
|
||||
"""Create a new campaign."""
|
||||
campaign = Campaign(
|
||||
name=name,
|
||||
workfront_id=workfront_id,
|
||||
client_lead=client_lead,
|
||||
agency_lead=agency_lead,
|
||||
brand_guidelines=brand_guidelines,
|
||||
agency_id=agency_id,
|
||||
created_by=created_by,
|
||||
)
|
||||
self.session.add(campaign)
|
||||
await self.session.flush()
|
||||
return campaign
|
||||
|
||||
async def get_by_id(self, campaign_id: uuid.UUID) -> Optional[Campaign]:
|
||||
"""Get campaign by ID with related data."""
|
||||
result = await self.session.execute(
|
||||
select(Campaign)
|
||||
.options(
|
||||
selectinload(Campaign.agency),
|
||||
selectinload(Campaign.created_by_user),
|
||||
selectinload(Campaign.proofs).selectinload(Proof.versions),
|
||||
)
|
||||
.where(Campaign.id == campaign_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str) -> Optional[Campaign]:
|
||||
"""Get campaign by name."""
|
||||
result = await self.session.execute(
|
||||
select(Campaign).where(Campaign.name == name)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_all(
|
||||
self,
|
||||
agency_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[Campaign]:
|
||||
"""List campaigns, optionally filtered by agency."""
|
||||
query = select(Campaign).options(
|
||||
selectinload(Campaign.agency),
|
||||
selectinload(Campaign.proofs),
|
||||
)
|
||||
if agency_id:
|
||||
query = query.where(Campaign.agency_id == agency_id)
|
||||
query = query.order_by(Campaign.updated_at.desc()).limit(limit).offset(offset)
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update(
|
||||
self,
|
||||
campaign_id: uuid.UUID,
|
||||
**kwargs,
|
||||
) -> Optional[Campaign]:
|
||||
"""Update campaign fields."""
|
||||
campaign = await self.get_by_id(campaign_id)
|
||||
if not campaign:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(campaign, key) and value is not None:
|
||||
setattr(campaign, key, value)
|
||||
|
||||
await self.session.flush()
|
||||
return campaign
|
||||
|
||||
async def delete(self, campaign_id: uuid.UUID) -> bool:
|
||||
"""Delete a campaign and all related proofs."""
|
||||
campaign = await self.get_by_id(campaign_id)
|
||||
if not campaign:
|
||||
return False
|
||||
|
||||
await self.session.delete(campaign)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
async def get_with_proof_counts(
|
||||
self,
|
||||
agency_id: Optional[uuid.UUID] = None,
|
||||
) -> list[dict]:
|
||||
"""Get campaigns with proof counts."""
|
||||
query = (
|
||||
select(
|
||||
Campaign,
|
||||
func.count(Proof.id).label("proof_count"),
|
||||
)
|
||||
.outerjoin(Proof)
|
||||
.group_by(Campaign.id)
|
||||
)
|
||||
if agency_id:
|
||||
query = query.where(Campaign.agency_id == agency_id)
|
||||
query = query.order_by(Campaign.updated_at.desc())
|
||||
|
||||
result = await self.session.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
campaigns_with_counts = []
|
||||
for row in rows:
|
||||
campaign = row[0]
|
||||
proof_count = row[1]
|
||||
campaigns_with_counts.append({
|
||||
"campaign": campaign,
|
||||
"proof_count": proof_count,
|
||||
})
|
||||
return campaigns_with_counts
|
||||
|
||||
async def get_analytics(
|
||||
self,
|
||||
agency_id: Optional[uuid.UUID] = None,
|
||||
) -> dict:
|
||||
"""Get analytics data for campaigns."""
|
||||
# Base query for proof versions with status counts
|
||||
query = (
|
||||
select(
|
||||
func.count(ProofVersion.id).label("total"),
|
||||
func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Passed").label("passed"),
|
||||
func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Failed").label("failed"),
|
||||
func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Analysis Error").label("errors"),
|
||||
func.count(ProofVersion.id).filter(ProofVersion.overall_status == "Requires Manual Legal Review").label("legal_review"),
|
||||
)
|
||||
.select_from(ProofVersion)
|
||||
.join(Proof)
|
||||
.join(Campaign)
|
||||
)
|
||||
if agency_id:
|
||||
query = query.where(Campaign.agency_id == agency_id)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
row = result.one()
|
||||
|
||||
return {
|
||||
"total_reviews": row.total,
|
||||
"passed": row.passed,
|
||||
"failed": row.failed,
|
||||
"errors": row.errors,
|
||||
"legal_review": row.legal_review,
|
||||
}
|
||||
219
backend/app/repositories/proof_repository.py
Normal file
219
backend/app/repositories/proof_repository.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.models import Proof, ProofVersion
|
||||
|
||||
|
||||
class ProofRepository:
|
||||
"""Repository for proof-related database operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
campaign_id: uuid.UUID,
|
||||
proof_name: str,
|
||||
channel: Optional[str] = None,
|
||||
sub_channel: Optional[str] = None,
|
||||
proof_type: Optional[str] = None,
|
||||
workfront_id: Optional[str] = None,
|
||||
created_by: Optional[uuid.UUID] = None,
|
||||
) -> Proof:
|
||||
"""Create a new proof."""
|
||||
proof = Proof(
|
||||
campaign_id=campaign_id,
|
||||
proof_name=proof_name,
|
||||
channel=channel,
|
||||
sub_channel=sub_channel,
|
||||
proof_type=proof_type,
|
||||
workfront_id=workfront_id,
|
||||
created_by=created_by,
|
||||
)
|
||||
self.session.add(proof)
|
||||
await self.session.flush()
|
||||
return proof
|
||||
|
||||
async def get_by_id(self, proof_id: uuid.UUID) -> Optional[Proof]:
|
||||
"""Get proof by ID with versions."""
|
||||
result = await self.session.execute(
|
||||
select(Proof)
|
||||
.options(
|
||||
selectinload(Proof.versions),
|
||||
selectinload(Proof.campaign),
|
||||
)
|
||||
.where(Proof.id == proof_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_campaign_and_name(
|
||||
self,
|
||||
campaign_id: uuid.UUID,
|
||||
proof_name: str,
|
||||
) -> Optional[Proof]:
|
||||
"""Get proof by campaign ID and proof name."""
|
||||
result = await self.session.execute(
|
||||
select(Proof)
|
||||
.options(selectinload(Proof.versions))
|
||||
.where(Proof.campaign_id == campaign_id, Proof.proof_name == proof_name)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_by_campaign(
|
||||
self,
|
||||
campaign_id: uuid.UUID,
|
||||
) -> list[Proof]:
|
||||
"""List all proofs for a campaign."""
|
||||
result = await self.session.execute(
|
||||
select(Proof)
|
||||
.options(selectinload(Proof.versions))
|
||||
.where(Proof.campaign_id == campaign_id)
|
||||
.order_by(Proof.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def delete(self, proof_id: uuid.UUID) -> bool:
|
||||
"""Delete a proof and all its versions."""
|
||||
proof = await self.get_by_id(proof_id)
|
||||
if not proof:
|
||||
return False
|
||||
|
||||
await self.session.delete(proof)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
async def create_version(
|
||||
self,
|
||||
proof_id: uuid.UUID,
|
||||
version: int,
|
||||
file_storage_key: Optional[str] = None,
|
||||
thumbnail_url: Optional[str] = None,
|
||||
agent_review: Optional[dict] = None,
|
||||
overall_status: Optional[str] = None,
|
||||
workfront_id: Optional[str] = None,
|
||||
) -> ProofVersion:
|
||||
"""Create a new version of a proof."""
|
||||
proof_version = ProofVersion(
|
||||
proof_id=proof_id,
|
||||
version=version,
|
||||
file_storage_key=file_storage_key,
|
||||
thumbnail_url=thumbnail_url,
|
||||
agent_review=agent_review,
|
||||
overall_status=overall_status,
|
||||
workfront_id=workfront_id,
|
||||
)
|
||||
self.session.add(proof_version)
|
||||
await self.session.flush()
|
||||
return proof_version
|
||||
|
||||
async def get_version(
|
||||
self,
|
||||
proof_id: uuid.UUID,
|
||||
version: int,
|
||||
) -> Optional[ProofVersion]:
|
||||
"""Get a specific version of a proof."""
|
||||
result = await self.session.execute(
|
||||
select(ProofVersion)
|
||||
.where(
|
||||
ProofVersion.proof_id == proof_id,
|
||||
ProofVersion.version == version,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_version_by_id(
|
||||
self,
|
||||
version_id: uuid.UUID,
|
||||
) -> Optional[ProofVersion]:
|
||||
"""Get proof version by ID."""
|
||||
result = await self.session.execute(
|
||||
select(ProofVersion)
|
||||
.options(selectinload(ProofVersion.proof).selectinload(Proof.campaign))
|
||||
.where(ProofVersion.id == version_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_latest_version_number(self, proof_id: uuid.UUID) -> int:
|
||||
"""Get the latest version number for a proof."""
|
||||
result = await self.session.execute(
|
||||
select(ProofVersion.version)
|
||||
.where(ProofVersion.proof_id == proof_id)
|
||||
.order_by(ProofVersion.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
version = result.scalar_one_or_none()
|
||||
return version if version else 0
|
||||
|
||||
async def get_or_create_proof(
|
||||
self,
|
||||
campaign_id: uuid.UUID,
|
||||
proof_name: str,
|
||||
channel: Optional[str] = None,
|
||||
sub_channel: Optional[str] = None,
|
||||
proof_type: Optional[str] = None,
|
||||
created_by: Optional[uuid.UUID] = None,
|
||||
) -> tuple[Proof, bool]:
|
||||
"""Get existing proof or create new one. Returns (proof, is_new)."""
|
||||
proof = await self.get_by_campaign_and_name(campaign_id, proof_name)
|
||||
if proof:
|
||||
return proof, False
|
||||
|
||||
proof = await self.create(
|
||||
campaign_id=campaign_id,
|
||||
proof_name=proof_name,
|
||||
channel=channel,
|
||||
sub_channel=sub_channel,
|
||||
proof_type=proof_type,
|
||||
created_by=created_by,
|
||||
)
|
||||
return proof, True
|
||||
|
||||
async def add_version_with_review(
|
||||
self,
|
||||
campaign_id: uuid.UUID,
|
||||
proof_name: str,
|
||||
channel: Optional[str],
|
||||
sub_channel: Optional[str],
|
||||
proof_type: Optional[str],
|
||||
file_storage_key: Optional[str],
|
||||
thumbnail_url: Optional[str],
|
||||
agent_review: dict,
|
||||
overall_status: str,
|
||||
created_by: Optional[uuid.UUID] = None,
|
||||
) -> tuple[Proof, ProofVersion]:
|
||||
"""Create or get proof and add a new version with review results."""
|
||||
proof, is_new = await self.get_or_create_proof(
|
||||
campaign_id=campaign_id,
|
||||
proof_name=proof_name,
|
||||
channel=channel,
|
||||
sub_channel=sub_channel,
|
||||
proof_type=proof_type,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
latest_version = await self.get_latest_version_number(proof.id)
|
||||
new_version_number = latest_version + 1
|
||||
|
||||
# Generate workfront ID
|
||||
import random
|
||||
base_id = proof.workfront_id or f"#WF_{random.randint(10000, 99999)}"
|
||||
version_workfront_id = f"{base_id.split('-V')[0]}-V{new_version_number}"
|
||||
|
||||
if not proof.workfront_id:
|
||||
proof.workfront_id = base_id
|
||||
|
||||
version = await self.create_version(
|
||||
proof_id=proof.id,
|
||||
version=new_version_number,
|
||||
file_storage_key=file_storage_key,
|
||||
thumbnail_url=thumbnail_url,
|
||||
agent_review=agent_review,
|
||||
overall_status=overall_status,
|
||||
workfront_id=version_workfront_id,
|
||||
)
|
||||
|
||||
return proof, version
|
||||
95
backend/app/repositories/user_repository.py
Normal file
95
backend/app/repositories/user_repository.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.models import User, Agency
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""Repository for user-related database operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_by_id(self, user_id: uuid.UUID) -> Optional[User]:
|
||||
"""Get user by ID."""
|
||||
result = await self.session.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.agency))
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_azure_oid(self, azure_ad_oid: str) -> Optional[User]:
|
||||
"""Get user by Azure AD Object ID."""
|
||||
result = await self.session.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.agency))
|
||||
.where(User.azure_ad_oid == azure_ad_oid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_or_create_from_azure(
|
||||
self,
|
||||
azure_ad_oid: str,
|
||||
email: str,
|
||||
name: str,
|
||||
agency_name: Optional[str] = None
|
||||
) -> User:
|
||||
"""Get existing user or create new one from Azure AD claims."""
|
||||
user = await self.get_by_azure_oid(azure_ad_oid)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Create new user
|
||||
agency = None
|
||||
if agency_name:
|
||||
agency = await self.get_or_create_agency(agency_name)
|
||||
|
||||
user = User(
|
||||
azure_ad_oid=azure_ad_oid,
|
||||
email=email,
|
||||
name=name,
|
||||
role="basic_user",
|
||||
agency_id=agency.id if agency else None,
|
||||
)
|
||||
self.session.add(user)
|
||||
await self.session.flush()
|
||||
return user
|
||||
|
||||
async def get_or_create_agency(self, name: str) -> Agency:
|
||||
"""Get existing agency or create new one."""
|
||||
result = await self.session.execute(
|
||||
select(Agency).where(Agency.name == name)
|
||||
)
|
||||
agency = result.scalar_one_or_none()
|
||||
if agency:
|
||||
return agency
|
||||
|
||||
agency = Agency(name=name)
|
||||
self.session.add(agency)
|
||||
await self.session.flush()
|
||||
return agency
|
||||
|
||||
async def list_all(self) -> list[User]:
|
||||
"""List all users."""
|
||||
result = await self.session.execute(
|
||||
select(User).options(selectinload(User.agency))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_role(self, user_id: uuid.UUID, role: str) -> Optional[User]:
|
||||
"""Update user role."""
|
||||
user = await self.get_by_id(user_id)
|
||||
if user:
|
||||
user.role = role
|
||||
await self.session.flush()
|
||||
return user
|
||||
|
||||
async def list_agencies(self) -> list[Agency]:
|
||||
"""List all agencies."""
|
||||
result = await self.session.execute(select(Agency))
|
||||
return list(result.scalars().all())
|
||||
115
backend/app/services/storage_service.py
Normal file
115
backend/app/services/storage_service.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class StorageService:
|
||||
"""Service for storing and retrieving proof files."""
|
||||
|
||||
def __init__(self):
|
||||
self.storage_path = Path(settings.FILE_STORAGE_PATH)
|
||||
self._ensure_storage_exists()
|
||||
|
||||
def _ensure_storage_exists(self) -> None:
|
||||
"""Ensure the storage directory exists."""
|
||||
self.storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _generate_storage_key(
|
||||
self,
|
||||
campaign_id: uuid.UUID,
|
||||
proof_name: str,
|
||||
version: int,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
"""Generate a unique storage key for a file."""
|
||||
date_prefix = datetime.utcnow().strftime("%Y/%m")
|
||||
safe_proof_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in proof_name)
|
||||
filename = f"{safe_proof_name}_v{version}{file_extension}"
|
||||
return f"{date_prefix}/{campaign_id}/{filename}"
|
||||
|
||||
def _get_file_path(self, storage_key: str) -> Path:
|
||||
"""Get the full file path for a storage key."""
|
||||
return self.storage_path / storage_key
|
||||
|
||||
async def store_file(
|
||||
self,
|
||||
file_data: bytes,
|
||||
campaign_id: uuid.UUID,
|
||||
proof_name: str,
|
||||
version: int,
|
||||
file_type: str,
|
||||
) -> str:
|
||||
"""Store a file and return the storage key."""
|
||||
# Determine file extension from MIME type
|
||||
extension_map = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"application/pdf": ".pdf",
|
||||
}
|
||||
extension = extension_map.get(file_type, ".bin")
|
||||
|
||||
storage_key = self._generate_storage_key(
|
||||
campaign_id=campaign_id,
|
||||
proof_name=proof_name,
|
||||
version=version,
|
||||
file_extension=extension,
|
||||
)
|
||||
|
||||
file_path = self._get_file_path(storage_key)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with aiofiles.open(file_path, "wb") as f:
|
||||
await f.write(file_data)
|
||||
|
||||
return storage_key
|
||||
|
||||
async def get_file(self, storage_key: str) -> Optional[bytes]:
|
||||
"""Retrieve a file by its storage key."""
|
||||
file_path = self._get_file_path(storage_key)
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
return await f.read()
|
||||
|
||||
async def delete_file(self, storage_key: str) -> bool:
|
||||
"""Delete a file by its storage key."""
|
||||
file_path = self._get_file_path(storage_key)
|
||||
if not file_path.exists():
|
||||
return False
|
||||
|
||||
os.remove(file_path)
|
||||
return True
|
||||
|
||||
def get_file_url(self, storage_key: str) -> str:
|
||||
"""Get a URL to access the file (for local storage, returns a relative path)."""
|
||||
return f"/files/{storage_key}"
|
||||
|
||||
async def generate_thumbnail_data_url(
|
||||
self,
|
||||
file_data: bytes,
|
||||
file_type: str,
|
||||
) -> str:
|
||||
"""Generate a data URL for the file (for small previews)."""
|
||||
import base64
|
||||
b64_data = base64.b64encode(file_data).decode("utf-8")
|
||||
return f"data:{file_type};base64,{b64_data}"
|
||||
|
||||
def get_checksum(self, file_data: bytes) -> str:
|
||||
"""Calculate MD5 checksum of file data."""
|
||||
return hashlib.md5(file_data).hexdigest()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
storage_service = StorageService()
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
import base64
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.websocket.manager import ConnectionManager
|
||||
from app.services.analysis_service import AnalysisService
|
||||
from app.models.schemas import SubReview
|
||||
from app.models.database import async_session_factory
|
||||
from app.repositories import ProofRepository, CampaignRepository, UserRepository
|
||||
from app.services.storage_service import storage_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -87,39 +93,97 @@ async def handle_analyze_message(
|
|||
is_wip=is_wip,
|
||||
)
|
||||
|
||||
# Build the result dict
|
||||
result_dict = {
|
||||
"legalAgentReview": {
|
||||
"ragStatus": result.legalAgentReview.ragStatus,
|
||||
"feedback": result.legalAgentReview.feedback,
|
||||
"issues": result.legalAgentReview.issues,
|
||||
"isFinancialPromotion": result.legalAgentReview.isFinancialPromotion,
|
||||
"financialPromotionReason": result.legalAgentReview.financialPromotionReason,
|
||||
},
|
||||
"brandAgentReview": {
|
||||
"ragStatus": result.brandAgentReview.ragStatus,
|
||||
"feedback": result.brandAgentReview.feedback,
|
||||
"issues": result.brandAgentReview.issues,
|
||||
},
|
||||
"toneAgentReview": {
|
||||
"ragStatus": result.toneAgentReview.ragStatus,
|
||||
"feedback": result.toneAgentReview.feedback,
|
||||
"issues": result.toneAgentReview.issues,
|
||||
},
|
||||
"channelAgentReview": {
|
||||
"ragStatus": result.channelAgentReview.ragStatus,
|
||||
"feedback": result.channelAgentReview.feedback,
|
||||
"issues": result.channelAgentReview.issues,
|
||||
},
|
||||
"leadAgentSummary": result.leadAgentSummary,
|
||||
"overallStatus": result.overallStatus,
|
||||
"financialPromotionReason": result.financialPromotionReason,
|
||||
}
|
||||
|
||||
# Persist to database if campaign info provided
|
||||
proof_id: Optional[str] = None
|
||||
version_id: Optional[str] = None
|
||||
campaign_id = data.get("campaign_id")
|
||||
proof_name = data.get("proof_name")
|
||||
|
||||
if campaign_id and proof_name:
|
||||
try:
|
||||
logger.info(f"[WEBSOCKET] Persisting result for campaign {campaign_id}, proof {proof_name}")
|
||||
async with async_session_factory() as session:
|
||||
proof_repo = ProofRepository(session)
|
||||
|
||||
# Store the file
|
||||
file_storage_key = await storage_service.store_file(
|
||||
file_data=file_data,
|
||||
campaign_id=uuid.UUID(campaign_id),
|
||||
proof_name=proof_name,
|
||||
version=1, # Will be updated by add_version_with_review
|
||||
file_type=file_type,
|
||||
)
|
||||
|
||||
# Generate thumbnail for small files
|
||||
thumbnail_url = None
|
||||
if len(file_data) < 500000: # < 500KB
|
||||
thumbnail_url = await storage_service.generate_thumbnail_data_url(file_data, file_type)
|
||||
|
||||
# Save proof and version
|
||||
proof, version = await proof_repo.add_version_with_review(
|
||||
campaign_id=uuid.UUID(campaign_id),
|
||||
proof_name=proof_name,
|
||||
channel=data.get("channel"),
|
||||
sub_channel=data.get("sub_channel"),
|
||||
proof_type=data.get("proof_type"),
|
||||
file_storage_key=file_storage_key,
|
||||
thumbnail_url=thumbnail_url,
|
||||
agent_review=result_dict,
|
||||
overall_status=result.overallStatus,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
proof_id = str(proof.id)
|
||||
version_id = str(version.id)
|
||||
logger.info(f"[WEBSOCKET] Persisted proof {proof_id} version {version.version}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[WEBSOCKET] Failed to persist result: {str(e)}")
|
||||
# Continue - still send result to client even if persistence fails
|
||||
|
||||
# Send the complete result
|
||||
logger.info(f"[WEBSOCKET] Analysis complete, sending result - overallStatus: {result.overallStatus}")
|
||||
if manager.is_connected(client_id):
|
||||
await manager.send_message(client_id, {
|
||||
response = {
|
||||
"type": "complete",
|
||||
"result": {
|
||||
"legalAgentReview": {
|
||||
"ragStatus": result.legalAgentReview.ragStatus,
|
||||
"feedback": result.legalAgentReview.feedback,
|
||||
"issues": result.legalAgentReview.issues,
|
||||
"isFinancialPromotion": result.legalAgentReview.isFinancialPromotion,
|
||||
"financialPromotionReason": result.legalAgentReview.financialPromotionReason,
|
||||
},
|
||||
"brandAgentReview": {
|
||||
"ragStatus": result.brandAgentReview.ragStatus,
|
||||
"feedback": result.brandAgentReview.feedback,
|
||||
"issues": result.brandAgentReview.issues,
|
||||
},
|
||||
"toneAgentReview": {
|
||||
"ragStatus": result.toneAgentReview.ragStatus,
|
||||
"feedback": result.toneAgentReview.feedback,
|
||||
"issues": result.toneAgentReview.issues,
|
||||
},
|
||||
"channelAgentReview": {
|
||||
"ragStatus": result.channelAgentReview.ragStatus,
|
||||
"feedback": result.channelAgentReview.feedback,
|
||||
"issues": result.channelAgentReview.issues,
|
||||
},
|
||||
"leadAgentSummary": result.leadAgentSummary,
|
||||
"overallStatus": result.overallStatus,
|
||||
"financialPromotionReason": result.financialPromotionReason,
|
||||
}
|
||||
})
|
||||
"result": result_dict,
|
||||
}
|
||||
# Include proof/version IDs if persisted
|
||||
if proof_id:
|
||||
response["proof_id"] = proof_id
|
||||
if version_id:
|
||||
response["version_id"] = version_id
|
||||
|
||||
await manager.send_message(client_id, response)
|
||||
logger.info(f"[WEBSOCKET] Result sent to client: {client_id}")
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -8,3 +8,6 @@ aiofiles>=23.2.1
|
|||
websockets>=12.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
httpx>=0.26.0
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
asyncpg>=0.29.0
|
||||
alembic>=1.13.0
|
||||
|
|
|
|||
57
deploy.sh
57
deploy.sh
|
|
@ -46,7 +46,11 @@ fi
|
|||
# Set defaults for docker compose variables
|
||||
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-modcomms}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||
echo " Environment: ${COMPOSE_PROJECT_NAME} (port ${BACKEND_PORT})"
|
||||
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||
POSTGRES_USER="${POSTGRES_USER:-modcomms}"
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-modcomms_dev}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-modcomms}"
|
||||
echo " Environment: ${COMPOSE_PROJECT_NAME} (backend:${BACKEND_PORT}, postgres:${POSTGRES_PORT})"
|
||||
|
||||
# Create .env file for docker compose (so manual docker compose commands work)
|
||||
cat > .env << EOF
|
||||
|
|
@ -54,6 +58,10 @@ cat > .env << EOF
|
|||
# Edit .env.deploy instead and re-run deploy.sh
|
||||
COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
|
||||
BACKEND_PORT=${BACKEND_PORT}
|
||||
POSTGRES_PORT=${POSTGRES_PORT}
|
||||
POSTGRES_USER=${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB=${POSTGRES_DB}
|
||||
EOF
|
||||
echo " ✓ .env created for docker compose"
|
||||
|
||||
|
|
@ -75,7 +83,7 @@ echo " ✓ backend/.env exists"
|
|||
|
||||
# --- 1. Pull latest code (skip if not a git repo or no remote) ---
|
||||
echo ""
|
||||
echo "[1/5] Updating code..."
|
||||
echo "[1/6] Updating code..."
|
||||
if [ -d .git ]; then
|
||||
if git remote -v | grep -q origin; then
|
||||
git pull || echo "Warning: git pull failed, continuing with local code"
|
||||
|
|
@ -88,7 +96,7 @@ fi
|
|||
|
||||
# --- 2. Build frontend ---
|
||||
echo ""
|
||||
echo "[2/5] Building frontend..."
|
||||
echo "[2/6] Building frontend..."
|
||||
cd frontend
|
||||
|
||||
# Install dependencies (npm install is idempotent)
|
||||
|
|
@ -106,7 +114,7 @@ cd "$SCRIPT_DIR"
|
|||
|
||||
# --- 3. Deploy frontend to Apache ---
|
||||
echo ""
|
||||
echo "[3/5] Deploying frontend to ${FRONTEND_DEPLOY_DIR}..."
|
||||
echo "[3/6] Deploying frontend to ${FRONTEND_DEPLOY_DIR}..."
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
sudo mkdir -p "$FRONTEND_DEPLOY_DIR"
|
||||
|
|
@ -123,7 +131,7 @@ echo " ✓ Frontend deployed"
|
|||
|
||||
# --- 4. Update backend configuration ---
|
||||
echo ""
|
||||
echo "[4/5] Updating backend configuration..."
|
||||
echo "[4/6] Updating backend configuration..."
|
||||
|
||||
# Update CORS_ORIGINS if specified
|
||||
if [ -n "$CORS_ORIGINS" ]; then
|
||||
|
|
@ -135,15 +143,46 @@ if [ -n "$CORS_ORIGINS" ]; then
|
|||
echo " ✓ CORS_ORIGINS updated"
|
||||
fi
|
||||
|
||||
# --- 5. Build and start/restart backend containers ---
|
||||
# --- 5. Build containers and run database migrations ---
|
||||
echo ""
|
||||
echo "[5/5] Building and starting backend..."
|
||||
echo "[5/6] Building containers and running database migrations..."
|
||||
|
||||
# Build image (always rebuild to pick up code changes)
|
||||
docker compose build
|
||||
|
||||
# Start or restart containers (docker compose up -d is idempotent)
|
||||
docker compose up -d
|
||||
# Start PostgreSQL first and wait for it to be healthy
|
||||
echo " Starting PostgreSQL..."
|
||||
docker compose up -d postgres
|
||||
|
||||
echo " Waiting for PostgreSQL to be ready..."
|
||||
for i in {1..30}; do
|
||||
if docker compose exec -T postgres pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
|
||||
echo " ✓ PostgreSQL is ready"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo " Error: PostgreSQL failed to start"
|
||||
docker compose logs postgres
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run database migrations
|
||||
echo " Running database migrations..."
|
||||
if docker compose run --rm backend alembic upgrade head; then
|
||||
echo " ✓ Database migrations complete"
|
||||
else
|
||||
echo " Error: Database migrations failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- 6. Start backend service ---
|
||||
echo ""
|
||||
echo "[6/6] Starting backend service..."
|
||||
|
||||
# Start backend container
|
||||
docker compose up -d backend
|
||||
|
||||
# Wait for health check
|
||||
echo " Waiting for backend to be healthy..."
|
||||
|
|
|
|||
|
|
@ -1,4 +1,22 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-modcomms}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-modcomms_dev}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-modcomms}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-modcomms} -d ${POSTGRES_DB:-modcomms}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -10,6 +28,10 @@ services:
|
|||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8000
|
||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-modcomms}:${POSTGRES_PASSWORD:-modcomms_dev}@postgres:5432/${POSTGRES_DB:-modcomms}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
|
|
@ -17,3 +39,6 @@ services:
|
|||
retries: 3
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
|
|||
317
frontend/services/apiService.ts
Normal file
317
frontend/services/apiService.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { IPublicClientApplication } from '@azure/msal-browser';
|
||||
import { getAccessToken } from './authService';
|
||||
import type { AgentReview, FlaggedItem, ResolvedItem, ErrorItem } from '../types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000';
|
||||
|
||||
// Types for API responses
|
||||
export interface CampaignResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
workfront_id: string | null;
|
||||
client_lead: string | null;
|
||||
agency_lead: string | null;
|
||||
brand_guidelines: string | null;
|
||||
status: string;
|
||||
agency: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
proofs: number;
|
||||
}
|
||||
|
||||
export interface ProofVersionResponse {
|
||||
id: string;
|
||||
version: number;
|
||||
file_storage_key: string | null;
|
||||
thumbnail_url: string | null;
|
||||
agent_review: AgentReview | null;
|
||||
overall_status: string | null;
|
||||
workfront_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProofResponse {
|
||||
id: string;
|
||||
proof_name: string;
|
||||
channel: string | null;
|
||||
sub_channel: string | null;
|
||||
proof_type: string | null;
|
||||
workfront_id: string | null;
|
||||
created_at: string;
|
||||
versions: ProofVersionResponse[];
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
total_reviews: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
errors: number;
|
||||
legal_review: number;
|
||||
}
|
||||
|
||||
export interface FlaggedItemResponse {
|
||||
id: string;
|
||||
proof_version_id: string;
|
||||
agent_flagged: string;
|
||||
comments: string | null;
|
||||
submitter_name: string | null;
|
||||
submitter_agency: string | null;
|
||||
campaign_name: string | null;
|
||||
proof_name: string | null;
|
||||
version: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ResolvedItemResponse {
|
||||
id: string;
|
||||
proof_version_id: string;
|
||||
agent: string;
|
||||
issue: string | null;
|
||||
resolution: string | null;
|
||||
submitter_name: string | null;
|
||||
submitter_agency: string | null;
|
||||
campaign_name: string | null;
|
||||
proof_name: string | null;
|
||||
version: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ErrorItemResponse {
|
||||
id: string;
|
||||
proof_version_id: string;
|
||||
error_summary: string | null;
|
||||
campaign_name: string | null;
|
||||
proof_name: string | null;
|
||||
version: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
private msalInstance: IPublicClientApplication | null = null;
|
||||
|
||||
setMsalInstance(instance: IPublicClientApplication) {
|
||||
this.msalInstance = instance;
|
||||
}
|
||||
|
||||
private async getHeaders(): Promise<HeadersInit> {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.msalInstance) {
|
||||
const token = await getAccessToken(this.msalInstance);
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async fetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = await this.getHeaders();
|
||||
const response = await fetch(`${API_URL}/api${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Campaign endpoints
|
||||
async getCampaigns(): Promise<CampaignResponse[]> {
|
||||
return this.fetch<CampaignResponse[]>('/campaigns');
|
||||
}
|
||||
|
||||
async getCampaign(id: string): Promise<CampaignResponse> {
|
||||
return this.fetch<CampaignResponse>(`/campaigns/${id}`);
|
||||
}
|
||||
|
||||
async createCampaign(data: {
|
||||
name: string;
|
||||
workfront_id?: string;
|
||||
client_lead?: string;
|
||||
agency_lead?: string;
|
||||
brand_guidelines?: string;
|
||||
}): Promise<CampaignResponse> {
|
||||
return this.fetch<CampaignResponse>('/campaigns', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateCampaign(id: string, data: {
|
||||
name?: string;
|
||||
workfront_id?: string;
|
||||
client_lead?: string;
|
||||
agency_lead?: string;
|
||||
brand_guidelines?: string;
|
||||
status?: string;
|
||||
}): Promise<CampaignResponse> {
|
||||
return this.fetch<CampaignResponse>(`/campaigns/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCampaign(id: string): Promise<void> {
|
||||
return this.fetch<void>(`/campaigns/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Proof endpoints
|
||||
async getProofs(campaignId: string): Promise<ProofResponse[]> {
|
||||
return this.fetch<ProofResponse[]>(`/campaigns/${campaignId}/proofs`);
|
||||
}
|
||||
|
||||
async getProof(id: string): Promise<ProofResponse> {
|
||||
return this.fetch<ProofResponse>(`/proofs/${id}`);
|
||||
}
|
||||
|
||||
async deleteProof(id: string): Promise<void> {
|
||||
return this.fetch<void>(`/proofs/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Audit endpoints
|
||||
async flagProofVersion(proofId: string, version: number, data: {
|
||||
agent_flagged: string;
|
||||
comments?: string;
|
||||
}): Promise<FlaggedItemResponse> {
|
||||
return this.fetch<FlaggedItemResponse>(`/proofs/${proofId}/versions/${version}/flag`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
proof_version_id: '', // Will be set by backend
|
||||
...data,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async resolveProofVersion(proofId: string, version: number, data: {
|
||||
agent: string;
|
||||
issue?: string;
|
||||
resolution?: string;
|
||||
}): Promise<ResolvedItemResponse> {
|
||||
return this.fetch<ResolvedItemResponse>(`/proofs/${proofId}/versions/${version}/resolve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
proof_version_id: '', // Will be set by backend
|
||||
...data,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getFlaggedItems(): Promise<FlaggedItemResponse[]> {
|
||||
return this.fetch<FlaggedItemResponse[]>('/audit/flagged');
|
||||
}
|
||||
|
||||
async getResolvedItems(): Promise<ResolvedItemResponse[]> {
|
||||
return this.fetch<ResolvedItemResponse[]>('/audit/resolved');
|
||||
}
|
||||
|
||||
async getErrorItems(): Promise<ErrorItemResponse[]> {
|
||||
return this.fetch<ErrorItemResponse[]>('/audit/errors');
|
||||
}
|
||||
|
||||
// Analytics endpoint
|
||||
async getAnalytics(): Promise<AnalyticsResponse> {
|
||||
return this.fetch<AnalyticsResponse>('/analytics');
|
||||
}
|
||||
|
||||
// Helper to convert API response to frontend format
|
||||
convertCampaignToFrontend(campaign: CampaignResponse) {
|
||||
return {
|
||||
name: campaign.name,
|
||||
workfrontId: campaign.workfront_id || '',
|
||||
clientLead: campaign.client_lead || '',
|
||||
agency: campaign.agency || '',
|
||||
agencyLead: campaign.agency_lead || '',
|
||||
proofs: campaign.proofs,
|
||||
status: campaign.status as 'In Progress' | 'Completed',
|
||||
lastModified: campaign.updated_at,
|
||||
brandGuidelines: campaign.brand_guidelines || 'Barclays',
|
||||
_id: campaign.id,
|
||||
};
|
||||
}
|
||||
|
||||
convertProofToFrontend(proof: ProofResponse) {
|
||||
const latestVersion = proof.versions[0];
|
||||
return {
|
||||
proofName: proof.proof_name,
|
||||
channel: proof.channel || '',
|
||||
subChannel: proof.sub_channel || '',
|
||||
proofType: proof.proof_type || '',
|
||||
status: latestVersion?.overall_status === 'Analysis Error' ? 'error' : 'completed' as 'completed' | 'analyzing' | 'error' | 'loading',
|
||||
overallStatus: latestVersion?.overall_status as any,
|
||||
versions: proof.versions.map(v => ({
|
||||
version: v.version,
|
||||
timestamp: v.created_at.split('T')[0],
|
||||
workfrontId: v.workfront_id || '',
|
||||
proofPreviewUrl: v.thumbnail_url || '',
|
||||
feedback: v.agent_review || {} as AgentReview,
|
||||
overallStatus: v.overall_status as any,
|
||||
})),
|
||||
_id: proof.id,
|
||||
};
|
||||
}
|
||||
|
||||
convertFlaggedItemToFrontend(item: FlaggedItemResponse): FlaggedItem {
|
||||
return {
|
||||
id: item.id,
|
||||
campaignName: item.campaign_name || '',
|
||||
proofName: item.proof_name || '',
|
||||
version: item.version || 1,
|
||||
submitter: item.submitter_name || '',
|
||||
submitAgency: item.submitter_agency || '',
|
||||
agentFlagged: item.agent_flagged,
|
||||
comments: item.comments || '',
|
||||
timestamp: item.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
convertResolvedItemToFrontend(item: ResolvedItemResponse): ResolvedItem {
|
||||
return {
|
||||
id: item.id,
|
||||
campaignName: item.campaign_name || '',
|
||||
proofName: item.proof_name || '',
|
||||
version: item.version || 1,
|
||||
submitter: item.submitter_name || '',
|
||||
submitAgency: item.submitter_agency || '',
|
||||
agent: item.agent,
|
||||
issue: item.issue || '',
|
||||
resolution: item.resolution || '',
|
||||
timestamp: item.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
convertErrorItemToFrontend(item: ErrorItemResponse): ErrorItem {
|
||||
return {
|
||||
id: item.id,
|
||||
campaignName: item.campaign_name || '',
|
||||
proofName: item.proof_name || '',
|
||||
version: item.version || 1,
|
||||
submitter: '',
|
||||
submitAgency: '',
|
||||
errorSummary: item.error_summary || '',
|
||||
timestamp: item.created_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
||||
export default apiService;
|
||||
|
|
@ -6,16 +6,38 @@ import { getAccessToken } from './authService';
|
|||
const WS_URL = import.meta.env.VITE_BACKEND_WS_URL || 'ws://localhost:8000/ws/analyze';
|
||||
const HTTP_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000';
|
||||
|
||||
/**
|
||||
* Options for proof analysis with optional database persistence.
|
||||
*/
|
||||
export interface AnalyzeProofOptions {
|
||||
campaignId?: string;
|
||||
proofName?: string;
|
||||
channel?: string;
|
||||
subChannel?: string;
|
||||
proofType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of proof analysis, including optional database IDs if persisted.
|
||||
*/
|
||||
export interface AnalyzeProofResult {
|
||||
review: AgentReview;
|
||||
proofId?: string;
|
||||
versionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a proof using the backend WebSocket API.
|
||||
* Provides real-time updates as each agent completes.
|
||||
* Now requires MSAL instance to acquire access token.
|
||||
* Optionally pass campaign info to persist results to database.
|
||||
*/
|
||||
export const analyzeProof = async (
|
||||
file: File,
|
||||
onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void,
|
||||
msalInstance: IPublicClientApplication
|
||||
): Promise<AgentReview> => {
|
||||
msalInstance: IPublicClientApplication,
|
||||
options?: AnalyzeProofOptions
|
||||
): Promise<AnalyzeProofResult> => {
|
||||
// Acquire token before connecting
|
||||
const accessToken = await getAccessToken(msalInstance);
|
||||
if (!accessToken) {
|
||||
|
|
@ -31,13 +53,32 @@ export const analyzeProof = async (
|
|||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64Data = (reader.result as string).split(',')[1];
|
||||
ws.send(JSON.stringify({
|
||||
const message: Record<string, any> = {
|
||||
type: 'analyze',
|
||||
file_data: base64Data,
|
||||
file_type: file.type,
|
||||
is_wip: false,
|
||||
access_token: accessToken
|
||||
}));
|
||||
};
|
||||
|
||||
// Include campaign info for database persistence if provided
|
||||
if (options?.campaignId) {
|
||||
message.campaign_id = options.campaignId;
|
||||
}
|
||||
if (options?.proofName) {
|
||||
message.proof_name = options.proofName;
|
||||
}
|
||||
if (options?.channel) {
|
||||
message.channel = options.channel;
|
||||
}
|
||||
if (options?.subChannel) {
|
||||
message.sub_channel = options.subChannel;
|
||||
}
|
||||
if (options?.proofType) {
|
||||
message.proof_type = options.proofType;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify(message));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
ws.close();
|
||||
|
|
@ -69,7 +110,11 @@ export const analyzeProof = async (
|
|||
// Analysis complete - resolve with full result
|
||||
resolved = true;
|
||||
ws.close();
|
||||
resolve(message.result as AgentReview);
|
||||
resolve({
|
||||
review: message.result as AgentReview,
|
||||
proofId: message.proof_id,
|
||||
versionId: message.version_id,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue