From 4a610ba94a953310167cb3cc64e000bdae9dec1d Mon Sep 17 00:00:00 2001 From: Kavalar Date: Sat, 26 Sep 2026 19:46:44 +0300 Subject: [PATCH] feat(db): all domain models, initial Alembic migration (32 tables), seed script (8 districts, 89 regions, roles) --- backend/alembic/script.py.mako | 25 + backend/alembic/versions/.gitkeep | 0 .../versions/fbd00c295888_initial_schema.py | 444 ++++++++++++++++++ backend/app/models/__init__.py | 49 +- backend/app/models/advice.py | 19 + backend/app/models/artifact.py | 24 + backend/app/models/audit.py | 49 ++ backend/app/models/broadcast.py | 65 +++ backend/app/models/enums.py | 96 ++++ backend/app/models/event.py | 33 ++ backend/app/models/goal.py | 75 +++ backend/app/models/history.py | 19 + backend/app/models/knowledge.py | 28 ++ backend/app/models/region_card.py | 46 ++ backend/app/models/request.py | 42 ++ backend/app/models/task.py | 47 ++ backend/app/models/types.py | 17 + backend/app/models/user.py | 108 +++++ backend/scripts/seed.py | 106 +++++ 19 files changed, 1290 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/.gitkeep create mode 100644 backend/alembic/versions/fbd00c295888_initial_schema.py create mode 100644 backend/app/models/advice.py create mode 100644 backend/app/models/artifact.py create mode 100644 backend/app/models/audit.py create mode 100644 backend/app/models/broadcast.py create mode 100644 backend/app/models/enums.py create mode 100644 backend/app/models/event.py create mode 100644 backend/app/models/goal.py create mode 100644 backend/app/models/history.py create mode 100644 backend/app/models/knowledge.py create mode 100644 backend/app/models/region_card.py create mode 100644 backend/app/models/request.py create mode 100644 backend/app/models/task.py create mode 100644 backend/app/models/types.py create mode 100644 backend/app/models/user.py create mode 100644 backend/scripts/seed.py diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic/versions/fbd00c295888_initial_schema.py b/backend/alembic/versions/fbd00c295888_initial_schema.py new file mode 100644 index 0000000..9f9a47f --- /dev/null +++ b/backend/alembic/versions/fbd00c295888_initial_schema.py @@ -0,0 +1,444 @@ +"""initial schema + +Revision ID: fbd00c295888 +Revises: +Create Date: 2026-09-26 19:45:54.541490 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'fbd00c295888' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('broadcast_templates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('type', sa.Enum('info', 'education', 'rating', name='broadcasttype', native_enum=False, length=20), nullable=False), + sa.Column('body_template', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_table('federal_districts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('code', sa.String(length=20), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code'), + sa.UniqueConstraint('name') + ) + op.create_table('goals', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.String(length=1000), nullable=True), + sa.Column('target_value', sa.Numeric(precision=14, scale=2), nullable=True), + sa.Column('unit', sa.String(length=32), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('knowledge_categories', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('roles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.Enum('admin', 'coordinator', 'region_head', 'member', 'deputy', name='rolecode', native_enum=False, length=20), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('full_name', sa.String(length=200), nullable=True), + sa.Column('messenger_type', sa.Enum('telegram', 'max', name='messengertype', native_enum=False, length=20), nullable=False), + sa.Column('messenger_id', sa.String(length=64), nullable=False), + sa.Column('phone', sa.String(length=32), nullable=True), + sa.Column('status', sa.Enum('active', 'blocked', 'pending', name='userstatus', native_enum=False, length=20), nullable=False), + sa.Column('member_type', sa.Enum('member', 'resident', name='membertype', native_enum=False, length=20), nullable=True), + sa.Column('joined_at', sa.Date(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('messenger_type', 'messenger_id', name='uq_user_messenger') + ) + op.create_table('assistant_actions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('owner_user_id', sa.Integer(), nullable=False), + sa.Column('action', sa.String(length=100), nullable=False), + sa.Column('entity_type', sa.String(length=100), nullable=True), + sa.Column('entity_id', sa.String(length=100), nullable=True), + sa.Column('payload', sa.JSON(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('assistant_configs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('owner_user_id', sa.Integer(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('permissions', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('owner_user_id') + ) + op.create_table('audit_log', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('actor_user_id', sa.Integer(), nullable=True), + sa.Column('actor_type', sa.Enum('user', 'assistant', 'system', name='actortype', native_enum=False, length=20), nullable=False), + sa.Column('action', sa.String(length=100), nullable=False), + sa.Column('entity_type', sa.String(length=100), nullable=True), + sa.Column('entity_id', sa.String(length=100), nullable=True), + sa.Column('payload', sa.JSON(), nullable=True), + sa.Column('ip', sa.String(length=64), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('broadcasts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('template_id', sa.Integer(), nullable=True), + sa.Column('type', sa.Enum('info', 'education', 'rating', name='broadcasttype', native_enum=False, length=20), nullable=False), + sa.Column('subject', sa.String(length=300), nullable=True), + sa.Column('body', sa.Text(), nullable=True), + sa.Column('audience_filter', sa.JSON(), nullable=True), + sa.Column('status', sa.Enum('draft', 'scheduled', 'sending', 'sent', 'failed', name='broadcaststatus', native_enum=False, length=20), nullable=False), + sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['template_id'], ['broadcast_templates.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('data_uploads', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('goal_id', sa.Integer(), nullable=False), + sa.Column('file_key', sa.String(length=500), nullable=False), + sa.Column('uploaded_by', sa.Integer(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ), + sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('events', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=300), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('ends_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('location', sa.String(length=300), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('knowledge_articles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=300), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('category_id', sa.Integer(), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['knowledge_categories.id'], ), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('platform_history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=300), nullable=False), + sa.Column('body', sa.Text(), nullable=True), + sa.Column('event_date', sa.Date(), nullable=True), + sa.Column('is_public', sa.Boolean(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('regions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('federal_district_id', sa.Integer(), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['federal_district_id'], ['federal_districts.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('sessions', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=128), nullable=False), + sa.Column('messenger_type', sa.Enum('telegram', 'max', name='messengertype', native_enum=False, length=20), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_table('snapshots', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('goal_id', sa.Integer(), nullable=False), + sa.Column('snapshot_date', sa.Date(), nullable=False), + sa.Column('kind', sa.Enum('point0', 'current', 'history', name='snapshotkind', native_enum=False, length=20), nullable=False), + sa.Column('source', sa.String(length=32), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('advices', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('region_id', sa.Integer(), nullable=False), + sa.Column('goal_id', sa.Integer(), nullable=True), + sa.Column('author_user_id', sa.Integer(), nullable=True), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['author_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('broadcast_recipients', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('broadcast_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('status', sa.Enum('pending', 'sent', 'failed', name='recipientstatus', native_enum=False, length=20), nullable=False), + sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['broadcast_id'], ['broadcasts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('broadcast_id', 'user_id', name='uq_broadcast_user') + ) + op.create_table('event_reminders', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('event_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('remind_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('login_tokens', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('region_id', sa.Integer(), nullable=True), + sa.Column('issued_by', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('active', 'used', 'expired', 'revoked', name='logintokenstatus', native_enum=False, length=20), 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), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['issued_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token') + ) + op.create_table('region_card_history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('region_id', sa.Integer(), nullable=False), + sa.Column('field', sa.String(length=100), nullable=False), + sa.Column('old_value', sa.String(length=1000), nullable=True), + sa.Column('new_value', sa.String(length=1000), nullable=True), + sa.Column('changed_by', sa.Integer(), nullable=True), + sa.Column('changed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['changed_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('region_cards', + sa.Column('region_id', sa.Integer(), nullable=False), + sa.Column('population', sa.Integer(), nullable=True), + sa.Column('head_user_id', sa.Integer(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['head_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('region_id') + ) + op.create_table('region_goal_values', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('snapshot_id', sa.Integer(), nullable=False), + sa.Column('region_id', sa.Integer(), nullable=False), + sa.Column('value', sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['snapshot_id'], ['snapshots.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('snapshot_id', 'region_id', name='uq_snapshot_region') + ) + op.create_table('region_links', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('region_id', sa.Integer(), nullable=False), + sa.Column('type', sa.Enum('community', 'chat', name='linktype', native_enum=False, length=20), nullable=False), + sa.Column('title', sa.String(length=200), nullable=True), + sa.Column('url', sa.String(length=500), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('requests', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.Enum('join', 'access', 'event', 'support', name='requesttype', native_enum=False, length=20), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('region_id', sa.Integer(), nullable=True), + sa.Column('subject', sa.String(length=300), nullable=True), + sa.Column('body', sa.Text(), nullable=True), + sa.Column('status', sa.Enum('new', 'in_progress', 'closed', name='requeststatus', native_enum=False, length=20), nullable=False), + sa.Column('assigned_to', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tasks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=300), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('due_date', sa.Date(), nullable=True), + sa.Column('priority', sa.Enum('low', 'medium', 'high', name='taskpriority', native_enum=False, length=20), nullable=False), + sa.Column('status', sa.Enum('new', 'in_progress', 'review', 'done', name='taskstatus', native_enum=False, length=20), nullable=False), + sa.Column('region_id', sa.Integer(), nullable=True), + sa.Column('assignee_user_id', sa.Integer(), nullable=True), + sa.Column('assignee_role', sa.Enum('admin', 'coordinator', 'region_head', 'member', 'deputy', name='rolecode', native_enum=False, length=20), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['assignee_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user_regions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('region_id', sa.Integer(), nullable=False), + sa.Column('is_primary', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'region_id', name='uq_user_region') + ) + op.create_table('user_roles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('region_id', sa.Integer(), nullable=True), + sa.Column('granted_by', sa.Integer(), nullable=True), + sa.Column('granted_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['granted_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'role_id', 'region_id', name='uq_user_role') + ) + op.create_table('artifacts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=300), nullable=True), + sa.Column('file_key', sa.String(length=500), nullable=False), + sa.Column('file_name', sa.String(length=300), nullable=False), + sa.Column('mime_type', sa.String(length=100), nullable=True), + sa.Column('size_bytes', sa.BigInteger(), nullable=True), + sa.Column('region_id', sa.Integer(), nullable=True), + sa.Column('goal_id', sa.Integer(), nullable=True), + sa.Column('task_id', sa.Integer(), nullable=True), + sa.Column('uploaded_by', sa.Integer(), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['goal_id'], ['goals.id'], ), + sa.ForeignKeyConstraint(['region_id'], ['regions.id'], ), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ), + sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('request_comments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('request_id', sa.Integer(), nullable=False), + sa.Column('author_id', sa.Integer(), nullable=True), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['request_id'], ['requests.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('task_comments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('task_id', sa.Integer(), nullable=False), + sa.Column('author_id', sa.Integer(), nullable=True), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('task_comments') + op.drop_table('request_comments') + op.drop_table('artifacts') + op.drop_table('user_roles') + op.drop_table('user_regions') + op.drop_table('tasks') + op.drop_table('requests') + op.drop_table('region_links') + op.drop_table('region_goal_values') + op.drop_table('region_cards') + op.drop_table('region_card_history') + op.drop_table('login_tokens') + op.drop_table('event_reminders') + op.drop_table('broadcast_recipients') + op.drop_table('advices') + op.drop_table('snapshots') + op.drop_table('sessions') + op.drop_table('regions') + op.drop_table('platform_history') + op.drop_table('knowledge_articles') + op.drop_table('events') + op.drop_table('data_uploads') + op.drop_table('broadcasts') + op.drop_table('audit_log') + op.drop_table('assistant_configs') + op.drop_table('assistant_actions') + op.drop_table('users') + op.drop_table('roles') + op.drop_table('knowledge_categories') + op.drop_table('goals') + op.drop_table('federal_districts') + op.drop_table('broadcast_templates') + # ### end Alembic commands ### diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 480b83c..fd57300 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,49 @@ -"""Модели данных.""" +"""Модели данных (регистрация для Alembic).""" +from app.models.advice import Advice +from app.models.artifact import Artifact +from app.models.audit import AssistantAction, AssistantConfig, AuditLog +from app.models.broadcast import Broadcast, BroadcastRecipient, BroadcastTemplate +from app.models.event import Event, EventReminder +from app.models.goal import DataUpload, Goal, RegionGoalValue, Snapshot +from app.models.history import PlatformHistory +from app.models.knowledge import KnowledgeArticle, KnowledgeCategory from app.models.region import FederalDistrict, Region +from app.models.region_card import RegionCard, RegionCardHistory, RegionLink +from app.models.request import Request, RequestComment +from app.models.task import Task, TaskComment +from app.models.user import LoginToken, Role, Session, User, UserRegion, UserRole -__all__ = ["FederalDistrict", "Region"] +__all__ = [ + "Advice", + "Artifact", + "AssistantAction", + "AssistantConfig", + "AuditLog", + "Broadcast", + "BroadcastRecipient", + "BroadcastTemplate", + "DataUpload", + "Event", + "EventReminder", + "FederalDistrict", + "Goal", + "KnowledgeArticle", + "KnowledgeCategory", + "LoginToken", + "PlatformHistory", + "Region", + "RegionCard", + "RegionCardHistory", + "RegionGoalValue", + "RegionLink", + "Request", + "RequestComment", + "Role", + "Session", + "Snapshot", + "Task", + "TaskComment", + "User", + "UserRegion", + "UserRole", +] diff --git a/backend/app/models/advice.py b/backend/app/models/advice.py new file mode 100644 index 0000000..3dc8c14 --- /dev/null +++ b/backend/app/models/advice.py @@ -0,0 +1,19 @@ +"""Модели советов от регионов.""" +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base + + +class Advice(Base): + __tablename__ = "advices" + + id: Mapped[int] = mapped_column(primary_key=True) + region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE")) + goal_id: Mapped[int | None] = mapped_column(ForeignKey("goals.id")) + author_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + body: Mapped[str] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(20), default="published") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/artifact.py b/backend/app/models/artifact.py new file mode 100644 index 0000000..c54ec51 --- /dev/null +++ b/backend/app/models/artifact.py @@ -0,0 +1,24 @@ +"""Модели артефактов (файлов).""" +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base + + +class Artifact(Base): + __tablename__ = "artifacts" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str | None] = mapped_column(String(300)) + file_key: Mapped[str] = mapped_column(String(500)) + file_name: Mapped[str] = mapped_column(String(300)) + mime_type: Mapped[str | None] = mapped_column(String(100)) + size_bytes: Mapped[int | None] = mapped_column(BigInteger) + region_id: Mapped[int | None] = mapped_column(ForeignKey("regions.id")) + goal_id: Mapped[int | None] = mapped_column(ForeignKey("goals.id")) + task_id: Mapped[int | None] = mapped_column(ForeignKey("tasks.id")) + uploaded_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..dacb3ea --- /dev/null +++ b/backend/app/models/audit.py @@ -0,0 +1,49 @@ +"""Модели аудита и AI-ассистента.""" +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, JSON, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.enums import ActorType +from app.models.types import enum_col + + +class AuditLog(Base): + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(primary_key=True) + actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + actor_type: Mapped[ActorType] = mapped_column(enum_col(ActorType)) + action: Mapped[str] = mapped_column(String(100)) + entity_type: Mapped[str | None] = mapped_column(String(100)) + entity_id: Mapped[str | None] = mapped_column(String(100)) + payload: Mapped[dict | None] = mapped_column(JSON) + ip: Mapped[str | None] = mapped_column(String(64)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class AssistantConfig(Base): + __tablename__ = "assistant_configs" + + id: Mapped[int] = mapped_column(primary_key=True) + owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=False) + permissions: Mapped[dict | None] = mapped_column(JSON) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class AssistantAction(Base): + __tablename__ = "assistant_actions" + + id: Mapped[int] = mapped_column(primary_key=True) + owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + action: Mapped[str] = mapped_column(String(100)) + entity_type: Mapped[str | None] = mapped_column(String(100)) + entity_id: Mapped[str | None] = mapped_column(String(100)) + payload: Mapped[dict | None] = mapped_column(JSON) + status: Mapped[str] = mapped_column(String(20), default="ok") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/broadcast.py b/backend/app/models/broadcast.py new file mode 100644 index 0000000..924237c --- /dev/null +++ b/backend/app/models/broadcast.py @@ -0,0 +1,65 @@ +"""Модели рассылок.""" +from datetime import datetime + +from sqlalchemy import ( + DateTime, + ForeignKey, + JSON, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.enums import BroadcastStatus, BroadcastType, RecipientStatus +from app.models.types import enum_col + + +class BroadcastTemplate(Base): + __tablename__ = "broadcast_templates" + + id: Mapped[int] = mapped_column(primary_key=True) + code: Mapped[str] = mapped_column(String(64), unique=True) + name: Mapped[str] = mapped_column(String(200)) + type: Mapped[BroadcastType] = mapped_column(enum_col(BroadcastType)) + body_template: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class Broadcast(Base): + __tablename__ = "broadcasts" + + id: Mapped[int] = mapped_column(primary_key=True) + template_id: Mapped[int | None] = mapped_column(ForeignKey("broadcast_templates.id")) + type: Mapped[BroadcastType] = mapped_column(enum_col(BroadcastType)) + subject: Mapped[str | None] = mapped_column(String(300)) + body: Mapped[str | None] = mapped_column(Text) + audience_filter: Mapped[dict | None] = mapped_column(JSON) + status: Mapped[BroadcastStatus] = mapped_column( + enum_col(BroadcastStatus), default=BroadcastStatus.DRAFT + ) + scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class BroadcastRecipient(Base): + __tablename__ = "broadcast_recipients" + + id: Mapped[int] = mapped_column(primary_key=True) + broadcast_id: Mapped[int] = mapped_column(ForeignKey("broadcasts.id", ondelete="CASCADE")) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + status: Mapped[RecipientStatus] = mapped_column( + enum_col(RecipientStatus), default=RecipientStatus.PENDING + ) + sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + error: Mapped[str | None] = mapped_column(Text) + + __table_args__ = (UniqueConstraint("broadcast_id", "user_id", name="uq_broadcast_user"),) diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py new file mode 100644 index 0000000..095b1e4 --- /dev/null +++ b/backend/app/models/enums.py @@ -0,0 +1,96 @@ +"""Перечисления предметной области.""" +from enum import StrEnum + + +class MessengerType(StrEnum): + TELEGRAM = "telegram" + MAX = "max" + + +class UserStatus(StrEnum): + ACTIVE = "active" + BLOCKED = "blocked" + PENDING = "pending" + + +class RoleCode(StrEnum): + ADMIN = "admin" + COORDINATOR = "coordinator" + REGION_HEAD = "region_head" + MEMBER = "member" + DEPUTY = "deputy" + + +class LoginTokenStatus(StrEnum): + ACTIVE = "active" + USED = "used" + EXPIRED = "expired" + REVOKED = "revoked" + + +class SnapshotKind(StrEnum): + POINT0 = "point0" + CURRENT = "current" + HISTORY = "history" + + +class MemberType(StrEnum): + MEMBER = "member" + RESIDENT = "resident" + + +class TaskStatus(StrEnum): + NEW = "new" + IN_PROGRESS = "in_progress" + REVIEW = "review" + DONE = "done" + + +class TaskPriority(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class RequestType(StrEnum): + JOIN = "join" + ACCESS = "access" + EVENT = "event" + SUPPORT = "support" + + +class RequestStatus(StrEnum): + NEW = "new" + IN_PROGRESS = "in_progress" + CLOSED = "closed" + + +class BroadcastType(StrEnum): + INFO = "info" + EDUCATION = "education" + RATING = "rating" + + +class BroadcastStatus(StrEnum): + DRAFT = "draft" + SCHEDULED = "scheduled" + SENDING = "sending" + SENT = "sent" + FAILED = "failed" + + +class RecipientStatus(StrEnum): + PENDING = "pending" + SENT = "sent" + FAILED = "failed" + + +class ActorType(StrEnum): + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + + +class LinkType(StrEnum): + COMMUNITY = "community" + CHAT = "chat" diff --git a/backend/app/models/event.py b/backend/app/models/event.py new file mode 100644 index 0000000..4ce473e --- /dev/null +++ b/backend/app/models/event.py @@ -0,0 +1,33 @@ +"""Модели календаря событий.""" +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base + + +class Event(Base): + __tablename__ = "events" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(300)) + description: Mapped[str | None] = mapped_column(Text) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + location: Mapped[str | None] = mapped_column(String(300)) + created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class EventReminder(Base): + __tablename__ = "event_reminders" + + id: Mapped[int] = mapped_column(primary_key=True) + event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE")) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + remind_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/models/goal.py b/backend/app/models/goal.py new file mode 100644 index 0000000..cd237d4 --- /dev/null +++ b/backend/app/models/goal.py @@ -0,0 +1,75 @@ +"""Модели целей, срезов и метрик.""" +from datetime import date, datetime + +from sqlalchemy import ( + Boolean, + Date, + DateTime, + ForeignKey, + Numeric, + String, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base +from app.models.enums import SnapshotKind +from app.models.types import enum_col + + +class Goal(Base): + __tablename__ = "goals" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(200)) + description: Mapped[str | None] = mapped_column(String(1000)) + target_value: Mapped[float | None] = mapped_column(Numeric(14, 2)) + unit: Mapped[str | None] = mapped_column(String(32)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + snapshots: Mapped[list["Snapshot"]] = relationship(back_populates="goal") + + +class Snapshot(Base): + __tablename__ = "snapshots" + + id: Mapped[int] = mapped_column(primary_key=True) + goal_id: Mapped[int] = mapped_column(ForeignKey("goals.id", ondelete="CASCADE")) + snapshot_date: Mapped[date] = mapped_column(Date) + kind: Mapped[SnapshotKind] = mapped_column(enum_col(SnapshotKind)) + source: Mapped[str | None] = mapped_column(String(32)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + goal: Mapped[Goal] = relationship(back_populates="snapshots") + values: Mapped[list["RegionGoalValue"]] = relationship(back_populates="snapshot") + + +class RegionGoalValue(Base): + __tablename__ = "region_goal_values" + + id: Mapped[int] = mapped_column(primary_key=True) + snapshot_id: Mapped[int] = mapped_column(ForeignKey("snapshots.id", ondelete="CASCADE")) + region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE")) + value: Mapped[float] = mapped_column(Numeric(14, 2)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = (UniqueConstraint("snapshot_id", "region_id", name="uq_snapshot_region"),) + + snapshot: Mapped[Snapshot] = relationship(back_populates="values") + + +class DataUpload(Base): + __tablename__ = "data_uploads" + + id: Mapped[int] = mapped_column(primary_key=True) + goal_id: Mapped[int] = mapped_column(ForeignKey("goals.id")) + file_key: Mapped[str] = mapped_column(String(500)) + uploaded_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + status: Mapped[str] = mapped_column(String(20), default="uploaded") + processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/history.py b/backend/app/models/history.py new file mode 100644 index 0000000..d82a770 --- /dev/null +++ b/backend/app/models/history.py @@ -0,0 +1,19 @@ +"""Модели истории платформы.""" +from datetime import date, datetime + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base + + +class PlatformHistory(Base): + __tablename__ = "platform_history" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(300)) + body: Mapped[str | None] = mapped_column(Text) + event_date: Mapped[date | None] = mapped_column(Date) + is_public: Mapped[bool] = mapped_column(Boolean, default=True) + created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/knowledge.py b/backend/app/models/knowledge.py new file mode 100644 index 0000000..375591e --- /dev/null +++ b/backend/app/models/knowledge.py @@ -0,0 +1,28 @@ +"""Модели базы знаний.""" +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base + + +class KnowledgeCategory(Base): + __tablename__ = "knowledge_categories" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(200), unique=True) + + +class KnowledgeArticle(Base): + __tablename__ = "knowledge_articles" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(300)) + body: Mapped[str] = mapped_column(Text) + category_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_categories.id")) + created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/models/region_card.py b/backend/app/models/region_card.py new file mode 100644 index 0000000..4355a36 --- /dev/null +++ b/backend/app/models/region_card.py @@ -0,0 +1,46 @@ +"""Модели карточки региона.""" +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.enums import LinkType +from app.models.types import enum_col + + +class RegionCard(Base): + __tablename__ = "region_cards" + + region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE"), primary_key=True) + population: Mapped[int | None] = mapped_column(Integer) + head_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class RegionLink(Base): + __tablename__ = "region_links" + + id: Mapped[int] = mapped_column(primary_key=True) + region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE")) + type: Mapped[LinkType] = mapped_column(enum_col(LinkType)) + title: Mapped[str | None] = mapped_column(String(200)) + url: Mapped[str] = mapped_column(String(500)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class RegionCardHistory(Base): + __tablename__ = "region_card_history" + + id: Mapped[int] = mapped_column(primary_key=True) + region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE")) + field: Mapped[str] = mapped_column(String(100)) + old_value: Mapped[str | None] = mapped_column(String(1000)) + new_value: Mapped[str | None] = mapped_column(String(1000)) + changed_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/request.py b/backend/app/models/request.py new file mode 100644 index 0000000..935ec7a --- /dev/null +++ b/backend/app/models/request.py @@ -0,0 +1,42 @@ +"""Модели запросов (заявок).""" +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base +from app.models.enums import RequestStatus, RequestType +from app.models.types import enum_col + + +class Request(Base): + __tablename__ = "requests" + + id: Mapped[int] = mapped_column(primary_key=True) + type: Mapped[RequestType] = mapped_column(enum_col(RequestType)) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + region_id: Mapped[int | None] = mapped_column(ForeignKey("regions.id")) + subject: Mapped[str | None] = mapped_column(String(300)) + body: Mapped[str | None] = mapped_column(Text) + status: Mapped[RequestStatus] = mapped_column( + enum_col(RequestStatus), default=RequestStatus.NEW + ) + assigned_to: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + comments: Mapped[list["RequestComment"]] = relationship(back_populates="request") + + +class RequestComment(Base): + __tablename__ = "request_comments" + + id: Mapped[int] = mapped_column(primary_key=True) + request_id: Mapped[int] = mapped_column(ForeignKey("requests.id", ondelete="CASCADE")) + author_id: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + body: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + request: Mapped[Request] = relationship(back_populates="comments") diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..a5dfa65 --- /dev/null +++ b/backend/app/models/task.py @@ -0,0 +1,47 @@ +"""Модели задач (трекера).""" +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base +from app.models.enums import RoleCode, TaskPriority, TaskStatus +from app.models.types import enum_col + + +class Task(Base): + __tablename__ = "tasks" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(300)) + description: Mapped[str | None] = mapped_column(Text) + due_date: Mapped[date | None] = mapped_column(Date) + priority: Mapped[TaskPriority] = mapped_column( + enum_col(TaskPriority), default=TaskPriority.MEDIUM + ) + status: Mapped[TaskStatus] = mapped_column( + enum_col(TaskStatus), default=TaskStatus.NEW + ) + region_id: Mapped[int | None] = mapped_column(ForeignKey("regions.id")) + assignee_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + assignee_role: Mapped[RoleCode | None] = mapped_column(enum_col(RoleCode)) + created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + comments: Mapped[list["TaskComment"]] = relationship(back_populates="task") + + +class TaskComment(Base): + __tablename__ = "task_comments" + + id: Mapped[int] = mapped_column(primary_key=True) + task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE")) + author_id: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + body: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + task: Mapped[Task] = relationship(back_populates="comments") diff --git a/backend/app/models/types.py b/backend/app/models/types.py new file mode 100644 index 0000000..ce4abfa --- /dev/null +++ b/backend/app/models/types.py @@ -0,0 +1,17 @@ +"""Вспомогательные типы для моделей.""" +from enum import Enum as PyEnum + +from sqlalchemy import Enum as SAEnum + + +def enum_col(enum_cls: type[PyEnum], length: int = 20) -> SAEnum: + """Enum-колонка, хранящая значения (value), а не имена членов. + + Например, BroadcastType.INFO хранится как 'info', а не 'INFO'. + """ + return SAEnum( + enum_cls, + native_enum=False, + length=length, + values_callable=lambda obj: [e.value for e in obj], + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..a810d15 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,108 @@ +"""Модели пользователей, ролей и доступа.""" +from datetime import date, datetime + +from sqlalchemy import ( + Boolean, + Date, + DateTime, + ForeignKey, + String, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base +from app.models.enums import LoginTokenStatus, MemberType, MessengerType, RoleCode, UserStatus +from app.models.types import enum_col + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + full_name: Mapped[str | None] = mapped_column(String(200)) + messenger_type: Mapped[MessengerType] = mapped_column(enum_col(MessengerType)) + messenger_id: Mapped[str] = mapped_column(String(64)) + phone: Mapped[str | None] = mapped_column(String(32)) + status: Mapped[UserStatus] = mapped_column( + enum_col(UserStatus), default=UserStatus.ACTIVE + ) + member_type: Mapped[MemberType | None] = mapped_column(enum_col(MemberType)) + joined_at: Mapped[date | None] = mapped_column(Date) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + __table_args__ = (UniqueConstraint("messenger_type", "messenger_id", name="uq_user_messenger"),) + + regions: Mapped[list["UserRegion"]] = relationship(back_populates="user") + roles: Mapped[list["UserRole"]] = relationship( + back_populates="user", foreign_keys="UserRole.user_id" + ) + + +class UserRegion(Base): + __tablename__ = "user_regions" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + region_id: Mapped[int] = mapped_column(ForeignKey("regions.id", ondelete="CASCADE")) + is_primary: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = (UniqueConstraint("user_id", "region_id", name="uq_user_region"),) + + user: Mapped[User] = relationship(back_populates="regions") + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[int] = mapped_column(primary_key=True) + code: Mapped[RoleCode] = mapped_column(enum_col(RoleCode), unique=True) + name: Mapped[str] = mapped_column(String(100)) + + +class UserRole(Base): + __tablename__ = "user_roles" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id")) + region_id: Mapped[int | None] = mapped_column(ForeignKey("regions.id")) + granted_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = (UniqueConstraint("user_id", "role_id", "region_id", name="uq_user_role"),) + + user: Mapped[User] = relationship(back_populates="roles", foreign_keys=[user_id]) + + +class LoginToken(Base): + __tablename__ = "login_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + token: Mapped[str] = mapped_column(String(128), unique=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + region_id: Mapped[int | None] = mapped_column(ForeignKey("regions.id")) + issued_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + status: Mapped[LoginTokenStatus] = mapped_column( + enum_col(LoginTokenStatus), default=LoginTokenStatus.ACTIVE + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class Session(Base): + __tablename__ = "sessions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + token_hash: Mapped[str] = mapped_column(String(128), unique=True) + messenger_type: Mapped[MessengerType] = mapped_column(enum_col(MessengerType)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py new file mode 100644 index 0000000..97f7d5e --- /dev/null +++ b/backend/scripts/seed.py @@ -0,0 +1,106 @@ +"""Наполнение справочников: федеральные округа, регионы, роли. + +Запуск: + docker compose exec api python -m scripts.seed +""" +from sqlalchemy import select + +from app.db.session import SessionLocal +from app.models.enums import RoleCode +from app.models.region import FederalDistrict, Region +from app.models.user import Role + +DISTRICTS: dict[str, list[str]] = { + "ЦФО": [ + "Белгородская область", "Брянская область", "Владимирская область", + "Воронежская область", "Ивановская область", "Калужская область", + "Костромская область", "Курская область", "Липецкая область", + "Московская область", "Орловская область", "Рязанская область", + "Смоленская область", "Тамбовская область", "Тверская область", + "Тульская область", "Ярославская область", "Москва", + ], + "СЗФО": [ + "Республика Карелия", "Республика Коми", "Архангельская область", + "Вологодская область", "Калининградская область", "Ленинградская область", + "Мурманская область", "Новгородская область", "Псковская область", + "Санкт-Петербург", "Ненецкий автономный округ", + ], + "ЮФО": [ + "Республика Адыгея", "Республика Калмыкия", "Республика Крым", + "Краснодарский край", "Астраханская область", "Волгоградская область", + "Ростовская область", "Севастополь", + "Донецкая Народная Республика", "Луганская Народная Республика", + "Запорожская область", "Херсонская область", + ], + "СКФО": [ + "Республика Дагестан", "Республика Ингушетия", + "Кабардино-Балкарская Республика", "Карачаево-Черкесская Республика", + "Республика Северная Осетия — Алания", "Чеченская Республика", + "Ставропольский край", + ], + "ПФО": [ + "Республика Башкортостан", "Республика Марий Эл", "Республика Мордовия", + "Республика Татарстан", "Удмуртская Республика", "Чувашская Республика", + "Пермский край", "Кировская область", "Нижегородская область", + "Оренбургская область", "Пензенская область", "Самарская область", + "Саратовская область", "Ульяновская область", + ], + "УФО": [ + "Курганская область", "Свердловская область", "Тюменская область", + "Челябинская область", "Ханты-Мансийский автономный округ — Югра", + "Ямало-Ненецкий автономный округ", + ], + "СФО": [ + "Республика Алтай", "Республика Тыва", "Республика Хакасия", + "Алтайский край", "Красноярский край", "Кемеровская область — Кузбасс", + "Иркутская область", "Новосибирская область", "Омская область", + "Томская область", + ], + "ДФО": [ + "Республика Бурятия", "Республика Саха (Якутия)", "Забайкальский край", + "Камчатский край", "Приморский край", "Хабаровский край", + "Амурская область", "Магаданская область", "Сахалинская область", + "Еврейская автономная область", "Чукотский автономный округ", + ], +} + +ROLES: list[tuple[RoleCode, str]] = [ + (RoleCode.ADMIN, "Администратор платформы"), + (RoleCode.COORDINATOR, "Координатор"), + (RoleCode.REGION_HEAD, "Руководитель регионального отделения"), + (RoleCode.MEMBER, "Член Организации"), + (RoleCode.DEPUTY, "Заместитель"), +] + + +def seed() -> None: + db = SessionLocal() + try: + # Роли + for code, name in ROLES: + if not db.scalar(select(Role).where(Role.code == code)): + db.add(Role(code=code, name=name)) + + # Округа и регионы + for district_name, regions in DISTRICTS.items(): + district = db.scalar( + select(FederalDistrict).where(FederalDistrict.name == district_name) + ) + if not district: + district = FederalDistrict(name=district_name, code=district_name) + db.add(district) + db.flush() + for region_name in regions: + if not db.scalar(select(Region).where(Region.name == region_name)): + db.add(Region(name=region_name, federal_district_id=district.id)) + + db.commit() + regions_count = len(db.scalars(select(Region)).all()) + districts_count = len(db.scalars(select(FederalDistrict)).all()) + print(f"Справочники наполнены: округов — {districts_count}, регионов — {regions_count}.") + finally: + db.close() + + +if __name__ == "__main__": + seed()