"""base schema: state of the database immediately before migration 001 Every database that has run this project since before Alembic was wired in was born from `Base.metadata.create_all()` (via `/api/setup/init-db`, or `init_db()` in dev) - never from a migration chain, because there wasn't one. `upgrade head` on a genuinely empty database therefore had no starting point: 001 is the oldest migration, and it only knows how to ADD columns to tables that 001-013 all assume already exist. This migration supplies that starting point, but only when there is nothing there yet: on a database that already has `recipes` (the table 001 was written against), it is a no-op, because that database was already born from `create_all()` and already has this exact schema. Written explicitly with `create_table` rather than `Base.metadata.create_all`: `create_all` builds the CURRENT model definitions, which already carry every column 001-013 add, and 001 would then try to add a column that is already there (`Duplicate column`). Revision ID: 000_base Revises: Create Date: 2026-09-21 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa from sqlalchemy import inspect as sa_inspect # revision identifiers, used by Alembic. revision: str = '000_base' down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None # Table creation order matters: each one only references tables created # before it (users -> recipes -> recipe_versions -> recipe_tasks -> # recipe_subtasks -> measurements -> access_logs / system_settings / # recipe_version_audit). Downgrade drops them in the opposite order. _TABLES_NEWEST_FIRST = ( 'recipe_version_audit', 'system_settings', 'access_logs', 'measurements', 'recipe_subtasks', 'recipe_tasks', 'recipe_versions', 'recipes', 'users', ) def upgrade() -> None: bind = op.get_bind() if sa_inspect(bind).has_table('recipes'): # Already created (by create_all, or by a previous run of this same # migration) - nothing to do. return op.create_table( 'users', sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), sa.Column('username', sa.String(100), nullable=False), sa.Column('password_hash', sa.String(255), nullable=False), sa.Column('email', sa.String(255), nullable=True), sa.Column('display_name', sa.String(255), nullable=False), sa.Column('roles', sa.JSON, nullable=False), sa.Column('is_admin', sa.Boolean, nullable=False, server_default='0'), sa.Column( 'language_pref', sa.Enum('it', 'en', name='language_enum'), nullable=False, server_default='it', ), sa.Column( 'theme_pref', sa.Enum('light', 'dark', name='theme_enum'), nullable=False, server_default='light', ), sa.Column('active', sa.Boolean, nullable=False, server_default='1'), sa.Column('api_key', sa.String(64), nullable=True), sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), sa.Column('last_login', sa.DateTime, nullable=True), sa.UniqueConstraint('username', name='uq_users_username'), sa.UniqueConstraint('api_key', name='uq_users_api_key'), sa.Index('ix_users_username', 'username'), sa.Index('ix_users_api_key', 'api_key'), mysql_engine='InnoDB', mysql_charset='utf8mb4', ) op.create_table( 'recipes', sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), sa.Column('code', sa.String(100), nullable=False), sa.Column('name', sa.String(255), nullable=False), sa.Column('description', sa.Text, nullable=True), sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False), sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), sa.Column('active', sa.Boolean, nullable=False, server_default='1'), sa.UniqueConstraint('code', name='uq_recipes_code'), sa.Index('ix_recipes_code', 'code'), sa.Index('ix_recipes_active', 'active'), mysql_engine='InnoDB', mysql_charset='utf8mb4', ) op.create_table( 'recipe_versions', sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False), sa.Column('version_number', sa.Integer, nullable=False), sa.Column('is_current', sa.Boolean, nullable=False, server_default='0'), sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False), sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), sa.Column('change_notes', sa.Text, nullable=True), sa.UniqueConstraint('recipe_id', 'version_number', name='uq_recipe_version'), sa.Index('ix_recipe_version_current', 'recipe_id', 'is_current'), mysql_engine='InnoDB', mysql_charset='utf8mb4', ) op.create_table( 'recipe_tasks', sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False), sa.Column('order_index', sa.Integer, nullable=False, server_default='0'), sa.Column('title', sa.String(255), nullable=False), sa.Column('directive', sa.Text, nullable=True), sa.Column('description', sa.Text, nullable=True), sa.Column('file_path', sa.String(500), nullable=True), sa.Column( 'file_type', sa.Enum('image', 'pdf', name='file_type_enum'), nullable=True, ), sa.Column('annotations_json', sa.JSON, nullable=True), mysql_engine='InnoDB', mysql_charset='utf8mb4', ) op.create_table( 'recipe_subtasks', sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), sa.Column( 'task_id', sa.Integer, sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False, ), sa.Column('marker_number', sa.Integer, nullable=False), sa.Column('description', sa.String(500), nullable=False), sa.Column('measurement_type', sa.String(100), nullable=True), sa.Column('nominal', sa.DECIMAL(12, 6), nullable=True), sa.Column('utl', sa.DECIMAL(12, 6), nullable=True), sa.Column('uwl', sa.DECIMAL(12, 6), nullable=True), sa.Column('lwl', sa.DECIMAL(12, 6), nullable=True), sa.Column('ltl', sa.DECIMAL(12, 6), nullable=True), sa.Column('unit', sa.String(20), nullable=False, server_default='mm'), sa.UniqueConstraint('task_id', 'marker_number', name='uq_task_marker'), sa.Index('ix_subtask_task_id', 'task_id'), mysql_engine='InnoDB', mysql_charset='utf8mb4', ) op.create_table( 'measurements', sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), sa.Column( 'subtask_id', sa.Integer, sa.ForeignKey('recipe_subtasks.id'), nullable=False, ), sa.Column( 'version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False, ), sa.Column( 'measured_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False, ), sa.Column('value', sa.DECIMAL(12, 6), nullable=False), sa.Column( 'pass_fail', sa.Enum('pass', 'warning', 'fail', name='pass_fail_enum'), nullable=False, ), sa.Column('deviation', sa.DECIMAL(12, 6), nullable=True), sa.Column('lot_number', sa.String(100), nullable=True), sa.Column('serial_number', sa.String(100), nullable=True), sa.Column( 'input_method', sa.Enum('usb_caliper', 'manual', name='input_method_enum'), nullable=False, server_default='manual', ), sa.Column( 'measured_at', sa.DateTime, nullable=False, server_default=sa.func.now(), ), sa.Column('synced_to_csv', sa.Boolean, nullable=True, server_default='0'), sa.Index('ix_measurements_subtask_id', 'subtask_id'), sa.Index('ix_measurements_version_id', 'version_id'), sa.Index('ix_measurements_measured_by', 'measured_by'), sa.Index('ix_measurements_lot_number', 'lot_number'), sa.Index('ix_measurements_serial_number', 'serial_number'), sa.Index('ix_measurements_measured_at', 'measured_at'), mysql_engine='InnoDB', mysql_charset='utf8mb4', ) op.create_table( 'access_logs', sa.Column('id', sa.BigInteger, primary_key=True, autoincrement=True), sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True), sa.Column('action', sa.String(100), nullable=False), sa.Column('details', sa.JSON, nullable=True), sa.Column('ip_address', sa.String(45), nullable=True), sa.Column('user_agent', sa.String(500), nullable=True), sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), sa.Index('ix_access_logs_action', 'action'), sa.Index('ix_access_log_user_time', 'user_id', 'created_at'), mysql_engine='InnoDB', mysql_charset='utf8mb4', ) op.create_table( 'system_settings', sa.Column('setting_key', sa.String(100), primary_key=True), sa.Column('setting_value', sa.Text, nullable=False), sa.Column( 'setting_type', sa.Enum('string', 'number', 'boolean', 'json', name='setting_type_enum'), nullable=False, server_default='string', ), sa.Column('description', sa.String(500), nullable=True), sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()), sa.Column('updated_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True), mysql_engine='InnoDB', mysql_charset='utf8mb4', ) op.create_table( 'recipe_version_audit', sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False), sa.Column('old_version_id', sa.Integer, nullable=True), sa.Column('new_version_id', sa.Integer, nullable=False), sa.Column('changed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False), sa.Column( 'change_type', sa.Enum('CREATE', 'UPDATE', 'ACTIVATE', 'RETIRE', name='change_type_enum'), nullable=False, ), sa.Column('change_reason', sa.Text, nullable=True), sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), sa.Index('ix_recipe_audit_recipe_time', 'recipe_id', 'created_at'), mysql_engine='InnoDB', mysql_charset='utf8mb4', ) def downgrade() -> None: bind = op.get_bind() inspector = sa_inspect(bind) for table in _TABLES_NEWEST_FIRST: if inspector.has_table(table): op.drop_table(table)