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

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

86
alembic/env.py Normal file
View File

@@ -0,0 +1,86 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from sqlalchemy.ext.asyncio import create_async_engine
from alembic import context
import app.models # noqa: F401
from app.core.config import settings
from sqlmodel import SQLModel
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Set the database URL from settings - convert async URL to sync for alembic
sync_db_url = settings.DATABASE_URL.replace("sqlite+aiosqlite", "sqlite")
sync_db_url = sync_db_url.replace("postgresql+asyncpg", "postgresql")
config.set_main_option("sqlalchemy.url", sync_db_url)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = SQLModel.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,28 @@
"""${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 identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[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:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

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")