From 5249744b01849b7158ff9cf796c550924f452320 Mon Sep 17 00:00:00 2001 From: Anson Bridges Date: Fri, 3 Apr 2026 16:02:56 -0700 Subject: start er up --- .gitignore | 2 + PREMISE.md | 23 + README.md | 1 + app.py | 956 ++++++++++++++++++++++++++++++++++++++ db_builder.py | 124 +++++ orchard_league.db | Bin 0 -> 36864 bytes requirements.txt | 3 + static/css/style.css | 372 +++++++++++++++ static/img/logo.png | Bin 0 -> 6863 bytes static/img/orchards_left.png | Bin 0 -> 285981 bytes static/img/orchards_right.png | Bin 0 -> 279472 bytes static/ttf/Decort.ttf | Bin 0 -> 38632 bytes static/ttf/Powell and Geary.ttf | Bin 0 -> 111644 bytes static/uploads/7_drahdiwaberl.png | Bin 0 -> 409795 bytes templates/admin.html | 138 ++++++ templates/admin_edit_game.html | 66 +++ templates/admin_manage_teams.html | 76 +++ templates/base.html | 54 +++ templates/game.html | 185 ++++++++ templates/login.html | 16 + templates/season.html | 285 ++++++++++++ templates/team.html | 65 +++ 22 files changed, 2366 insertions(+) create mode 100644 .gitignore create mode 100644 PREMISE.md create mode 100644 README.md create mode 100644 app.py create mode 100644 db_builder.py create mode 100644 orchard_league.db create mode 100644 requirements.txt create mode 100644 static/css/style.css create mode 100644 static/img/logo.png create mode 100644 static/img/orchards_left.png create mode 100644 static/img/orchards_right.png create mode 100644 static/ttf/Decort.ttf create mode 100644 static/ttf/Powell and Geary.ttf create mode 100644 static/uploads/7_drahdiwaberl.png create mode 100644 templates/admin.html create mode 100644 templates/admin_edit_game.html create mode 100644 templates/admin_manage_teams.html create mode 100644 templates/base.html create mode 100644 templates/game.html create mode 100644 templates/login.html create mode 100644 templates/season.html create mode 100644 templates/team.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92afa22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +venv/ diff --git a/PREMISE.md b/PREMISE.md new file mode 100644 index 0000000..7b1f22d --- /dev/null +++ b/PREMISE.md @@ -0,0 +1,23 @@ +Create a website for a Super Mega Baseball 4 league called "The Orchard League" according to the following requirements: + +## Functional Requirements + +1. Make all site pages visible to un-authenticated users, except the team management page and the admin pages. +2. Create a login page with the credentials being a simple username and password. Make sure the password is hashed/encrypted. +3. Users should have the associated fields besides the username/hashed password: whether the user is an admin, team name, team icon, and seasons being partaken in. Potentially more fields could be required to match additional features +4. Create a standings page that displays the standings for a given season. Columns will be wins, losses, runs for, runs against, and games back. +5. Create a schedule page that by default when not logged in shows the full schedule for the current season with a calendar. Games in the calendar should have the teams with their records and icons, visiting team first. Clicking on games within the calendar loads the game page. When a user is logged in, it should show the schedule for the logged in team (if the team is participating in the season). There should be a dropdown on the schedule page that allows the visitor to view the schedules of the whole league or of specific teams. +6. Create an admin-only season management page that allows admins to create seasons, add teams to seasons, generate schedules, and add/remove game results to a season. Season generation should have the following parameters: total number of games, games per week, number of playoff teams, number of playoff games per series. Even number of playoff teams should result in an even two sided bracket with seeding going top seed vs last seed, 2nd vs 2nd to last, etc. Odd numbers should result in the top seed getting a bye and playing the winner. +7. Create a game page that displays the scheduled or already completed game. The game page should have the two participating teams and their logos/records with the away team on the left. It should also have the scheduled game date and the score (if any). The users for both participating teams can submit a score/result after or on the scheduled game date. If their submissions agree, the match score is finalized. Result submissions have an optional box score with 9 regular innings with an extra innings box (optional), hits, and errors. This is ignored if the box score contradicts the final score or both users submit box scores that contradict each other. Users of the involved teams can also submit petitions to change the start date, which happens automatically if both their petitions agree. Admins can change any game information at will, but the fields only become editable after pressing a pencil edit button visible only to admins. +8. Create a user management page where users can change their password, team name, and upload team icons. + +## Technical Requirements + +1. Use Flask for hosting the website and serving the page templates. +2. Use Flask-login for authentication. +3. Use SQLite for storing information. +4. Create data structures and functions that can be used for generating SQLite tables with ease of iteration and regeneration. Tables may include user logins, teams, seasons, schedules, individual games, etc. + +## Stylistic Requirements + +1. The website appearance should be evocative of themes of futuristic art deco urbanism growing out of dusty rural agricultural surroundings. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9913bed --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +init repo diff --git a/app.py b/app.py new file mode 100644 index 0000000..826410b --- /dev/null +++ b/app.py @@ -0,0 +1,956 @@ +import os +import json +import itertools +from flask import Flask, render_template, request, redirect, url_for, flash +from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user +from werkzeug.security import generate_password_hash, check_password_hash +from werkzeug.utils import secure_filename +import sqlite3 +from datetime import datetime, timedelta + +app = Flask(__name__) +app.secret_key = 'super_secret_orchard_key' +app.config['UPLOAD_FOLDER'] = 'static/uploads' + +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'login' + +DB_FILE = 'orchard_league.db' + +def get_db(): + conn = sqlite3.connect(DB_FILE) + conn.row_factory = sqlite3.Row + return conn + +class User(UserMixin): + def __init__(self, id, username, is_admin, team_name, team_icon): + self.id = str(id) + self.username = username + self.is_admin = bool(is_admin) + self.team_name = team_name + self.team_icon = team_icon + +@login_manager.user_loader +def load_user(user_id): + conn = get_db() + c = conn.cursor() + c.execute('SELECT * FROM Users WHERE id = ?', (user_id,)) + user_row = c.fetchone() + conn.close() + if user_row: + return User(user_row['id'], user_row['username'], user_row['is_admin'], user_row['team_name'], user_row['team_icon']) + return None + +@app.route('/') +def index(): + season_id = request.args.get('season_id') + team_id = request.args.get('team_id') + tab = request.args.get('tab', 'overview') + + conn = get_db() + c = conn.cursor() + + # Get all seasons for the dropdown + c.execute('SELECT id, name FROM Seasons ORDER BY id DESC') + seasons = c.fetchall() + + if not seasons: + conn.close() + return render_template('season.html', seasons=[], selected_season_id=None) + + if not season_id: + season_id = seasons[0]['id'] + + # Season Overview Data + c.execute('SELECT * FROM Seasons WHERE id = ?', (season_id,)) + season_info = dict(c.fetchone()) + + # Season Runtime (Start/End dates) + c.execute('SELECT MIN(scheduled_date), MAX(scheduled_date) FROM Games WHERE season_id = ?', (season_id,)) + runtime = c.fetchone() + season_info['start_date'] = runtime[0] if runtime[0] else "N/A" + season_info['end_date'] = runtime[1] if runtime[1] else "N/A" + + # Final Placement (if all games are Final and games exist) + c.execute("SELECT COUNT(*) as c FROM Games WHERE season_id = ?", (season_id,)) + has_games = c.fetchone()['c'] > 0 + c.execute("SELECT COUNT(*) as c FROM Games WHERE season_id = ? AND status != 'Final'", (season_id,)) + all_final = c.fetchone()['c'] == 0 + is_finished = has_games and all_final + season_info['is_finished'] = is_finished + + # Join Status for current user + user_season_status = None + if current_user.is_authenticated: + c.execute('SELECT status FROM SeasonTeams WHERE season_id = ? AND user_id = ?', (season_id, current_user.id)) + status_row = c.fetchone() + if status_row: + user_season_status = status_row['status'] + + # Standings Data (Regular Season only) + c.execute(''' + SELECT + u.id as user_id, u.team_name, u.team_icon, + SUM(CASE WHEN (g.home_team_id = u.id AND g.home_score > g.away_score) OR (g.away_team_id = u.id AND g.away_score > g.home_score) THEN 1 ELSE 0 END) as wins, + SUM(CASE WHEN (g.home_team_id = u.id AND g.home_score < g.away_score) OR (g.away_team_id = u.id AND g.away_score < g.home_score) THEN 1 ELSE 0 END) as losses, + SUM(CASE WHEN g.home_team_id = u.id THEN g.home_score WHEN g.away_team_id = u.id THEN g.away_score ELSE 0 END) as runs_for, + SUM(CASE WHEN g.home_team_id = u.id THEN g.away_score WHEN g.away_team_id = u.id THEN g.home_score ELSE 0 END) as runs_against, + (SELECT MIN(scheduled_date) FROM Games WHERE (home_team_id = u.id OR away_team_id = u.id) AND season_id = st.season_id) as first_game + FROM SeasonTeams st + JOIN Users u ON st.user_id = u.id + LEFT JOIN Games g ON (g.home_team_id = u.id OR g.away_team_id = u.id) AND g.season_id = st.season_id AND g.status = 'Final' AND g.is_playoff = 0 + WHERE st.season_id = ? AND st.status = 'Approved' + GROUP BY u.id, u.team_name, u.team_icon + ORDER BY wins DESC, runs_for DESC, runs_against ASC, first_game ASC + ''', (season_id,)) + standings_raw = c.fetchall() + + # Clinch Calculation + standings = [] + if standings_raw: + top_wins = standings_raw[0]['wins'] + top_losses = standings_raw[0]['losses'] + p_count = season_info['playoff_teams'] or 0 + total_sched = season_info['total_games'] or 0 + + # Get remaining games for each team + c.execute(''' + SELECT u.id, COUNT(g.id) as remaining + FROM SeasonTeams st + JOIN Users u ON st.user_id = u.id + LEFT JOIN Games g ON (g.home_team_id = u.id OR g.away_team_id = u.id) + AND g.season_id = st.season_id AND g.status = 'Scheduled' AND g.is_playoff = 0 + WHERE st.season_id = ? + GROUP BY u.id + ''', (season_id,)) + remaining_map = {row['id']: row['remaining'] for row in c.fetchall()} + + team_stats = [] + for row in standings_raw: + d = dict(row) + d['max_wins'] = d['wins'] + remaining_map.get(d['user_id'], 0) + team_stats.append(d) + + # Clinch Playoff Marker + # A team clinches if their wins > max possible wins of the team currently in the first 'out' spot + clinch_out_threshold = float('inf') + if p_count > 0 and len(team_stats) > p_count: + clinch_out_threshold = team_stats[p_count]['max_wins'] + + # Clinch 1st Seed Marker + clinch_1st_threshold = float('inf') + if p_count > 0 and len(team_stats) > 1: + clinch_1st_threshold = team_stats[1]['max_wins'] + + for i, d in enumerate(team_stats): + games_back = ((top_wins - d['wins']) + (d['losses'] - top_losses)) / 2.0 + d['games_back'] = "-" if games_back == 0 else str(games_back) + + d['clinch'] = "" + if d['wins'] > clinch_out_threshold: + d['clinch'] = "*" + if i == 0 and d['wins'] > clinch_1st_threshold: + d['clinch'] = "**" + standings.append(d) + + # Schedule Data + c.execute('SELECT id, team_name FROM Users WHERE is_admin = 0') + all_teams = c.fetchall() + + if not team_id and current_user.is_authenticated and not current_user.is_admin: + team_id = str(current_user.id) + elif not team_id: + team_id = 'all' + + query = ''' + SELECT g.*, + a.team_name as away_team, a.team_icon as away_icon, + h.team_name as home_team, h.team_icon as home_icon, + (SELECT COUNT(*) FROM GameSubmissions gs + WHERE g.status != 'Final' AND gs.game_id = g.id + AND ( + (gs.proposed_date IS NOT NULL AND gs.proposed_date != g.scheduled_date) OR + (gs.away_score IS NOT NULL AND (g.away_score IS NULL OR gs.away_score != g.away_score)) OR + (gs.home_score IS NOT NULL AND (g.home_score IS NULL OR gs.home_score != g.home_score)) + ) + ) as pending_proposals + FROM Games g + LEFT JOIN Users a ON g.away_team_id = a.id + LEFT JOIN Users h ON g.home_team_id = h.id + WHERE g.season_id = ? + ''' + params = [season_id] + + # We fetch ALL games first, then filter regular season games in Python if needed, + # OR we modify the query to include ALL playoff games but only filtered regular games. + if team_id and team_id != 'all': + query += ' AND (g.is_playoff = 1 OR g.away_team_id = ? OR g.home_team_id = ?)' + params.extend([team_id, team_id]) + + query += ' ORDER BY g.scheduled_date ASC' + c.execute(query, params) + games_list = c.fetchall() + + # Get team records for bracket display + team_records = {} + for row in standings: + team_records[row['user_id']] = f"({row['wins']}-{row['losses']})" + + # Group games by date for calendar + games_by_date = {} + playoff_games = [] + for g_row in games_list: + g = dict(g_row) + if g['is_playoff']: + g['away_record'] = team_records.get(g['away_team_id'], "") + g['home_record'] = team_records.get(g['home_team_id'], "") + playoff_games.append(g) + + dt = g['scheduled_date'] + if dt not in games_by_date: + games_by_date[dt] = [] + games_by_date[dt].append(g) + + # Bracket Logic (using series_id) + bracket_series = {} + for pg in playoff_games: + sid = pg['series_id'] + if sid not in bracket_series: + bracket_series[sid] = pg.copy() + c.execute(''' + SELECT + SUM(CASE WHEN away_score > home_score THEN 1 ELSE 0 END) as aw, + SUM(CASE WHEN home_score > away_score THEN 1 ELSE 0 END) as hw + FROM Games WHERE season_id = ? AND series_id = ? AND status = 'Final' + ''', (season_id, sid)) + s_res = c.fetchone() + bracket_series[sid]['away_series_wins'] = s_res['aw'] or 0 + bracket_series[sid]['home_series_wins'] = s_res['hw'] or 0 + + # Group series into rounds + bracket_rounds = {} + for sid, s_data in bracket_series.items(): + rnd = s_data['playoff_round'] + if rnd not in bracket_rounds: + bracket_rounds[rnd] = [] + bracket_rounds[rnd].append(s_data) + + sorted_rounds = sorted(bracket_rounds.items()) + bracket = [r[1] for r in sorted_rounds] + + # Calendar generation logic + import calendar + today = datetime.now().strftime('%Y-%m-%d') + + # Determine range of months to show + if games_list: + dates = [datetime.strptime(g['scheduled_date'], '%Y-%m-%d') for g in games_list if g['scheduled_date'] and g['scheduled_date'] != 'TBD'] + if dates: + min_date = min(dates) + max_date = max(dates) + else: + min_date = datetime.now() + max_date = datetime.now() + else: + min_date = datetime.now() + max_date = datetime.now() + + months = [] + curr = datetime(min_date.year, min_date.month, 1) + while curr <= datetime(max_date.year, max_date.month, 1): + cal = calendar.Calendar(firstweekday=6) # Sunday start + weeks = cal.monthdayscalendar(curr.year, curr.month) + months.append({ + 'name': curr.strftime('%B %Y'), + 'year': curr.year, + 'month': curr.month, + 'weeks': weeks + }) + if curr.month == 12: + curr = datetime(curr.year + 1, 1, 1) + else: + curr = datetime(curr.year, curr.month + 1, 1) + + conn.close() + return render_template('season.html', + seasons=seasons, + selected_season_id=season_id, + season_info=season_info, + standings=standings, + games_by_date=games_by_date, + playoff_games=playoff_games, + bracket=bracket, + months=months, + today=today, + all_teams=all_teams, + selected_team_id=team_id, + active_tab=tab, + user_season_status=user_season_status) + +def run_consensus_check(game_id, c): + # Consensus Check: Find most recent from each team + c.execute('SELECT away_team_id, home_team_id FROM Games WHERE id = ?', (game_id,)) + g_teams = c.fetchone() + if not g_teams: return + + c.execute(''' + SELECT * FROM GameSubmissions + WHERE game_id = ? AND submitted_by_id = ? + ORDER BY id DESC LIMIT 1 + ''', (game_id, g_teams['away_team_id'])) + sub_away = c.fetchone() + + c.execute(''' + SELECT * FROM GameSubmissions + WHERE game_id = ? AND submitted_by_id = ? + ORDER BY id DESC LIMIT 1 + ''', (game_id, g_teams['home_team_id'])) + sub_home = c.fetchone() + + if sub_away and sub_home: + # Check score consensus + if sub_away['away_score'] is not None and sub_home['away_score'] is not None and \ + sub_away['home_score'] is not None and sub_home['home_score'] is not None and \ + sub_away['away_score'] == sub_home['away_score'] and \ + sub_away['home_score'] == sub_home['home_score']: + + # Consensus reached on score + box_score_final = None + if sub_away['box_score_json'] and sub_home['box_score_json'] and sub_away['box_score_json'] == sub_home['box_score_json']: + bs = json.loads(sub_away['box_score_json']) + # bs["away"][10] is the 'runs' column (index 10) + if bs["away"][10] == sub_away['away_score'] and bs["home"][10] == sub_away['home_score']: + box_score_final = sub_away['box_score_json'] + + c.execute(''' + UPDATE Games + SET away_score = ?, home_score = ?, box_score_json = ?, status = 'Final' + WHERE id = ? + ''', (sub_away['away_score'], sub_away['home_score'], box_score_final, game_id)) + + c.execute('DELETE FROM GameSubmissions WHERE game_id = ?', (game_id,)) + + check_and_trigger_playoff_updates(game_id, c) + flash('Scores agreed! Game finalized.') + + # Check date consensus + if sub_away['proposed_date'] and sub_home['proposed_date'] and sub_away['proposed_date'] == sub_home['proposed_date']: + c.execute('UPDATE Games SET scheduled_date = ? WHERE id = ?', (sub_away['proposed_date'], game_id)) + c.execute('DELETE FROM GameSubmissions WHERE game_id = ?', (game_id,)) + flash('Date change agreed! Game rescheduled.') + +@app.route('/game//retract', methods=['POST']) +@login_required +def retract_submission(game_id): + conn = get_db() + c = conn.cursor() + c.execute('DELETE FROM GameSubmissions WHERE game_id = ? AND submitted_by_id = ?', (game_id, current_user.id)) + conn.commit() + conn.close() + flash('Submission retracted.') + return redirect(url_for('game', game_id=game_id)) + +@app.route('/game//agree/', methods=['POST']) +@login_required +def agree_submission(game_id, submission_id): + conn = get_db() + c = conn.cursor() + + # Get the submission to agree with + c.execute('SELECT * FROM GameSubmissions WHERE id = ?', (submission_id,)) + sub = c.fetchone() + + if not sub or sub['game_id'] != game_id: + conn.close() + flash('Submission not found.') + return redirect(url_for('game', game_id=game_id)) + + if str(sub['submitted_by_id']) == str(current_user.id): + conn.close() + flash('You cannot agree with your own submission.') + return redirect(url_for('game', game_id=game_id)) + + # Create an identical submission for the current user to trigger consensus + c.execute(''' + INSERT INTO GameSubmissions (game_id, submitted_by_id, away_score, home_score, box_score_json, proposed_date) + VALUES (?, ?, ?, ?, ?, ?) + ''', (game_id, current_user.id, sub['away_score'], sub['home_score'], sub['box_score_json'], sub['proposed_date'])) + + run_consensus_check(game_id, c) + + conn.commit() + conn.close() + return redirect(url_for('game', game_id=game_id)) +@app.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + + conn = get_db() + c = conn.cursor() + c.execute('SELECT * FROM Users WHERE username = ?', (username,)) + user_row = c.fetchone() + conn.close() + + if user_row and check_password_hash(user_row['password_hash'], password): + user = User(user_row['id'], user_row['username'], user_row['is_admin'], user_row['team_name'], user_row['team_icon']) + login_user(user) + return redirect(url_for('index')) + else: + flash('Invalid username or password') + + return render_template('login.html') + +@app.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('index')) + +@app.route('/game/') +def game(game_id): + conn = get_db() + c = conn.cursor() + c.execute(''' + SELECT g.*, + a.team_name as away_team, a.team_icon as away_icon, + h.team_name as home_team, h.team_icon as home_icon + FROM Games g + LEFT JOIN Users a ON g.away_team_id = a.id + LEFT JOIN Users h ON g.home_team_id = h.id + WHERE g.id = ? + ''', (game_id,)) + game_row = c.fetchone() + + if not game_row: + conn.close() + return "Game not found", 404 + + game_dict = dict(game_row) + if game_dict['box_score_json']: + game_dict['box_score'] = json.loads(game_dict['box_score_json']) + else: + game_dict['box_score'] = None + + # Fetch pending submissions + c.execute(''' + SELECT s.*, u.username, u.team_name + FROM GameSubmissions s + JOIN Users u ON s.submitted_by_id = u.id + WHERE s.game_id = ? + ORDER BY s.id DESC + ''', (game_id,)) + submissions_raw = c.fetchall() + submissions = [] + for s in submissions_raw: + s_dict = dict(s) + + # Hide components that match current game state + if s_dict['proposed_date'] == game_dict['scheduled_date']: + s_dict['proposed_date'] = None + + if game_dict['status'] == 'Final': + if s_dict['away_score'] == game_dict['away_score'] and s_dict['home_score'] == game_dict['home_score']: + s_dict['away_score'] = None + s_dict['home_score'] = None + + # Only show if there's still something to show + if s_dict['proposed_date'] or s_dict['away_score'] is not None: + if s_dict['box_score_json']: + s_dict['box_score'] = json.loads(s_dict['box_score_json']) + else: + s_dict['box_score'] = None + submissions.append(s_dict) + + conn.close() + return render_template('game.html', game=game_dict, submissions=submissions) + +def generate_tbd_playoffs(season_id, playoff_teams, series_length, c): + # Generates TBD playoff series + # For N=4: Series 1 (Semi A), Series 2 (Semi B), Series 3 (Finals) + # For N=2: Series 1 (Finals) + games_to_create = [] + wins_needed = (series_length // 2) + 1 + start_date = datetime.now() + timedelta(days=30) + + series_map = { + 2: [1], # Finals + 4: [1, 2, 3] # Semis then Finals + } + + rounds_map = { + 2: {1: 1}, + 4: {1: 1, 2: 1, 3: 2} + } + + active_series = series_map.get(playoff_teams, []) + for sid in active_series: + rnd = rounds_map[playoff_teams][sid] + for g_num in range(1, series_length + 1): + is_conditional = 1 if g_num > wins_needed else 0 + games_to_create.append(( + season_id, None, None, 'TBD', + (start_date + timedelta(days=sid*7 + g_num)).strftime('%Y-%m-%d'), + 1, sid, rnd, g_num, is_conditional + )) + + c.executemany(''' + INSERT INTO Games (season_id, away_team_id, home_team_id, status, scheduled_date, is_playoff, series_id, playoff_round, playoff_game_num, is_conditional) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', games_to_create) + +def check_and_trigger_playoff_updates(game_id, c): + c.execute('SELECT season_id, is_playoff, series_id, playoff_round FROM Games WHERE id = ?', (game_id,)) + game = c.fetchone() + if not game: return + season_id = game['season_id'] + + if not game['is_playoff']: + # Regular season check + c.execute("SELECT COUNT(*) as c FROM Games WHERE season_id = ? AND is_playoff = 0 AND status != 'Final'", (season_id,)) + if c.fetchone()['c'] == 0: + c.execute('SELECT playoff_teams FROM Seasons WHERE id = ?', (season_id,)) + p_teams = c.fetchone()['playoff_teams'] or 0 + + c.execute(''' + SELECT u.id + FROM SeasonTeams st + JOIN Users u ON st.user_id = u.id + LEFT JOIN Games g ON (g.home_team_id = u.id OR g.away_team_id = u.id) + AND g.season_id = st.season_id AND g.status = 'Final' AND g.is_playoff = 0 + WHERE st.season_id = ? AND st.status = 'Approved' + GROUP BY u.id ORDER BY SUM(CASE WHEN (g.home_team_id = u.id AND g.home_score > g.away_score) OR (g.away_team_id = u.id AND g.away_score > g.home_score) THEN 1 ELSE 0 END) DESC, + SUM(CASE WHEN g.home_team_id = u.id THEN g.home_score ELSE g.away_score END) DESC + LIMIT ? + ''', (season_id, p_teams)) + top_teams = [row['id'] for row in c.fetchall()] + + if len(top_teams) == 2: + c.execute("UPDATE Games SET away_team_id = ?, home_team_id = ?, status = 'Scheduled' WHERE season_id = ? AND series_id = 1", (top_teams[1], top_teams[0], season_id)) + elif len(top_teams) == 4: + # 1v4 (S1), 2v3 (S2) + c.execute("UPDATE Games SET away_team_id = ?, home_team_id = ?, status = 'Scheduled' WHERE season_id = ? AND series_id = 1", (top_teams[3], top_teams[0], season_id)) + c.execute("UPDATE Games SET away_team_id = ?, home_team_id = ?, status = 'Scheduled' WHERE season_id = ? AND series_id = 2", (top_teams[2], top_teams[1], season_id)) + else: + # Playoff advancement + c.execute('SELECT playoff_teams, playoff_series_length FROM Seasons WHERE id = ?', (season_id,)) + s_info = c.fetchone() + wins_needed = (s_info['playoff_series_length'] // 2) + 1 + sid = game['series_id'] + + c.execute(''' + SELECT + SUM(CASE WHEN away_score > home_score THEN 1 ELSE 0 END) as aw, + SUM(CASE WHEN home_score > away_score THEN 1 ELSE 0 END) as hw, + MAX(away_team_id) as aid, MAX(home_team_id) as hid + FROM Games WHERE season_id = ? AND series_id = ? AND status = 'Final' + ''', (season_id, sid)) + res = c.fetchone() + + winner = None + if res['aw'] >= wins_needed: winner = res['aid'] + elif res['hw'] >= wins_needed: winner = res['hid'] + + if winner: + c.execute("UPDATE Games SET status = 'Canceled' WHERE season_id = ? AND series_id = ? AND status IN ('Scheduled', 'TBD')", (season_id, sid)) + if s_info['playoff_teams'] == 4: + if sid == 1: c.execute("UPDATE Games SET away_team_id = ? WHERE season_id = ? AND series_id = 3", (winner, season_id)) + elif sid == 2: c.execute("UPDATE Games SET home_team_id = ? WHERE season_id = ? AND series_id = 3", (winner, season_id)) + c.execute("UPDATE Games SET status = 'Scheduled' WHERE season_id = ? AND series_id = 3 AND away_team_id IS NOT NULL AND home_team_id IS NOT NULL AND status = 'TBD'", (season_id,)) + +@app.route('/submit_game/', methods=['POST']) +@login_required +def submit_game(game_id): + away_score = request.form.get('away_score') + home_score = request.form.get('home_score') + proposed_date = request.form.get('proposed_date') + + # Collect Box Score from grid (Optional) + box_score = {"away": [], "home": []} + cols = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "extra", "runs", "hits", "errors"] + has_box_data = False + for team in ["away", "home"]: + for col in cols: + val = request.form.get(f"{team}_inn_{col}") + if val and val != "0" and val != "": has_box_data = True + box_score[team].append(int(val) if val and val.isdigit() else 0) + + box_score_json = json.dumps(box_score) if has_box_data else None + + conn = get_db() + c = conn.cursor() + + # Check if user is part of the game + c.execute('SELECT away_team_id, home_team_id, status FROM Games WHERE id = ?', (game_id,)) + game = c.fetchone() + + if not game or game['status'] == 'Final': + conn.close() + flash('Game not found or already finalized.') + return redirect(url_for('game', game_id=game_id)) + + if str(current_user.id) not in (str(game['away_team_id']), str(game['home_team_id'])): + conn.close() + flash('You are not authorized to submit for this game.') + return redirect(url_for('game', game_id=game_id)) + + # Save submission (Update existing or Insert new) + away_score_val = int(away_score) if away_score else None + home_score_val = int(home_score) if home_score else None + + c.execute('SELECT * FROM GameSubmissions WHERE game_id = ? AND submitted_by_id = ?', (game_id, current_user.id)) + existing = c.fetchone() + + if existing: + if away_score_val is not None: + c.execute(''' + UPDATE GameSubmissions + SET away_score = ?, home_score = ?, box_score_json = ? + WHERE id = ? + ''', (away_score_val, home_score_val, box_score_json, existing['id'])) + if proposed_date: + c.execute(''' + UPDATE GameSubmissions + SET proposed_date = ? + WHERE id = ? + ''', (proposed_date, existing['id'])) + else: + c.execute(''' + INSERT INTO GameSubmissions (game_id, submitted_by_id, away_score, home_score, box_score_json, proposed_date) + VALUES (?, ?, ?, ?, ?, ?) + ''', (game_id, current_user.id, away_score_val, home_score_val, box_score_json, proposed_date)) + + run_consensus_check(game_id, c) + + conn.commit() + conn.close() + flash('Submission recorded.') + return redirect(url_for('game', game_id=game_id)) + +@app.route('/team', methods=['GET', 'POST']) +@login_required +def team(): + if request.method == 'POST': + conn = get_db() + c = conn.cursor() + + new_team_name = request.form.get('team_name') + if new_team_name: + c.execute('UPDATE Users SET team_name = ? WHERE id = ?', (new_team_name, current_user.id)) + current_user.team_name = new_team_name + + new_password = request.form.get('new_password') + if new_password: + c.execute('UPDATE Users SET password_hash = ? WHERE id = ?', (generate_password_hash(new_password), current_user.id)) + + icon_file = request.files.get('team_icon') + if icon_file and icon_file.filename != '': + filename = secure_filename(icon_file.filename) + filepath = os.path.join(app.config['UPLOAD_FOLDER'], f"{current_user.id}_{filename}") + icon_file.save(filepath) + c.execute('UPDATE Users SET team_icon = ? WHERE id = ?', (f"{current_user.id}_{filename}", current_user.id)) + current_user.team_icon = f"{current_user.id}_{filename}" + + conn.commit() + conn.close() + flash('Team profile updated successfully.') + return redirect(url_for('team')) + + conn = get_db() + c = conn.cursor() + c.execute('SELECT * FROM Seasons') + seasons = c.fetchall() + + c.execute('SELECT season_id, status FROM SeasonTeams WHERE user_id = ?', (current_user.id,)) + my_seasons = {row['season_id']: row['status'] for row in c.fetchall()} + conn.close() + + return render_template('team.html', seasons=seasons, my_seasons=my_seasons) + +@app.route('/team/join_season/', methods=['POST']) +@login_required +def join_season(season_id): + if current_user.is_admin: + flash('Admins cannot join seasons as teams.') + return redirect(url_for('team')) + + conn = get_db() + c = conn.cursor() + try: + c.execute('INSERT INTO SeasonTeams (season_id, user_id, status) VALUES (?, ?, ?)', (season_id, current_user.id, 'Pending')) + conn.commit() + flash('Requested to join season.') + except sqlite3.IntegrityError: + flash('Already requested or joined this season.') + finally: + conn.close() + + return redirect(url_for('index', season_id=season_id)) + +@app.route('/admin', methods=['GET', 'POST']) +@login_required +def admin(): + if not current_user.is_admin: + return "Access Denied", 403 + + conn = get_db() + c = conn.cursor() + + if request.method == 'POST': + action = request.form.get('action') + if action == 'create_season': + name = request.form['name'] + c.execute('INSERT INTO Seasons (name) VALUES (?)', (name,)) + conn.commit() + flash('Season created.') + + elif action == 'create_team': + username = request.form['username'] + password = request.form['password'] + team_name = request.form['team_name'] + + try: + c.execute(''' + INSERT INTO Users (username, password_hash, team_name) + VALUES (?, ?, ?) + ''', (username, generate_password_hash(password), team_name)) + conn.commit() + flash('Team created.') + except sqlite3.IntegrityError: + flash('Username already exists.') + + c.execute('SELECT * FROM Seasons') + seasons = c.fetchall() + + c.execute('SELECT * FROM Users WHERE is_admin = 0') + teams = c.fetchall() + + conn.close() + return render_template('admin.html', seasons=seasons, teams=teams) + +@app.route('/admin/season//manage_teams', methods=['GET', 'POST']) +@login_required +def admin_manage_teams(season_id): + if not current_user.is_admin: + return "Access Denied", 403 + + conn = get_db() + c = conn.cursor() + + if request.method == 'POST': + action = request.form.get('action') + user_id = request.form.get('user_id') + + if action == 'approve': + c.execute("UPDATE SeasonTeams SET status = 'Approved' WHERE season_id = ? AND user_id = ?", (season_id, user_id)) + flash('Team approved.') + elif action == 'deny' or action == 'remove': + c.execute("DELETE FROM SeasonTeams WHERE season_id = ? AND user_id = ?", (season_id, user_id)) + flash(f"Team {action}d.") + elif action == 'add': + team_ids = request.form.getlist('team_ids') + for tid in team_ids: + try: + c.execute("INSERT INTO SeasonTeams (season_id, user_id, status) VALUES (?, ?, 'Approved')", (season_id, tid)) + except sqlite3.IntegrityError: + c.execute("UPDATE SeasonTeams SET status = 'Approved' WHERE season_id = ? AND user_id = ?", (season_id, tid)) + flash('Selected teams added directly.') + + conn.commit() + return redirect(url_for('admin_manage_teams', season_id=season_id)) + + c.execute('SELECT * FROM Seasons WHERE id = ?', (season_id,)) + season = c.fetchone() + + c.execute('SELECT * FROM Users WHERE is_admin = 0') + all_teams = c.fetchall() + + c.execute(''' + SELECT u.id, u.team_name, u.username, st.status + FROM SeasonTeams st + JOIN Users u ON st.user_id = u.id + WHERE st.season_id = ? + ''', (season_id,)) + enrolled_data = c.fetchall() + + pending_teams = [t for t in enrolled_data if t['status'] == 'Pending'] + approved_teams = [t for t in enrolled_data if t['status'] == 'Approved'] + enrolled_ids = [t['id'] for t in enrolled_data] + + conn.close() + return render_template('admin_manage_teams.html', season=season, all_teams=all_teams, pending_teams=pending_teams, approved_teams=approved_teams, enrolled_ids=enrolled_ids) + +@app.route('/admin/season//delete', methods=['POST']) +@login_required +def admin_delete_season(season_id): + if not current_user.is_admin: + return "Access Denied", 403 + + conn = get_db() + c = conn.cursor() + c.execute('DELETE FROM GameSubmissions WHERE game_id IN (SELECT id FROM Games WHERE season_id = ?)', (season_id,)) + c.execute('DELETE FROM Games WHERE season_id = ?', (season_id,)) + c.execute('DELETE FROM SeasonTeams WHERE season_id = ?', (season_id,)) + c.execute('DELETE FROM Seasons WHERE id = ?', (season_id,)) + conn.commit() + conn.close() + flash('Season deleted.') + return redirect(url_for('admin')) + +@app.route('/admin/season//generate_schedule', methods=['POST']) +@login_required +def admin_generate_schedule(season_id): + if not current_user.is_admin: + return "Access Denied", 403 + + confirm = request.form.get('confirm') == 'true' + total_games_per_team = int(request.form['total_games']) + gpw = int(request.form['games_per_week']) + p_teams = int(request.form['playoff_teams']) + p_series_len = int(request.form['playoff_series_length']) + + conn = get_db() + c = conn.cursor() + + # Check if any games are finalized + c.execute("SELECT COUNT(*) as c FROM Games WHERE season_id = ? AND status = 'Final'", (season_id,)) + final_count = c.fetchone()['c'] + + if final_count > 0 and not confirm: + conn.close() + return {"status": "confirm_required", "message": f"There are {final_count} finalized games. Regenerating will delete ALL games. Proceed?"}, 200 + + # Update Season Parameters + c.execute(''' + UPDATE Seasons + SET total_games = ?, games_per_week = ?, playoff_teams = ?, playoff_series_length = ? + WHERE id = ? + ''', (total_games_per_team, gpw, p_teams, p_series_len, season_id)) + + c.execute('SELECT user_id FROM SeasonTeams WHERE season_id = ? AND status = "Approved"', (season_id,)) + team_ids = [row['user_id'] for row in c.fetchall()] + + if len(team_ids) < 2: + conn.close() + flash('Not enough approved teams to generate schedule.') + return redirect(url_for('admin')) + + # Delete ALL existing games for this season + c.execute("DELETE FROM Games WHERE season_id = ?", (season_id,)) + c.execute("DELETE FROM GameSubmissions WHERE game_id NOT IN (SELECT id FROM Games)") + + # Per-team schedule generation using the circle method + num_teams = len(team_ids) + + if num_teams % 2 != 0: + team_ids.append(None) # Add a bye team + num_teams += 1 + + games_count = {tid: 0 for tid in team_ids if tid is not None} + current_teams = list(team_ids) + games_to_create = [] + start_date = datetime.now() + + round_num = 0 + while any(count < total_games_per_team for count in games_count.values()): + for i in range(num_teams // 2): + team1 = current_teams[i] + team2 = current_teams[num_teams - 1 - i] + + if team1 is not None and team2 is not None: + if games_count[team1] < total_games_per_team and games_count[team2] < total_games_per_team: + if round_num % 2 == 0: away_id, home_id = team1, team2 + else: away_id, home_id = team2, team1 + + day_offset = int((round_num * 7) / gpw) + game_date = (start_date + timedelta(days=day_offset)).strftime('%Y-%m-%d') + games_to_create.append((season_id, away_id, home_id, game_date)) + games_count[team1] += 1 + games_count[team2] += 1 + + current_teams = [current_teams[0]] + [current_teams[-1]] + current_teams[1:-1] + round_num += 1 + + c.executemany(''' + INSERT INTO Games (season_id, away_team_id, home_team_id, scheduled_date) + VALUES (?, ?, ?, ?) + ''', games_to_create) + + # Also generate Playoff TBDs + generate_tbd_playoffs(season_id, p_teams, p_series_len, c) + + conn.commit() + conn.close() + flash(f'Schedule and Playoffs generated.') + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {"status": "success", "redirect": url_for('admin')} + return redirect(url_for('admin')) + +@app.route('/admin/game//edit', methods=['GET', 'POST']) +@login_required +def admin_edit_game(game_id): + if not current_user.is_admin: + return "Access Denied", 403 + + conn = get_db() + c = conn.cursor() + + if request.method == 'POST': + scheduled_date = request.form.get('scheduled_date') + status = request.form.get('status') + away_score = request.form.get('away_score') + home_score = request.form.get('home_score') + + # Collect Box Score from grid + box_score = {"away": [], "home": []} + cols = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "extra", "runs", "hits", "errors"] + has_box_data = False + for team in ["away", "home"]: + for col in cols: + val = request.form.get(f"{team}_inn_{col}") + if val and val != "0" and val != "": has_box_data = True + box_score[team].append(int(val) if val and val.isdigit() else 0) + + box_score_json = json.dumps(box_score) if has_box_data else None + + c.execute(''' + UPDATE Games + SET scheduled_date = ?, status = ?, away_score = ?, home_score = ?, box_score_json = ? + WHERE id = ? + ''', (scheduled_date, status, away_score if away_score else None, home_score if home_score else None, box_score_json, game_id)) + + if status == 'Final': + check_and_trigger_playoff_updates(game_id, c) + c.execute('DELETE FROM GameSubmissions WHERE game_id = ?', (game_id,)) + + conn.commit() + flash('Game updated.') + return redirect(url_for('game', game_id=game_id)) + + c.execute(''' + SELECT g.*, a.team_name as away_team, h.team_name as home_team + FROM Games g + LEFT JOIN Users a ON g.away_team_id = a.id + LEFT JOIN Users h ON g.home_team_id = h.id + WHERE g.id = ? + ''', (game_id,)) + game_row = c.fetchone() + if not game_row: + conn.close() + return "Game not found", 404 + + game_dict = dict(game_row) + if game_dict['box_score_json']: + game_dict['box_score'] = json.loads(game_dict['box_score_json']) + else: + game_dict['box_score'] = None + + conn.close() + return render_template('admin_edit_game.html', game=game_dict) + +if __name__ == '__main__': + app.run(debug=True, port=5000) \ No newline at end of file diff --git a/db_builder.py b/db_builder.py new file mode 100644 index 0000000..ed4934b --- /dev/null +++ b/db_builder.py @@ -0,0 +1,124 @@ +import sqlite3 +import json +from werkzeug.security import generate_password_hash + +DB_FILE = 'orchard_league.db' + +def get_db(): + conn = sqlite3.connect(DB_FILE) + conn.row_factory = sqlite3.Row + return conn + +def init_db(): + conn = get_db() + c = conn.cursor() + + # Drop existing tables + c.execute('DROP TABLE IF EXISTS GameSubmissions') + c.execute('DROP TABLE IF EXISTS Games') + c.execute('DROP TABLE IF EXISTS SeasonTeams') + c.execute('DROP TABLE IF EXISTS Seasons') + c.execute('DROP TABLE IF EXISTS Users') + + # Users Table + c.execute(''' + CREATE TABLE Users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT 0, + team_name TEXT, + team_icon TEXT + ) + ''') + + # Seasons Table + c.execute(''' + CREATE TABLE Seasons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'Scheduled', -- 'Scheduled', 'Active', 'Completed' + total_games INTEGER, + games_per_week INTEGER, + playoff_teams INTEGER, + playoff_series_length INTEGER + ) + ''') + + # SeasonTeams Junction Table + c.execute(''' + CREATE TABLE SeasonTeams ( + season_id INTEGER, + user_id INTEGER, + status TEXT NOT NULL DEFAULT 'Pending', -- 'Pending', 'Approved' + PRIMARY KEY (season_id, user_id), + FOREIGN KEY (season_id) REFERENCES Seasons(id), + FOREIGN KEY (user_id) REFERENCES Users(id) + ) + ''') + + # Games Table + c.execute(''' + CREATE TABLE Games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + season_id INTEGER, + away_team_id INTEGER, + home_team_id INTEGER, + scheduled_date TEXT, + status TEXT NOT NULL DEFAULT 'Scheduled', -- 'Scheduled', 'Final', 'TBD' + away_score INTEGER, + home_score INTEGER, + box_score_json TEXT, + is_playoff BOOLEAN NOT NULL DEFAULT 0, + series_id INTEGER, + playoff_round INTEGER, + playoff_game_num INTEGER, + is_conditional BOOLEAN NOT NULL DEFAULT 0, + FOREIGN KEY (season_id) REFERENCES Seasons(id), + FOREIGN KEY (away_team_id) REFERENCES Users(id), + FOREIGN KEY (home_team_id) REFERENCES Users(id) + ) + ''') + + # GameSubmissions Table + c.execute(''' + CREATE TABLE GameSubmissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id INTEGER, + submitted_by_id INTEGER, + away_score INTEGER, + home_score INTEGER, + box_score_json TEXT, + proposed_date TEXT, + FOREIGN KEY (game_id) REFERENCES Games(id), + FOREIGN KEY (submitted_by_id) REFERENCES Users(id) + ) + ''') + + # Create Initial Admin User + admin_password = generate_password_hash('admin') + c.execute(''' + INSERT INTO Users (username, password_hash, is_admin, team_name) + VALUES (?, ?, 1, ?) + ''', ('admin', admin_password, 'The Adminators')) + + # Create 8 Placeholder Teams + teams = [ + "Yankees", "Giants", "Dodgers", "Cubs", + "Red Sox", "Cardinals", "White Sox", "Braves" + ] + test_password = generate_password_hash('test') + for team in teams: + username = f"test_{team.lower().replace(' ', '_')}" + team_name = f"Test {team}" + c.execute(''' + INSERT INTO Users (username, password_hash, is_admin, team_name) + VALUES (?, ?, 0, ?) + ''', (username, test_password, team_name)) + + conn.commit() + conn.close() + print("Database initialized successfully.") + +if __name__ == '__main__': + init_db() \ No newline at end of file diff --git a/orchard_league.db b/orchard_league.db new file mode 100644 index 0000000..88bd44f Binary files /dev/null and b/orchard_league.db differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..40801be --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +Flask-Login==0.6.3 +Werkzeug==3.0.3 \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..51ecbfe --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,372 @@ +:root { + --bg-primary: #e0e0e0; /* Light newspaper grey */ + --bg-secondary: #f4f4f4; /* Lighter grey for content areas */ + --text-primary: #2b2b2b; /* Dark grey for main text */ + --text-accent: #111111; /* Almost black for headers */ + --deco-border: #555555; /* Medium grey for borders */ + --deco-highlight: #777777; /* Subtle highlight grey */ + --deco-shadow: #aaaaaa; /* Soft shadow */ + --newspaper-font: 'Times New Roman', 'Georgia', serif; + --sans-font: 'Times New Roman', 'Helvetica Neue', sans-serif; +} + +@font-face { + font-family: 'Powell and Geary'; + src: url('../ttf/Powell and Geary.ttf') format('truetype'); +} + +body { + font-family: var(--newspaper-font); + background-color: var(--bg-primary); + color: var(--text-primary); + margin: 0; + padding: 0; + line-height: 1.5; +} + +header { + background-color: #ffffff; + border-bottom: 5px solid var(--text-accent); + border-top: 10px solid var(--text-accent); + padding: 1rem 2rem; + margin-bottom: 1rem; + position: relative; +} + +.logo-container { + display: flex; + align-items: center; + justify-content: center; + gap: 2rem; +} + +.logo-container img { + height: 8rem; /* Increased size */ + width: auto; +} + +.logo-container .text-wrap { + text-align: left; +} + +.logo-container h1 { + font-family: 'Powell and Geary', var(--sans-font); + font-size: 5rem; + margin: 0; + text-transform: uppercase; + letter-spacing: 2px; + color: var(--text-accent); + font-weight: 900; + line-height: 1; +} + +.logo-container .subtitle { + font-size: 1.2rem; + color: var(--deco-border); + text-transform: uppercase; + letter-spacing: 1px; + margin-top: 0.2rem; + font-style: italic; + font-family: var(--sans-font); + line-height: 1; +} + +nav { + margin-top: 1rem; + display: flex; + justify-content: center; + gap: 2rem; + border-top: 1px solid var(--deco-border); + border-bottom: 1px solid var(--deco-border); + padding: 0.5rem 0; + background-color: #ffffff; +} + +nav a { + color: var(--text-accent); + text-decoration: none; + text-transform: uppercase; + font-weight: bold; + font-family: var(--sans-font); + font-size: 0.9rem; + letter-spacing: 1px; +} + +nav a:hover { + text-decoration: underline; + color: #000; +} + +main { + max-width: 1000px; + margin: 0 auto; + padding: 2rem; + background-color: var(--bg-secondary); + border: 1px solid var(--deco-border); + box-shadow: 2px 2px 5px var(--deco-shadow); +} + +h2, h3, h4 { + font-family: var(--sans-font); + color: var(--text-accent); + text-transform: uppercase; + border-bottom: 2px solid var(--text-accent); + padding-bottom: 0.5rem; + margin-top: 1.5rem; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; + font-family: var(--sans-font); +} + +table th, table td { + border: 1px solid var(--deco-border); + padding: 8px; + text-align: center; +} + +table th { + background-color: #dddddd; + color: var(--text-accent); + text-transform: uppercase; + font-size: 0.85rem; + font-weight: bold; +} + +table tr:nth-child(even) { + background-color: #eeeeee; +} + +form { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 400px; + font-family: var(--sans-font); +} + +label { + font-weight: bold; + color: var(--text-accent); + font-size: 0.9rem; +} + +input[type="text"], +input[type="password"], +input[type="number"], +select, +textarea { + padding: 8px; + background-color: #ffffff; + border: 1px solid var(--deco-border); + color: var(--text-primary); + font-size: 1rem; + font-family: var(--sans-font); +} + +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--text-accent); +} + +button { + background-color: var(--text-accent); + color: #ffffff; + border: none; + padding: 8px 16px; + text-transform: uppercase; + font-weight: bold; + font-family: var(--sans-font); + cursor: pointer; + transition: background-color 0.2s; +} + +button:hover { + background-color: #333333; +} + +footer { + text-align: center; + padding: 1.5rem; + font-family: var(--sans-font); + color: var(--deco-highlight); + font-size: 0.8rem; + margin-top: 2rem; + border-top: 1px solid var(--deco-border); +} + +.flash-messages { + list-style: none; + padding: 0; + margin-bottom: 2rem; +} + +.flash-messages li { + background-color: #f8f8f8; + border-left: 4px solid var(--text-accent); + padding: 10px; + color: var(--text-primary); + font-family: var(--sans-font); + font-weight: bold; + margin-bottom: 0.5rem; +} + +.conditional-game { + color: #555; + font-style: italic; +} + +/* Calendar Styles */ +.calendar-month { + background: #fff; + border: 1px solid var(--deco-border); + margin-bottom: 2rem; + padding: 1rem; +} + +.calendar-month h4 { + text-align: center; + margin-top: 0; + border-bottom: 1px solid #ddd; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + border-top: 1px solid #ddd; + border-left: 1px solid #ddd; +} + +.calendar-day-head { + background: #eee; + font-weight: bold; + text-align: center; + padding: 5px; + border-right: 1px solid #ddd; + border-bottom: 1px solid #ddd; + font-size: 0.8rem; + text-transform: uppercase; +} + +.calendar-day { + min-height: 100px; + border-right: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding: 5px; + background: #fff; +} + +.calendar-day.other-month { + background: #f9f9f9; +} + +.calendar-day.today { + border: 3px solid #cc0000 !important; +} + +.calendar-day-num { + font-weight: bold; + font-size: 0.9rem; + margin-bottom: 5px; + display: block; +} + +.calendar-game-item { + font-size: 0.7rem; + background: #f0f0f0; + margin-bottom: 3px; + padding: 3px; + border: 1px solid #ccc; + cursor: pointer; + border-radius: 3px; +} + +.calendar-game-item:hover { + background: #e0e0e0; +} + +.team-icon-small { + width: 18px; + height: 18px; + border-radius: 50%; + vertical-align: middle; +} + +.team-icon-highlight { + border: 2px solid gold !important; + box-shadow: 0 0 5px gold; +} + +/* Warning Triangle */ +.pending-icon { + color: #cc9900; + font-weight: bold; + font-size: 1.2rem; + margin-left: 5px; + cursor: help; +} + +/* Bracket Styles */ +.bracket-container { + display: flex; + flex-direction: row; + gap: 2rem; + padding: 2rem; + overflow-x: auto; + background: #fff; + border: 1px solid var(--deco-border); + margin-bottom: 2rem; +} + +.bracket-round { + display: flex; + flex-direction: column; + justify-content: space-around; + gap: 1rem; + min-width: 200px; +} + +.bracket-game { + border: 1px solid #ccc; + background: #f9f9f9; + padding: 0.5rem; + font-size: 0.8rem; + position: relative; +} + +.bracket-game.winner { + border-left: 4px solid var(--text-accent); +} + +.bracket-team { + display: flex; + justify-content: space-between; + padding: 2px 0; +} + +.clinch-marker { + color: #cc0000; + font-weight: bold; + margin-left: 5px; +} + +/* Side Images */ +.side-image { + position: fixed; + top: 0; + height: 100vh; + width: auto; + z-index: -1; + pointer-events: none; +} + +.side-image.left { + left: 0; +} + +.side-image.right { + right: 0; +} \ No newline at end of file diff --git a/static/img/logo.png b/static/img/logo.png new file mode 100644 index 0000000..6e4f7b7 Binary files /dev/null and b/static/img/logo.png differ diff --git a/static/img/orchards_left.png b/static/img/orchards_left.png new file mode 100644 index 0000000..7fbedc9 Binary files /dev/null and b/static/img/orchards_left.png differ diff --git a/static/img/orchards_right.png b/static/img/orchards_right.png new file mode 100644 index 0000000..955117f Binary files /dev/null and b/static/img/orchards_right.png differ diff --git a/static/ttf/Decort.ttf b/static/ttf/Decort.ttf new file mode 100644 index 0000000..6c765cc Binary files /dev/null and b/static/ttf/Decort.ttf differ diff --git a/static/ttf/Powell and Geary.ttf b/static/ttf/Powell and Geary.ttf new file mode 100644 index 0000000..7fd7fb3 Binary files /dev/null and b/static/ttf/Powell and Geary.ttf differ diff --git a/static/uploads/7_drahdiwaberl.png b/static/uploads/7_drahdiwaberl.png new file mode 100644 index 0000000..9bea221 Binary files /dev/null and b/static/uploads/7_drahdiwaberl.png differ diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..19c7bd9 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,138 @@ +{% extends "base.html" %} + +{% block content %} +

