Add Alembic for database migrations and initial migration scripts

- Created alembic.ini configuration file for Alembic migrations.
- Added README file for Alembic with a brief description.
- Implemented env.py for Alembic to manage database migrations.
- Created script.py.mako template for migration scripts.
- Added initial migration script to create database tables.
- Created a migration script to add initial plan and playlist data.
- Updated database initialization to run Alembic migrations.
- Enhanced credit service to automatically recharge user credits based on their plan.
- Implemented delete_task method in scheduler service to remove scheduled tasks.
- Updated scheduler API to reflect task deletion instead of cancellation.
- Added CLI tool for managing database migrations.
- Updated tests to cover new functionality for task deletion and credit recharge.
- Updated pyproject.toml and lock files to include Alembic as a dependency.
This commit is contained in:
JSC
2025-09-16 13:45:14 +02:00
parent e8f979c137
commit 83239cb4fa
16 changed files with 828 additions and 29 deletions

View File

@@ -0,0 +1,222 @@
"""Initial migration
Revision ID: 7aa9892ceff3
Revises:
Create Date: 2025-09-16 13:16:58.233360
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '7aa9892ceff3'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('plan',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('credits', sa.Integer(), nullable=False),
sa.Column('max_credits', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_plan_code'), 'plan', ['code'], unique=True)
op.create_table('sound',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('duration', sa.Integer(), nullable=False),
sa.Column('size', sa.Integer(), nullable=False),
sa.Column('hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('normalized_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('normalized_duration', sa.Integer(), nullable=True),
sa.Column('normalized_size', sa.Integer(), nullable=True),
sa.Column('normalized_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('thumbnail', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('play_count', sa.Integer(), nullable=False),
sa.Column('is_normalized', sa.Boolean(), nullable=False),
sa.Column('is_music', sa.Boolean(), nullable=False),
sa.Column('is_deletable', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('hash', name='uq_sound_hash')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('plan_id', sa.Integer(), nullable=False),
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('picture', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('credits', sa.Integer(), nullable=False),
sa.Column('api_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('api_token_expires_at', sa.DateTime(), nullable=True),
sa.Column('refresh_token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('refresh_token_expires_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['plan_id'], ['plan.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('api_token'),
sa.UniqueConstraint('email')
)
op.create_table('credit_transaction',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('action_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('balance_before', sa.Integer(), nullable=False),
sa.Column('balance_after', sa.Integer(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('success', sa.Boolean(), nullable=False),
sa.Column('metadata_json', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('extraction',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('service', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('service_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('sound_id', sa.Integer(), nullable=True),
sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('track', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('artist', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('album', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('genre', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('error', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('playlist',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('genre', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_main', sa.Boolean(), nullable=False),
sa.Column('is_current', sa.Boolean(), nullable=False),
sa.Column('is_deletable', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('scheduled_task',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
sa.Column('task_type', sa.Enum('CREDIT_RECHARGE', 'PLAY_SOUND', 'PLAY_PLAYLIST', name='tasktype'), nullable=False),
sa.Column('status', sa.Enum('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED', name='taskstatus'), nullable=False),
sa.Column('scheduled_at', sa.DateTime(), nullable=False),
sa.Column('timezone', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('recurrence_type', sa.Enum('NONE', 'MINUTELY', 'HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY', 'CRON', name='recurrencetype'), nullable=False),
sa.Column('cron_expression', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('recurrence_count', sa.Integer(), nullable=True),
sa.Column('executions_count', sa.Integer(), nullable=False),
sa.Column('parameters', sa.JSON(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('last_executed_at', sa.DateTime(), nullable=True),
sa.Column('next_execution_at', sa.DateTime(), nullable=True),
sa.Column('error_message', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('sound_played',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('sound_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user_oauth',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('provider_user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('picture', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('provider', 'provider_user_id', name='uq_user_oauth_provider_user_id')
)
op.create_table('favorite',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('sound_id', sa.Integer(), nullable=True),
sa.Column('playlist_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['playlist_id'], ['playlist.id'], ),
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'playlist_id', name='uq_favorite_user_playlist'),
sa.UniqueConstraint('user_id', 'sound_id', name='uq_favorite_user_sound')
)
op.create_table('playlist_sound',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('playlist_id', sa.Integer(), nullable=False),
sa.Column('sound_id', sa.Integer(), nullable=False),
sa.Column('position', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['playlist_id'], ['playlist.id'], ),
sa.ForeignKeyConstraint(['sound_id'], ['sound.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('playlist_id', 'position', name='uq_playlist_sound_playlist_position'),
sa.UniqueConstraint('playlist_id', 'sound_id', name='uq_playlist_sound_playlist_sound')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('playlist_sound')
op.drop_table('favorite')
op.drop_table('user_oauth')
op.drop_table('sound_played')
op.drop_table('scheduled_task')
op.drop_table('playlist')
op.drop_table('extraction')
op.drop_table('credit_transaction')
op.drop_table('user')
op.drop_table('sound')
op.drop_index(op.f('ix_plan_code'), table_name='plan')
op.drop_table('plan')
# ### end Alembic commands ###

View File

@@ -0,0 +1,106 @@
"""Add initial plan and playlist data
Revision ID: a0d322857b2c
Revises: 7aa9892ceff3
Create Date: 2025-09-16 13:23:31.682276
"""
from typing import Sequence, Union
from datetime import datetime
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a0d322857b2c'
down_revision: Union[str, Sequence[str], None] = '7aa9892ceff3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema and add initial data."""
# Get the current timestamp
now = datetime.utcnow()
# Insert initial plans
plans_table = sa.table(
'plan',
sa.column('code', sa.String),
sa.column('name', sa.String),
sa.column('description', sa.String),
sa.column('credits', sa.Integer),
sa.column('max_credits', sa.Integer),
sa.column('created_at', sa.DateTime),
sa.column('updated_at', sa.DateTime),
)
op.bulk_insert(
plans_table,
[
{
'code': 'free',
'name': 'Free Plan',
'description': 'Basic free plan with limited features',
'credits': 25,
'max_credits': 75,
'created_at': now,
'updated_at': now,
},
{
'code': 'premium',
'name': 'Premium Plan',
'description': 'Premium plan with more features',
'credits': 50,
'max_credits': 150,
'created_at': now,
'updated_at': now,
},
{
'code': 'pro',
'name': 'Pro Plan',
'description': 'Pro plan with unlimited features',
'credits': 100,
'max_credits': 300,
'created_at': now,
'updated_at': now,
},
]
)
# Insert main playlist
playlist_table = sa.table(
'playlist',
sa.column('name', sa.String),
sa.column('description', sa.String),
sa.column('is_main', sa.Boolean),
sa.column('is_deletable', sa.Boolean),
sa.column('is_current', sa.Boolean),
sa.column('created_at', sa.DateTime),
sa.column('updated_at', sa.DateTime),
)
op.bulk_insert(
playlist_table,
[
{
'name': 'All',
'description': 'The default main playlist with all the tracks',
'is_main': True,
'is_deletable': False,
'is_current': True,
'created_at': now,
'updated_at': now,
}
]
)
def downgrade() -> None:
"""Downgrade schema and remove initial data."""
# Remove initial plans
op.execute("DELETE FROM plan WHERE code IN ('free', 'premium', 'pro')")
# Remove main playlist
op.execute("DELETE FROM playlist WHERE is_main = 1")