diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..20adede Binary files /dev/null and b/__pycache__/app.cpython-312.pyc differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 11a8568..eb7ec95 100644 Binary files a/__pycache__/models.cpython-312.pyc and b/__pycache__/models.cpython-312.pyc differ diff --git a/app.py b/app.py index f70f2b2..bc711e3 100644 --- a/app.py +++ b/app.py @@ -5,13 +5,14 @@ import subprocess from werkzeug.utils import secure_filename from functools import wraps from extensions import db, bcrypt, login_manager +from sqlalchemy import text # First import models from models import User, Player, Content, Group, ServerLog # Then import utilities that use the models from flask_login import login_user, logout_user, login_required, current_user -from utils.logger import get_recent_logs, log_action, log_upload, log_process +from utils.logger import get_recent_logs, log_action, log_upload, log_process, log_user_deleted, log_user_created from utils.group_player_management import ( create_group as create_group_util, edit_group as edit_group_util, @@ -19,7 +20,12 @@ from utils.group_player_management import ( add_player as add_player_util, edit_player as edit_player_util, delete_player as delete_player_util, - get_group_content + get_group_content, + get_player_content, + update_player_content_order, + update_group_content_order, + edit_group_media, + delete_group_media ) # Finally, import modules that depend on both models and logger @@ -156,9 +162,6 @@ def upload_content(): return render_template('upload_content.html', target_type=target_type, target_id=target_id, players=players, groups=groups, return_url=return_url) - - - @app.route('/admin') @login_required @admin_required @@ -216,7 +219,7 @@ def create_user(): @login_required def player_page(player_id): player = db.session.get(Player, player_id) - content = Content.query.filter_by(player_id=player_id).all() + content = get_player_content(player_id) return render_template('player_page.html', player=player, content=content) @app.route('/player//upload', methods=['POST']) @@ -486,6 +489,10 @@ def create_group(): def manage_group(group_id): group = Group.query.get_or_404(group_id) content = get_group_content(group_id) + # Debug content ordering + print("Group content positions before sorting:", [(c.id, c.file_name, c.position) for c in content]) + content = sorted(content, key=lambda c: c.position) + print("Group content positions after sorting:", [(c.id, c.file_name, c.position) for c in content]) return render_template('manage_group.html', group=group, content=content) @app.route('/group//edit', methods=['GET', 'POST']) @@ -516,39 +523,34 @@ def delete_group(group_id): @login_required def group_fullscreen(group_id): group = Group.query.get_or_404(group_id) - content = Content.query.filter(Content.player_id.in_([player.id for player in group.players])).all() + content = Content.query.filter(Content.player_id.in_([player.id for player in group.players])).order_by(Content.position).all() return render_template('group_fullscreen.html', group=group, content=content) @app.route('/group//media//edit', methods=['POST']) @login_required @admin_required -def edit_group_media(group_id, content_id): - group = Group.query.get_or_404(group_id) +def edit_group_media_route(group_id, content_id): new_duration = int(request.form['duration']) + success = edit_group_media(group_id, content_id, new_duration) - # Update the duration for all players in the group - for player in group.players: - content = Content.query.filter_by(player_id=player.id, file_name=Content.query.get(content_id).file_name).first() - if content: - content.duration = new_duration + if success: + flash('Media duration updated successfully.', 'success') + else: + flash('Error updating media duration.', 'danger') - db.session.commit() return redirect(url_for('manage_group', group_id=group_id)) @app.route('/group//media//delete', methods=['POST']) @login_required @admin_required -def delete_group_media(group_id, content_id): - group = Group.query.get_or_404(group_id) - file_name = Content.query.get(content_id).file_name +def delete_group_media_route(group_id, content_id): + success = delete_group_media(group_id, content_id) - # Delete the media for all players in the group - for player in group.players: - content = Content.query.filter_by(player_id=player.id, file_name=file_name).first() - if content: - db.session.delete(content) + if success: + flash('Media deleted successfully.', 'success') + else: + flash('Error deleting media.', 'danger') - db.session.commit() return redirect(url_for('manage_group', group_id=group_id)) @app.route('/api/playlist_version', methods=['GET']) @@ -571,5 +573,55 @@ def get_playlist_version(): 'hashed_quickconnect': player.quickconnect_password }) +@app.route('/player//update_order', methods=['POST']) +@login_required +def update_content_order(player_id): + if not request.is_json: + return jsonify({'success': False, 'error': 'Invalid request format'}), 400 + + player = Player.query.get_or_404(player_id) + if player.groups and current_user.role != 'admin': + return jsonify({'success': False, 'error': 'Cannot reorder playlist for players in groups'}), 403 + + items = request.json.get('items', []) + + success, error, new_version = update_player_content_order(player_id, items) + + if success: + return jsonify({'success': True, 'new_version': new_version}) + else: + return jsonify({'success': False, 'error': error}), 500 + +@app.route('/group//update_order', methods=['POST']) +@login_required +@admin_required +def update_group_content_order_route(group_id): + if not request.is_json: + return jsonify({'success': False, 'error': 'Invalid request format'}), 400 + + items = request.json.get('items', []) + success, error = update_group_content_order(group_id, items) + + if success: + return jsonify({'success': True}) + else: + return jsonify({'success': False, 'error': error}), 500 + +@app.route('/debug/content_positions/') +@login_required +@admin_required +def debug_content_positions(group_id): + group = Group.query.get_or_404(group_id) + player_ids = [p.id for p in group.players] + + # Query directly with SQL to see positions + sql = text("SELECT id, file_name, position, player_id FROM content WHERE player_id IN :player_ids ORDER BY position") + result = db.session.execute(sql, {"player_ids": tuple(player_ids)}) + + content_data = [{"id": row.id, "file_name": row.file_name, "position": row.position, "player_id": row.player_id} for row in result] + + return jsonify(content_data) + +# Add this at the end of app.py if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0') \ No newline at end of file + app.run(debug=True, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/instance/dashboard.db b/instance/dashboard.db index f9bae3d..6abf2db 100644 Binary files a/instance/dashboard.db and b/instance/dashboard.db differ diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/__pycache__/env.cpython-312.pyc b/migrations/__pycache__/env.cpython-312.pyc new file mode 100644 index 0000000..509c236 Binary files /dev/null and b/migrations/__pycache__/env.cpython-312.pyc differ diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +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') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + 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 + +# 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 get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """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=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # 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): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/54d8ece92767_add_position_field_to_content_model.py b/migrations/versions/54d8ece92767_add_position_field_to_content_model.py new file mode 100644 index 0000000..bdf6e2d --- /dev/null +++ b/migrations/versions/54d8ece92767_add_position_field_to_content_model.py @@ -0,0 +1,46 @@ +"""Add position field to Content model + +Revision ID: 54d8ece92767 +Revises: +Create Date: 2025-06-29 15:32:30.794390 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '54d8ece92767' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('content', schema=None) as batch_op: + batch_op.add_column(sa.Column('position', sa.Integer(), nullable=True)) + batch_op.alter_column('file_name', + existing_type=sa.VARCHAR(length=120), + type_=sa.String(length=255), + existing_nullable=False) + batch_op.alter_column('player_id', + existing_type=sa.INTEGER(), + nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('content', schema=None) as batch_op: + batch_op.alter_column('player_id', + existing_type=sa.INTEGER(), + nullable=True) + batch_op.alter_column('file_name', + existing_type=sa.String(length=255), + type_=sa.VARCHAR(length=120), + existing_nullable=False) + batch_op.drop_column('position') + + # ### end Alembic commands ### diff --git a/migrations/versions/__pycache__/54d8ece92767_add_position_field_to_content_model.cpython-312.pyc b/migrations/versions/__pycache__/54d8ece92767_add_position_field_to_content_model.cpython-312.pyc new file mode 100644 index 0000000..f3bff9e Binary files /dev/null and b/migrations/versions/__pycache__/54d8ece92767_add_position_field_to_content_model.cpython-312.pyc differ diff --git a/migrations/versions/__pycache__/c2aad6547472_add_position_field_to_content_model.cpython-312.pyc b/migrations/versions/__pycache__/c2aad6547472_add_position_field_to_content_model.cpython-312.pyc new file mode 100644 index 0000000..c7d0d69 Binary files /dev/null and b/migrations/versions/__pycache__/c2aad6547472_add_position_field_to_content_model.cpython-312.pyc differ diff --git a/migrations/versions/c2aad6547472_add_position_field_to_content_model.py b/migrations/versions/c2aad6547472_add_position_field_to_content_model.py new file mode 100644 index 0000000..907322d --- /dev/null +++ b/migrations/versions/c2aad6547472_add_position_field_to_content_model.py @@ -0,0 +1,62 @@ +"""Add position field to Content model + +Revision ID: c2aad6547472 +Revises: 54d8ece92767 +Create Date: 2025-06-29 15:58:57.678396 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c2aad6547472' +down_revision = '54d8ece92767' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('player', schema=None) as batch_op: + batch_op.alter_column('username', + existing_type=sa.VARCHAR(length=100), + type_=sa.String(length=255), + existing_nullable=False) + batch_op.alter_column('hostname', + existing_type=sa.VARCHAR(length=100), + type_=sa.String(length=255), + existing_nullable=False) + batch_op.alter_column('password', + existing_type=sa.VARCHAR(length=200), + type_=sa.String(length=255), + existing_nullable=False) + batch_op.alter_column('quickconnect_password', + existing_type=sa.VARCHAR(length=200), + type_=sa.String(length=255), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('player', schema=None) as batch_op: + batch_op.alter_column('quickconnect_password', + existing_type=sa.String(length=255), + type_=sa.VARCHAR(length=200), + existing_nullable=True) + batch_op.alter_column('password', + existing_type=sa.String(length=255), + type_=sa.VARCHAR(length=200), + existing_nullable=False) + batch_op.alter_column('hostname', + existing_type=sa.String(length=255), + type_=sa.VARCHAR(length=100), + existing_nullable=False) + batch_op.alter_column('username', + existing_type=sa.String(length=255), + type_=sa.VARCHAR(length=100), + existing_nullable=False) + + # ### end Alembic commands ### diff --git a/models.py b/models.py index 020ea55..a08713d 100644 --- a/models.py +++ b/models.py @@ -16,17 +16,18 @@ class ServerLog(db.Model): class Content(db.Model): id = db.Column(db.Integer, primary_key=True) - file_name = db.Column(db.String(120), nullable=False) + file_name = db.Column(db.String(255), nullable=False) duration = db.Column(db.Integer, nullable=False) - player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=True) + player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=False) + position = db.Column(db.Integer, default=0) # This field must exist class Player(db.Model): id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(100), nullable=False, unique=True) - hostname = db.Column(db.String(100), nullable=False) - password = db.Column(db.String(200), nullable=False) - quickconnect_password = db.Column(db.String(200), nullable=True) # Add this field - playlist_version = db.Column(db.Integer, default=0) # Playlist version counter + username = db.Column(db.String(255), nullable=False) + hostname = db.Column(db.String(255), nullable=False) + password = db.Column(db.String(255), nullable=False) + quickconnect_password = db.Column(db.String(255), nullable=True) + playlist_version = db.Column(db.Integer, default=1) # Make sure this exists locked_to_group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True) locked_to_group = db.relationship('Group', foreign_keys=[locked_to_group_id], backref='locked_players') diff --git a/templates/manage_group.html b/templates/manage_group.html index 35dbc47..6535791 100644 --- a/templates/manage_group.html +++ b/templates/manage_group.html @@ -29,6 +29,26 @@ margin-bottom: 1rem; } } + + .sortable-list li { + cursor: move; + transition: background-color 0.2s ease; + } + + .sortable-list li.dragging { + opacity: 0.5; + background-color: #f8f9fa; + } + + .drag-handle { + cursor: grab; + color: #aaa; + font-size: 1.2rem; + } + + .drag-over { + border-top: 2px solid #0d6efd; + } @@ -71,9 +91,18 @@
{% if content %} -
    +
      {% for media in content %} -
    • +
    • + +
      + + ☰ +
      +

      Media Name: {{ media.file_name }}

      @@ -90,6 +119,8 @@
    • {% endfor %}
    + + {% else %}

    No media uploaded for this group.

    {% endif %} @@ -106,5 +137,89 @@