League Administration

+ +
+
+

Create New Season

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

Create Team/User

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

Manage Seasons

+ {% if seasons %} + + + + + + + + {% for season in seasons %} + + + + + + + {% endfor %} +
IDNameStatusActions
{{ season.id }}{{ season.name }}{{ season.status }} +
+ +
+ + + +
+ +
+
+ + + {% else %} +

No seasons created yet.

+ {% endif %} +
+ +
+

Registered Teams

+ {% if teams %} +
    + {% for team in teams %} +
  • {{ team.team_name }} ({{ team.username }})
  • + {% endfor %} +
+ {% else %} +

No teams registered yet.

+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/admin_edit_game.html b/templates/admin_edit_game.html new file mode 100644 index 0000000..2651185 --- /dev/null +++ b/templates/admin_edit_game.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} + +{% block content %} +

Admin: Edit Game {{ game.id }}

+

Matchup: {{ game.away_team if game.away_team else 'TBD' }} @ {{ game.home_team if game.home_team else 'TBD' }}

+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + + + {% set bs_a = game.box_score.away if game.box_score else [0,0,0,0,0,0,0,0,0,0,0,0,0] %} + {% for col in ["1", "2", "3", "4", "5", "6", "7", "8", "9", "extra", "runs", "hits", "errors"] %} + + {% endfor %} + + + + {% set bs_h = game.box_score.home if game.box_score else [0,0,0,0,0,0,0,0,0,0,0,0,0] %} + {% for col in ["1", "2", "3", "4", "5", "6", "7", "8", "9", "extra", "runs", "hits", "errors"] %} + + {% endfor %} + +
Team123456789ExRHE
{{ game.away_team if game.away_team else 'Away' }}
{{ game.home_team if game.home_team else 'Home' }}
+
+ +
+ + Cancel +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin_manage_teams.html b/templates/admin_manage_teams.html new file mode 100644 index 0000000..669d622 --- /dev/null +++ b/templates/admin_manage_teams.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% block content %} +

