Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 2m14s
Foundations:
- TranslationSegment model + migration f7e8d9c0b1a2 (segments, workspaces,
workspace_members, glossaries.workspace_id)
- SegmentRecorder injected into all 4 translators: unique (source,
translation) pairs captured per job and persisted (best-effort)
- set_segment_overrides: human-reviewed translations applied verbatim on
rebuild — top priority over TM and provider, zero API calls
Review API (routes/review_routes.py):
- GET /translations/{id}/segments (owner or job token)
- PATCH /segments/{id} edit/approve — feeds the per-user TM so approved
translations are reused in later jobs
- POST /translations/{id}/rebuild — rebuild document with reviewed text
- GET/POST /translations/{id}/xliff — XLIFF 1.2 export/import (edited
segments export their reviewed text)
Review editor (frontend /dashboard/reviews/[jobId]):
- side-by-side source/translation table, inline edit, approve (single or
all), rebuild & download (auth blob), XLIFF export/import, 13 locales
- 'Relire et corriger' link on the translation-complete screen
Team workspaces (routes/workspace_routes.py + /dashboard/teams):
- Workspace/WorkspaceMember models, roles owner/admin/member
- create (Business plan), list with seat usage, invite by email with
seat-limit enforcement (Business=5, Enterprise unlimited), removal
- shared glossaries: workspace members can use a glossary shared to their
workspace (access check extended)
Tests: 1184 passed / 0 failed (11 new: recorder, overrides, docx
capture->rebuild e2e, XLIFF structure/escaping, seats, workspace CRUD,
shared glossary access)
543 lines
18 KiB
Python
543 lines
18 KiB
Python
"""
|
|
SQLAlchemy models for the Document Translation API
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
import warnings
|
|
|
|
from sqlalchemy import (
|
|
Column,
|
|
String,
|
|
Integer,
|
|
Float,
|
|
Boolean,
|
|
DateTime,
|
|
Text,
|
|
ForeignKey,
|
|
Enum,
|
|
Index,
|
|
JSON,
|
|
BigInteger,
|
|
CheckConstraint,
|
|
)
|
|
from sqlalchemy.orm import relationship, declarative_base
|
|
import enum
|
|
|
|
|
|
def _utcnow() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
Base = declarative_base()
|
|
|
|
|
|
def generate_uuid():
|
|
"""Generate a new UUID string"""
|
|
return str(uuid.uuid4())
|
|
|
|
|
|
def generate_uuid_value():
|
|
"""Generate a new UUID value for PostgreSQL UUID column"""
|
|
return uuid.uuid4()
|
|
|
|
|
|
class PlanType(str, enum.Enum):
|
|
FREE = "free"
|
|
STARTER = "starter"
|
|
PRO = "pro"
|
|
BUSINESS = "business"
|
|
ENTERPRISE = "enterprise"
|
|
|
|
|
|
class SubscriptionStatus(str, enum.Enum):
|
|
ACTIVE = "active"
|
|
CANCELED = "canceled"
|
|
PAST_DUE = "past_due"
|
|
TRIALING = "trialing"
|
|
PAUSED = "paused"
|
|
|
|
|
|
class User(Base):
|
|
"""User model for authentication and billing"""
|
|
|
|
__tablename__ = "users"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
email = Column(String(255), unique=True, nullable=False, index=True)
|
|
name = Column(String(255), nullable=False)
|
|
hashed_password = Column(String(255), nullable=False)
|
|
|
|
tier = Column(String(10), default="free", nullable=False)
|
|
daily_translation_count = Column(Integer, default=0, nullable=False)
|
|
|
|
email_verified = Column(Boolean, default=False)
|
|
is_active = Column(Boolean, default=True)
|
|
avatar_url = Column(String(500), nullable=True)
|
|
|
|
plan = Column(Enum(PlanType), default=PlanType.FREE)
|
|
subscription_status = Column(
|
|
Enum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE
|
|
)
|
|
|
|
stripe_customer_id = Column(String(255), nullable=True, index=True)
|
|
stripe_subscription_id = Column(String(255), nullable=True)
|
|
subscription_ends_at = Column(DateTime, nullable=True)
|
|
cancel_at_period_end = Column(Boolean, default=False, nullable=False)
|
|
|
|
reset_token = Column(String(255), nullable=True)
|
|
reset_token_expires = Column(DateTime, nullable=True)
|
|
|
|
docs_translated_this_month = Column(Integer, default=0)
|
|
pages_translated_this_month = Column(Integer, default=0)
|
|
api_calls_this_month = Column(Integer, default=0)
|
|
extra_credits = Column(Integer, default=0)
|
|
usage_reset_date = Column(DateTime, default=_utcnow)
|
|
|
|
created_at = Column(DateTime, default=_utcnow)
|
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
|
last_login_at = Column(DateTime, nullable=True)
|
|
|
|
translations = relationship("Translation", back_populates="user", lazy="select")
|
|
api_keys = relationship("ApiKey", back_populates="user", lazy="select")
|
|
|
|
__table_args__ = (
|
|
CheckConstraint("tier IN ('free', 'starter', 'pro', 'business', 'enterprise')", name="ck_users_tier"),
|
|
Index("ix_users_email_active", "email", "is_active"),
|
|
Index("ix_users_stripe_customer", "stripe_customer_id"),
|
|
)
|
|
|
|
@property
|
|
def password_hash(self) -> str:
|
|
warnings.warn(
|
|
"password_hash is deprecated, use hashed_password instead",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
return self.hashed_password
|
|
|
|
@password_hash.setter
|
|
def password_hash(self, value: str) -> None:
|
|
warnings.warn(
|
|
"password_hash is deprecated, use hashed_password instead",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
self.hashed_password = value
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"email": self.email,
|
|
"name": self.name,
|
|
"avatar_url": self.avatar_url,
|
|
"tier": self.tier,
|
|
"plan": self.plan.value if self.plan else "free",
|
|
"subscription_status": self.subscription_status.value
|
|
if self.subscription_status
|
|
else "active",
|
|
"daily_translation_count": self.daily_translation_count,
|
|
"docs_translated_this_month": self.docs_translated_this_month,
|
|
"pages_translated_this_month": self.pages_translated_this_month,
|
|
"api_calls_this_month": self.api_calls_this_month,
|
|
"extra_credits": self.extra_credits,
|
|
"email_verified": self.email_verified,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
}
|
|
|
|
|
|
class Translation(Base):
|
|
"""Translation history for analytics and billing"""
|
|
|
|
__tablename__ = "translations"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
user_id = Column(
|
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
# File info
|
|
original_filename = Column(String(255), nullable=False)
|
|
file_type = Column(String(20), nullable=False) # xlsx, docx, pptx
|
|
file_size_bytes = Column(BigInteger, default=0)
|
|
page_count = Column(Integer, default=0)
|
|
|
|
# Translation details
|
|
source_language = Column(String(10), default="auto")
|
|
target_language = Column(String(10), nullable=False)
|
|
provider = Column(String(50), nullable=False) # google, deepl, ollama, etc.
|
|
|
|
# Processing info
|
|
status = Column(
|
|
String(20), default="pending"
|
|
) # pending, processing, completed, failed
|
|
error_message = Column(Text, nullable=True)
|
|
processing_time_ms = Column(Integer, nullable=True)
|
|
|
|
# Cost tracking (for paid providers)
|
|
characters_translated = Column(Integer, default=0)
|
|
estimated_cost_usd = Column(Float, default=0.0)
|
|
|
|
# Timestamps
|
|
created_at = Column(DateTime, default=_utcnow)
|
|
completed_at = Column(DateTime, nullable=True)
|
|
|
|
# Relationship
|
|
user = relationship("User", back_populates="translations")
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
Index("ix_translations_user_date", "user_id", "created_at"),
|
|
Index("ix_translations_status", "status"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"original_filename": self.original_filename,
|
|
"file_type": self.file_type,
|
|
"file_size_bytes": self.file_size_bytes,
|
|
"page_count": self.page_count,
|
|
"source_language": self.source_language,
|
|
"target_language": self.target_language,
|
|
"provider": self.provider,
|
|
"status": self.status,
|
|
"processing_time_ms": self.processing_time_ms,
|
|
"characters_translated": self.characters_translated,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"completed_at": self.completed_at.isoformat()
|
|
if self.completed_at
|
|
else None,
|
|
}
|
|
|
|
|
|
class ApiKey(Base):
|
|
"""API keys for programmatic access"""
|
|
|
|
__tablename__ = "api_keys"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
user_id = Column(
|
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
# Key info
|
|
name = Column(String(100), nullable=False) # User-friendly name
|
|
key_hash = Column(String(255), nullable=False) # SHA256 of the key
|
|
key_prefix = Column(String(10), nullable=False) # First 8 chars for identification
|
|
|
|
# Permissions
|
|
is_active = Column(Boolean, default=True)
|
|
scopes = Column(JSON, default=list) # ["translate", "read", "write"]
|
|
|
|
# Usage tracking
|
|
last_used_at = Column(DateTime, nullable=True)
|
|
usage_count = Column(Integer, default=0)
|
|
|
|
# Timestamps
|
|
created_at = Column(DateTime, default=_utcnow)
|
|
expires_at = Column(DateTime, nullable=True)
|
|
revoked_at = Column(DateTime, nullable=True) # Set when is_active=False
|
|
|
|
# Relationship
|
|
user = relationship("User", back_populates="api_keys")
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
Index("ix_api_keys_prefix", "key_prefix"),
|
|
Index("ix_api_keys_hash", "key_hash"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"key_prefix": self.key_prefix,
|
|
"is_active": self.is_active,
|
|
"scopes": self.scopes,
|
|
"last_used_at": self.last_used_at.isoformat()
|
|
if self.last_used_at
|
|
else None,
|
|
"usage_count": self.usage_count,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
}
|
|
|
|
|
|
class UsageLog(Base):
|
|
"""Daily usage aggregation for billing and analytics"""
|
|
|
|
__tablename__ = "usage_logs"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
user_id = Column(
|
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
# Date (for daily aggregation)
|
|
date = Column(DateTime, nullable=False, index=True)
|
|
|
|
# Aggregated counts
|
|
documents_count = Column(Integer, default=0)
|
|
pages_count = Column(Integer, default=0)
|
|
characters_count = Column(BigInteger, default=0)
|
|
api_calls_count = Column(Integer, default=0)
|
|
|
|
# By provider breakdown (JSON)
|
|
provider_breakdown = Column(JSON, default=dict)
|
|
|
|
# Indexes
|
|
__table_args__ = (Index("ix_usage_logs_user_date", "user_id", "date", unique=True),)
|
|
|
|
|
|
class PaymentHistory(Base):
|
|
"""Payment and invoice history"""
|
|
|
|
__tablename__ = "payment_history"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
user_id = Column(
|
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
# Stripe info
|
|
stripe_payment_intent_id = Column(String(255), nullable=True)
|
|
stripe_invoice_id = Column(String(255), nullable=True)
|
|
|
|
# Payment details
|
|
amount_cents = Column(Integer, nullable=False)
|
|
currency = Column(String(3), default="usd")
|
|
payment_type = Column(String(50), nullable=False) # subscription, credits, one_time
|
|
status = Column(String(20), nullable=False) # succeeded, failed, pending, refunded
|
|
|
|
# Description
|
|
description = Column(String(255), nullable=True)
|
|
|
|
# Timestamps
|
|
created_at = Column(DateTime, default=_utcnow)
|
|
|
|
# Indexes
|
|
__table_args__ = (Index("ix_payment_history_user", "user_id", "created_at"),)
|
|
|
|
|
|
class Glossary(Base):
|
|
"""User's glossary containing source->target term pairs.
|
|
Story 3.9: Glossaires - Endpoint CRUD
|
|
"""
|
|
|
|
__tablename__ = "glossaries"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
user_id = Column(
|
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
# Optional: workspace this glossary is shared with (owner still administers it)
|
|
workspace_id = Column(String(36), nullable=True)
|
|
name = Column(String(255), nullable=False)
|
|
source_language = Column(String(10), nullable=False, default="fr")
|
|
target_language = Column(String(10), nullable=True, default="en")
|
|
template_id = Column(String(50), nullable=True)
|
|
created_at = Column(DateTime, default=_utcnow)
|
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
|
|
|
# Relationship
|
|
terms = relationship(
|
|
"GlossaryTerm", back_populates="glossary", cascade="all, delete-orphan"
|
|
)
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
Index("ix_glossaries_user_id", "user_id"),
|
|
Index("ix_glossaries_template_id", "template_id"),
|
|
Index("ix_glossaries_workspace", "workspace_id"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"user_id": self.user_id,
|
|
"workspace_id": self.workspace_id,
|
|
"name": self.name,
|
|
"source_language": self.source_language,
|
|
"target_language": self.target_language,
|
|
"template_id": self.template_id,
|
|
"terms": [term.to_dict() for term in self.terms] if self.terms else [],
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
|
|
|
|
class GlossaryTerm(Base):
|
|
"""Single term pair in a glossary.
|
|
Story 3.9: Glossaires - Endpoint CRUD
|
|
"""
|
|
|
|
__tablename__ = "glossary_terms"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
glossary_id = Column(
|
|
String(36), ForeignKey("glossaries.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
source = Column(String(500), nullable=False)
|
|
target = Column(String(500), nullable=False)
|
|
translations = Column(JSON, nullable=True, default=dict)
|
|
created_at = Column(DateTime, default=_utcnow)
|
|
|
|
# Relationship
|
|
glossary = relationship("Glossary", back_populates="terms")
|
|
|
|
# Indexes
|
|
__table_args__ = (Index("ix_glossary_terms_glossary_id", "glossary_id"),)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"source": self.source,
|
|
"target": self.target,
|
|
"translations": self.translations or {},
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
}
|
|
|
|
|
|
class CustomPrompt(Base):
|
|
"""User's custom prompts for LLM translation context.
|
|
Story 3.11: Custom Prompts - Endpoint CRUD
|
|
"""
|
|
|
|
__tablename__ = "custom_prompts"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
user_id = Column(
|
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
name = Column(String(255), nullable=False)
|
|
content = Column(Text, nullable=False)
|
|
created_at = Column(DateTime, default=_utcnow)
|
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
|
|
|
# Indexes
|
|
__table_args__ = (Index("ix_custom_prompts_user_id", "user_id"),)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"user_id": self.user_id,
|
|
"name": self.name,
|
|
"content": self.content,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
|
|
|
|
class TranslationSegment(Base):
|
|
"""One translatable unit of a finished job (review foundation).
|
|
|
|
Segments persist (source, translation) pairs per job so reviewers can
|
|
edit/approve them side-by-side, rebuild the document with the reviewed
|
|
text, and export/import XLIFF.
|
|
"""
|
|
|
|
__tablename__ = "translation_segments"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
job_id = Column(String(64), nullable=False)
|
|
user_id = Column(
|
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
segment_index = Column(Integer, nullable=False, default=0)
|
|
source_text = Column(Text, nullable=False)
|
|
translated_text = Column(Text, nullable=False, default="")
|
|
# pending | approved | edited
|
|
status = Column(String(20), nullable=False, default="pending")
|
|
# Reviewer's edit (used for rebuild when status == "edited")
|
|
reviewed_text = Column(Text, nullable=True)
|
|
is_heading = Column(Boolean, nullable=False, default=False)
|
|
created_at = Column(DateTime, default=_utcnow)
|
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
|
|
|
__table_args__ = (
|
|
Index("ix_segments_job_index", "job_id", "segment_index"),
|
|
Index("ix_segments_user", "user_id"),
|
|
Index("ix_segments_status", "job_id", "status"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"job_id": self.job_id,
|
|
"segment_index": self.segment_index,
|
|
"source_text": self.source_text,
|
|
"translated_text": self.translated_text,
|
|
"status": self.status,
|
|
"reviewed_text": self.reviewed_text,
|
|
"is_heading": self.is_heading,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
|
|
|
|
class Workspace(Base):
|
|
"""Team workspace: shared glossaries + seat-based membership.
|
|
|
|
Seats are enforced from the owner's plan (Business = 5 seats).
|
|
"""
|
|
|
|
__tablename__ = "workspaces"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
name = Column(String(255), nullable=False)
|
|
owner_id = Column(
|
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
created_at = Column(DateTime, default=_utcnow)
|
|
|
|
members = relationship(
|
|
"WorkspaceMember", back_populates="workspace", cascade="all, delete-orphan"
|
|
)
|
|
|
|
__table_args__ = (Index("ix_workspaces_owner", "owner_id"),)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"owner_id": self.owner_id,
|
|
"member_count": len(self.members) if self.members else 0,
|
|
"members": [m.to_dict() for m in self.members] if self.members else [],
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
}
|
|
|
|
|
|
class WorkspaceMember(Base):
|
|
"""Membership row linking a user to a workspace with a role."""
|
|
|
|
__tablename__ = "workspace_members"
|
|
|
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
workspace_id = Column(
|
|
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
user_id = Column(
|
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
# owner | admin | member
|
|
role = Column(String(20), nullable=False, default="member")
|
|
created_at = Column(DateTime, default=_utcnow)
|
|
|
|
workspace = relationship("Workspace", back_populates="members")
|
|
|
|
__table_args__ = (
|
|
Index("ix_workspace_members_workspace", "workspace_id"),
|
|
Index("ix_workspace_members_user", "user_id"),
|
|
# UniqueConstraint imported lazily below if needed; enforced in app layer
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"workspace_id": self.workspace_id,
|
|
"user_id": self.user_id,
|
|
"role": self.role,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
}
|