From f07b971b67cdf8e6130edd8e62a22e4b0be0c0e1 Mon Sep 17 00:00:00 2001 From: eudald Date: Wed, 9 Sep 2026 22:53:12 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Aplicar=20migraci=C3=B3n=20inicial=20de=20b?= =?UTF-8?q?ase=20de=20datos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Pipfile | 2 +- Pipfile.lock | 8 +- migrations/versions/0763d677d453_.py | 35 -- ...5_initial_schema_from_clientflow_models.py | 417 ++++++++++++++++++ src/wsgi.py | 1 + 5 files changed, 423 insertions(+), 40 deletions(-) delete mode 100644 migrations/versions/0763d677d453_.py create mode 100644 migrations/versions/120643bd6ef5_initial_schema_from_clientflow_models.py diff --git a/Pipfile b/Pipfile index 4d37701..bf30f92 100644 --- a/Pipfile +++ b/Pipfile @@ -11,7 +11,6 @@ flask-sqlalchemy = "*" flask-migrate = "*" flask-swagger = "*" psycopg2-binary = "*" -python-dotenv = "*" flask-cors = "*" gunicorn = "*" cloudinary = "*" @@ -20,6 +19,7 @@ typing-extensions = "*" flask-jwt-extended = "==4.6.0" wtforms = "==3.1.2" sqlalchemy = "*" +python-dotenv = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index d9e474e..326b50d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -398,12 +398,12 @@ }, "python-dotenv": { "hashes": [ - "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", - "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61" + "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", + "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35" ], "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==1.2.1" + "markers": "python_version >= '3.10'", + "version": "==1.2.3" }, "pyyaml": { "hashes": [ diff --git a/migrations/versions/0763d677d453_.py b/migrations/versions/0763d677d453_.py deleted file mode 100644 index 8896417..0000000 --- a/migrations/versions/0763d677d453_.py +++ /dev/null @@ -1,35 +0,0 @@ -"""empty message - -Revision ID: 0763d677d453 -Revises: -Create Date: 2025-02-25 14:47:16.337069 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '0763d677d453' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(length=120), nullable=False), - sa.Column('password', sa.String(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('user') - # ### end Alembic commands ### diff --git a/migrations/versions/120643bd6ef5_initial_schema_from_clientflow_models.py b/migrations/versions/120643bd6ef5_initial_schema_from_clientflow_models.py new file mode 100644 index 0000000..f42ea46 --- /dev/null +++ b/migrations/versions/120643bd6ef5_initial_schema_from_clientflow_models.py @@ -0,0 +1,417 @@ +"""initial schema from ClientFlow models + +Revision ID: 120643bd6ef5 +Revises: +Create Date: 2026-09-09 20:43:11.334821 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '120643bd6ef5' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('companies', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=160), nullable=False), + sa.Column('slug', sa.String(length=160), nullable=False), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=40), nullable=True), + sa.Column('timezone', sa.String(length=60), nullable=False), + sa.Column('default_language', sa.String(length=5), nullable=False), + sa.Column('theme', sa.String(length=10), nullable=False), + sa.Column('primary_colour', sa.String(length=20), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug') + ) + op.create_table('plans', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=60), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('price_gbp', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('billing_interval', sa.String(length=20), nullable=False), + sa.Column('trial_days', sa.Integer(), nullable=False), + sa.Column('limits', sa.JSON(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('first_name', sa.String(length=100), nullable=False), + sa.Column('last_name', sa.String(length=100), nullable=False), + sa.Column('preferred_language', sa.String(length=5), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('ai_agents', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('purpose', sa.String(length=120), nullable=False), + sa.Column('model_name', sa.String(length=80), nullable=False), + sa.Column('main_instruction', sa.Text(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('requires_human_approval', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('clients', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('first_name', sa.String(length=100), nullable=False), + sa.Column('last_name', sa.String(length=100), nullable=True), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=40), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('company_memberships', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('role', sa.Enum('OWNER', 'ADMIN', 'MANAGER', 'AGENT', 'TECHNICIAN', name='membershiprole'), nullable=False), + sa.Column('colour', sa.String(length=20), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('company_id', 'user_id', name='uq_company_user') + ) + op.create_table('integrations', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('provider', sa.String(length=60), nullable=False), + sa.Column('status', sa.String(length=30), nullable=False), + sa.Column('external_account_id', sa.String(length=255), nullable=True), + sa.Column('credentials_reference', sa.String(length=500), nullable=True), + sa.Column('settings', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('company_id', 'provider', name='uq_company_provider') + ) + op.create_table('password_reset_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=255), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_table('service_types', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('colour', sa.String(length=20), nullable=False), + sa.Column('default_duration_minutes', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('service_zones', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('postcode_pattern', sa.String(length=30), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('subscriptions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('plan_id', sa.Integer(), nullable=False), + sa.Column('status', sa.Enum('TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'EXPIRED', name='subscriptionstatus'), nullable=False), + sa.Column('trial_started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('trial_ends_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('current_period_started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('current_period_ends_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('external_customer_id', sa.String(length=255), nullable=True), + sa.Column('external_subscription_id', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['plan_id'], ['plans.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('external_subscription_id') + ) + with op.batch_alter_table('subscriptions', schema=None) as batch_op: + batch_op.create_index('ix_subscription_company_status', ['company_id', 'status'], unique=False) + + op.create_table('client_addresses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.Integer(), nullable=False), + sa.Column('label', sa.String(length=60), nullable=True), + sa.Column('line_1', sa.String(length=160), nullable=False), + sa.Column('line_2', sa.String(length=160), nullable=True), + sa.Column('city', sa.String(length=100), nullable=False), + sa.Column('postcode', sa.String(length=20), nullable=False), + sa.Column('is_primary', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('jobs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('client_id', sa.Integer(), nullable=False), + sa.Column('service_type_id', sa.Integer(), nullable=True), + sa.Column('service_zone_id', sa.Integer(), nullable=True), + sa.Column('assigned_membership_id', sa.Integer(), nullable=True), + sa.Column('title', sa.String(length=180), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.Enum('DRAFT', 'SCHEDULED', 'IN_PROGRESS', 'REVIEW', 'COMPLETED', 'CANCELLED', name='jobstatus'), nullable=False), + sa.Column('priority', sa.String(length=20), nullable=False), + sa.Column('quoted_amount', sa.Numeric(precision=12, scale=2), nullable=True), + sa.Column('scheduled_start', sa.DateTime(timezone=True), nullable=True), + sa.Column('scheduled_end', sa.DateTime(timezone=True), nullable=True), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['assigned_membership_id'], ['company_memberships.id'], ), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['service_type_id'], ['service_types.id'], ), + sa.ForeignKeyConstraint(['service_zone_id'], ['service_zones.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('jobs', schema=None) as batch_op: + batch_op.create_index('ix_job_company_status', ['company_id', 'status'], unique=False) + + op.create_table('knowledge_documents', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('source_type', sa.String(length=30), nullable=False), + sa.Column('storage_key', sa.String(length=500), nullable=True), + sa.Column('checksum', sa.String(length=128), nullable=True), + sa.Column('ingestion_status', sa.String(length=30), nullable=False), + sa.Column('uploaded_by_membership_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['uploaded_by_membership_id'], ['company_memberships.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('leads', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('assigned_membership_id', sa.Integer(), nullable=True), + sa.Column('service_type_id', sa.Integer(), nullable=True), + sa.Column('converted_client_id', sa.Integer(), nullable=True), + sa.Column('first_name', sa.String(length=100), nullable=False), + sa.Column('last_name', sa.String(length=100), nullable=True), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=40), nullable=True), + sa.Column('source', sa.String(length=80), nullable=True), + sa.Column('status', sa.Enum('NEW', 'CONTACTED', 'QUALIFIED', 'WON', 'LOST', name='leadstatus'), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('converted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['assigned_membership_id'], ['company_memberships.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['converted_client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['service_type_id'], ['service_types.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('converted_client_id') + ) + with op.batch_alter_table('leads', schema=None) as batch_op: + batch_op.create_index('ix_lead_company_status', ['company_id', 'status'], unique=False) + + op.create_table('member_invitations', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('token_hash', sa.String(length=255), nullable=False), + sa.Column('role', sa.Enum('OWNER', 'ADMIN', 'MANAGER', 'AGENT', 'TECHNICIAN', name='membershiprole'), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_by_membership_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['created_by_membership_id'], ['company_memberships.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_table('agent_documents', + sa.Column('agent_id', sa.Integer(), nullable=False), + sa.Column('document_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['agent_id'], ['ai_agents.id'], ), + sa.ForeignKeyConstraint(['document_id'], ['knowledge_documents.id'], ), + sa.PrimaryKeyConstraint('agent_id', 'document_id') + ) + op.create_table('appointments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('job_id', sa.Integer(), nullable=True), + sa.Column('client_id', sa.Integer(), nullable=False), + sa.Column('assigned_membership_id', sa.Integer(), nullable=False), + sa.Column('service_type_id', sa.Integer(), nullable=True), + sa.Column('title', sa.String(length=180), nullable=False), + sa.Column('status', sa.Enum('SCHEDULED', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW', name='appointmentstatus'), nullable=False), + sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('ends_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('address_text', sa.String(length=300), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['assigned_membership_id'], ['company_memberships.id'], ), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ), + sa.ForeignKeyConstraint(['service_type_id'], ['service_types.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('appointments', schema=None) as batch_op: + batch_op.create_index('ix_appointment_assignee_start', ['assigned_membership_id', 'starts_at'], unique=False) + batch_op.create_index('ix_appointment_company_start', ['company_id', 'starts_at'], unique=False) + + op.create_table('conversations', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('lead_id', sa.Integer(), nullable=True), + sa.Column('client_id', sa.Integer(), nullable=True), + sa.Column('assigned_membership_id', sa.Integer(), nullable=True), + sa.Column('ai_agent_id', sa.Integer(), nullable=True), + sa.Column('channel', sa.Enum('WEB', 'EMAIL', 'WHATSAPP', 'INSTAGRAM', name='channeltype'), nullable=False), + sa.Column('external_id', sa.String(length=255), nullable=True), + sa.Column('subject', sa.String(length=255), nullable=True), + sa.Column('status', sa.Enum('OPEN', 'WAITING', 'RESOLVED', name='conversationstatus'), nullable=False), + sa.Column('last_message_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['ai_agent_id'], ['ai_agents.id'], ), + sa.ForeignKeyConstraint(['assigned_membership_id'], ['company_memberships.id'], ), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['lead_id'], ['leads.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('company_id', 'channel', 'external_id', name='uq_conversation_external_channel') + ) + with op.batch_alter_table('conversations', schema=None) as batch_op: + batch_op.create_index('ix_conversation_company_status', ['company_id', 'status'], unique=False) + + op.create_table('knowledge_chunks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('document_id', sa.Integer(), nullable=False), + sa.Column('chunk_index', sa.Integer(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('embedding_reference', sa.String(length=255), nullable=True), + sa.Column('token_count', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['document_id'], ['knowledge_documents.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('document_id', 'chunk_index', name='uq_document_chunk') + ) + op.create_table('conversation_participants', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('conversation_id', sa.Integer(), nullable=False), + sa.Column('membership_id', sa.Integer(), nullable=True), + sa.Column('lead_id', sa.Integer(), nullable=True), + sa.Column('client_id', sa.Integer(), nullable=True), + sa.Column('external_identifier', sa.String(length=255), nullable=True), + sa.Column('display_name', sa.String(length=160), nullable=True), + sa.Column('participant_type', sa.String(length=30), nullable=False), + sa.Column('joined_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ), + sa.ForeignKeyConstraint(['lead_id'], ['leads.id'], ), + sa.ForeignKeyConstraint(['membership_id'], ['company_memberships.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('messages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('conversation_id', sa.Integer(), nullable=False), + sa.Column('sender_participant_id', sa.Integer(), nullable=True), + sa.Column('direction', sa.Enum('INBOUND', 'OUTBOUND', name='messagedirection'), nullable=False), + sa.Column('external_id', sa.String(length=255), nullable=True), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('content_type', sa.String(length=30), nullable=False), + sa.Column('sent_by_ai', sa.Boolean(), nullable=False), + sa.Column('delivery_status', sa.String(length=30), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ), + sa.ForeignKeyConstraint(['sender_participant_id'], ['conversation_participants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('messages', schema=None) as batch_op: + batch_op.create_index('ix_message_conversation_created', ['conversation_id', 'created_at'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('messages', schema=None) as batch_op: + batch_op.drop_index('ix_message_conversation_created') + + op.drop_table('messages') + op.drop_table('conversation_participants') + op.drop_table('knowledge_chunks') + with op.batch_alter_table('conversations', schema=None) as batch_op: + batch_op.drop_index('ix_conversation_company_status') + + op.drop_table('conversations') + with op.batch_alter_table('appointments', schema=None) as batch_op: + batch_op.drop_index('ix_appointment_company_start') + batch_op.drop_index('ix_appointment_assignee_start') + + op.drop_table('appointments') + op.drop_table('agent_documents') + op.drop_table('member_invitations') + with op.batch_alter_table('leads', schema=None) as batch_op: + batch_op.drop_index('ix_lead_company_status') + + op.drop_table('leads') + op.drop_table('knowledge_documents') + with op.batch_alter_table('jobs', schema=None) as batch_op: + batch_op.drop_index('ix_job_company_status') + + op.drop_table('jobs') + op.drop_table('client_addresses') + with op.batch_alter_table('subscriptions', schema=None) as batch_op: + batch_op.drop_index('ix_subscription_company_status') + + op.drop_table('subscriptions') + op.drop_table('service_zones') + op.drop_table('service_types') + op.drop_table('password_reset_tokens') + op.drop_table('integrations') + op.drop_table('company_memberships') + op.drop_table('clients') + op.drop_table('ai_agents') + op.drop_table('users') + op.drop_table('plans') + op.drop_table('companies') + # ### end Alembic commands ### diff --git a/src/wsgi.py b/src/wsgi.py index 494740a..bdf8177 100644 --- a/src/wsgi.py +++ b/src/wsgi.py @@ -5,3 +5,4 @@ if __name__ == "__main__": application.run() + From 1a700f5443ae13acd103923ab6605f93fca412bf Mon Sep 17 00:00:00 2001 From: eudald Date: Thu, 10 Sep 2026 01:50:09 +0000 Subject: [PATCH 2/2] =?UTF-8?q?completar=20migraci=C3=B3n=20inicial=20de?= =?UTF-8?q?=20base=20de=20datos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Pipfile | 1 + Pipfile.lock | 46 ++++- ...5_initial_schema_from_clientflow_models.py | 9 + ..._update_plan_model_with_correct_columns.py | 180 ++++++++++++++++++ src/api/commands/__init__.py | 92 +++++++++ src/app.py | 20 +- 6 files changed, 338 insertions(+), 10 deletions(-) create mode 100644 migrations/versions/38050b846173_update_plan_model_with_correct_columns.py create mode 100644 src/api/commands/__init__.py diff --git a/Pipfile b/Pipfile index bf30f92..c9a36d9 100644 --- a/Pipfile +++ b/Pipfile @@ -4,6 +4,7 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] +pytest = "*" [packages] flask = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 326b50d..0df5350 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ffbfb32d0afa5e4bcaba5c2d08c81381a97abd90f22284d2b76647365df5dc50" + "sha256": "901fb651de11db9deab512a4fb52e6e6a85d132a6dbf5bcaa173ac5171a6e7b2" }, "pipfile-spec": 6, "requires": { @@ -591,5 +591,47 @@ "version": "==3.1.2" } }, - "develop": {} + "develop": { + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "pluggy": { + "hashes": [ + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" + ], + "markers": "python_version >= '3.9'", + "version": "==1.6.0" + }, + "pygments": { + "hashes": [ + "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", + "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" + ], + "markers": "python_version >= '3.9'", + "version": "==2.21.0" + }, + "pytest": { + "hashes": [ + "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", + "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==9.1.1" + } + } } diff --git a/migrations/versions/120643bd6ef5_initial_schema_from_clientflow_models.py b/migrations/versions/120643bd6ef5_initial_schema_from_clientflow_models.py index f42ea46..7459c8d 100644 --- a/migrations/versions/120643bd6ef5_initial_schema_from_clientflow_models.py +++ b/migrations/versions/120643bd6ef5_initial_schema_from_clientflow_models.py @@ -414,4 +414,13 @@ def downgrade(): op.drop_table('users') op.drop_table('plans') op.drop_table('companies') + + op.execute("DROP TYPE IF EXISTS messagedirection") + op.execute("DROP TYPE IF EXISTS conversationstatus") + op.execute("DROP TYPE IF EXISTS channeltype") + op.execute("DROP TYPE IF EXISTS appointmentstatus") + op.execute("DROP TYPE IF EXISTS leadstatus") + op.execute("DROP TYPE IF EXISTS jobstatus") + op.execute("DROP TYPE IF EXISTS subscriptionstatus") + op.execute("DROP TYPE IF EXISTS membershiprole") # ### end Alembic commands ### diff --git a/migrations/versions/38050b846173_update_plan_model_with_correct_columns.py b/migrations/versions/38050b846173_update_plan_model_with_correct_columns.py new file mode 100644 index 0000000..5c091af --- /dev/null +++ b/migrations/versions/38050b846173_update_plan_model_with_correct_columns.py @@ -0,0 +1,180 @@ +"""Update Plan model with correct columns + +Revision ID: 38050b846173 +Revises: 120643bd6ef5 +Create Date: 2026-09-10 00:13:49.728505 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '38050b846173' +down_revision = '120643bd6ef5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('job_assignments', + sa.Column('job_id', sa.Integer(), nullable=False), + sa.Column('membership_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(length=60), nullable=True), + sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ), + sa.ForeignKeyConstraint(['membership_id'], ['company_memberships.id'], ), + sa.PrimaryKeyConstraint('job_id', 'membership_id') + ) + op.create_table('job_materials', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('job_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=160), nullable=False), + sa.Column('quantity', sa.Numeric(precision=12, scale=3), nullable=False), + sa.Column('unit', sa.String(length=30), nullable=False), + sa.Column('unit_cost', sa.Numeric(precision=12, scale=2), nullable=True), + sa.Column('status', sa.Enum('REQUIRED', 'ORDERED', 'AVAILABLE', 'USED', name='materialstatus'), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('job_stages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('job_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=160), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'IN_PROGRESS', 'COMPLETED', name='jobstagestatus'), nullable=False), + sa.Column('due_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('job_id', 'position', name='uq_job_stage_position') + ) + op.create_table('next_actions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('assigned_membership_id', sa.Integer(), nullable=False), + sa.Column('created_by_membership_id', sa.Integer(), nullable=False), + sa.Column('lead_id', sa.Integer(), nullable=True), + sa.Column('client_id', sa.Integer(), nullable=True), + sa.Column('job_id', sa.Integer(), nullable=True), + sa.Column('title', sa.String(length=180), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('due_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', name='nextactionstatus'), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint('(CASE WHEN lead_id IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN client_id IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN job_id IS NOT NULL THEN 1 ELSE 0 END) = 1', name='ck_next_action_one_target'), + sa.ForeignKeyConstraint(['assigned_membership_id'], ['company_memberships.id'], ), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['created_by_membership_id'], ['company_memberships.id'], ), + sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ), + sa.ForeignKeyConstraint(['lead_id'], ['leads.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('next_actions', schema=None) as batch_op: + batch_op.create_index('ix_next_action_assignee_status', ['assigned_membership_id', 'status'], unique=False) + batch_op.create_index('ix_next_action_company_due', ['company_id', 'due_at'], unique=False) + + op.create_table('activities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('actor_membership_id', sa.Integer(), nullable=True), + sa.Column('lead_id', sa.Integer(), nullable=True), + sa.Column('client_id', sa.Integer(), nullable=True), + sa.Column('job_id', sa.Integer(), nullable=True), + sa.Column('conversation_id', sa.Integer(), nullable=True), + sa.Column('event_type', sa.String(length=80), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('metadata_json', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint('(CASE WHEN lead_id IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN client_id IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN job_id IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN conversation_id IS NOT NULL THEN 1 ELSE 0 END) = 1', name='ck_activity_one_target'), + sa.ForeignKeyConstraint(['actor_membership_id'], ['company_memberships.id'], ), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ), + sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ), + sa.ForeignKeyConstraint(['lead_id'], ['leads.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('activities', schema=None) as batch_op: + batch_op.create_index('ix_activity_company_created', ['company_id', 'created_at'], unique=False) + + op.create_table('attachments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_id', sa.Integer(), nullable=False), + sa.Column('uploaded_by_membership_id', sa.Integer(), nullable=False), + sa.Column('lead_id', sa.Integer(), nullable=True), + sa.Column('client_id', sa.Integer(), nullable=True), + sa.Column('job_id', sa.Integer(), nullable=True), + sa.Column('message_id', sa.Integer(), nullable=True), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('storage_key', sa.String(length=500), nullable=False), + sa.Column('content_type', sa.String(length=120), nullable=False), + sa.Column('size_bytes', sa.Integer(), nullable=False), + sa.Column('category', sa.String(length=40), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint('(CASE WHEN lead_id IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN client_id IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN job_id IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN message_id IS NOT NULL THEN 1 ELSE 0 END) = 1', name='ck_attachment_one_target'), + sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ), + sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ), + sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ), + sa.ForeignKeyConstraint(['lead_id'], ['leads.id'], ), + sa.ForeignKeyConstraint(['message_id'], ['messages.id'], ), + sa.ForeignKeyConstraint(['uploaded_by_membership_id'], ['company_memberships.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('attachments', schema=None) as batch_op: + batch_op.create_index('ix_attachment_company_created', ['company_id', 'created_at'], unique=False) + + with op.batch_alter_table('plans', schema=None) as batch_op: + batch_op.add_column(sa.Column('price_eur', sa.Numeric(precision=10, scale=2), nullable=True)) + + op.execute("UPDATE plans SET price_eur = price_gbp") + + with op.batch_alter_table('plans', schema=None) as batch_op: + batch_op.alter_column('price_eur', nullable=False) + batch_op.drop_column('price_gbp') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('plans', schema=None) as batch_op: + batch_op.add_column(sa.Column('price_gbp', sa.NUMERIC(precision=10, scale=2), autoincrement=False, nullable=True)) + + op.execute("UPDATE plans SET price_gbp = price_eur") + + with op.batch_alter_table('plans', schema=None) as batch_op: + batch_op.alter_column('price_gbp', nullable=False) + batch_op.drop_column('price_eur') + + with op.batch_alter_table('attachments', schema=None) as batch_op: + batch_op.drop_index('ix_attachment_company_created') + + op.drop_table('attachments') + with op.batch_alter_table('activities', schema=None) as batch_op: + batch_op.drop_index('ix_activity_company_created') + + op.drop_table('activities') + with op.batch_alter_table('next_actions', schema=None) as batch_op: + batch_op.drop_index('ix_next_action_company_due') + batch_op.drop_index('ix_next_action_assignee_status') + + op.drop_table('next_actions') + op.drop_table('job_stages') + op.drop_table('job_materials') + op.drop_table('job_assignments') + + op.execute("DROP TYPE IF EXISTS jobstagestatus") + op.execute("DROP TYPE IF EXISTS materialstatus") + op.execute("DROP TYPE IF EXISTS nextactionstatus") + # ### end Alembic commands ### diff --git a/src/api/commands/__init__.py b/src/api/commands/__init__.py new file mode 100644 index 0000000..0d51c8d --- /dev/null +++ b/src/api/commands/__init__.py @@ -0,0 +1,92 @@ +import click +from api.models import db, User, Plan +from flask.cli import with_appcontext + +""" +In this file, you can add as many commands as you want using the @app.cli.command decorator +Flask commands are usefull to run cronjobs or tasks outside of the API but sill in integration +with youy database, for example: Import the price of bitcoin every night as 12am +""" +def setup_commands(app): + + """ + This is an example command "insert-test-users" that you can run from the command line + by typing: $ flask insert-test-users 5 + Note: 5 is the number of users to add + """ + @app.cli.command("insert-test-users") + @click.argument("count") + def insert_test_users(count): + print("Creating test users") + for x in range(1, int(count) + 1): + user = User() + user.email = "test_user" + str(x) + "@test.com" + user.password = "123456" + user.is_active = True + db.session.add(user) + db.session.commit() + print("User: ", user.email, " created.") + + print("All test users created") + + @app.cli.command("insert-test-data") + def insert_test_data(): + pass + + +@click.command('seed') +@with_appcontext +def seed_data(): + """Seed initial plans.""" + if Plan.query.first() is not None: + click.echo("Plans already seeded!") + return + + plans = [ + Plan( + code='free', + name='Free', + description='Free plan with basic features', + price_eur=0, + billing_interval='monthly', + trial_days=7, + limits={'leads': 100, 'conversations': 10}, + is_active=True + ), + Plan( + code='starter', + name='Starter', + description='Starter plan for small businesses', + price_eur=29.99, + billing_interval='monthly', + trial_days=14, + limits={'leads': 500, 'conversations': 50}, + is_active=True + ), + Plan( + code='professional', + name='Professional', + description='Professional plan for growing teams', + price_eur=99.99, + billing_interval='monthly', + trial_days=30, + limits={'leads': 2000, 'conversations': 200}, + is_active=True + ), + Plan( + code='enterprise', + name='Enterprise', + description='Enterprise plan with unlimited features', + price_eur=299.99, + billing_interval='monthly', + trial_days=30, + limits={'leads': None, 'conversations': None}, + is_active=True + ), + ] + + db.session.add_all(plans) + db.session.commit() + click.echo("Seed data created successfully!") +def register_seed(app): + app.cli.add_command(seed_data) \ No newline at end of file diff --git a/src/app.py b/src/app.py index f1e908a..09b226f 100644 --- a/src/app.py +++ b/src/app.py @@ -1,17 +1,18 @@ """ This module takes care of starting the API Server, Loading the DB and Adding the endpoints """ +from api.commands import register_seed +from api.commands import setup_commands +from api.admin import setup_admin +from api.routes import api +from api.models import db +from api.utils import APIException, generate_sitemap +from flask_swagger import swagger +from flask_migrate import Migrate +from flask import Flask, request, jsonify, url_for, send_from_directory import os from dotenv import load_dotenv load_dotenv() -from flask import Flask, request, jsonify, url_for, send_from_directory -from flask_migrate import Migrate -from flask_swagger import swagger -from api.utils import APIException, generate_sitemap -from api.models import db -from api.routes import api -from api.admin import setup_admin -from api.commands import setup_commands # from models import Person @@ -38,6 +39,7 @@ # add the admin setup_commands(app) +register_seed(app) # Add all endpoints form the API with a "api" prefix app.register_blueprint(api, url_prefix='/api') @@ -59,6 +61,8 @@ def sitemap(): return send_from_directory(static_file_dir, 'index.html') # any other endpoint will try to serve it like a static file + + @app.route('/', methods=['GET']) def serve_any_other_file(path): if not os.path.isfile(os.path.join(static_file_dir, path)):