Manage Teams for Season: {{ season.name }}

+ +
+ +
+

Pending Requests

+ {% if pending_teams %} +
    + {% for team in pending_teams %} +
  • + {{ team.team_name }} ({{ team.username }}) +
    +
    + + + +
    +
    + + + +
    +
    +
  • + {% endfor %} +
+ {% else %} +

No pending requests.

+ {% endif %} + +

Approved Teams

+ {% if approved_teams %} +
    + {% for team in approved_teams %} +
  • + {{ team.team_name }} ({{ team.username }}) +
    + + + +
    +
  • + {% endfor %} +
+ {% else %} +

No approved teams.

+ {% endif %} +
+ + +
+

Directly Add Teams

+

Select teams to bypass the request process and approve immediately.

+
+ +
+ {% for team in all_teams %} + {% if team.id not in enrolled_ids %} + + {% endif %} + {% endfor %} +
+ +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..9172079 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,54 @@ + + + + + + The Orchard League + + + + + +
+
+ +
+

THE ORCHARD LEAGUE

+

Established 2026

+
+
+ +
+ +
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} +
+ + {% block content %}{% endblock %} +
+ +
+

© Blessings; WWCH -Chairman of the Orchard League.

+
+ + \ No newline at end of file diff --git a/templates/game.html b/templates/game.html new file mode 100644 index 0000000..690e16d --- /dev/null +++ b/templates/game.html @@ -0,0 +1,185 @@ +{% extends "base.html" %} + +{% block content %} +
+ + ← Back to Schedule + +

