Compare commits

...
2 Commits
31 changed files with 1843 additions and 3 deletions

No files matched your search

+2
View File
@@ -25,3 +25,5 @@ Thumbs.db
# Data
*.log
*.db
*.sqlite3
+25
View File
@@ -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"}
View File
Whitespace-only changes.
@@ -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 ###
+74
View File
@@ -0,0 +1,74 @@
"""Зависимости FastAPI: аутентификация и авторизация."""
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from app.core.config import settings
from app.core.security import decode_access_token
from app.db.session import get_db
from app.models.enums import RoleCode
from app.models.user import Role, User, UserRole
from app.models.user import Session as UserSession
bearer = HTTPBearer(auto_error=False)
def get_current_session(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
db: DBSession = Depends(get_db),
) -> UserSession:
"""Текущая сессия по access-токену."""
if not credentials:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Требуется авторизация")
try:
payload = decode_access_token(credentials.credentials)
except Exception as exc: # noqa: BLE001
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный токен") from exc
session = db.get(UserSession, payload.get("sid"))
if not session or session.revoked_at:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Сессия завершена")
return session
def get_current_user(
session: UserSession = Depends(get_current_session),
db: DBSession = Depends(get_db),
) -> User:
"""Текущий пользователь."""
user = db.get(User, session.user_id)
if not user:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Пользователь не найден")
return user
def get_user_role_codes(db: DBSession, user_id: int) -> set[RoleCode]:
"""Множество ролей пользователя."""
rows = db.scalars(select(UserRole).where(UserRole.user_id == user_id)).all()
codes: set[RoleCode] = set()
for row in rows:
role = db.get(Role, row.role_id)
if role:
codes.add(role.code)
return codes
def require_roles(*codes: RoleCode):
"""Фабрика зависимости: доступ только для указанных ролей."""
def checker(
user: User = Depends(get_current_user),
db: DBSession = Depends(get_db),
) -> User:
if not get_user_role_codes(db, user.id).intersection(set(codes)):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Недостаточно прав")
return user
return checker
def require_service_token(x_service_token: str | None = Header(default=None)) -> None:
"""Проверка сервисного токена бота."""
if not settings.bot_service_token or x_service_token != settings.bot_service_token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный сервисный токен")
+60
View File
@@ -0,0 +1,60 @@
"""Эндпоинты аутентификации."""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session as DBSession
from app.api.deps import get_current_session, get_current_user, require_roles
from app.db.session import get_db
from app.models.enums import RoleCode
from app.models.user import User
from app.models.user import Session as UserSession
from app.schemas.auth import (
LoginRequest,
LoginResponse,
LoginTokenCreate,
LoginTokenOut,
RefreshRequest,
TokenPair,
UserOut,
)
from app.services import auth as auth_service
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login-token", response_model=LoginTokenOut, status_code=201)
def create_login_token(
data: LoginTokenCreate,
user: User = Depends(require_roles(RoleCode.COORDINATOR, RoleCode.ADMIN)),
db: DBSession = Depends(get_db),
) -> LoginTokenOut:
lt, url = auth_service.create_login_token(db, data, issued_by=user.id)
return LoginTokenOut(id=lt.id, url=url, expires_at=lt.expires_at)
@router.post("/login", response_model=LoginResponse)
def login(data: LoginRequest, db: DBSession = Depends(get_db)) -> LoginResponse:
tokens, user = auth_service.login(db, data)
return LoginResponse(
**tokens.model_dump(), user=auth_service.build_user_out(db, user)
)
@router.post("/refresh", response_model=TokenPair)
def refresh(data: RefreshRequest, db: DBSession = Depends(get_db)) -> TokenPair:
return auth_service.refresh(db, data.refresh_token)
@router.post("/logout", status_code=204)
def logout(
session: UserSession = Depends(get_current_session),
db: DBSession = Depends(get_db),
) -> None:
auth_service.logout(db, session.id)
@router.get("/me", response_model=UserOut)
def me(
user: User = Depends(get_current_user),
db: DBSession = Depends(get_db),
) -> UserOut:
return auth_service.build_user_out(db, user)
+2 -1
View File
@@ -1,7 +1,8 @@
"""Корневой роутер API v1."""
from fastapi import APIRouter
from app.api.v1 import health
from app.api.v1 import auth, health
api_router = APIRouter()
api_router.include_router(health.router, tags=["system"])
api_router.include_router(auth.router)
+1
View File
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
# CORS
cors_origins: list[str] = ["https://opora.my-dpr.ru"]
frontend_base_url: str = "https://opora.my-dpr.ru"
# MinIO / S3
s3_endpoint: str = "http://minio:9000"
+36
View File
@@ -0,0 +1,36 @@
"""Утилиты безопасности: JWT и хеширование токенов."""
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from jose import jwt
from app.core.config import settings
def create_access_token(user_id: int, session_id: str) -> str:
"""Создать access-токен (JWT)."""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"sid": session_id,
"type": "access",
"iat": int(now.timestamp()),
"exp": int((now + timedelta(days=settings.access_token_ttl_days)).timestamp()),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict:
"""Декодировать и проверить access-токен."""
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
def generate_opaque_token() -> str:
"""Сгенерировать случайный opaque-токен (refresh, одноразовая ссылка)."""
return secrets.token_urlsafe(48)
def hash_token(token: str) -> str:
"""SHA-256 хеш токена (для хранения в БД)."""
return hashlib.sha256(token.encode()).hexdigest()
+47 -2
View File
@@ -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",
]
+19
View File
@@ -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())
+24
View File
@@ -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())
+49
View File
@@ -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())
+65
View File
@@ -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"),)
+96
View File
@@ -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"
+33
View File
@@ -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))
+75
View File
@@ -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())
+19
View File
@@ -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())
+28
View File
@@ -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()
)
+46
View File
@@ -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())
+42
View File
@@ -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")
+47
View File
@@ -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")
+17
View File
@@ -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],
)
+108
View File
@@ -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))
View File
Whitespace-only changes.
+62
View File
@@ -0,0 +1,62 @@
"""Pydantic-схемы аутентификации."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import MessengerType
class LoginTokenCreate(BaseModel):
region_id: int | None = None
user_id: int | None = None
ttl_minutes: int = 60
class LoginTokenOut(BaseModel):
id: str
url: str
expires_at: datetime
class LoginRequest(BaseModel):
token: str
messenger_type: MessengerType
messenger_id: str
full_name: str | None = None
class RefreshRequest(BaseModel):
refresh_token: str
class TokenPair(BaseModel):
access_token: str
refresh_token: str
expires_at: datetime
class RegionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
is_primary: bool
class RoleOut(BaseModel):
code: str
region_id: int | None = None
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
full_name: str | None = None
member_type: str | None = None
regions: list[RegionOut] = []
roles: list[RoleOut] = []
class LoginResponse(TokenPair):
user: UserOut
View File
Whitespace-only changes.
+173
View File
@@ -0,0 +1,173 @@
"""Сервис аутентификации: одноразовые ссылки, вход, refresh, выход."""
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from app.core.config import settings
from app.core.security import create_access_token, generate_opaque_token, hash_token
from app.models.enums import LoginTokenStatus
from app.models.user import LoginToken, Role, User, UserRegion, UserRole
from app.models.user import Session as UserSession
from app.schemas.auth import (
LoginRequest,
LoginTokenCreate,
RegionOut,
RoleOut,
TokenPair,
UserOut,
)
def _now() -> datetime:
return datetime.now(timezone.utc)
def _as_aware(dt: datetime | None) -> datetime | None:
"""Привести naive datetime (например, из SQLite) к aware UTC."""
if dt is not None and dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def create_login_token(
db: DBSession, data: LoginTokenCreate, issued_by: int | None
) -> tuple[LoginToken, str]:
"""Создать одноразовую ссылку входа."""
token = generate_opaque_token()
expires_at = _now() + timedelta(minutes=data.ttl_minutes)
lt = LoginToken(
id=str(uuid4()),
token=token,
user_id=data.user_id,
region_id=data.region_id,
issued_by=issued_by,
expires_at=expires_at,
)
db.add(lt)
db.commit()
db.refresh(lt)
url = f"{settings.frontend_base_url}/login?token={token}"
return lt, url
def _issue_tokens(db: DBSession, user: User, messenger_type) -> TokenPair:
refresh = generate_opaque_token()
session = UserSession(
id=str(uuid4()),
user_id=user.id,
token_hash=hash_token(refresh),
messenger_type=messenger_type,
expires_at=_now() + timedelta(days=settings.refresh_token_ttl_days),
)
db.add(session)
db.commit()
access = create_access_token(user.id, session.id)
access_exp = _now() + timedelta(days=settings.access_token_ttl_days)
return TokenPair(access_token=access, refresh_token=refresh, expires_at=access_exp)
def login(db: DBSession, data: LoginRequest) -> tuple[TokenPair, User]:
"""Вход по одноразовой ссылке."""
lt = db.scalar(select(LoginToken).where(LoginToken.token == data.token))
if not lt:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ссылка не найдена")
now = _now()
if lt.status == LoginTokenStatus.USED:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Ссылка уже была использована. Запросите новую у координатора",
)
if lt.status != LoginTokenStatus.ACTIVE or _as_aware(lt.expires_at) < now:
lt.status = LoginTokenStatus.EXPIRED
db.commit()
raise HTTPException(status.HTTP_410_GONE, "Ссылка истекла")
user = db.scalar(
select(User).where(
User.messenger_type == data.messenger_type,
User.messenger_id == data.messenger_id,
)
)
if not user:
user = User(
messenger_type=data.messenger_type,
messenger_id=data.messenger_id,
full_name=data.full_name,
)
db.add(user)
db.flush()
elif data.full_name and not user.full_name:
user.full_name = data.full_name
if lt.region_id:
linked = db.scalar(
select(UserRegion).where(
UserRegion.user_id == user.id, UserRegion.region_id == lt.region_id
)
)
if not linked:
db.add(UserRegion(user_id=user.id, region_id=lt.region_id, is_primary=True))
lt.status = LoginTokenStatus.USED
lt.used_at = now
db.commit()
tokens = _issue_tokens(db, user, data.messenger_type)
return tokens, user
def refresh(db: DBSession, refresh_token: str) -> TokenPair:
"""Обновить access-токен (с ротацией refresh)."""
session = db.scalar(
select(UserSession).where(UserSession.token_hash == hash_token(refresh_token))
)
now = _now()
if not session or session.revoked_at or _as_aware(session.expires_at) < now:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Недействительный refresh-токен")
new_refresh = generate_opaque_token()
session.token_hash = hash_token(new_refresh)
db.commit()
access = create_access_token(session.user_id, session.id)
access_exp = now + timedelta(days=settings.access_token_ttl_days)
return TokenPair(access_token=access, refresh_token=new_refresh, expires_at=access_exp)
def logout(db: DBSession, session_id: str) -> None:
"""Завершить сессию."""
session = db.get(UserSession, session_id)
if session and not session.revoked_at:
session.revoked_at = _now()
db.commit()
def build_user_out(db: DBSession, user: User) -> UserOut:
"""Собрать профиль пользователя с регионами и ролями."""
from app.models.region import Region
links = db.scalars(select(UserRegion).where(UserRegion.user_id == user.id)).all()
regions = []
for link in links:
region = db.get(Region, link.region_id)
if region:
regions.append(RegionOut(id=region.id, name=region.name, is_primary=link.is_primary))
role_rows = db.scalars(select(UserRole).where(UserRole.user_id == user.id)).all()
roles = []
for r in role_rows:
role = db.get(Role, r.role_id)
if role:
roles.append(RoleOut(code=role.code.value, region_id=r.region_id))
return UserOut(
id=user.id,
full_name=user.full_name,
member_type=user.member_type.value if user.member_type else None,
regions=regions,
roles=roles,
)
+106
View File
@@ -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()
+35
View File
@@ -0,0 +1,35 @@
"""Общие фикстуры для тестов."""
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///./_test.db")
os.environ.setdefault("JWT_SECRET", "test-secret")
os.environ.setdefault("BOT_SERVICE_TOKEN", "svc-token")
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
import app.models # noqa: E402,F401
from app.db.session import Base, SessionLocal, engine # noqa: E402
from app.main import app # noqa: E402
@pytest.fixture(scope="session", autouse=True)
def _setup_db():
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
@pytest.fixture()
def db():
session = SessionLocal()
try:
yield session
finally:
session.close()
@pytest.fixture()
def client():
return TestClient(app)
+108
View File
@@ -0,0 +1,108 @@
"""Тесты аутентификации."""
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from app.models.enums import MessengerType, RoleCode
from app.models.user import LoginToken, Role, User, UserRole
def _seed_admin(db) -> User:
for code, name in [(RoleCode.ADMIN, "Админ"), (RoleCode.COORDINATOR, "Координатор")]:
if not db.query(Role).filter(Role.code == code).first():
db.add(Role(code=code, name=name))
db.commit()
admin = (
db.query(User)
.filter(User.messenger_type == MessengerType.TELEGRAM, User.messenger_id == "1")
.first()
)
if not admin:
admin = User(messenger_type=MessengerType.TELEGRAM, messenger_id="1", full_name="Админ")
db.add(admin)
db.flush()
role = db.query(Role).filter(Role.code == RoleCode.ADMIN).first()
if not db.query(UserRole).filter(
UserRole.user_id == admin.id, UserRole.role_id == role.id
).first():
db.add(UserRole(user_id=admin.id, role_id=role.id))
db.commit()
return admin
def _make_login_token(db, value: str) -> None:
db.add(
LoginToken(
id=str(uuid4()),
token=value,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=30),
)
)
db.commit()
def test_login_flow(client, db):
_seed_admin(db)
_make_login_token(db, "tok-1")
# вход
r = client.post(
"/api/v1/auth/login",
json={"token": "tok-1", "messenger_type": "telegram", "messenger_id": "1"},
)
assert r.status_code == 200, r.text
data = r.json()
assert data["user"]["roles"][0]["code"] == "admin"
access, refresh = data["access_token"], data["refresh_token"]
# повторное использование ссылки
r2 = client.post(
"/api/v1/auth/login",
json={"token": "tok-1", "messenger_type": "telegram", "messenger_id": "1"},
)
assert r2.status_code == 409
# профиль
r3 = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert r3.status_code == 200
# выдача новой ссылки (как админ)
r4 = client.post(
"/api/v1/auth/login-token",
json={"ttl_minutes": 15},
headers={"Authorization": f"Bearer {access}"},
)
assert r4.status_code == 201, r4.text
assert "token=" in r4.json()["url"]
# refresh
r5 = client.post("/api/v1/auth/refresh", json={"refresh_token": refresh})
assert r5.status_code == 200, r5.text
new_access = r5.json()["access_token"]
# logout
r6 = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {new_access}"})
assert r6.status_code == 204
# после logout
r7 = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {new_access}"})
assert r7.status_code == 401
def test_login_token_requires_role(client, db):
_seed_admin(db)
_make_login_token(db, "tok-2")
r = client.post(
"/api/v1/auth/login",
json={"token": "tok-2", "messenger_type": "telegram", "messenger_id": "999"},
)
assert r.status_code == 200
access = r.json()["access_token"]
# обычный пользователь без роли не может выдавать ссылки
r2 = client.post(
"/api/v1/auth/login-token",
json={"ttl_minutes": 15},
headers={"Authorization": f"Bearer {access}"},
)
assert r2.status_code == 403