From 4337a921ae938ad50a796d6f3cdf09917334e186 Mon Sep 17 00:00:00 2001 From: dogeystamp Date: Sun, 30 Apr 2023 19:20:16 -0400 Subject: [PATCH] sachet/server/commands.py: added cleanup --- migrations/env.py | 27 +++++------ migrations/versions/4cd7cdbc2d1f_.py | 69 +++++++++++++++------------- sachet/server/commands.py | 22 ++++++++- sachet/server/config.py | 2 + tests/test_cli.py | 35 ++++++++++++-- 5 files changed, 105 insertions(+), 50 deletions(-) diff --git a/migrations/env.py b/migrations/env.py index 89f80b2..650c629 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -12,32 +12,31 @@ config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) -logger = logging.getLogger('alembic.env') +logger = logging.getLogger("alembic.env") def get_engine(): try: # this works with Flask-SQLAlchemy<3 and Alchemical - return current_app.extensions['migrate'].db.get_engine() + return current_app.extensions["migrate"].db.get_engine() except TypeError: # this works with Flask-SQLAlchemy>=3 - return current_app.extensions['migrate'].db.engine + return current_app.extensions["migrate"].db.engine def get_engine_url(): try: - return get_engine().url.render_as_string(hide_password=False).replace( - '%', '%%') + return get_engine().url.render_as_string(hide_password=False).replace("%", "%%") except AttributeError: - return str(get_engine().url).replace('%', '%%') + return str(get_engine().url).replace("%", "%%") # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -config.set_main_option('sqlalchemy.url', get_engine_url()) -target_db = current_app.extensions['migrate'].db +config.set_main_option("sqlalchemy.url", get_engine_url()) +target_db = current_app.extensions["migrate"].db # other values from the config, defined by the needs of env.py, # can be acquired: @@ -46,7 +45,7 @@ target_db = current_app.extensions['migrate'].db def get_metadata(): - if hasattr(target_db, 'metadatas'): + if hasattr(target_db, "metadatas"): return target_db.metadatas[None] return target_db.metadata @@ -64,9 +63,7 @@ def run_migrations_offline(): """ url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, target_metadata=get_metadata(), literal_binds=True - ) + context.configure(url=url, target_metadata=get_metadata(), literal_binds=True) with context.begin_transaction(): context.run_migrations() @@ -84,11 +81,11 @@ def run_migrations_online(): # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): - if getattr(config.cmd_opts, 'autogenerate', False): + if getattr(config.cmd_opts, "autogenerate", False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] - logger.info('No changes in schema detected.') + logger.info("No changes in schema detected.") connectable = get_engine() @@ -97,7 +94,7 @@ def run_migrations_online(): connection=connection, target_metadata=get_metadata(), process_revision_directives=process_revision_directives, - **current_app.extensions['migrate'].configure_args + **current_app.extensions["migrate"].configure_args ) with context.begin_transaction(): diff --git a/migrations/versions/4cd7cdbc2d1f_.py b/migrations/versions/4cd7cdbc2d1f_.py index 835791e..5e507bc 100644 --- a/migrations/versions/4cd7cdbc2d1f_.py +++ b/migrations/versions/4cd7cdbc2d1f_.py @@ -11,7 +11,7 @@ import sqlalchemy_utils # revision identifiers, used by Alembic. -revision = '4cd7cdbc2d1f' +revision = "4cd7cdbc2d1f" down_revision = None branch_labels = None depends_on = None @@ -19,43 +19,50 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('blacklist_tokens', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('token', sa.String(length=500), nullable=False), - sa.Column('expires', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('token') + op.create_table( + "blacklist_tokens", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("token", sa.String(length=500), nullable=False), + sa.Column("expires", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token"), ) - op.create_table('server_settings', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('default_permissions_number', sa.BigInteger(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "server_settings", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("default_permissions_number", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - op.create_table('users', - sa.Column('username', sa.String(length=255), nullable=False), - sa.Column('password', sa.String(length=255), nullable=False), - sa.Column('register_date', sa.DateTime(), nullable=False), - sa.Column('permissions_number', sa.BigInteger(), nullable=False), - sa.PrimaryKeyConstraint('username'), - sa.UniqueConstraint('username') + op.create_table( + "users", + sa.Column("username", sa.String(length=255), nullable=False), + sa.Column("password", sa.String(length=255), nullable=False), + sa.Column("register_date", sa.DateTime(), nullable=False), + sa.Column("permissions_number", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("username"), + sa.UniqueConstraint("username"), ) - op.create_table('shares', - sa.Column('share_id', sqlalchemy_utils.types.uuid.UUIDType(), nullable=False), - sa.Column('owner_name', sa.String(), nullable=True), - sa.Column('initialized', sa.Boolean(), nullable=False), - sa.Column('locked', sa.Boolean(), nullable=False), - sa.Column('create_date', sa.DateTime(), nullable=False), - sa.Column('file_name', sa.String(), nullable=False), - sa.ForeignKeyConstraint(['owner_name'], ['users.username'], ), - sa.PrimaryKeyConstraint('share_id') + op.create_table( + "shares", + sa.Column("share_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=False), + sa.Column("owner_name", sa.String(), nullable=True), + sa.Column("initialized", sa.Boolean(), nullable=False), + sa.Column("locked", sa.Boolean(), nullable=False), + sa.Column("create_date", sa.DateTime(), nullable=False), + sa.Column("file_name", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["owner_name"], + ["users.username"], + ), + sa.PrimaryKeyConstraint("share_id"), ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('shares') - op.drop_table('users') - op.drop_table('server_settings') - op.drop_table('blacklist_tokens') + op.drop_table("shares") + op.drop_table("users") + op.drop_table("server_settings") + op.drop_table("blacklist_tokens") # ### end Alembic commands ### diff --git a/sachet/server/commands.py b/sachet/server/commands.py index 033185d..cfd699c 100644 --- a/sachet/server/commands.py +++ b/sachet/server/commands.py @@ -1,9 +1,10 @@ import click from sachet.server import app, db -from sachet.server.models import User, Permissions +from sachet.server.models import User, Share, Permissions from sachet.server.users import manage from flask.cli import AppGroup from bitmask import Bitmask +import datetime user_cli = AppGroup("user") @@ -44,3 +45,22 @@ def delete_user(username): app.cli.add_command(user_cli) + + +@user_cli.command("cleanup") +def cleanup(): + """Clean up stale database entries. + + Shares that are not initialized are deleted if they are older than 25 minutes. + """ + res = Share.query.filter( + Share.create_date < (datetime.datetime.now() - datetime.timedelta(minutes=25)), + # do not use `Share.initialized` or `is False` here + # sqlalchemy doesn't like it + Share.initialized == False, + ) + res.delete() + db.session.commit() + + +app.cli.add_command(cleanup) diff --git a/sachet/server/config.py b/sachet/server/config.py index 6875717..13c3407 100644 --- a/sachet/server/config.py +++ b/sachet/server/config.py @@ -14,12 +14,14 @@ class BaseConfig: class TestingConfig(BaseConfig): + SERVER_NAME = "localhost.test" SQLALCHEMY_DATABASE_URI = sqlalchemy_base + "_test" + ".db" BCRYPT_LOG_ROUNDS = 4 SACHET_FILE_DIR = "storage_test" class DevelopmentConfig(BaseConfig): + SERVER_NAME = "localhost.dev" SQLALCHEMY_DATABASE_URI = sqlalchemy_base + "_dev" + ".db" BCRYPT_LOG_ROUNDS = 4 SACHET_FILE_DIR = "storage_dev" diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d6afb0..604f755 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,9 @@ import pytest -from sachet.server.commands import create_user, delete_user +from sachet.server.commands import create_user, delete_user, cleanup from sqlalchemy import inspect - -from sachet.server.models import User +from sachet.server import db +import datetime +from sachet.server.models import User, Share def test_user(client, cli): @@ -24,3 +25,31 @@ def test_user(client, cli): # delete non-existent user result = cli.invoke(delete_user, ["--yes", "jeff"]) assert isinstance(result.exception, KeyError) + + +def test_cleanup(client, cli): + """Test the CLI's ability to destroy uninitialized shares past expiry.""" + # create shares + # this one will be destroyed + share = Share() + db.session.add(share) + share.create_date = datetime.datetime.now() - datetime.timedelta(minutes=30) + destroyed = share.share_id + # this one won't + share = Share() + db.session.add(share) + safe = share.share_id + # this one neither + share = Share() + share.initialized = True + share.create_date = datetime.datetime.now() - datetime.timedelta(minutes=30) + db.session.add(share) + safe2 = share.share_id + + db.session.commit() + + result = cli.invoke(cleanup) + assert result.exit_code == 0 + assert Share.query.filter_by(share_id=destroyed).first() is None + assert Share.query.filter_by(share_id=safe).first() is not None + assert Share.query.filter_by(share_id=safe2).first() is not None