+ \ No newline at end of file diff --git a/templates/player_page.html b/templates/player_page.html index 77670b0..a4b0a3b 100644 --- a/templates/player_page.html +++ b/templates/player_page.html @@ -29,6 +29,25 @@ margin-bottom: 1rem; } } + .sortable-list li { + cursor: move; + transition: background-color 0.2s ease; + } + + .sortable-list li.dragging { + opacity: 0.5; + background-color: #f8f9fa; + } + + .drag-handle { + cursor: grab; + color: #aaa; + font-size: 1.2rem; + } + + .drag-over { + border-top: 2px solid #0d6efd; + } @@ -74,10 +93,19 @@
{% if content %} -
    +
      {% for media in content %} -
    • +
    • + +
      + + ☰ +
      +

      Media Name: {{ media.file_name }}

      @@ -100,6 +128,8 @@
    • {% endfor %}
    + + {% else %}

    No media uploaded for this player.

    {% endif %} @@ -117,5 +147,91 @@
+ \ No newline at end of file diff --git a/utils/__pycache__/group_player_management.cpython-312.pyc b/utils/__pycache__/group_player_management.cpython-312.pyc index 9a82978..079f229 100644 Binary files a/utils/__pycache__/group_player_management.cpython-312.pyc and b/utils/__pycache__/group_player_management.cpython-312.pyc differ diff --git a/utils/__pycache__/logger.cpython-312.pyc b/utils/__pycache__/logger.cpython-312.pyc index 9abf051..a965d93 100644 Binary files a/utils/__pycache__/logger.cpython-312.pyc and b/utils/__pycache__/logger.cpython-312.pyc differ diff --git a/utils/__pycache__/uploads.cpython-312.pyc b/utils/__pycache__/uploads.cpython-312.pyc index 7ae819d..423572d 100644 Binary files a/utils/__pycache__/uploads.cpython-312.pyc and b/utils/__pycache__/uploads.cpython-312.pyc differ diff --git a/utils/group_player_management.py b/utils/group_player_management.py index 06e5cac..cf50be9 100644 --- a/utils/group_player_management.py +++ b/utils/group_player_management.py @@ -1,7 +1,12 @@ from models import Player, Group, Content from extensions import db -from utils.logger import log_group_created, log_group_edited, log_group_deleted -from utils.logger import log_player_created, log_player_edited, log_player_deleted +from utils.logger import ( + log_group_created, log_group_edited, log_group_deleted, + log_player_created, log_player_edited, log_player_deleted, + log_player_added_to_group, log_player_removed_from_group, + log_player_unlocked, log_content_reordered, + log_content_duration_changed, log_content_added +) def create_group(name, player_ids): """ @@ -35,6 +40,7 @@ def edit_group(group_id, name, player_ids): Handles locking/unlocking players appropriately. """ group = Group.query.get_or_404(group_id) + old_name = group.name # Store old name in case it changes group.name = name # Get current players in the group @@ -56,6 +62,9 @@ def edit_group(group_id, name, player_ids): # Lock to group player.locked_to_group_id = group.id + + # Log this action + log_player_added_to_group(player.username, name) # Handle players to remove for player_id in players_to_remove: @@ -66,9 +75,19 @@ def edit_group(group_id, name, player_ids): # Unlock from group player.locked_to_group_id = None + + # Log this action + log_player_removed_from_group(player.username, name) + log_player_unlocked(player.username) db.session.commit() - log_group_edited(group.name) + + # Log the group edit + if old_name != name: + log_group_edited(f"{old_name} → {name}") + else: + log_group_edited(name) + return group def delete_group(group_id): @@ -81,6 +100,7 @@ def delete_group(group_id): # Unlock all players in the group for player in group.players: player.locked_to_group_id = None + log_player_unlocked(player.username) db.session.delete(group) db.session.commit() @@ -146,16 +166,190 @@ def delete_player(player_id): def get_group_content(group_id): """ - Get unique content for a group. + Get content for all players in a group, ordered by position. """ + from models import Group, Content + group = Group.query.get_or_404(group_id) - # Get unique media files for the group - content = ( - db.session.query(Content.id, Content.file_name, db.func.min(Content.duration).label('duration')) - .filter(Content.player_id.in_([player.id for player in group.players])) - .group_by(Content.file_name) - .all() - ) + # Get all player IDs in the group + player_ids = [player.id for player in group.players] - return content \ No newline at end of file + # Get unique content based on file_name, preserving position + unique_content = {} + + # For each player, get their content + for player_id in player_ids: + # Get content for this player, ordered by position + player_content = Content.query.filter_by(player_id=player_id).order_by(Content.position).all() + + for content in player_content: + if content.file_name not in unique_content: + unique_content[content.file_name] = content + + # Sort the unique content by position + return sorted(unique_content.values(), key=lambda c: c.position) + +def get_player_content(player_id): + """ + Get content for a specific player, ordered by position. + """ + from models import Content + return Content.query.filter_by(player_id=player_id).order_by(Content.position).all() + +def update_player_content_order(player_id, items): + """ + Update the order of content items for a player. + + Args: + player_id (int): ID of the player + items (list): List of items with id and position + + Returns: + tuple: (success, error_message, new_version) + """ + from models import Player, Content + from extensions import db + + player = Player.query.get_or_404(player_id) + + try: + # Update the position field for each content item + for item in items: + content_id = int(item['id']) + position = int(item['position']) + content = Content.query.get_or_404(content_id) + if content.player_id != player_id: + continue # Skip if not for this player + content.position = position + + # Force increment the playlist version to trigger client refresh + player.playlist_version = (player.playlist_version or 0) + 1 + + db.session.commit() + + # Log the reordering action + log_content_reordered("player", player.username) + + return True, None, player.playlist_version + except Exception as e: + db.session.rollback() + return False, str(e), None + +def update_group_content_order(group_id, items): + """ + Update the order of content items for all players in a group. + + Args: + group_id (int): ID of the group + items (list): List of items with id and position + + Returns: + tuple: (success, error_message) + """ + from models import Group, Content + from extensions import db + + group = Group.query.get_or_404(group_id) + + try: + # Get file names corresponding to the content IDs + content_files = {} + for item in items: + content_id = int(item['id']) + position = int(item['position']) + content = Content.query.get_or_404(content_id) + content_files[content.file_name] = position + + # Update all content items for all players in this group + for player in group.players: + for content in Content.query.filter_by(player_id=player.id).all(): + if content.file_name in content_files: + content.position = content_files[content.file_name] + + # Force increment the playlist version to trigger client refresh + player.playlist_version = (player.playlist_version or 0) + 1 + + db.session.commit() + + # Log the reordering action + log_content_reordered("group", group.name) + + return True, None + except Exception as e: + db.session.rollback() + return False, str(e) + +def edit_group_media(group_id, content_id, new_duration): + """ + Update the duration for all instances of a media item across all players in a group. + + Args: + group_id (int): ID of the group + content_id (int): ID of the content item + new_duration (int): New duration in seconds + + Returns: + bool: Success or failure + """ + from models import Group, Content + from extensions import db + + group = Group.query.get_or_404(group_id) + content = Content.query.get(content_id) + file_name = content.file_name + old_duration = content.duration + + try: + # Update the duration for all players in the group + for player in group.players: + content = Content.query.filter_by(player_id=player.id, file_name=file_name).first() + if content: + content.duration = new_duration + + db.session.commit() + + # Log the duration change + log_content_duration_changed(file_name, old_duration, new_duration, "group", group.name) + + return True + except Exception as e: + db.session.rollback() + return False + +def delete_group_media(group_id, content_id): + """ + Delete a media item from all players in a group. + + Args: + group_id (int): ID of the group + content_id (int): ID of the content item + + Returns: + bool: Success or failure + """ + from models import Group, Content + from extensions import db + + group = Group.query.get_or_404(group_id) + content = Content.query.get(content_id) + file_name = content.file_name + + try: + # Delete the media for all players in the group + count = 0 + for player in group.players: + content = Content.query.filter_by(player_id=player.id, file_name=file_name).first() + if content: + db.session.delete(content) + count += 1 + + db.session.commit() + + # Log the content deletion + log_content_deleted(file_name, "group", group.name) + + return True + except Exception as e: + db.session.rollback() + return False \ No newline at end of file diff --git a/utils/logger.py b/utils/logger.py index 3738491..16df638 100644 --- a/utils/logger.py +++ b/utils/logger.py @@ -62,4 +62,23 @@ def log_settings_changed(setting_name): log_action(f"Setting '{setting_name}' was changed") def log_files_cleaned(count): - log_action(f"{count} unused files were cleaned from storage") \ No newline at end of file + log_action(f"{count} unused files were cleaned from storage") + +# New logging functions for more detailed activities +def log_player_added_to_group(player_name, group_name): + log_action(f"Player '{player_name}' was added to group '{group_name}'") + +def log_player_removed_from_group(player_name, group_name): + log_action(f"Player '{player_name}' was removed from group '{group_name}'") + +def log_player_unlocked(player_name): + log_action(f"Player '{player_name}' was unlocked from its group") + +def log_content_reordered(target_type, target_name): + log_action(f"Content for {target_type} '{target_name}' was reordered") + +def log_content_duration_changed(content_name, old_duration, new_duration, target_type, target_name): + log_action(f"Duration for '{content_name}' changed from {old_duration}s to {new_duration}s in {target_type} '{target_name}'") + +def log_content_added(content_name, target_type, target_name): + log_action(f"Content '{content_name}' added to {target_type} '{target_name}'") \ No newline at end of file diff --git a/utils/uploads.py b/utils/uploads.py index 7ff9190..f7d27a6 100644 --- a/utils/uploads.py +++ b/utils/uploads.py @@ -5,7 +5,7 @@ from werkzeug.utils import secure_filename from pdf2image import convert_from_path from extensions import db from models import Content, Player, Group -from utils.logger import log_upload, log_process +from utils.logger import log_content_added, log_upload, log_process # Function to add image to playlist def add_image_to_playlist(app, file, filename, duration, target_type, target_id): @@ -24,19 +24,16 @@ def add_image_to_playlist(app, file, filename, duration, target_type, target_id) for player in group.players: new_content = Content(file_name=filename, duration=duration, player_id=player.id) db.session.add(new_content) - player.playlist_version += 1 - group.playlist_version += 1 - # Log the action - log_upload('image', filename, 'group', group.name) + log_content_added(filename, target_type, group.name) elif target_type == 'player': player = Player.query.get_or_404(target_id) new_content = Content(file_name=filename, duration=duration, player_id=target_id) db.session.add(new_content) - player.playlist_version += 1 - # Log the action - log_upload('image', filename, 'player', player.username) + log_content_added(filename, target_type, player.username) db.session.commit() + log_upload('image', filename, target_type, target_id) + return True # Video conversion functions def convert_video(input_file, output_folder):