Compare commits
2 Commits
1326543418
...
318f783de3
| Author | SHA1 | Date | |
|---|---|---|---|
| 318f783de3 | |||
| 70d76f45e7 |
6
app.py
6
app.py
@@ -488,7 +488,8 @@ def create_group():
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
group_name = request.form['name']
|
group_name = request.form['name']
|
||||||
player_ids = request.form.getlist('players')
|
player_ids = request.form.getlist('players')
|
||||||
create_group_util(group_name, player_ids)
|
orientation = request.form.get('orientation', 'Landscape')
|
||||||
|
create_group_util(group_name, player_ids, orientation)
|
||||||
flash(f'Group "{group_name}" created successfully.', 'success')
|
flash(f'Group "{group_name}" created successfully.', 'success')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
players = Player.query.all()
|
players = Player.query.all()
|
||||||
@@ -514,7 +515,8 @@ def edit_group(group_id):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
name = request.form['name']
|
name = request.form['name']
|
||||||
player_ids = request.form.getlist('players')
|
player_ids = request.form.getlist('players')
|
||||||
edit_group_util(group_id, name, player_ids)
|
orientation = request.form.get('orientation', group.orientation)
|
||||||
|
edit_group_util(group_id, name, player_ids, orientation)
|
||||||
flash(f'Group "{name}" updated successfully.', 'success')
|
flash(f'Group "{name}" updated successfully.', 'success')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
players = Player.query.all()
|
players = Player.query.all()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#version: '"1.1.0"'
|
#version: '"1.1.0"'
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
|
build: .
|
||||||
image: digiserver:latest
|
image: digiserver:latest
|
||||||
ports:
|
ports:
|
||||||
- "8880:5000"
|
- "8880:5000"
|
||||||
@@ -12,5 +13,5 @@ services:
|
|||||||
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
|
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/digi-s/instance:/app/instance
|
- /opt/digi-s/instance:/app/instance
|
||||||
- /opt/digi-s/static/uploads:/app/static/uploads
|
- /opt/digi-s/uploads:/app/static/uploads
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -7,16 +7,7 @@ mkdir -p instance
|
|||||||
|
|
||||||
# Check if database exists
|
# Check if database exists
|
||||||
if [ ! -f instance/dashboard.db ]; then
|
if [ ! -f instance/dashboard.db ]; then
|
||||||
echo "No database found, initializing..."
|
echo "No database found, creating fresh database..."
|
||||||
|
|
||||||
# Remove and recreate migrations directory to ensure clean state
|
|
||||||
rm -rf migrations
|
|
||||||
mkdir -p migrations
|
|
||||||
|
|
||||||
# Initialize the database
|
|
||||||
flask db init
|
|
||||||
flask db migrate -m "Initial migration"
|
|
||||||
flask db upgrade
|
|
||||||
|
|
||||||
# Create admin user if environment variables are set
|
# Create admin user if environment variables are set
|
||||||
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
|
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
|
||||||
@@ -26,8 +17,11 @@ if [ ! -f instance/dashboard.db ]; then
|
|||||||
echo "Warning: ADMIN_USER or ADMIN_PASSWORD not set, skipping admin creation"
|
echo "Warning: ADMIN_USER or ADMIN_PASSWORD not set, skipping admin creation"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Existing database found, applying migrations..."
|
echo "Existing database found, skipping initialization..."
|
||||||
flask db upgrade
|
echo "Creating admin user if needed..."
|
||||||
|
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
|
||||||
|
flask create-admin --username "$ADMIN_USER" --password "$ADMIN_PASSWORD" 2>/dev/null || echo "Default user '$ADMIN_USER' already exists."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting DigiServer..."
|
echo "Starting DigiServer..."
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
Single-database configuration for Flask.
|
|
||||||
Binary file not shown.
@@ -1,50 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""${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"}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""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 ###
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,62 +0,0 @@
|
|||||||
"""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 ###
|
|
||||||
@@ -49,12 +49,24 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="orientation" class="form-label">Group Orientation</label>
|
||||||
|
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
|
||||||
|
<option value="Landscape" {% if group.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
||||||
|
<option value="Portret" {% if group.orientation == 'Portret' %}selected{% endif %}>Portret</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add this above the player selection -->
|
<!-- Add this above the player selection -->
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">
|
||||||
<strong>Warning:</strong> Adding new players to this group will delete their individual playlists.
|
<strong>Warning:</strong> Adding new players to this group will delete their individual playlists.
|
||||||
Removing players from the group will allow them to have their own playlists again.
|
Removing players from the group will allow them to have their own playlists again.
|
||||||
</div>
|
</div>
|
||||||
|
<div id="orientation-warning" class="alert alert-danger d-none" role="alert">
|
||||||
|
No players with the selected orientation are available.
|
||||||
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
||||||
@@ -62,5 +74,45 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Get all players and their orientations from the backend
|
||||||
|
const players = [
|
||||||
|
{% for player in players %}
|
||||||
|
{id: {{ player.id }}, username: "{{ player.username }}", orientation: "{{ player.orientation }}", inGroup: {% if player in group.players %}true{% else %}false{% endif %}},
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
const orientationSelect = document.getElementById('orientation');
|
||||||
|
const playersSelect = document.getElementById('players');
|
||||||
|
const orientationWarning = document.getElementById('orientation-warning');
|
||||||
|
|
||||||
|
function filterPlayers() {
|
||||||
|
const selectedOrientation = orientationSelect.value;
|
||||||
|
const currentSelection = Array.from(playersSelect.selectedOptions).map(option => option.value);
|
||||||
|
playersSelect.innerHTML = '';
|
||||||
|
let compatibleCount = 0;
|
||||||
|
|
||||||
|
players.forEach(player => {
|
||||||
|
if (player.orientation === selectedOrientation) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = player.id;
|
||||||
|
option.textContent = player.username;
|
||||||
|
// Re-select if it was previously selected
|
||||||
|
if (currentSelection.includes(player.id.toString()) || player.inGroup) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
playersSelect.appendChild(option);
|
||||||
|
compatibleCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
orientationWarning.classList.toggle('d-none', compatibleCount > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
orientationSelect.addEventListener('change', filterPlayers);
|
||||||
|
|
||||||
|
// Initial filter on page load
|
||||||
|
filterPlayers();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -189,7 +189,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
fetch('{{ url_for("update_group_content_order", group_id=group.id) }}', {
|
fetch('{{ url_for("update_group_content_order_route", group_id=group.id) }}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -35,15 +35,24 @@ def create_group(name, player_ids, orientation='Landscape'):
|
|||||||
log_group_created(name)
|
log_group_created(name)
|
||||||
return new_group
|
return new_group
|
||||||
|
|
||||||
def edit_group(group_id, name, player_ids):
|
def edit_group(group_id, name, player_ids, orientation=None):
|
||||||
"""
|
"""
|
||||||
Edit an existing group, updating its name and players.
|
Edit an existing group, updating its name, orientation, and players.
|
||||||
Handles locking/unlocking players appropriately.
|
Handles locking/unlocking players appropriately.
|
||||||
"""
|
"""
|
||||||
group = Group.query.get_or_404(group_id)
|
group = Group.query.get_or_404(group_id)
|
||||||
old_name = group.name # Store old name in case it changes
|
old_name = group.name # Store old name in case it changes
|
||||||
group.name = name
|
group.name = name
|
||||||
|
|
||||||
|
# Update orientation if provided
|
||||||
|
if orientation:
|
||||||
|
group.orientation = orientation
|
||||||
|
# Validate that all selected players have the matching orientation
|
||||||
|
for player_id in player_ids:
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player and player.orientation != orientation:
|
||||||
|
raise ValueError(f"Player '{player.username}' has orientation '{player.orientation}', which does not match group orientation '{orientation}'.")
|
||||||
|
|
||||||
# Get current players in the group
|
# Get current players in the group
|
||||||
current_player_ids = [player.id for player in group.players]
|
current_player_ids = [player.id for player in group.players]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user