"""Player Evolution — Objective management routes. Allows coaches and staff to create, update, delete, and list KPI-driven objectives for individual players or teams. """ import datetime from flask import Blueprint, 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.api import json_endpoint from app.extensions import db from app.forms import flash_validation_errors, form_payload from app.models import ( DEFAULT_OBJECTIVE_TEMPLATES, OBJECTIVE_CHECK_FREQUENCIES, OBJECTIVE_PRIORITIES, OBJECTIVE_STATUSES, OBJECTIVE_TYPES, Admin, Coach, Manager, Objective, ObjectiveComment, ObjectiveEntry, OrgTeam, PersonalNote, TeamNote, User, ) from app.pagination import paginate from app.permissions import ( can_manage_objective, can_manage_objectives, coach_org_team_ids, coach_player_ids, ) from app.validators import ObjectiveSchema objectives_bp = Blueprint('objectives', __name__, url_prefix='/objectives') # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _resolve_objective(obj_id): """Load an objective or 404.""" return db.get_or_404(Objective, obj_id) def _can_edit(user, objective): """Short-hand permission check with flash fallback.""" if can_manage_objective(user, objective): return True flash(_('You do not have permission to manage this objective.'), 'danger') return False # --------------------------------------------------------------------------- # List / Dashboard # --------------------------------------------------------------------------- @objectives_bp.route('') @login_required def list_objectives(): """List all objectives the current user may see.""" user = current_user can_edit = can_manage_objectives(user) # ── Query base ────────────────────────────────────────────────────── base = Objective.query # Scope: players only see their own, coaches/admins see theirs, etc. if isinstance(user, Manager): base = base.filter(Objective.created_by == user.id) elif isinstance(user, Coach): team_ids = coach_org_team_ids(user) player_ids = coach_player_ids(user) conditions = [Objective.created_by == user.id] if team_ids: conditions.append(Objective.team_id.in_(team_ids)) if player_ids: conditions.append(Objective.player_id.in_(player_ids)) base = base.filter(db.or_(*conditions)) elif isinstance(user, Admin): pass # all elif hasattr(user, 'role') and user.role == 'player': base = base.filter(Objective.player_id == user.id) else: base = base.filter(Objective.created_by == user.id) # ── Tab / filter ──────────────────────────────────────────────────── tab = request.args.get('tab', 'all') if tab == 'kpi': base = base.filter(Objective.kpi_metric.isnot(None)) elif tab in ('modeles', 'templates'): base = base.filter(Objective.is_template.is_(True)) search = request.args.get('search', '').strip() if search: base = base.filter(Objective.title.ilike(f'%{search}%')) f_status = request.args.get('status', '').strip() f_priority = request.args.get('priority', '').strip() f_type = request.args.get('type', '').strip() f_team = request.args.get('team', type=int) if f_status: base = base.filter(Objective.status == f_status) if f_priority: base = base.filter(Objective.priority == f_priority) if f_type: base = base.filter(Objective.type == f_type) if f_team: base = base.filter(Objective.team_id == f_team) sort_column = request.args.get('sort', 'due_date') sort_order = request.args.get('order', 'asc') if sort_order not in ('asc', 'desc'): sort_order = 'asc' sort_map = { 'title': Objective.title, 'priority': Objective.priority, 'status': Objective.status, 'progress': Objective.progress, 'due_date': Objective.due_date, 'created_at': Objective.created_at, } sort_expr = sort_map.get(sort_column, Objective.due_date) if sort_order == 'desc': sort_expr = sort_expr.desc() else: sort_expr = sort_expr.asc() objectives_page = paginate(base.order_by(sort_expr, Objective.id)) all_q = Objective.query count_total = all_q.count() count_kpi = all_q.filter(Objective.kpi_metric.isnot(None)).count() count_templates = all_q.filter(Objective.is_template.is_(True)).count() success_rate = 0.0 total_with_status = all_q.count() if total_with_status > 0: completed = all_q.filter(Objective.status == 'completed').count() success_rate = round((completed / total_with_status) * 100, 1) teams = OrgTeam.query.order_by(OrgTeam.name).all() players = User.query.filter(User.role == 'player').order_by(User.username).all() templates = Objective.query.filter_by(is_template=True).order_by(Objective.title).all() return render_template( 'pages/objectives.html', objectives=objectives_page, pagination=objectives_page, can_edit=can_edit, tab=tab, search=search, f_status=f_status, f_priority=f_priority, f_type=f_type, f_team=f_team, sort_column=sort_column, sort_order=sort_order, count_total=count_total, count_kpi=count_kpi, count_templates=count_templates, success_rate=success_rate, objective_types=OBJECTIVE_TYPES, objective_statuses=OBJECTIVE_STATUSES, objective_priorities=OBJECTIVE_PRIORITIES, objective_frequencies=OBJECTIVE_CHECK_FREQUENCIES, teams=teams, players=players, templates=templates, ) # --------------------------------------------------------------------------- # Create # --------------------------------------------------------------------------- # New / Edit form page # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # View (player-facing detail page) # --------------------------------------------------------------------------- @objectives_bp.route('/') @login_required def view_objective(objective_id): """Player-facing detail page for a specific objective.""" from app.permissions import can_view_objective as _can_view objective = _resolve_objective(objective_id) if not _can_view(current_user, objective): flash(_('You do not have permission to view this objective.'), 'danger') return redirect(url_for('objectives.list_objectives')) can_edit = can_manage_objectives(current_user) entries = ( ObjectiveEntry.query.filter_by(objective_id=objective.id) .order_by(ObjectiveEntry.recorded_at) .all() ) comments = ( ObjectiveComment.query.filter_by(objective_id=objective.id) .order_by(ObjectiveComment.created_at.desc()) .all() ) personal_notes = [] if objective.player_id: personal_notes = ( PersonalNote.query.filter_by(player_id=objective.player_id) .order_by(PersonalNote.created_at.desc()) .limit(10) .all() ) team_notes = [] if objective.team_id: team_notes = ( TeamNote.query.filter_by(org_team_id=objective.team_id) .order_by(TeamNote.created_at.desc()) .limit(10) .all() ) return render_template( 'pages/objective_view.html', objective=objective, entries=entries, comments=comments, personal_notes=personal_notes, team_notes=team_notes, can_edit=can_edit, objective_types=OBJECTIVE_TYPES, objective_statuses=OBJECTIVE_STATUSES, objective_priorities=OBJECTIVE_PRIORITIES, objective_frequencies=OBJECTIVE_CHECK_FREQUENCIES, ) def _form_context(): """Shared context for the create/edit form page.""" teams = OrgTeam.query.order_by(OrgTeam.name).all() players = User.query.filter(User.role == 'player').order_by(User.username).all() templates = Objective.query.filter_by(is_template=True).order_by(Objective.title).all() return { 'objective_types': OBJECTIVE_TYPES, 'objective_statuses': OBJECTIVE_STATUSES, 'objective_priorities': OBJECTIVE_PRIORITIES, 'objective_frequencies': OBJECTIVE_CHECK_FREQUENCIES, 'teams': teams, 'players': players, 'templates': templates, } @objectives_bp.route('/new') @login_required def new_objective(): """Render the create-objective form page with preview.""" if not can_manage_objectives(current_user): flash(_('You do not have permission to create objectives.'), 'danger') return redirect(url_for('objectives.list_objectives')) ctx = _form_context() ctx['objective'] = None ctx['edit_mode'] = False ctx['entries'] = [] ctx['comments'] = [] ctx['personal_notes'] = [] ctx['team_notes'] = [] return render_template('pages/objective_form.html', **ctx) @objectives_bp.route('//edit') @login_required def edit_objective(objective_id): """Render the edit-objective form page with preview.""" objective = _resolve_objective(objective_id) if not _can_edit(current_user, objective): return redirect(url_for('objectives.list_objectives')) ctx = _form_context() ctx['objective'] = objective ctx['edit_mode'] = True ctx['entries'] = ( ObjectiveEntry.query.filter_by(objective_id=objective.id) .order_by(ObjectiveEntry.recorded_at) .all() ) ctx['comments'] = ( ObjectiveComment.query.filter_by(objective_id=objective.id) .order_by(ObjectiveComment.created_at.desc()) .all() ) ctx['personal_notes'] = [] if objective.player_id: ctx['personal_notes'] = ( PersonalNote.query.filter_by(player_id=objective.player_id) .order_by(PersonalNote.created_at.desc()) .limit(10) .all() ) ctx['team_notes'] = [] if objective.team_id: ctx['team_notes'] = ( TeamNote.query.filter_by(org_team_id=objective.team_id) .order_by(TeamNote.created_at.desc()) .limit(10) .all() ) return render_template('pages/objective_form.html', **ctx) # --------------------------------------------------------------------------- @objectives_bp.route('/create', methods=['POST']) @login_required def create_objective(): """Create a new objective.""" if not can_manage_objectives(current_user): flash(_('You do not have permission to create objectives.'), 'danger') return redirect(url_for('objectives.list_objectives')) try: data = ObjectiveSchema().load( form_payload(list_fields=(), optional_blank=('password', 'check_frequency')) ) except ValidationError as err: flash_validation_errors(err) return redirect(url_for('objectives.list_objectives')) objective = Objective( title=data['title'], description=data.get('description'), type=data['type'], status=data['status'], priority=data['priority'], category=data.get('category'), is_template=data.get('is_template', False), player_id=data.get('player_id'), team_id=data.get('team_id'), kpi_metric=data.get('kpi_metric'), kpi_target=data.get('kpi_target'), kpi_unit=data.get('kpi_unit'), kpi_precision=data.get('kpi_precision'), start_date=data.get('start_date'), due_date=data['due_date'], check_frequency=data.get('check_frequency') or None, progress=data.get('progress', 0), tags=data.get('tags'), created_by=current_user.id, ) db.session.add(objective) db.session.commit() flash(_('Objective created successfully.'), 'success') return redirect(url_for('objectives.list_objectives')) # --------------------------------------------------------------------------- # Update # --------------------------------------------------------------------------- @objectives_bp.route('//update', methods=['POST']) @login_required def update_objective(objective_id): """Update an existing objective.""" objective = _resolve_objective(objective_id) if not _can_edit(current_user, objective): return redirect(url_for('objectives.list_objectives')) try: data = ObjectiveSchema().load( form_payload(list_fields=(), optional_blank=('password', 'check_frequency')), partial=('due_date',), ) except ValidationError as err: flash_validation_errors(err) return redirect(url_for('objectives.list_objectives')) for field in ( 'title', 'description', 'type', 'status', 'priority', 'category', 'is_template', 'player_id', 'team_id', 'kpi_metric', 'kpi_unit', 'kpi_precision', 'check_frequency', 'progress', 'tags', ): val = data.get(field) setattr(objective, field, val) if 'kpi_target' in data: objective.kpi_target = data['kpi_target'] if 'start_date' in data: objective.start_date = data['start_date'] if 'due_date' in data: objective.due_date = data['due_date'] db.session.commit() flash(_('Objective updated successfully.'), 'success') return redirect(url_for('objectives.list_objectives')) # --------------------------------------------------------------------------- # Delete # --------------------------------------------------------------------------- @objectives_bp.route('//delete', methods=['POST']) @login_required def delete_objective(objective_id): """Delete an objective.""" objective = _resolve_objective(objective_id) if not _can_edit(current_user, objective): return redirect(url_for('objectives.list_objectives')) db.session.delete(objective) db.session.commit() flash(_('Objective deleted.'), 'success') return redirect(url_for('objectives.list_objectives')) # --------------------------------------------------------------------------- # JSON data endpoint for the edit modal # --------------------------------------------------------------------------- @objectives_bp.route('//data') @json_endpoint @login_required def objective_data(objective_id): """Return objective details as JSON for the edit modal.""" from flask import jsonify objective = _resolve_objective(objective_id) return jsonify( { 'id': objective.id, 'title': objective.title, 'description': objective.description, 'type': objective.type, 'status': objective.status, 'priority': objective.priority, 'category': objective.category, 'is_template': objective.is_template, 'player_id': objective.player_id, 'team_id': objective.team_id, 'kpi_metric': objective.kpi_metric, 'kpi_target': str(objective.kpi_target) if objective.kpi_target is not None else None, 'kpi_unit': objective.kpi_unit, 'kpi_precision': objective.kpi_precision, 'start_date': objective.start_date.isoformat() if objective.start_date else None, 'due_date': objective.due_date.isoformat() if objective.due_date else None, 'check_frequency': objective.check_frequency, 'progress': objective.progress, 'tags': objective.tags, } ) # --------------------------------------------------------------------------- # Seed default templates (idempotent) # --------------------------------------------------------------------------- def seed_default_templates(): """Create the default Objective templates if they don't exist yet. Idempotent — safe to call on every startup. Skips if no admin user exists yet (first run seeding may not have run). """ admin = User.query.filter(User.role == 'admin').first() if not admin: return existing = {t.title for t in Objective.query.filter_by(is_template=True).all()} for ( priority, title, description, kpi_metric, kpi_target, kpi_unit, typ, category, ) in DEFAULT_OBJECTIVE_TEMPLATES: if title in existing: continue obj = Objective( title=title, description=description, type=typ, status='active', priority=priority, category=category, is_template=True, kpi_metric=kpi_metric, kpi_target=kpi_target, kpi_unit=kpi_unit, progress=0, due_date=datetime.date(2099, 12, 31), created_by=admin.id, ) db.session.add(obj) db.session.commit() # --------------------------------------------------------------------------- # KPI Entries — measurements for the quantitative graph # --------------------------------------------------------------------------- @objectives_bp.route('//entries', methods=['GET']) @login_required @json_endpoint def list_entries(objective_id): """Return KPI entries for an objective as JSON.""" from flask import jsonify objective = _resolve_objective(objective_id) entries = ( ObjectiveEntry.query.filter_by(objective_id=objective.id) .order_by(ObjectiveEntry.recorded_at) .all() ) return jsonify( [ { 'id': e.id, 'value': str(e.value), 'recorded_at': e.recorded_at.isoformat(), 'note': e.note, } for e in entries ] ) @objectives_bp.route('//entries', methods=['POST']) @login_required def add_entry(objective_id): """Record a KPI measurement for an objective. Accepts an optional numeric value (for quantitative KPI tracking) and an optional note. If neither is provided the entry is rejected. The assigned player may record entries on their own objectives. """ from datetime import datetime from app.permissions import can_add_objective_entry objective = _resolve_objective(objective_id) if not can_add_objective_entry(current_user, objective): flash(_('You do not have permission to record entries for this objective.'), 'danger') return redirect(url_for('objectives.list_objectives')) raw_value = request.form.get('value', '').strip() note = request.form.get('note', '').strip() value = None if raw_value: try: value = float(raw_value) except (ValueError, TypeError): flash(_('Invalid measurement value.'), 'danger') return redirect(url_for('objectives.view_objective', objective_id=objective_id)) if value is None and not note: flash(_('Provide a value, a note, or both.'), 'warning') return redirect(url_for('objectives.view_objective', objective_id=objective_id)) entry = ObjectiveEntry( objective_id=objective.id, value=value, recorded_at=datetime.now(), note=note or None, recorded_by=current_user.id, ) db.session.add(entry) db.session.commit() flash(_('Measurement recorded.'), 'success') return redirect(url_for('objectives.view_objective', objective_id=objective_id)) flash(_('Measurement recorded.'), 'success') return redirect(url_for('objectives.edit_objective', objective_id=objective_id)) # --------------------------------------------------------------------------- # Comments — coach feedback on an objective # --------------------------------------------------------------------------- @objectives_bp.route('//comments', methods=['POST']) @login_required def add_comment(objective_id): """Add a coach comment on an objective.""" objective = _resolve_objective(objective_id) if not can_manage_objectives(current_user): flash(_('You do not have permission to comment.'), 'danger') return redirect(url_for('objectives.list_objectives')) from app.validators import NoteContentSchema try: data = NoteContentSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) return redirect(url_for('objectives.edit_objective', objective_id=objective_id)) comment = ObjectiveComment( objective_id=objective.id, coach_id=current_user.id, content=data['content'], ) db.session.add(comment) db.session.commit() flash(_('Comment added.'), 'success') return redirect(url_for('objectives.edit_objective', objective_id=objective_id)) # --------------------------------------------------------------------------- # Trend / prediction data for the chart # --------------------------------------------------------------------------- @objectives_bp.route('//trend') @login_required @json_endpoint def trend_data(objective_id): """Return the trend + prediction data for an objective's chart.""" from flask import jsonify from app.objectives_analytics import compute_trend objective = _resolve_objective(objective_id) entries = ( ObjectiveEntry.query.filter_by(objective_id=objective.id) .order_by(ObjectiveEntry.recorded_at) .all() ) timestamps = [e.recorded_at for e in entries] values = [float(e.value) for e in entries] target = float(objective.kpi_target) if objective.kpi_target is not None else None trend = compute_trend(timestamps, values, target) return jsonify(trend)