Game Details

+
+ + {% if game %} +
+
+

Away

+ {% set is_away_team = current_user.is_authenticated and current_user.id|string == game.away_team_id|string %} + {% if game.away_icon %} + + {% else %} +
+ {% endif %} +

{{ game.away_team if game.away_team else 'TBD' }}

+
{{ game.away_score if game.status == 'Final' else '-' }}
+
+ +
+
VS
+
Date: {{ game.scheduled_date }}
+
+ {{ game.status }} + {% if game.is_conditional %}*{% endif %} +
+ + {% if current_user.is_admin %} + + {% endif %} +
+ +
+

Home

+ {% set is_home_team = current_user.is_authenticated and current_user.id|string == game.home_team_id|string %} + {% if game.home_icon %} + + {% else %} +
+ {% endif %} +

{{ game.home_team if game.home_team else 'TBD' }}

+
{{ game.home_score if game.status == 'Final' else '-' }}
+
+
+ + {% if game.status == 'Final' and game.box_score and (game.box_score.away|sum + game.box_score.home|sum) > 0 %} +
+

Box Score

+ + + + + + + + + {% for val in game.box_score.away %} + + {% endfor %} + + + + {% for val in game.box_score.home %} + + {% endfor %} + +
Team123456789ExtraRHE
{{ game.away_team }}{{ val }}
{{ game.home_team }}{{ val }}
+
+ {% endif %} + + {% if game.status != 'Final' and submissions %} +
+

