diff --git a/config.py b/config.py index fa31513..2cf8a04 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.5.17" +APP_VERSION = "2.5.18" def load_local_env(env_path: Path) -> None: diff --git a/league_repositories.py b/league_repositories.py index 0665a67..dbc53e2 100644 --- a/league_repositories.py +++ b/league_repositories.py @@ -375,3 +375,76 @@ def transfer_league_ownership( new_owner.role = "owner" league.created_by_user_id = new_owner_user_id return current_owner, new_owner + + +# --------------------------------------------------------------------------- +# Seasons +# --------------------------------------------------------------------------- + +def list_seasons_for_league(league_id: str, include_archived: bool = False) -> list[Season]: + q = Season.query.filter_by(league_id=league_id) + if not include_archived: + q = q.filter(Season.archived_at.is_(None)) + return q.order_by(Season.sort_order.asc(), Season.created_at.asc()).all() + + +def find_season(league_id: str, season_id: str) -> Season | None: + return Season.query.filter_by(league_id=league_id, id=season_id).one_or_none() + + +def update_season( + season: Season, + name: str, + start_date: date | None = None, + end_date: date | None = None, +) -> Season: + season.name = name.strip() + season.start_date = start_date + season.end_date = end_date + return season + + +def archive_season(season: Season) -> Season: + from datetime import datetime, timezone + season.archived_at = datetime.now(timezone.utc) + return season + + +def unarchive_season(season: Season) -> Season: + season.archived_at = None + return season + + +def delete_season(season: Season) -> None: + PokerSession.query.filter_by(season_id=season.id).update({"season_id": None}) + db.session.delete(season) + + +def auto_assign_sessions_to_seasons(league_id: str) -> int: + """Assign unassigned sessions to seasons based on date ranges. + + Only seasons with both start_date and end_date set are considered. + Sessions with exactly one matching season are assigned; sessions that + match zero or multiple seasons are left untouched (caller decides). + Returns the number of sessions assigned. + """ + eligible_seasons = [ + s for s in list_seasons_for_league(league_id, include_archived=False) + if s.start_date is not None and s.end_date is not None + ] + if not eligible_seasons: + return 0 + + unassigned = PokerSession.query.filter_by( + league_id=league_id, season_id=None + ).all() + assigned = 0 + for session in unassigned: + matches = [ + s for s in eligible_seasons + if s.start_date <= session.session_date <= s.end_date + ] + if len(matches) == 1: + session.season_id = matches[0].id + assigned += 1 + return assigned diff --git a/routes/leagues.py b/routes/leagues.py index adc2fba..6432268 100644 --- a/routes/leagues.py +++ b/routes/leagues.py @@ -307,7 +307,11 @@ def leaderboard(league_ref: str): return redirect(url_for("public.home")) from ledger_repositories import list_event_rows_for_league - from league_repositories import list_players_for_league, user_has_league_role + from league_repositories import ( + list_players_for_league, + list_seasons_for_league, + user_has_league_role, + ) league, resp = get_league_with_visibility_gate(league_ref) if resp: @@ -319,6 +323,25 @@ def leaderboard(league_ref: str): all_sessions = build_session_summaries(list_event_rows_for_league(league.id)) ordered_sessions = sorted(all_sessions, key=session_sort_key) + # Season filter: restrict to sessions belonging to a specific season. + seasons = list_seasons_for_league(league.id) + selected_season_id = request.args.get("season", "").strip() + selected_season = None + if selected_season_id: + from league_repositories import find_season, list_sessions_for_league + selected_season = find_season(league.id, selected_season_id) + if selected_season: + from ledger_repositories import session_event_ref + season_db_sessions = list_sessions_for_league(league.id) + season_refs = { + session_event_ref(s) + for s in season_db_sessions + if s.season_id == selected_season_id + } + ordered_sessions = [s for s in ordered_sessions if s.session_id in season_refs] + else: + selected_season_id = "" + session_ids = [session.session_id for session in ordered_sessions] selected_session_id = request.args.get("through_session", "").strip() mode = request.args.get("mode", "eligible").strip() @@ -390,7 +413,7 @@ def leaderboard(league_ref: str): total_session_count=len(all_sessions), cash_paid_out_cents=cash_paid_out_cents, chart_data=chart_data, - available_sessions=all_sessions, + available_sessions=ordered_sessions, selected_session_id=selected_session_id, selected_session_label=(label if selected_session_id else "Latest session"), selected_session_date=( @@ -406,6 +429,9 @@ def leaderboard(league_ref: str): }, can_manage=can_manage, is_owner=is_owner, + seasons=seasons, + selected_season=selected_season, + selected_season_id=selected_season_id, ) @@ -759,6 +785,7 @@ def sessions(league_ref: str): from ledger_repositories import append_ledger_event from league_repositories import ( create_poker_session, + list_seasons_for_league, list_sessions_for_league, user_has_league_role, ) @@ -777,6 +804,7 @@ def sessions(league_ref: str): "label": request.form.get("label", "").strip(), "notes": request.form.get("notes", "").strip(), "status": request.form.get("status", "open").strip(), + "season_id": request.form.get("season_id", "").strip(), } if request.method == "POST": @@ -786,9 +814,23 @@ def sessions(league_ref: str): flash("Session date must be a valid date.", "error") else: status = "closed" if form["status"] == "closed" else "open" + season_id = form["season_id"] or None + if season_id is None: + from league_repositories import auto_assign_sessions_to_seasons as _auto + # try auto-assign: create the session first, then let the + # function match it; we pass a temporary session date check inline + from league_repositories import list_seasons_for_league as _ls + eligible = [ + s for s in _ls(league.id, include_archived=False) + if s.start_date and s.end_date + and s.start_date <= session_date <= s.end_date + ] + if len(eligible) == 1: + season_id = eligible[0].id session = create_poker_session( league.id, session_date, + season_id=season_id, status=status, ) session.label = form["label"] or None @@ -826,6 +868,17 @@ def sessions(league_ref: str): for sm in summaries if sm.session_id in ref_to_db_id } + seasons = list_seasons_for_league(league.id) + season_map = {s.id: s for s in seasons} + + selected_season_id = request.args.get("season", "").strip() + selected_season = season_map.get(selected_season_id) if selected_season_id else None + if selected_season: + display_sessions = [s for s in all_sessions if s.season_id == selected_season_id] + else: + selected_season_id = "" + display_sessions = all_sessions + empty_count = sum( 1 for s in all_sessions if s.id not in db_id_to_summary or not db_id_to_summary[s.id].entries @@ -834,12 +887,17 @@ def sessions(league_ref: str): return render_template( "league_sessions.html", league=league, - sessions=all_sessions, + sessions=display_sessions, + all_session_count=len(all_sessions), db_id_to_summary=db_id_to_summary, form=form, can_manage=can_manage, is_owner=is_owner, empty_count=empty_count, + seasons=seasons, + season_map=season_map, + selected_season=selected_season, + selected_season_id=selected_season_id, ) @@ -916,6 +974,7 @@ def edit_session(league_ref: str, session_id: str): new_label = request.form.get("label", "").strip() or None new_notes = request.form.get("notes", "").strip() or None new_date_str = request.form.get("session_date", "").strip() + new_season_id = request.form.get("season_id", "").strip() or None try: new_date = date.fromisoformat(new_date_str) @@ -933,8 +992,14 @@ def edit_session(league_ref: str, session_id: str): session.sequence_on_date = int(max_seq or 0) + 1 session.session_date = new_date + if new_season_id is not None: + from league_repositories import find_season + valid = find_season(league.id, new_season_id) + new_season_id = valid.id if valid else None + session.label = new_label session.notes = new_notes + session.season_id = new_season_id db.session.commit() flash("Session updated.", "success") return redirect(url_for("leagues.session_detail", league_ref=league.url_ref, session_id=session_id)) @@ -951,6 +1016,7 @@ def session_detail(league_ref: str, session_id: str): from league_repositories import ( find_session_for_league, list_players_for_league, + list_seasons_for_league, user_has_league_role, ) @@ -1012,6 +1078,7 @@ def session_detail(league_ref: str, session_id: str): summary = summaries[0] if summaries else empty_session_summary(session) all_rows = list_all_event_rows_for_session(league.id, session.id) + seasons = list_seasons_for_league(league.id) return render_template( "league_session_detail.html", league=league, @@ -1024,6 +1091,7 @@ def session_detail(league_ref: str, session_id: str): can_manage=can_manage, is_owner=is_owner, session_label=session_label, + seasons=seasons, ) @@ -1035,7 +1103,7 @@ def session_public_view(league_ref: str, session_id: str): from charts import session_breakdown_series from ledger_repositories import list_event_rows_for_league, list_event_rows_for_session - from league_repositories import find_league_by_public_key, find_session_for_league, list_sessions_for_league, user_has_league_role + from league_repositories import find_league_by_public_key, find_season, find_session_for_league, list_sessions_for_league, user_has_league_role _slug, public_key = split_league_ref(league_ref) league = find_league_by_public_key(public_key) @@ -1078,6 +1146,8 @@ def session_public_view(league_ref: str, session_id: str): prev_summary = all_sessions[chrono_idx - 1] if chrono_idx > 0 else None next_summary = all_sessions[chrono_idx + 1] if chrono_idx < len(all_sessions) - 1 else None + season = find_season(league.id, session.season_id) if session.season_id else None + return render_template( "league_session_view.html", league=league, @@ -1091,6 +1161,7 @@ def session_public_view(league_ref: str, session_id: str): next_session_id=ref_to_db_id.get(next_summary.session_id) if next_summary else None, can_manage=can_manage, is_owner=is_owner, + season=season, ) @@ -1137,6 +1208,193 @@ def update_session_status(league_ref: str, session_id: str, status: str, message return redirect(url_for("leagues.sessions", **league_url_values(league))) +@leagues_bp.route("/l//seasons", methods=["GET", "POST"]) +def seasons(league_ref: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import ( + create_season, + list_seasons_for_league, + user_has_league_role, + ) + + if request.method == "POST": + league = require_league(league_ref, {"owner", "manager"}) + else: + league, resp = get_league_with_visibility_gate(league_ref) + if resp: + return resp + + user_id = current_user_id() or "" + can_manage = user_has_league_role(user_id, league.id, {"owner", "manager"}) + is_owner = user_has_league_role(user_id, league.id, {"owner"}) + + name = request.form.get("name", "").strip() + start_raw = request.form.get("start_date", "").strip() + end_raw = request.form.get("end_date", "").strip() + + if request.method == "POST": + if not name: + flash("Season name is required.", "error") + else: + try: + start_date = date.fromisoformat(start_raw) if start_raw else None + end_date = date.fromisoformat(end_raw) if end_raw else None + except ValueError: + flash("Invalid date format.", "error") + start_date = end_date = None + else: + existing = list_seasons_for_league(league.id, include_archived=True) + create_season( + league.id, + name, + start_date=start_date, + end_date=end_date, + sort_order=len(existing), + ) + db.session.commit() + flash(f"Season \"{name}\" created.", "success") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + active_seasons = list_seasons_for_league(league.id, include_archived=False) + archived_seasons = list_seasons_for_league(league.id, include_archived=True) + archived_seasons = [s for s in archived_seasons if s.archived_at is not None] + return render_template( + "league_seasons.html", + league=league, + active_seasons=active_seasons, + archived_seasons=archived_seasons, + form={ + "name": name if request.method == "POST" else "", + "start_date": start_raw if request.method == "POST" else "", + "end_date": end_raw if request.method == "POST" else "", + }, + can_manage=can_manage, + is_owner=is_owner, + ) + + +@leagues_bp.post("/l//seasons/auto-assign") +@login_required +def auto_assign_seasons(league_ref: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import auto_assign_sessions_to_seasons + + league = require_league(league_ref, {"owner", "manager"}) + count = auto_assign_sessions_to_seasons(league.id) + db.session.commit() + if count: + flash(f"Assigned {count} session{'s' if count != 1 else ''} to seasons.", "success") + else: + flash("No sessions could be auto-assigned. Check that your seasons have start and end dates set, and that unassigned sessions fall within exactly one season.", "info") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + +@leagues_bp.post("/l//seasons//update") +@login_required +def update_season(league_ref: str, season_id: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import find_season, update_season as repo_update_season + + league = require_league(league_ref, {"owner", "manager"}) + season = find_season(league.id, season_id) + if season is None: + flash("Season not found.", "error") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + name = request.form.get("name", "").strip() + start_raw = request.form.get("start_date", "").strip() + end_raw = request.form.get("end_date", "").strip() + + if not name: + flash("Season name is required.", "error") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + try: + start_date = date.fromisoformat(start_raw) if start_raw else None + end_date = date.fromisoformat(end_raw) if end_raw else None + except ValueError: + flash("Invalid date format.", "error") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + repo_update_season(season, name, start_date=start_date, end_date=end_date) + db.session.commit() + flash(f"Season \"{name}\" updated.", "success") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + +@leagues_bp.post("/l//seasons//archive") +@login_required +def archive_season(league_ref: str, season_id: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import archive_season as repo_archive, find_season + + league = require_league(league_ref, {"owner", "manager"}) + season = find_season(league.id, season_id) + if season is None: + flash("Season not found.", "error") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + repo_archive(season) + db.session.commit() + flash(f"Season \"{season.name}\" archived.", "success") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + +@leagues_bp.post("/l//seasons//unarchive") +@login_required +def unarchive_season(league_ref: str, season_id: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import find_season, unarchive_season as repo_unarchive + + league = require_league(league_ref, {"owner", "manager"}) + season = find_season(league.id, season_id) + if season is None: + flash("Season not found.", "error") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + repo_unarchive(season) + db.session.commit() + flash(f"Season \"{season.name}\" restored.", "success") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + +@leagues_bp.post("/l//seasons//delete") +@login_required +def delete_season(league_ref: str, season_id: str): + if not db_ready(): + flash("League database is not available.", "error") + return redirect(url_for("public.home")) + + from league_repositories import delete_season as repo_delete, find_season + + league = require_league(league_ref, {"owner", "manager"}) + season = find_season(league.id, season_id) + if season is None: + flash("Season not found.", "error") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + name = season.name + repo_delete(season) + db.session.commit() + flash(f"Season \"{name}\" deleted. Sessions in this season were unassigned.", "success") + return redirect(url_for("leagues.seasons", **league_url_values(league))) + + @leagues_bp.route("/l//settings", methods=["GET", "POST"]) @login_required def league_settings(league_ref: str): @@ -1194,7 +1452,7 @@ def league_settings(league_ref: str): from league_repositories import list_members_for_league members = list_members_for_league(league.id) - return render_template("league_settings.html", league=league, form=form, is_owner=True, members=members) + return render_template("league_settings.html", league=league, form=form, is_owner=True, can_manage=True, members=members) @leagues_bp.post("/l//settings/invite") diff --git a/static/css/style.css b/static/css/style.css index 8310268..48d7c58 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2449,6 +2449,21 @@ select.control { cursor: pointer; } background: var(--field); } +.season-tag { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: .02em; + vertical-align: middle; + margin-left: 4px; +} + /* Session list row with left accent */ .session-row { display: flex; @@ -4814,6 +4829,16 @@ select.control { cursor: pointer; } } .db-nav-tile--ledger:hover { border-left-color: rgba(224,177,92,.5); } +.db-nav-tile--seasons { + border-left: 3px solid rgba(155,140,240,.28); +} +.db-nav-tile--seasons .db-nav-tile__icon { + background: rgba(155,140,240,.1); + border: 1px solid rgba(155,140,240,.24); + color: var(--accent); +} +.db-nav-tile--seasons:hover { border-left-color: rgba(155,140,240,.55); } + /* Live tile overrides the sessions color */ .db-nav-tile--sessions.db-nav-tile--live { border-left-color: rgba(111,192,147,.55); diff --git a/templates/_league_sidebar.html b/templates/_league_sidebar.html index 81bfec6..fd67a05 100644 --- a/templates/_league_sidebar.html +++ b/templates/_league_sidebar.html @@ -51,6 +51,15 @@ Leaderboard + + + Seasons + + {% if can_manage is defined and can_manage %} diff --git a/templates/league_dashboard.html b/templates/league_dashboard.html index a541ce8..3cbb193 100644 --- a/templates/league_dashboard.html +++ b/templates/league_dashboard.html @@ -307,6 +307,22 @@ + +
+ +
+
+
+ Seasons +
+

Browse sessions by season, view season standings, and manage date ranges.

+
+ +
+ {% if can_manage %}
diff --git a/templates/league_leaderboard.html b/templates/league_leaderboard.html index 7d6fa2d..1669106 100644 --- a/templates/league_leaderboard.html +++ b/templates/league_leaderboard.html @@ -64,8 +64,20 @@

+ {% if seasons %} +
+ + +
+ {% endif %}
+ {% if selected_season_id %}{% endif %} + +
+
+ {% endif %} + +
+ +
+

Seasons

+

New season

+
+
+ + + +
+
+ +
+
+ {% endif %}{# can_manage #} + +
+
+
+

Active

+

Current seasons

+
+ {{ active_seasons|length }} season{{ 's' if active_seasons|length != 1 else '' }} +
+ {% if active_seasons %} +
+ {% for season in active_seasons %} +
+
+
+ +
+ {% if season.start_date %} + {{ season.start_date.strftime('%b %-d, %Y') }} + {% endif %} + {% if season.start_date and season.end_date %} + + {% endif %} + {% if season.end_date %} + {{ season.end_date.strftime('%b %-d, %Y') }} + {% endif %} + {% if not season.start_date and not season.end_date %} + No dates set + {% endif %} +
+
+
+ {% if can_manage %} +
+ +
+ + +
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+

No active seasons. Create one above.

+
+ {% endif %} +
+ + {% if archived_seasons %} +
+
+
+

Archived

+

Past seasons

+
+ {{ archived_seasons|length }} +
+
+ {% for season in archived_seasons %} +
+
+
+
{{ season.name }}
+
+ {% if season.start_date %} + {{ season.start_date.strftime('%b %-d, %Y') }} + {% endif %} + {% if season.start_date and season.end_date %} + + {% endif %} + {% if season.end_date %} + {{ season.end_date.strftime('%b %-d, %Y') }} + {% endif %} + {% if not season.start_date and not season.end_date %} + No dates set + {% endif %} +
+
+
+ {% if can_manage %} +
+
+ + +
+ +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + + +{% if can_manage %} + + + + + + + +{% endif %}{# can_manage #} + +{% endblock %} diff --git a/templates/league_session_detail.html b/templates/league_session_detail.html index a10b46d..fcc7d2a 100644 --- a/templates/league_session_detail.html +++ b/templates/league_session_detail.html @@ -271,6 +271,17 @@ Date + {% if seasons %} + + {% endif %} +