progression 50% pour evolution des joueurs
CI - Security, Lint & Tests / validate (push) Failing after 1m13s

This commit is contained in:
cedrick2711
2026-09-05 11:30:13 -04:00
parent cf551867c9
commit f468217745
22 changed files with 2235 additions and 8 deletions
+661
View File
@@ -0,0 +1,661 @@
"""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('/<int:objective_id>')
@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('/<int:objective_id>/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('/<int:objective_id>/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('/<int:objective_id>/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('/<int:objective_id>/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('/<int:objective_id>/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('/<int:objective_id>/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('/<int:objective_id>/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('/<int:objective_id>/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)