feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
77
alembic/versions/002_add_tier_daily_count.py
Normal file
77
alembic/versions/002_add_tier_daily_count.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Add tier, daily_translation_count, and hashed_password fields
|
||||
|
||||
Revision ID: 002_add_tier_daily_count
|
||||
Revises: 001_initial
|
||||
Create Date: 2026-02-20
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "002_add_tier_daily_count"
|
||||
down_revision: Union[str, None] = "001_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
dialect = conn.dialect.name
|
||||
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("tier", sa.String(10), nullable=True, server_default="free"),
|
||||
)
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"daily_translation_count",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
op.add_column("users", sa.Column("hashed_password", sa.String(255), nullable=True))
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET hashed_password = password_hash WHERE hashed_password IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET tier = CASE "
|
||||
"WHEN plan IN ('pro', 'business', 'enterprise') THEN 'pro' "
|
||||
"ELSE 'free' END "
|
||||
"WHERE tier IS NULL OR tier = 'free'"
|
||||
)
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET daily_translation_count = 0 WHERE daily_translation_count IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
# SQLite does not support ALTER COLUMN — skip nullable enforcement there
|
||||
if dialect != "sqlite":
|
||||
op.alter_column("users", "tier", nullable=False)
|
||||
op.alter_column("users", "daily_translation_count", nullable=False)
|
||||
op.create_check_constraint("ck_users_tier", "users", "tier IN ('free', 'pro')")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
dialect = conn.dialect.name
|
||||
|
||||
if dialect != "sqlite":
|
||||
op.drop_constraint("ck_users_tier", "users", type_="check")
|
||||
|
||||
op.drop_column("users", "hashed_password")
|
||||
op.drop_column("users", "daily_translation_count")
|
||||
op.drop_column("users", "tier")
|
||||
29
alembic/versions/003_add_revoked_at_to_api_keys.py
Normal file
29
alembic/versions/003_add_revoked_at_to_api_keys.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Add revoked_at column to api_keys table
|
||||
|
||||
Revision ID: 003_add_revoked_at
|
||||
Revises: 002_add_tier_daily_count
|
||||
Create Date: 2026-02-22
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '003_add_revoked_at'
|
||||
down_revision = '002_add_tier_daily_count'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add revoked_at column to api_keys table for tracking key revocation timestamps."""
|
||||
op.add_column(
|
||||
'api_keys',
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove revoked_at column from api_keys table."""
|
||||
op.drop_column('api_keys', 'revoked_at')
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Rename legacy account_tier column to tier when tier is missing
|
||||
|
||||
Some SQLite databases were stamped at head but still use account_tier from an
|
||||
older branch; the ORM expects column name tier (migration 002).
|
||||
|
||||
Revision ID: 004_rename_account_tier
|
||||
Revises: 5206607942a2
|
||||
Create Date: 2026-04-13
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "004_rename_account_tier"
|
||||
down_revision: Union[str, None] = "5206607942a2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
insp = sa.inspect(conn)
|
||||
cols = {c["name"] for c in insp.get_columns("users")}
|
||||
if "account_tier" in cols and "tier" not in cols:
|
||||
op.alter_column(
|
||||
"users",
|
||||
"account_tier",
|
||||
new_column_name="tier",
|
||||
existing_type=sa.String(10),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
insp = sa.inspect(conn)
|
||||
cols = {c["name"] for c in insp.get_columns("users")}
|
||||
if "tier" in cols and "account_tier" not in cols:
|
||||
op.alter_column(
|
||||
"users",
|
||||
"tier",
|
||||
new_column_name="account_tier",
|
||||
existing_type=sa.String(10),
|
||||
existing_nullable=True,
|
||||
)
|
||||
42
alembic/versions/5206607942a2_add_custom_prompts_table.py
Normal file
42
alembic/versions/5206607942a2_add_custom_prompts_table.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Alembic migration script template
|
||||
add_custom_prompts_table
|
||||
|
||||
Revision ID: 5206607942a2
|
||||
Revises: cb71a958ad92
|
||||
Create Date: 2026-02-22 20:07:33.224269
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5206607942a2"
|
||||
down_revision: Union[str, None] = "cb71a958ad92"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"custom_prompts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_custom_prompts_user_id", "custom_prompts", ["user_id"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_custom_prompts_user_id", table_name="custom_prompts")
|
||||
op.drop_table("custom_prompts")
|
||||
56
alembic/versions/cb71a958ad92_add_glossaries_tables.py
Normal file
56
alembic/versions/cb71a958ad92_add_glossaries_tables.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Alembic migration script template
|
||||
add_glossaries_tables
|
||||
|
||||
Revision ID: cb71a958ad92
|
||||
Revises: 003_add_revoked_at
|
||||
Create Date: 2026-02-22 15:13:10.685242
|
||||
|
||||
Story 3.9: Glossaires - Endpoint CRUD
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "cb71a958ad92"
|
||||
down_revision: Union[str, None] = "003_add_revoked_at"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"glossaries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_glossaries_user_id", "glossaries", ["user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"glossary_terms",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("glossary_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source", sa.String(length=500), nullable=False),
|
||||
sa.Column("target", sa.String(length=500), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["glossary_id"], ["glossaries.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_glossary_terms_glossary_id", "glossary_terms", ["glossary_id"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_glossary_terms_glossary_id", table_name="glossary_terms")
|
||||
op.drop_table("glossary_terms")
|
||||
op.drop_index("ix_glossaries_user_id", table_name="glossaries")
|
||||
op.drop_table("glossaries")
|
||||
Reference in New Issue
Block a user