Active Proposals

+ {% for s in submissions %} + {% set show_score = s.away_score is not none and (s.away_score != game.away_score or s.home_score != game.home_score) %} + {% set show_date = s.proposed_date and s.proposed_date != game.scheduled_date %} + + {% if show_score or show_date %} +
+
+

From: {{ s.team_name }} ({{ s.username }})

+ {% if show_score %} +

Score: {{ game.away_team }} {{ s.away_score }} - {{ game.home_team }} {{ s.home_score }}

+ {% if s.box_score %} +
+ View Proposed Box Score + + + + + {% for v in s.box_score.away %}{% endfor %} + {% for v in s.box_score.home %}{% endfor %} +
Team123456789ExRHE
Away{{ v }}
Home{{ v }}
+
+ {% endif %} + {% endif %} + {% if show_date %} +

Date Proposal: {{ s.proposed_date }}

+ {% endif %} +
+
+ {% if current_user.id|string == s.submitted_by_id|string %} +
+ +
+ {% elif current_user.is_authenticated and (current_user.id|string == game.away_team_id|string or current_user.id|string == game.home_team_id|string) %} +
+ +
+ {% endif %} +
+
+ {% endif %} + {% endfor %} +
+ {% endif %} + + {% if game.status != 'Final' and current_user.is_authenticated and (current_user.id|string == game.away_team_id|string or current_user.id|string == game.home_team_id|string) %} +
+ +
+

