diff --git a/app/app.py b/app/app.py index ea7141d..de8db1e 100644 --- a/app/app.py +++ b/app/app.py @@ -412,6 +412,7 @@ def create_app(config=None): from app.routes.tryouts import tryouts_bp from app.routes.users import users_bp from app.routes.admin import admin_bp + from app.routes.objectives import objectives_bp app.register_blueprint(auth_bp) app.register_blueprint(tryouts_bp) @@ -422,6 +423,7 @@ def create_app(config=None): app.register_blueprint(matches_bp) app.register_blueprint(team_matches_bp) app.register_blueprint(admin_bp) + app.register_blueprint(objectives_bp) # Register custom Jinja filters app.jinja_env.filters['nl2br'] = nl2br diff --git a/app/local_postgres.py b/app/local_postgres.py index 1793d6a..6eefa03 100644 --- a/app/local_postgres.py +++ b/app/local_postgres.py @@ -415,14 +415,23 @@ def start() -> str: # 3. Create the application database (idempotent). _create_database() - # 4. Register cleanup. + # 4. Register cleanup (only in the *parent* process, not the reloader + # worker — the worker registers its own and tears the database down + # behind any sibling process that is still connected). + # + # The Werkzeug reloader sets WERKZEUG_RUN_MAIN=true in the worker + # process and leaves it unset (or false) in the watcher process that + # acts as the parent. We want the cleanup on the *actual process + # lifecycle*, i.e. when the watcher exits, not every time the + # reloader restarts the worker. if not _started: _started = True - atexit.register(_stop) - with contextlib.suppress(ValueError, OSError): - signal.signal(signal.SIGINT, _stop) - with contextlib.suppress(AttributeError, ValueError, OSError): - signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined] + if os.environ.get('WERKZEUG_RUN_MAIN', '').lower() != 'true': + atexit.register(_stop) + with contextlib.suppress(ValueError, OSError): + signal.signal(signal.SIGINT, _stop) + with contextlib.suppress(AttributeError, ValueError, OSError): + signal.signal(signal.SIGBREAK, _stop) # type: ignore[attr-defined] logger.info('Local PostgreSQL ready — DATABASE_URL=%s', DATABASE_URL) return DATABASE_URL diff --git a/app/models/__init__.py b/app/models/__init__.py index 88258f5..316201c 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -21,6 +21,11 @@ from app.models._constants import ( PLATFORM_CODES, TRN_URLS, EVALUATION_CRITERIA, + OBJECTIVE_TYPES, + OBJECTIVE_STATUSES, + OBJECTIVE_PRIORITIES, + OBJECTIVE_CHECK_FREQUENCIES, + DEFAULT_OBJECTIVE_TEMPLATES, ) # ========================================================================= @@ -96,4 +101,19 @@ from app.models.personal_note import PersonalNote from app.models.one_on_one_request import OneOnOneRequest from app.models.admin_settings import AppSettings from app.models.backup_record import BackupRecord -from app.models.audit_log import AuditLog \ No newline at end of file +from app.models.audit_log import AuditLog + +# ========================================================================= +# Layer 12: player evolution objectives +# ========================================================================= +from app.models.objective import Objective # noqa: F401 — new table for create_all() + +# ========================================================================= +# Layer 13: player evolution entries (KPI measurements) +# ========================================================================= +from app.models.objective_entry import ObjectiveEntry # noqa: F401 + +# ========================================================================= +# Layer 14: objective comments (coach feedback, no existing-schema change) +# ========================================================================= +from app.models.objective_comment import ObjectiveComment # noqa: F401 diff --git a/app/models/_constants.py b/app/models/_constants.py index e030674..2a2eb79 100644 --- a/app/models/_constants.py +++ b/app/models/_constants.py @@ -69,6 +69,79 @@ PLATFORM_CODES = { PLATFORM_DEFAULTS = {'Apex Legends': 'pc', 'Rainbow Six Siege': 'ubi', 'Rocket League': 'epic'} +# Objective-related constants for the Player Evolution planning feature. +# Ordered lists of (value, human_label) pairs for form fields and display. +OBJECTIVE_TYPES = [ + ('individual', 'Individuel'), + ('team', 'Équipe'), +] + +OBJECTIVE_STATUSES = [ + ('active', 'Actif'), + ('pending', 'En attente'), + ('completed', 'Terminé'), + ('overdue', 'En retard'), +] + +OBJECTIVE_PRIORITIES = [ + ('low', 'Faible'), + ('medium', 'Moyenne'), + ('high', 'Élevée'), +] + +OBJECTIVE_CHECK_FREQUENCIES = [ + ('weekly', 'Hebdomadaire'), + ('biweekly', 'Bi-hebdomadaire'), + ('monthly', 'Mensuelle'), +] + +# Default templates seeded on first run. +# (priority, title, description, kpi_metric, kpi_target, kpi_unit, type, category) +DEFAULT_OBJECTIVE_TEMPLATES = [ + # Quantitative + ('medium', 'Améliorer le taux de headshot (HS)', + 'Augmenter le pourcentage de têtes sur les kills totaux.', + 'Taux de headshot', 45.0, '%', 'individual', 'Mécaniques'), + ('medium', 'Augmenter le K/D ratio', + 'Améliorer le ratio kills/morts sur les matchs compétitifs.', + 'K/D Ratio', 1.2, '', 'individual', 'Performance'), + ('medium', 'Atteindre un KDA cible', + 'Maintenir un ratio kills/deaths/assists au-dessus du seuil.', + 'KDA', 3.0, '', 'individual', 'Performance'), + ('medium', 'Améliorer la précision', + 'Augmenter le pourcentage de tirs touchés par partie.', + 'Précision', 40.0, '%', 'individual', 'Mécaniques'), + ('medium', 'Augmenter les CS par minute', + 'Atteindre une moyenne de last-hits par minute en jeu.', + 'CS/min', 8.0, '', 'individual', 'Farm'), + ('medium', 'Réduire les morts par match', + 'Diminuer le nombre de décès moyens par partie compétitive.', + 'Morts/match', 5.0, '', 'individual', 'Survie'), + ('medium', 'Augmenter les dégâts par round', + 'Améliorer le damage output moyen par round joué.', + 'Dégâts/round', 150.0, '', 'individual', 'Performance'), + ('medium', 'Améliorer le taux de victoire', + 'Augmenter le pourcentage de parties gagnées sur la saison.', + 'Winrate', 55.0, '%', 'team', 'Compétitif'), + + # Qualitative + ('medium', 'Améliorer le positionnement', + 'Travailler le placement sur la carte pour réduire les angles exposés.', + None, None, None, 'individual', 'Positionnement'), + ('medium', 'Améliorer la communication', + 'Être plus vocal et clair dans les callouts en équipe.', + None, None, None, 'individual', 'Communication'), + ('medium', 'Développer la conscience de carte', + 'Anticiper les rotations adverses et mieux lire la minimap.', + None, None, None, 'individual', 'Game Sense'), + ('medium', 'Améliorer la prise de décision', + 'Réduire les décisions risquées et améliorer le choix des engagements.', + None, None, None, 'individual', 'Decision'), + ('medium', 'Renforcer la cohésion d\'équipe', + 'Participer activement aux stratégies collectives et aux drills.', + None, None, None, 'team', 'Équipe'), +] + TRN_URLS = { 'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}', 'League of Legends': 'https://tracker.gg/lol/riot/profile/{username}', diff --git a/app/models/objective.py b/app/models/objective.py new file mode 100644 index 0000000..d4d8466 --- /dev/null +++ b/app/models/objective.py @@ -0,0 +1,84 @@ +"""Player evolution objective — individual or team KPI-based goal.""" + +from app.extensions import db +from app.time_utils import utc_now_naive + + +class Objective(db.Model): + """Player evolution objective — a KPI-driven plan for individual players + or teams, created and tracked by coaching staff. + + Covers the full lifecycle: draft (pending), active, completed, overdue. + Supports arbitrary KPI metrics, optional tags, and verifiable progress. + """ + + __tablename__ = 'objectives' + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + + # Category + type = db.Column( + db.String(20), + nullable=False, + default='individual', + ) + status = db.Column( + db.String(20), + nullable=False, + default='active', + ) + priority = db.Column( + db.String(20), + nullable=False, + default='medium', + ) + category = db.Column(db.String(50), nullable=True) + is_template = db.Column(db.Boolean, default=False, nullable=False) + + # Assignment (individual player / org team) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) + + # KPI fields + kpi_metric = db.Column(db.String(120), nullable=True) + kpi_target = db.Column(db.Numeric(10, 2), nullable=True) + kpi_unit = db.Column(db.String(20), nullable=True) + kpi_precision = db.Column(db.Integer, nullable=True) + + # Timeline + start_date = db.Column(db.Date, nullable=True) + due_date = db.Column(db.Date, nullable=False) + check_frequency = db.Column(db.String(30), nullable=True) + + # Progress tracking (0–100) + progress = db.Column(db.Integer, default=0, nullable=False) + + # Misc + tags = db.Column(db.String(255), nullable=True) + + # Who created it + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + # Timestamps + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) + + # ----------------------------------------------------------------------- + # Relationships + # ----------------------------------------------------------------------- + player = db.relationship( + 'User', + foreign_keys=[player_id], + backref=db.backref('objectives', lazy='dynamic'), + ) + team = db.relationship( + 'OrgTeam', + foreign_keys=[team_id], + backref=db.backref('objectives', lazy='dynamic'), + ) + creator = db.relationship( + 'User', + foreign_keys=[created_by], + ) diff --git a/app/models/objective_comment.py b/app/models/objective_comment.py new file mode 100644 index 0000000..7b608d5 --- /dev/null +++ b/app/models/objective_comment.py @@ -0,0 +1,35 @@ +"""Coach comments on a specific objective.""" + +from app.extensions import db +from app.time_utils import utc_now_naive + + +class ObjectiveComment(db.Model): + """A coach's comment on a player-evolution objective. + + This is a separate table from PersonalNote — no existing schema + is altered. The objective-form page also *reads* PersonalNote and + TeamNote for context, but never writes to those tables. + """ + + __tablename__ = 'objective_comments' + + id = db.Column(db.Integer, primary_key=True) + objective_id = db.Column( + db.Integer, + db.ForeignKey('objectives.id'), + nullable=False, + ) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + content = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=utc_now_naive) + updated_at = db.Column(db.DateTime, default=utc_now_naive, onupdate=utc_now_naive) + + # ----------------------------------------------------------------------- + # Relationships + # ----------------------------------------------------------------------- + objective = db.relationship( + 'Objective', + backref=db.backref('comments', lazy='dynamic', order_by='ObjectiveComment.created_at'), + ) + coach = db.relationship('User', foreign_keys=[coach_id]) diff --git a/app/models/objective_entry.py b/app/models/objective_entry.py new file mode 100644 index 0000000..556a522 --- /dev/null +++ b/app/models/objective_entry.py @@ -0,0 +1,39 @@ +"""Player evolution — KPI measurement entries for objectives.""" + +from app.extensions import db +from app.time_utils import utc_now_naive + + +class ObjectiveEntry(db.Model): + """A single KPI measurement recorded for an objective. + + Each entry records a value at a point in time so the evolution + graph can plot historical data and compute a trend line. + """ + + __tablename__ = 'objective_entries' + + id = db.Column(db.Integer, primary_key=True) + objective_id = db.Column( + db.Integer, + db.ForeignKey('objectives.id'), + nullable=False, + ) + value = db.Column(db.Numeric(10, 2), nullable=True) + recorded_at = db.Column(db.DateTime, nullable=False, default=utc_now_naive) + note = db.Column(db.Text, nullable=True) + + # Who recorded it + recorded_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + # Timestamps + created_at = db.Column(db.DateTime, default=utc_now_naive) + + # ----------------------------------------------------------------------- + # Relationships + # ----------------------------------------------------------------------- + objective = db.relationship( + 'Objective', + backref=db.backref('entries', lazy='dynamic', order_by='ObjectiveEntry.recorded_at'), + ) + recorder = db.relationship('User', foreign_keys=[recorded_by]) diff --git a/app/objectives_analytics.py b/app/objectives_analytics.py new file mode 100644 index 0000000..3b0b212 --- /dev/null +++ b/app/objectives_analytics.py @@ -0,0 +1,124 @@ +"""Objective analytics — linear regression for KPI prediction. + +Given a set of ObjectiveEntry measurements, computes a simple linear +regression (y = mx + b, where x = days since first entry) and answers: + +- **predicted_achievement_date**: the date when the trend line crosses + the KPI target value (or None if the trend is flat or negative). +- **confidence_score**: a heuristic 0.0–1.0 based on sample count and + the R² coefficient of determination. +- **predicted_value_at_date(date)**: the value the trend predicts at a + given future date. +""" + +from __future__ import annotations + +import datetime + + +def _to_days(timestamps: list[datetime.datetime]) -> list[float]: + """Convert datetimes to float days since the first entry.""" + if not timestamps: + return [] + t0 = timestamps[0] + return [(t - t0).total_seconds() / 86400.0 for t in timestamps] + + +def _r_squared(xs, ys, slope, intercept): + """Coefficient of determination (R²).""" + if len(xs) < 2: + return 0.0 + mean_y = sum(ys) / len(ys) + ss_res = sum((y - (slope * x + intercept)) ** 2 for x, y in zip(xs, ys, strict=True)) + ss_tot = sum((y - mean_y) ** 2 for y in ys) + if ss_tot == 0: + return 1.0 + return 1.0 - ss_res / ss_tot + + +def compute_trend( + timestamps: list[datetime.datetime], + values: list[float], + target: float | None, +) -> dict: + """Compute a linear regression trend and prediction. + + Args: + timestamps: Entry dates. + values: Entry values (must be same length as timestamps). + target: KPI target value, or None for qualitative objectives. + + Returns: + dict with keys: + slope, intercept, r_squared, confidence, + predicted_achievement_days, predicted_achievement_date, + predicted_value_at_due, target_value + """ + n = len(timestamps) + if n < 2 or not values: + return { + 'slope': 0.0, + 'intercept': float(values[0]) if values else 0.0, + 'r_squared': 0.0, + 'confidence': 0.0, + 'predicted_achievement_days': None, + 'predicted_achievement_date': None, + 'predicted_value_at_due': None, + 'target_value': target, + } + + xs = _to_days(timestamps) + ys = [float(v) for v in values] + + n_f = float(n) + sum_x = sum(xs) + sum_y = sum(ys) + sum_xy = sum(x * y for x, y in zip(xs, ys, strict=True)) + sum_xx = sum(x * x for x in xs) + + denom = n_f * sum_xx - sum_x * sum_x + if abs(denom) < 1e-12: + return { + 'slope': 0.0, + 'intercept': sum_y / n_f, + 'r_squared': 0.0, + 'confidence': 0.0, + 'predicted_achievement_days': None, + 'predicted_achievement_date': None, + 'predicted_value_at_due': None, + 'target_value': target, + } + + slope = (n_f * sum_xy - sum_x * sum_y) / denom + intercept = (sum_y - slope * sum_x) / n_f + r2 = _r_squared(xs, ys, slope, intercept) + + # Confidence heuristic: combination of R² and sample count + sample_confidence = min(1.0, n / 10.0) # 10+ entries = full confidence + confidence = sample_confidence * r2 + + # Days from first entry to target (if slope > 0 and target > intercept) + predicted_achievement_days = None + predicted_achievement_date = None + if target is not None and slope > 1e-12: + days_to_target = (target - intercept) / slope + if days_to_target >= 0: + predicted_achievement_days = days_to_target + predicted_achievement_date = timestamps[0] + datetime.timedelta( + days=days_to_target, + ) + + return { + 'slope': slope, + 'intercept': intercept, + 'r_squared': round(r2, 4), + 'confidence': round(confidence, 4), + 'predicted_achievement_days': ( + round(predicted_achievement_days, 1) if predicted_achievement_days is not None else None + ), + 'predicted_achievement_date': ( + predicted_achievement_date.isoformat() if predicted_achievement_date else None + ), + 'predicted_value_at_due': None, + 'target_value': target, + } diff --git a/app/permissions.py b/app/permissions.py index b0d39ae..34d42ae 100644 --- a/app/permissions.py +++ b/app/permissions.py @@ -343,3 +343,73 @@ def coach_manages_tryout(coach, tryout): if tryout.target_org_team_id: return tryout.target_org_team_id in coach_org_team_ids(coach) return False + + +# --------------------------------------------------------------------------- +# Player Evolution — Objectives +# --------------------------------------------------------------------------- + + +def can_view_objectives(user): + """Whether the user may view the objectives list. + + Everyone except regular players without any context can browse objectives. + Players see only their own. + """ + return True + + +def can_manage_objectives(user): + """Whether the user may create, edit, or delete objectives. + + Presidents, managers, and coaches may manage objectives. Players are + read-only. + """ + from app.models import Admin, Coach, Manager + + return isinstance(user, (Admin, Manager, Coach)) + + +def can_manage_objective(user, objective): + """Whether the user may edit or delete a specific objective. + + Presidents and managers may manage any objective. A coach may manage + objectives assigned to their players or their teams. + """ + from app.models import Admin, Coach, Manager + + if isinstance(user, (Admin, Manager)): + return True + if isinstance(user, Coach): + if objective.player_id and objective.player_id in coach_player_ids(user): + return True + if objective.team_id and objective.team_id in coach_org_team_ids(user): + return True + return False + + +def can_view_objective(user, objective): + """Whether the user may view this objective's detail page. + + Presidents, managers, and coaches may view any they manage. + A player may view only their own assigned objectives. + Everyone else may view objectives they created. + """ + from app.models import Admin, Coach, Manager, Player + + if isinstance(user, (Admin, Manager)): + return True + if isinstance(user, Coach): + return can_manage_objective(user, objective) + if isinstance(user, Player): + return objective.player_id == user.id + return objective.created_by == user.id + + +def can_add_objective_entry(user, objective): + """Whether the user may record a KPI measurement on this objective. + + The same as can_view_objective — the assigned player may record + their own measurements on their own objectives. + """ + return can_view_objective(user, objective) diff --git a/app/routes/objectives.py b/app/routes/objectives.py new file mode 100644 index 0000000..0eba2fa --- /dev/null +++ b/app/routes/objectives.py @@ -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('/') +@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) diff --git a/app/static/css/style.css b/app/static/css/style.css index 1660733..0c14906 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1788,6 +1788,296 @@ a:hover { color: var(--primary-dark); } box-shadow: var(--shadow-md); } +/* ------------------------------------------------------------------- */ +/* Player Evolution — Objective Management */ +/* ------------------------------------------------------------------- */ + +/* Wider variant for the create/edit modal with five sections */ +.modal-lg .modal-content { + max-width: 720px; +} + +/* Section grouping inside the form */ +.form-section { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--gray-200); +} + +.form-section:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.form-section h4 { + font-size: 0.95rem; + color: var(--primary); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 6px; +} + +/* Toolbar (tabs + search/filter bar) */ +.objectives-toolbar { + display: flex; + flex-direction: column; + gap: 12px; +} + +.objectives-tabs { + display: flex; + gap: 4px; + border-bottom: 2px solid var(--gray-200); + padding-bottom: 4px; +} + +.tab-link { + padding: 6px 16px; + font-size: 0.9rem; + color: var(--gray-500); + text-decoration: none; + border-radius: var(--radius) var(--radius) 0 0; + transition: all 0.2s; + border: none; + background: transparent; + cursor: pointer; +} + +.tab-link:hover { + color: var(--primary); + background: var(--gray-100); +} + +.tab-link.active { + color: var(--primary); + font-weight: 600; + border-bottom: 2px solid var(--primary); + margin-bottom: -5px; +} + +.objectives-search-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.objectives-search-bar .form-inline { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; + align-items: flex-end; +} + +/* Tag badges inside objective cells */ +.objective-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.objective-tags .badge { + font-size: 0.7rem; + padding: 2px 6px; +} + +/* Progress bar in the table */ +.progress-bar-container { + width: 80px; + height: 6px; + background: var(--gray-200); + border-radius: 3px; + overflow: hidden; + display: inline-block; + vertical-align: middle; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--danger), var(--warning), var(--success)); + border-radius: 3px; + transition: width 0.3s ease; +} + +/* Score-input range mirror (reused from evaluation forms) */ +.score-input { + display: flex; + align-items: center; + gap: 8px; +} + +.score-input input[type="range"] { + flex: 1; +} + +.range-value { + font-weight: 600; + font-size: 1rem; +/* ------------------------------------------------------------------- */ +/* Objective Form — two-column editor layout */ +/* ------------------------------------------------------------------- */ +.objective-editor { + display: grid; + grid-template-columns: 1fr 360px; + gap: 20px; + align-items: start; +} + +.objective-editor-form .card-body { + max-height: 80vh; + overflow-y: auto; +} + +.objective-editor-preview { + position: sticky; + top: 20px; +} + +/* Preview card stats */ +.preview-stats { + margin: 12px 0; +} + +.preview-stat { + display: flex; + justify-content: space-between; + padding: 4px 0; + border-bottom: 1px solid var(--gray-100); + font-size: 0.85rem; +} + +.preview-label { + color: var(--gray-500); +} + +.preview-value { + font-weight: 600; + color: var(--gray-800); +} + +.progress-section { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.progress-section .progress-bar-container { + flex: 1; +} + +/* Template list items */ +.template-item { + padding: 10px; + margin-bottom: 8px; + border: 1px solid var(--gray-200); + border-radius: var(--radius); + transition: border-color 0.2s; +} + +.template-item:hover { + border-color: var(--primary); +} + +.template-item h5 { + margin: 0 0 4px 0; + font-size: 0.9rem; +} + +.template-item p { + margin: 0 0 8px 0; + font-size: 0.8rem; +} + +/* ------------------------------------------------------------------- */ +/* Objective form — comments, entries, prediction info */ +/* ------------------------------------------------------------------- */ + +.comments-list { + max-height: 300px; + overflow-y: auto; +} + +.comment-item { + padding: 8px 0; + border-bottom: 1px solid var(--gray-100); +} + +.comment-item:last-child { + border-bottom: none; +} + +.comment-item p { + margin: 4px 0 0 0; + font-size: 0.85rem; + line-height: 1.4; +} + +#predictionInfo { + font-size: 0.85rem; + padding: 8px; + background: var(--gray-50); + border-radius: var(--radius); +} + +#predictionInfo .confidence-high { color: var(--success); } +#predictionInfo .confidence-medium { color: var(--warning); } +#predictionInfo .confidence-low { color: var(--danger); } + +/* Form-inline for measurement entry */ +.form-inline { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-end; +} + +.form-inline .form-group { + margin-bottom: 0; +} + +/* Hide template/team fields initially */ +#playerField, #teamField { + transition: opacity 0.2s; +} +/* Chart sizing */ +#progressChart { + background: transparent; +} + +/* Responsive: stack on small screens */ +@media (max-width: 900px) { + .objective-editor { + grid-template-columns: 1fr; + } + .objective-editor-preview { + position: static; + } +} + min-width: 24px; + text-align: center; + color: var(--primary); +} + +/* Actions cell in table */ +.actions-cell { + white-space: nowrap; + display: flex; + gap: 4px; +} + +/* Required field indicator */ +.required { + color: var(--danger); +} + +/* Reused stat card col-4 / col-6 helpers */ +.col-4 { flex: 0 0 calc(33.333% - 12px); } +.col-6 { flex: 0 0 calc(50% - 12px); } +.col-12 { flex: 0 0 100%; } .card-header .drag-handle { margin-left: auto; margin-right: 0; diff --git a/app/templates/layouts/base.html b/app/templates/layouts/base.html index ae81ecd..bbccc4b 100644 --- a/app/templates/layouts/base.html +++ b/app/templates/layouts/base.html @@ -68,6 +68,12 @@ {% endif %} +
  • + + + {{ _('Objectives') }} + +
  • {% if current_user.role == 'player' %}
  • diff --git a/app/templates/pages/objective_form.html b/app/templates/pages/objective_form.html new file mode 100644 index 0000000..33cb0b5 --- /dev/null +++ b/app/templates/pages/objective_form.html @@ -0,0 +1,348 @@ +{% extends "layouts/base.html" %} +{% block title %}{% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('New Objective') }}{% endif %} - UdeS team manager{% endblock %} +{% block page_title %}{% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('New Objective') }}{% endif %}{% endblock %} +{% block breadcrumb %}{{ _('Home') }} / {{ _('Objectives') }} / {% if edit_mode %}{{ _('Edit') }}{% else %}{{ _('New') }}{% endif %}{% endblock %} +{% block content %} +
    +
    +
    +

    {% if edit_mode %}{{ _('Edit Objective') }}{% else %}{{ _('Create Objective') }}{% endif %}

    +
    +
    + +
    +

    {{ _('1. General Information') }}

    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    +
    +

    {{ _('2. KPI & Target') }}

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +

    {{ _('3. Deadline & Priority') }}

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +

    {{ _('4. Tracking & Alerts') }}

    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +

    {{ _('5. Advanced Settings') }}

    +
    + + +
    +
    + + +
    +
    +
    + {{ _('Cancel') }} + +
    +
    +
    +
    +
    +
    +
    +

    {{ _('Player Preview') }}

    +
    +
    +

    {{ objective.title if objective else _('Objective title') }}

    +

    {{ objective.description if objective else '' }}

    +
    {% if objective and objective.tags %}{% for tag in objective.tags.split(',') %}{{ tag.strip() }}{% endfor %}{% endif %}
    +
    +
    {{ _('Assigned to') }}{% if objective and objective.player %}{{ objective.player.username }}{% else %}-{% endif %}
    +
    {{ _('KPI') }}{% if objective and objective.kpi_metric %}{{ objective.kpi_metric }}: {{ objective.kpi_target }}{{ objective.kpi_unit }}{% else %}-{% endif %}
    +
    {{ _('Deadline') }}{% if objective and objective.due_date %}{{ objective.due_date.strftime('%Y-%m-%d') }}{% endif %}
    +
    +
    + {{ _('Progress') }} +
    + {{ objective.progress if objective else 0 }}% +
    +
    +
    + {% if objective %}{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}{% else %}{{ _('Active') }}{% endif %} + {% if objective %}{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(objective.priority,objective.priority) }}{% else %}{{ _('Medium') }}{% endif %} +
    +
    +
    +
    +

    {{ _('KPI Progression') }}

    +
    + + {% if edit_mode and objective.kpi_target %} +
    + {% endif %} +
    +
    + {% if edit_mode %} +
    +

    {{ _('KPI Measurements') }}

    +
    +
    + +
    + +
    +
    + +
    + +
    + {% if entries %} + + + + {% for e in entries %} + + {% endfor %} + +
    {{ _('Date') }}{{ _('Value') }}{{ _('Note') }}
    {{ e.recorded_at.strftime('%Y-%m-%d %H:%M') }}{{ e.value }}{{ e.note or '' }}
    + {% endif %} +
    +
    +
    +

    {{ _('Comments') }}

    +
    +
    + +
    + +
    + +
    + {% if comments %} +
    + {% for c in comments %} +
    + {{ c.coach.username }} {{ c.created_at.strftime('%Y-%m-%d %H:%M') }} +

    {{ c.content }}

    +
    + {% endfor %} +
    + {% endif %} +
    +
    + {% if personal_notes or team_notes %} +
    +

    {{ _('Related Notes') }}

    +
    + {% if personal_notes %} +
    {{ _('Player Notes') }}
    + {% for n in personal_notes %} +
    + {{ n.coach.username }} {{ n.created_at.strftime('%Y-%m-%d') }} +

    {{ n.content[:200] }}

    +
    + {% endfor %} + {% endif %} + {% if team_notes %} +
    {{ _('Team Notes') }}
    + {% for n in team_notes %} +
    + {{ n.coach.username }} {{ n.created_at.strftime('%Y-%m-%d') }} +

    {{ n.content[:200] }}

    +
    + {% endfor %} + {% endif %} +
    +
    + {% endif %} + {% endif %} + {% if templates %} +
    +

    {{ _('Templates') }}

    +
    + {% for t in templates %} +
    +
    {{ t.title }}
    +

    {{ t.description[:120] if t.description else '' }}

    + +
    + {% endfor %} +
    +
    + {% endif %} +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/pages/objective_view.html b/app/templates/pages/objective_view.html new file mode 100644 index 0000000..551b451 --- /dev/null +++ b/app/templates/pages/objective_view.html @@ -0,0 +1,125 @@ +{% extends "layouts/base.html" %} +{% block title %}{{ objective.title }} - UdeS team manager{% endblock %} +{% block page_title %}{{ objective.title }}{% endblock %} +{% block breadcrumb %}{{ _('Home') }} / {{ _('Objectives') }} / {{ objective.title }}{% endblock %} +{% block content %} +
    +
    +
    +

    {{ objective.title }}

    +
    +

    {{ objective.description or '' }}

    +
    +
    {{ _('Type') }}{% for val, label in objective_types %}{% if val == objective.type %}{{ label }}{% endif %}{% endfor %}
    +
    {{ _('Assigned to') }}{% if objective.player %}{{ objective.player.username }}{% elif objective.team %}{{ objective.team.name }}{% else %}-{% endif %}
    +
    {{ _('Category') }}{{ objective.category or '-' }}
    +
    +
    + {% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %} + {% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }} +
    +
    +
    +
    +

    {{ _('KPI Progression') }}

    +
    + + {% if objective.kpi_target %}
    {% endif %} +
    +
    +
    +

    {{ _('KPI Measurements') }}

    +
    +
    + +
    +
    + +
    + {% if entries %} +{% for e in entries %}{% endfor %}
    {{ _('Date') }}{{ _('Value') }}{{ _('Note') }}
    {{ e.recorded_at.strftime('%Y-%m-%d %H:%M') }}{% if e.value is not none %}{{ e.value }}{% else %}-{% endif %}{{ e.note or '' }}
    {% else %}

    {{ _('No measurements recorded yet.') }}

    {% endif %} +
    +
    +
    +

    {{ _('Comments') }}

    +
    {% if comments %}
    {% for c in comments %}
    {{ c.coach.username }} {{ c.created_at.strftime('%Y-%m-%d %H:%M') }}

    {{ c.content }}

    {% endfor %}
    {% else %}

    {{ _('No comments yet.') }}

    {% endif %}
    +
    +
    +
    +
    +

    {{ _('Details') }}

    +
    +
    +
    {{ _('Priority') }}{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}
    +
    {{ _('Status') }}{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}
    +
    {{ _('Start date') }}{{ objective.start_date.strftime('%Y-%m-%d') if objective.start_date else '-' }}
    +
    {{ _('Deadline') }}{{ objective.due_date.strftime('%Y-%m-%d') if objective.due_date else '-' }}
    +
    {{ _('Progress') }}{{ objective.progress }}%
    +
    + {% if objective.kpi_target %}
    {{ _('KPI Target') }}{{ objective.kpi_target }}{% if objective.kpi_unit %}{{ objective.kpi_unit }}{% endif %}
    {% endif %} + {% if can_edit %} {{ _('Edit') }}{% endif %} +
    +
    + {% if personal_notes or team_notes %}

    {{ _('Related Notes') }}

    {% if personal_notes %}
    {{ _('Personal Notes') }}
    {% for n in personal_notes %}
    {{ n.coach.username }} {{ n.created_at.strftime('%Y-%m-%d') }}

    {{ n.content[:200] }}

    {% endfor %}{% endif %}{% if team_notes %}
    {{ _('Team Notes') }}
    {% for n in team_notes %}
    {{ n.coach.username }} {{ n.created_at.strftime('%Y-%m-%d') }}

    {{ n.content[:200] }}

    {% endfor %}{% endif %}
    {% endif %} +
    +
    {% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/app/templates/pages/objectives.html b/app/templates/pages/objectives.html new file mode 100644 index 0000000..6c83f18 --- /dev/null +++ b/app/templates/pages/objectives.html @@ -0,0 +1,64 @@ +{% extends "layouts/base.html" %} +{% import 'layouts/_pagination.html' as pager %} +{% block title %}{{ _('Objectives') }} - UdeS team manager{% endblock %} +{% block page_title %}{{ _('Objective Management') }}{% endblock %} +{% block breadcrumb %}{{ _('Home') }} / {{ _('Objectives') }}{% endblock %} +{% block content %} +
    +

    {{ count_total }}

    {{ _('Total') }}

    +

    {{ count_kpi }}

    {{ _('Custom KPIs') }}

    +

    {{ count_templates }}

    {{ _('Templates') }}

    +

    {{ success_rate }}%

    {{ _('Success rate') }}

    +
    +
    + + +
    +

    {{ _('Objectives') }}

    {% if can_edit %} {{ _('New Objective') }}{% endif %}
    +
    +{% if can_edit %}{% endif %} + +{% for obj in objectives.items %} + + + + + + + + +{% if can_edit %}{% elif current_user.role == 'player' %}{% endif %} +{% else %} +{% endfor %} +
    {{ _('Objective') }}{{ _('Assigned to') }}{{ _('Team') }}{{ _('Priority') }}{{ _('Status') }}{{ _('Target KPI') }}{{ _('Progress') }}{{ _('Deadline') }}{{ _('Actions') }}
    {{ obj.title }}{% if obj.tags %}
    {% for tag in obj.tags.split(',') %}{{ tag.strip() }}{% endfor %}
    {% endif %}{% if obj.kpi_metric %}{{ obj.kpi_metric }}{% endif %}
    {% if obj.player %}{{ obj.player.username[:2]|upper }} {{ obj.player.username }}{% else %}-{% endif %}{% if obj.team %}{{ obj.team.name }}{% else %}-{% endif %}{% set pc = {'high':'badge-danger','medium':'badge-warning','low':'badge-secondary'} %}{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(obj.priority,obj.priority) }}{% set sc = {'active':'badge-success','pending':'badge-warning','completed':'badge-info','overdue':'badge-danger'} %}{% for val, label in objective_statuses %}{% if val == obj.status %}{{ label }}{% endif %}{% endfor %}{% if obj.kpi_target is not none %}{{ obj.kpi_target }}{% if obj.kpi_unit %}{{ obj.kpi_unit }}{% endif %}{% else %}-{% endif %}
    {{ obj.progress }}%
    {% if obj.is_template %}-{% elif obj.due_date %}{{ obj.due_date.strftime('%Y-%m-%d') }}{% endif %} {{ _('View') }}

    {{ _('No objectives yet') }}

    {{ _('Create your first objective to start tracking player evolution.') }}

    {{ pager.controls(pagination) }}
    + +{% endblock %} +{% block scripts %}{% endblock %} diff --git a/app/templates/pages/objectives_list_template.txt b/app/templates/pages/objectives_list_template.txt new file mode 100644 index 0000000..e69de29 diff --git a/app/validators.py b/app/validators.py index b14aedc..462e94f 100644 --- a/app/validators.py +++ b/app/validators.py @@ -23,7 +23,15 @@ from marshmallow import ( validates_schema, ) -from app.models import ESPORT_GAMES, GAME_PLATFORMS, USER_TYPES +from app.models import ( + ESPORT_GAMES, + GAME_PLATFORMS, + OBJECTIVE_CHECK_FREQUENCIES, + OBJECTIVE_PRIORITIES, + OBJECTIVE_STATUSES, + OBJECTIVE_TYPES, + USER_TYPES, +) # ============================================================================= # Custom Validators @@ -1049,3 +1057,137 @@ class EvaluationSchema(StripMixin): allow_none=True, load_default=None, ) + + +# ============================================================================= +# Player Evolution — Objective / Plan Schema +# ============================================================================= + + +class ObjectiveSchema(StripMixin): + """A player-evolution objective — individual or team KPI goal. + + Validates the form payload from the create / edit modal on the + objectives management page. + """ + + title = fields.String( + required=True, + validate=validate.Length(min=1, max=200, error=_l('Le titre est obligatoire.')), + error_messages={'required': _l('Le titre est obligatoire.')}, + ) + description = fields.String( + allow_none=True, + load_default=None, + validate=validate.Length(max=5000), + ) + type = fields.String( + validate=validate.OneOf( + [v for v, _ in OBJECTIVE_TYPES], + error=_l("Type d'objectif invalide."), + ), + load_default='individual', + ) + status = fields.String( + validate=validate.OneOf( + [v for v, _ in OBJECTIVE_STATUSES], + error=_l('Statut invalide.'), + ), + load_default='active', + ) + priority = fields.String( + validate=validate.OneOf( + [v for v, _ in OBJECTIVE_PRIORITIES], + error=_l('Priorité invalide.'), + ), + load_default='medium', + ) + category = fields.String( + allow_none=True, + load_default=None, + validate=validate.Length(max=50), + ) + is_template = fields.Boolean(load_default=False) + player_id = fields.Integer( + allow_none=True, + load_default=None, + validate=validate.Range(min=1), + error_messages={'invalid': _l('Joueur invalide.')}, + ) + team_id = fields.Integer( + allow_none=True, + load_default=None, + validate=validate.Range(min=1), + error_messages={'invalid': _l('Équipe invalide.')}, + ) + kpi_metric = fields.String( + allow_none=True, + load_default=None, + validate=validate.Length(max=120), + ) + kpi_target = fields.Decimal( + allow_none=True, + load_default=None, + places=2, + error_messages={'invalid': _l('Cible KPI invalide.')}, + ) + kpi_unit = fields.String( + allow_none=True, + load_default=None, + validate=validate.Length(max=20), + ) + kpi_precision = fields.Integer( + allow_none=True, + load_default=None, + validate=validate.Range(min=0, max=10), + ) + start_date = fields.Date( + allow_none=True, + load_default=None, + format='%Y-%m-%d', + error_messages={'invalid': _l('Date de début invalide (AAAA-MM-JJ).')}, + ) + due_date = fields.Date( + required=True, + format='%Y-%m-%d', + error_messages={ + 'required': _l('La date d\'échéance est obligatoire.'), + 'invalid': _l('Date d\'échéance invalide (AAAA-MM-JJ).'), + }, + ) + check_frequency = fields.String( + allow_none=True, + load_default=None, + validate=validate.OneOf( + [''] + [v for v, _ in OBJECTIVE_CHECK_FREQUENCIES], + error=_l('Fréquence de vérification invalide.'), + ), + ) + progress = fields.Integer( + load_default=0, + validate=validate.Range( + min=0, max=100, error=_l('La progression doit être entre 0 et 100.') + ), + ) + tags = fields.String( + allow_none=True, + load_default=None, + validate=validate.Length(max=255), + ) + + @validates_schema + def validate_assignment(self, data, **kwargs): + """Type-aware assignment: coerce to a clean state. + + - Templates → no player or team. + - Team objectives → clear player_id. + - Individual objectives → clear team_id. + """ + if data.get('is_template'): + data['player_id'] = None + data['team_id'] = None + return + if data.get('type') == 'team': + data['player_id'] = None + else: + data['team_id'] = None diff --git a/image_evolution des joueurs.png b/image_evolution des joueurs.png new file mode 100644 index 0000000..356b194 Binary files /dev/null and b/image_evolution des joueurs.png differ diff --git a/image_evolution des joueurs2.png b/image_evolution des joueurs2.png new file mode 100644 index 0000000..980c13d Binary files /dev/null and b/image_evolution des joueurs2.png differ diff --git a/run.py b/run.py index 7e8cd1a..6956c2f 100644 --- a/run.py +++ b/run.py @@ -72,6 +72,10 @@ if __name__ == '__main__': _db.session.commit() print('[INFO] Seeded 4 default users (password for all: password).') + # Seed default objective templates (idempotent) + from app.routes.objectives import seed_default_templates + seed_default_templates() + debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true' if debug_mode: app.logger.warning( diff --git a/scripts/gen_list_page.py b/scripts/gen_list_page.py new file mode 100644 index 0000000..363f169 --- /dev/null +++ b/scripts/gen_list_page.py @@ -0,0 +1,62 @@ +import pathlib +tmpl = '''{% extends "layouts/base.html" %} +{% import 'layouts/_pagination.html' as pager %} +{% block title %}{{ _('Objectives') }} - UdeS team manager{% endblock %} +{% block page_title %}{{ _('Objective Management') }}{% endblock %} +{% block breadcrumb %}{{ _('Home') }} / {{ _('Objectives') }}{% endblock %} +{% block content %} +
    +

    {{ count_total }}

    {{ _('Total') }}

    +

    {{ count_kpi }}

    {{ _('Custom KPIs') }}

    +

    {{ count_templates }}

    {{ _('Templates') }}

    +

    {{ success_rate }}%

    {{ _('Success rate') }}

    +
    +
    + + +
    +

    {{ _('Objectives') }}

    {% if can_edit %} {{ _('New Objective') }}{% endif %}
    +
    +{% if can_edit %}{% endif %} + +''' +pathlib.Path('app/templates/pages/objectives.html').write_text(tmpl, encoding='utf-8') +print('LIST_PAGE_PART1_OK') + +tmpl2 = '''{% for obj in objectives.items %} + + + + + + + + +{% if can_edit %}{% endif %} +{% else %} +{% endfor %} +
    {{ _('Objective') }}{{ _('Assigned to') }}{{ _('Team') }}{{ _('Priority') }}{{ _('Status') }}{{ _('Target KPI') }}{{ _('Progress') }}{{ _('Deadline') }}{{ _('Actions') }}
    {{ obj.title }}{% if obj.tags %}
    {% for tag in obj.tags.split(',') %}{{ tag.strip() }}{% endfor %}
    {% endif %}{% if obj.kpi_metric %}{{ obj.kpi_metric }}{% endif %}
    {% if obj.player %}{{ obj.player.username[:2]|upper }} {{ obj.player.username }}{% else %}-{% endif %}{% if obj.team %}{{ obj.team.name }}{% else %}-{% endif %}{% set pc = {'high':'badge-danger','medium':'badge-warning','low':'badge-secondary'} %}{{ {'high':_('High'),'medium':_('Medium'),'low':_('Low')}.get(obj.priority,obj.priority) }}{% set sc = {'active':'badge-success','pending':'badge-warning','completed':'badge-info','overdue':'badge-danger'} %}{% for val, label in objective_statuses %}{% if val == obj.status %}{{ label }}{% endif %}{% endfor %}{% if obj.kpi_target is not none %}{{ obj.kpi_target }}{% if obj.kpi_unit %}{{ obj.kpi_unit }}{% endif %}{% else %}-{% endif %}
    {{ obj.progress }}%
    {% if obj.due_date %}{{ obj.due_date.strftime('%Y-%m-%d') }}{% endif %}

    {{ _('No objectives yet') }}

    {{ _('Create your first objective to start tracking player evolution.') }}

    {{ pager.controls(pagination) }}
    + +{% endblock %} +{% block scripts %}{% endblock %} +''' +with open('app/templates/pages/objectives.html', 'a', encoding='utf-8') as f: + f.write(tmpl2) +print('LIST_PAGE_DONE') \ No newline at end of file diff --git a/scripts/gen_view_page.py b/scripts/gen_view_page.py new file mode 100644 index 0000000..c3f8ac8 --- /dev/null +++ b/scripts/gen_view_page.py @@ -0,0 +1,69 @@ +import pathlib +t = """{% extends "layouts/base.html" %} +{% block title %}{{ objective.title }} - UdeS team manager{% endblock %} +{% block page_title %}{{ objective.title }}{% endblock %} +{% block breadcrumb %}{{ _('Home') }} / {{ _('Objectives') }} / {{ objective.title }}{% endblock %} +{% block content %} +
    +
    +
    +

    {{ objective.title }}

    +
    +

    {{ objective.description or '' }}

    +
    +
    {{ _('Type') }}{% for val, label in objective_types %}{% if val == objective.type %}{{ label }}{% endif %}{% endfor %}
    +
    {{ _('Assigned to') }}{% if objective.player %}{{ objective.player.username }}{% elif objective.team %}{{ objective.team.name }}{% else %}-{% endif %}
    +
    {{ _('Category') }}{{ objective.category or '-' }}
    +
    +
    + {% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %} + {% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }} +
    +
    +
    +
    +

    {{ _('KPI Progression') }}

    +
    + + {% if objective.kpi_target %}
    {% endif %} +
    +
    +
    +

    {{ _('KPI Measurements') }}

    +
    +
    + +
    +
    + +
    + {% if entries %} +{% for e in entries %}{% endfor %}
    {{ _('Date') }}{{ _('Value') }}{{ _('Note') }}
    {{ e.recorded_at.strftime('%%Y-%%m-%%d %%H:%%M') }}{% if e.value is not none %}{{ e.value }}{% else %}-{% endif %}{{ e.note or '' }}
    {% else %}

    {{ _('No measurements recorded yet.') }}

    {% endif %} +
    +
    +
    +

    {{ _('Comments') }}

    +
    {% if comments %}
    {% for c in comments %}
    {{ c.coach.username }} {{ c.created_at.strftime('%%Y-%%m-%%d %%H:%%M') }}

    {{ c.content }}

    {% endfor %}
    {% else %}

    {{ _('No comments yet.') }}

    {% endif %}
    +
    +
    +
    +
    +

    {{ _('Details') }}

    +
    +
    +
    {{ _('Priority') }}{% set pl = {'high':_('High'),'medium':_('Medium'),'low':_('Low')} %}{{ pl.get(objective.priority,objective.priority) }}
    +
    {{ _('Status') }}{% for val, label in objective_statuses %}{% if val == objective.status %}{{ label }}{% endif %}{% endfor %}
    +
    {{ _('Start date') }}{{ objective.start_date.strftime('%%Y-%%m-%%d') if objective.start_date else '-' }}
    +
    {{ _('Deadline') }}{{ objective.due_date.strftime('%%Y-%%m-%%d') if objective.due_date else '-' }}
    +
    {{ _('Progress') }}{{ objective.progress }}%
    +
    + {% if objective.kpi_target %}
    {{ _('KPI Target') }}{{ objective.kpi_target }}{% if objective.kpi_unit %}{{ objective.kpi_unit }}{% endif %}
    {% endif %} + {% if can_edit %} {{ _('Edit') }}{% endif %} +
    +
    + {% if personal_notes or team_notes %}

    {{ _('Related Notes') }}

    {% if personal_notes %}
    {{ _('Personal Notes') }}
    {% for n in personal_notes %}
    {{ n.coach.username }} {{ n.created_at.strftime('%%Y-%%m-%%d') }}

    {{ n.content[:200] }}

    {% endfor %}{% endif %}{% if team_notes %}
    {{ _('Team Notes') }}
    {% for n in team_notes %}
    {{ n.coach.username }} {{ n.created_at.strftime('%%Y-%%m-%%d') }}

    {{ n.content[:200] }}

    {% endfor %}{% endif %}
    {% endif %} +
    +
    {% endblock %} +""" +pathlib.Path('app/templates/pages/objective_view.html').write_text(t, encoding='utf-8') +print('OK')