"""Account administration — the president's view of the user list. Creating, editing, deleting and viewing accounts. Everything here is admin-only except view_user, which renders a public profile. """ from flask import flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError from app.extensions import db, hash_password from app.logging_config import log_auth_event from app.models import ( ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES, Admin, CoachAvailability, Contract, Evaluation, Match, MatchParticipant, OneOnOneRequest, OrgTeam, PersonalNote, Player, PlayerDisponibility, Team, TeamMember, TeamNote, TeamPlayer, Tryout, TryoutRegistration, User, UserGamertag, ) from app.pagination import paginate from app.routes.users._shared import ( USER_CLASS_MAP, flash_validation_errors, form_payload, update_user_gamertags, ) from app.routes.users.blueprint import users_bp from app.storage import discard_documents from app.validators import CreateUserSchema, EditUserSchema @users_bp.route('') @login_required def list_users(): """List all users for management (Admin only).""" if not isinstance(current_user, Admin): flash(_('Only the president can manage users.'), 'danger') return redirect(url_for('main.dashboard')) # Ordered before paginated, and by a unique-enough key: a paginated # query without a stable ORDER BY can show the same row twice and never # show another (MNT-14). page = paginate(User.query.order_by(User.role, User.username, User.id)) return render_template('pages/users.html', users=page.items, pagination=page, roles=USER_TYPES) @users_bp.route('//edit', methods=['GET', 'POST']) @login_required def edit_user(user_id): """Edit an existing user (Admin only).""" if not isinstance(current_user, Admin): flash(_('Only the president can edit users.'), 'danger') return redirect(url_for('main.dashboard')) user = db.get_or_404(User, user_id) if request.method == 'POST': actor_name, actor_id = current_user.username, current_user.id def _rerender(): return render_template( 'pages/edit_user.html', user=user, roles=USER_TYPES, esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, user_gamertags={ gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in user.gamertags }, ) try: validated = EditUserSchema().load(form_payload(checkboxes=('is_active_account',))) except ValidationError as err: flash_validation_errors(err) return _rerender() full_name = validated['full_name'] email = validated['email'] phone = validated.get('phone') role = validated['role'] is_active = validated['is_active_account'] selected_games = validated.get('games', []) discord_username = validated.get('discord_username') discord_user_id = validated.get('discord_user_id') league_os_profile = validated.get('league_os_profile') # Previously absent: the column is unique, so assigning a taken # address surfaced as an IntegrityError, i.e. a 500. clash = User.query.filter(User.email == email, User.id != user.id).first() if clash: flash(_('Email already in use by another account.'), 'danger') return _rerender() discord_clash = None if discord_user_id: discord_clash = User.query.filter( User.discord_user_id == discord_user_id, User.id != user.id, ).first() if discord_clash: flash(_('This Discord account is already linked to another account.'), 'danger') return _rerender() try: update_user_gamertags(user, selected_games) except ValidationError as err: flash_validation_errors(err) return _rerender() role_changed = user.role != role previous_role = user.role if role_changed: # Two ways to lock everyone out of administration, neither of # which any interface can undo afterwards. if user.id == actor_id: flash( _('You cannot change your own role. Ask another president to do it.'), 'danger' ) return _rerender() if user.role == 'admin': remaining_admins = User.query.filter( User.role == 'admin', User.is_active_account.is_(True), User.id != user.id, ).count() if remaining_admins == 0: flash( _( 'This is the last active president. Promote ' 'another account before changing this one.' ), 'danger', ) return _rerender() if role_changed: # The role column is the polymorphic discriminator, and SQLAlchemy # decides an instance's class when it loads it. Assigning to it # through the ORM leaves a Player object in the identity map for a # row that now says 'coach', so every later isinstance() check — # which is how this application does authorisation — answers with # the old role. Hence the statement-level UPDATE. # # The instance then has to be re-read. This used to call # db.session.remove(), which throws away the whole session: # everything the request still held was detached, current_user # included, and the next attribute access on any of them raised # DetachedInstanceError. Expunging the one stale instance is # enough, and it leaves the transaction open — so the role change # and the rest of the edit now commit together instead of the # role landing on its own and the remaining fields failing after # it (ARCH-008). user_pk = user.id db.session.execute( db.text('UPDATE users SET role = :role WHERE id = :id'), {'role': role, 'id': user_pk}, ) db.session.expunge(user) user = db.session.get(User, user_pk) user.full_name = full_name user.email = email user.phone = phone user.is_active_account = is_active user.games = ','.join(selected_games) if selected_games else None user.discord_username = discord_username or None user.discord_user_id = discord_user_id or None user.league_os_profile = league_os_profile or None # Blank means "keep the current password"; anything else has already # been checked against the policy by the schema. password = validated.get('password') if password: user.password_hash = hash_password(password) db.session.commit() # Logged after the commit, not before: the audit trail should record # what happened, and until this point nothing had. if role_changed: log_auth_event( 'account.role_changed', actor=actor_name, actor_id=actor_id, target=user.username, target_id=user.id, previous_role=previous_role, new_role=role, ) if password: log_auth_event( 'account.password_reset_by_admin', actor=actor_name, actor_id=actor_id, target=user.username, target_id=user.id, ) log_auth_event( 'account.updated', actor=actor_name, actor_id=actor_id, target=user.username, target_id=user.id, active=is_active, ) flash(_('User %(username)s updated successfully!', username=user.username), 'success') return redirect(url_for('users.list_users')) user_gamertags = { gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in user.gamertags } return render_template( 'pages/edit_user.html', user=user, roles=USER_TYPES, esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, user_gamertags=user_gamertags, ) @users_bp.route('//delete', methods=['POST']) @login_required def delete_user(user_id): """Delete a user (Admin only).""" if not isinstance(current_user, Admin): flash(_('Only the president can delete users.'), 'danger') return redirect(url_for('main.dashboard')) if current_user.id == user_id: flash(_('You cannot delete your own account.'), 'danger') return redirect(url_for('users.list_users')) user = db.get_or_404(User, user_id) Evaluation.query.filter( db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id), ).delete(synchronize_session=False) PlayerDisponibility.query.filter_by(player_id=user_id).delete() CoachAvailability.query.filter_by(coach_id=user_id).delete() PersonalNote.query.filter( db.or_(PersonalNote.player_id == user_id, PersonalNote.coach_id == user_id), ).delete(synchronize_session=False) TeamNote.query.filter_by(coach_id=user_id).delete() OneOnOneRequest.query.filter( db.or_(OneOnOneRequest.player_id == user_id, OneOnOneRequest.coach_id == user_id), ).delete(synchronize_session=False) UserGamertag.query.filter_by(user_id=user_id).delete() # Read the file paths before the rows go: afterwards there is nothing # left to say where the PDFs are (DATA-012). The files themselves are # removed after the commit, below. contract_files = [ path for contract in Contract.query.filter_by(player_id=user_id).all() for path in (contract.file_path, contract.signed_file_path) ] Contract.query.filter_by(player_id=user_id).delete() TryoutRegistration.query.filter_by(player_id=user_id).delete() TeamPlayer.query.filter_by(player_id=user_id).delete() TeamMember.query.filter_by(player_id=user_id).delete() MatchParticipant.query.filter_by(player_id=user_id).delete() OrgTeam.query.filter_by(coach_id=user_id).update({'coach_id': None}) OrgTeam.query.filter_by(manager_id=user_id).update({'manager_id': None}) Tryout.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) Match.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) Team.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) OrgTeam.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) Contract.query.filter_by(uploaded_by_id=user_id).update({'uploaded_by_id': current_user.id}) deleted_username, deleted_role = user.username, user.role db.session.delete(user) db.session.commit() # After the commit, deliberately. A failure here leaves a file with no # row — recoverable, and exactly what happened before this existed — # rather than a row with no file, which is a download that 500s for ever. discarded = discard_documents(contract_files) log_auth_event( 'account.deleted', actor=current_user.username, actor_id=current_user.id, target=deleted_username, target_id=user_id, role=deleted_role, contract_files_removed=discarded, ) flash( _('User %(deleted_username)s has been removed.', deleted_username=deleted_username), 'success', ) return redirect(url_for('users.list_users')) @users_bp.route('/create', methods=['GET', 'POST']) @login_required def create_user(): """Create a new user (Admin only). Uses the correct polymorphic subclass.""" if not isinstance(current_user, Admin): flash(_('Only the president can create users.'), 'danger') return redirect(url_for('main.dashboard')) if request.method == 'POST': try: validated = CreateUserSchema().load(request.form) except ValidationError as err: flash_validation_errors(err) return render_template('pages/create_user.html', roles=USER_TYPES) username = validated['username'] email = validated['email'] password = validated['password'] full_name = validated['full_name'] phone = validated.get('phone') # The schema constrains role with OneOf(USER_TYPES), so the former # manual membership check is now redundant. role = validated['role'] if User.query.filter_by(username=username).first(): flash(_('Username already exists.'), 'danger') return render_template('pages/create_user.html', roles=USER_TYPES) if User.query.filter_by(email=email).first(): flash(_('Email already registered.'), 'danger') return render_template('pages/create_user.html', roles=USER_TYPES) hashed_password = hash_password(password) user_cls = USER_CLASS_MAP.get(role, Player) user = user_cls( username=username, password_hash=hashed_password, role=role, full_name=full_name, email=email, phone=phone, ) db.session.add(user) db.session.commit() log_auth_event( 'account.created_by_admin', actor=current_user.username, actor_id=current_user.id, target=user.username, target_id=user.id, role=role, ) flash( _('User %(full_name)s created as %(role)s!', full_name=full_name, role=role), 'success' ) return redirect(url_for('users.list_users')) return render_template('pages/create_user.html', roles=USER_TYPES) @users_bp.route('//view') @login_required def view_user(user_id): """View a public profile for any user.""" user = db.get_or_404(User, user_id) return render_template('pages/view_user.html', profile_user=user)