Submit Game Results

+
+
+
+ + +
+
+ + +
+
+ +
+ + + + + + + + + {% for col in ["1", "2", "3", "4", "5", "6", "7", "8", "9", "extra", "runs", "hits", "errors"] %} + + {% endfor %} + + + + {% for col in ["1", "2", "3", "4", "5", "6", "7", "8", "9", "extra", "runs", "hits", "errors"] %} + + {% endfor %} + +
Team123456789ExtraRHE
{{ game.away_team if game.away_team else 'Away' }}
{{ game.home_team if game.home_team else 'Home' }}
+
+ + +
+
+ + +
+

Propose Date Change

+
+
+ + +
+ +
+
+
+ {% endif %} + {% else %} +

Game not found.

+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..f8f19b6 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block content %} +

Team Owner Login

+
+
+ + +
+
+ + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/templates/season.html b/templates/season.html new file mode 100644 index 0000000..43ca0de --- /dev/null +++ b/templates/season.html @@ -0,0 +1,285 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ + + +
+
+ + + + + {% if active_tab == 'overview' %} +
+

{{ season_info.name }} - Overview

+
+
+

Total Games (Per Team): {{ season_info.total_games }}

+

Games Per Week: {{ season_info.games_per_week }}

+

Season Start: {{ season_info.start_date }}

