feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
@@ -1,19 +1,34 @@
|
||||
"""
|
||||
SQLAlchemy models for the Document Translation API
|
||||
"""
|
||||
import os
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timezone
|
||||
import warnings
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, String, Integer, Float, Boolean, DateTime, Text,
|
||||
ForeignKey, Enum, Index, JSON, BigInteger
|
||||
Column,
|
||||
String,
|
||||
Integer,
|
||||
Float,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Text,
|
||||
ForeignKey,
|
||||
Enum,
|
||||
Index,
|
||||
JSON,
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship, declarative_base
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
import enum
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -22,6 +37,11 @@ def generate_uuid():
|
||||
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"
|
||||
@@ -40,57 +60,78 @@ class SubscriptionStatus(str, enum.Enum):
|
||||
|
||||
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)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
|
||||
# Account status
|
||||
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)
|
||||
|
||||
# Subscription info
|
||||
|
||||
plan = Column(Enum(PlanType), default=PlanType.FREE)
|
||||
subscription_status = Column(Enum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE)
|
||||
|
||||
# Stripe integration
|
||||
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)
|
||||
|
||||
# Usage tracking (reset monthly)
|
||||
|
||||
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) # Purchased credits
|
||||
usage_reset_date = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
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)
|
||||
|
||||
# Relationships
|
||||
translations = relationship("Translation", back_populates="user", lazy="dynamic")
|
||||
api_keys = relationship("ApiKey", back_populates="user", lazy="dynamic")
|
||||
|
||||
# Indexes
|
||||
|
||||
translations = relationship("Translation", back_populates="user", lazy="select")
|
||||
api_keys = relationship("ApiKey", back_populates="user", lazy="select")
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_users_email_active', 'email', 'is_active'),
|
||||
Index('ix_users_stripe_customer', 'stripe_customer_id'),
|
||||
CheckConstraint("tier IN ('free', 'pro')", 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:
|
||||
"""Convert user to dictionary for API response"""
|
||||
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",
|
||||
"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,
|
||||
@@ -102,44 +143,49 @@ class User(Base):
|
||||
|
||||
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)
|
||||
|
||||
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(10), nullable=False) # xlsx, docx, pptx
|
||||
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
|
||||
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=datetime.utcnow)
|
||||
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'),
|
||||
Index("ix_translations_user_date", "user_id", "created_at"),
|
||||
Index("ix_translations_status", "status"),
|
||||
)
|
||||
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
@@ -154,43 +200,49 @@ class Translation(Base):
|
||||
"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,
|
||||
"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)
|
||||
|
||||
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=datetime.utcnow)
|
||||
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'),
|
||||
Index("ix_api_keys_prefix", "key_prefix"),
|
||||
Index("ix_api_keys_hash", "key_hash"),
|
||||
)
|
||||
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
@@ -198,7 +250,9 @@ class ApiKey(Base):
|
||||
"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,
|
||||
"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,
|
||||
@@ -207,53 +261,149 @@ class ApiKey(Base):
|
||||
|
||||
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)
|
||||
|
||||
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),
|
||||
)
|
||||
__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)
|
||||
|
||||
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=datetime.utcnow)
|
||||
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_payment_history_user', 'user_id', 'created_at'),
|
||||
__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
|
||||
)
|
||||
name = Column(String(255), nullable=False)
|
||||
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"),)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"name": self.name,
|
||||
"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)
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user