+

Season End: {{ season_info.end_date }}

+ {% if season_info.total_games %} +

Playoff Teams: {{ season_info.playoff_teams }}

+ {% endif %} +

Status: {{ season_info.status }} {% if season_info.is_finished %}(Finished){% else %}(In Progress){% endif %}

+
+ +
+ {% if season_info.is_finished %} +

Final Standings

+ {% if standings %} + + {% for row in standings %} + + + + + {% endfor %} +
{{ loop.index }}. {{ row.team_name }}{{ row.wins }} - {{ row.losses }}
+ {% else %} +

No teams enrolled.

+ {% endif %} + {% else %} +

League Action

+

The season is currently active or upcoming.

+ + {% if current_user.is_authenticated and not current_user.is_admin %} + {% if user_season_status == 'Approved' %} +
+

You are currently enrolled in this season.

+
+ {% elif user_season_status == 'Pending' %} +
+

Your request to join is pending approval.

+
+ {% else %} +
+ +
+ {% endif %} + {% elif not current_user.is_authenticated %} +

+ Login to apply for this season. +

+ {% endif %} + {% endif %} +
+
+
+ + {% elif active_tab == 'standings' %} +
+

{{ season_info.name }} - Standings

+ {% if standings %} + + + + + + + + + + {% for row in standings %} + {% set is_my_team = current_user.is_authenticated and current_user.id|string == row.user_id|string %} + {% set is_cutoff = loop.index == season_info.playoff_teams %} + + + + + + + + + {% endfor %} +
TeamWins (W)Losses (L)Runs For (RF)Runs Against (RA)Games Back (GB)
+ {% if row.team_icon %} + {{ row.team_name }} + {% else %} +
+ {% endif %} + {{ row.team_name }} + {% if row.clinch %}{{ row.clinch }}{% endif %} +
{{ row.wins }}{{ row.losses }}{{ row.runs_for }}{{ row.runs_against }}{{ row.games_back }}
+ {% else %} +

No standings available for the selected season.

+ {% endif %} +
+ + {% elif active_tab == 'schedule' %} +
+

{{ season_info.name }} - Schedule

+ +
+ + + + +
+ + {% for month in months %} +
+

{{ month.name }}

+
+ {% for day in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] %} +
{{ day }}
+ {% endfor %} + + {% for week in month.weeks %} + {% for day in week %} + {% set date_str = "%04d-%02d-%02d" % (month.year, month.month, day) if day != 0 else "" %} +
+ {% if day != 0 %} + {{ day }} + {% if date_str in games_by_date %} + {% for game in games_by_date[date_str] %} + {% set is_away_mine = current_user.is_authenticated and current_user.id|string == game.away_team_id|string %} + {% set is_home_mine = current_user.is_authenticated and current_user.id|string == game.home_team_id|string %} +
+ + {% if game.away_icon %} + + {% else %} +
+ {% endif %} + {{ game.away_score if game.status == 'Final' else '' }} @ + {{ game.home_score if game.status == 'Final' else '' }} + {% if game.home_icon %} + + {% else %} +
+ {% endif %} + {% if game.is_conditional %}*{% endif %} + {% if game.pending_proposals > 0 %} + + {% endif %} +
+
+ {% endfor %} + {% endif %} + {% endif %} +
+ {% endfor %} + {% endfor %} +
+
+ {% endfor %} + + {% if games_by_date['TBD'] %} +

TBD Games

+
+ {% for game in games_by_date['TBD'] %} +
+ TBD @ TBD (Playoff) +
+ {% endfor %} +
+ {% endif %} +
+ {% elif active_tab == 'playoffs' %} +
+

{{ season_info.name }} - Playoffs

+ + {% if bracket %} +
+ {% for round_games in bracket %} +
+

Round {{ loop.index }}

+ {% for g in round_games %} +
+
+ {{ g.away_team if g.away_team else 'TBD' }} {{ g.away_record if g.away_team else '' }} + {{ g.away_series_wins }} +
+
+ {{ g.home_team if g.home_team else 'TBD' }} {{ g.home_record if g.home_team else '' }} + {{ g.home_series_wins }} +
+
+ Best of {{ season_info.playoff_series_length }} +
+
+ {% endfor %} +
+ {% endfor %} +
+ {% else %} +

Playoff bracket not yet generated.

+ {% endif %} + +

Playoff Schedule

+ {% if playoff_games %} +
+ {% for game in playoff_games %} + {% set is_away_mine = current_user.is_authenticated and current_user.id|string == game.away_team_id|string %} + {% set is_home_mine = current_user.is_authenticated and current_user.id|string == game.home_team_id|string %} +
+
+ {{ game.scheduled_date }} - {{ game.status }} + {% if game.is_conditional %}*{% endif %} + {% if game.pending_proposals > 0 %}{% endif %} +
+
+
+ {% if game.away_icon %} + + {% else %} +
+ {% endif %} +
{{ game.away_team if game.away_team else 'TBD' }} +
{{ game.away_score if game.status == 'Final' else '-' }} +
+
@
+
+ {% if game.home_icon %} + + {% else %} +
+ {% endif %} +
{{ game.home_team if game.home_team else 'TBD' }} +
{{ game.home_score if game.status == 'Final' else '-' }} +
+
+
+ {% endfor %} +
+ {% else %} +

No playoff games scheduled.

+ {% endif %} +
+ {% endif %} + +{% endblock %} \ No newline at end of file diff --git a/templates/team.html b/templates/team.html new file mode 100644 index 0000000..e7098be --- /dev/null +++ b/templates/team.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} + +{% block content %} +

Team Management: {{ current_user.team_name }}

+ + {% if current_user.team_icon %} +
+ Team Icon +
+ {% endif %} + +
+
+

Update Profile

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

Seasons

+ {% if seasons %} +
    + {% for season in seasons %} +
  • + {{ season.name }} ({{ season.status }}) + + {% if not current_user.is_admin %} + {% if season.id in my_seasons %} + {% if my_seasons[season.id] == 'Approved' %} + Joined + {% else %} + Pending Approval + {% endif %} + {% else %} +
    + +
    + {% endif %} + {% endif %} +
  • + {% endfor %} +
+ {% else %} +

No seasons available right now.

+ {% endif %} + +

Your Schedule

+

Below is your team's schedule for the active season.

+ View Full Team Schedule +
+
+{% endblock %} \ No newline at end of file -- cgit v1.2.3