diff --git a/Dockerfile b/Dockerfile index 356373c..30ba365 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,4 @@ COPY . /app EXPOSE ${PORT} -CMD ["streamlit", "run", "app.py", "--server.port", "${PORT}", "--server.address", "0.0.0.0"] +CMD streamlit run app.py --server.port $PORT --server.address 0.0.0.0 diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc index 52c7a21..a4bfbd2 100644 Binary files a/__pycache__/app.cpython-314.pyc and b/__pycache__/app.cpython-314.pyc differ diff --git a/app.py b/app.py index e9345e3..73de9e5 100644 --- a/app.py +++ b/app.py @@ -128,6 +128,27 @@ TRANSLATIONS = { 'es': 'Elige un rival', 'ru': 'Выберите соперника', }, + 'select_focus_team_label': { + 'de': 'Land für KO-Analyse wählen', + 'en': 'Choose country for KO analysis', + 'fr': 'Choisir le pays pour l’analyse KO', + 'es': 'Elige país para análisis KO', + 'ru': 'Выберите страну для анализа плей-офф', + }, + 'group_place_phrase': { + 'de': 'Gruppenplatz', + 'en': 'group place', + 'fr': 'place en groupe', + 'es': 'puesto de grupo', + 'ru': 'место в группе', + }, + 'path_phrase': { + 'de': 'Pfad', + 'en': 'path', + 'fr': 'parcours', + 'es': 'ruta', + 'ru': 'путь', + }, 'germany_vs': { 'de': '**Deutschland vs. {opponent}**', 'en': '**Germany vs. {opponent}**', @@ -637,7 +658,7 @@ def simulate_group(group_teams, rng, strengths): return teams_sorted, {'pts': pts, 'gd': gd, 'gf': gf, 'cards': cards, 'matches': matches}, match_records -def run_single_tournament(groups, rng, strengths, annex_mapping=None, team_rankings=None): +def run_single_tournament(groups, rng, strengths, annex_mapping=None, team_rankings=None, focal_team='Germany'): if team_rankings is None: team_rankings = strengths @@ -793,23 +814,23 @@ def run_single_tournament(groups, rng, strengths, annex_mapping=None, team_ranki rounds = {} for a, b in r32_matches: - if a == 'Germany' or b == 'Germany': - opp = b if a == 'Germany' else a + if a == focal_team or b == focal_team: + opp = b if a == focal_team else a rounds['R32'] = opp for a, b in r16_matches: - if a == 'Germany' or b == 'Germany': - opp = b if a == 'Germany' else a + if a == focal_team or b == focal_team: + opp = b if a == focal_team else a rounds['R16'] = opp for a, b in qf_matches: - if a == 'Germany' or b == 'Germany': - opp = b if a == 'Germany' else a + if a == focal_team or b == focal_team: + opp = b if a == focal_team else a rounds['QF'] = opp for a, b in sf_matches: - if a == 'Germany' or b == 'Germany': - opp = b if a == 'Germany' else a + if a == focal_team or b == focal_team: + opp = b if a == focal_team else a rounds['SF'] = opp - if 'Germany' in final_teams: - opp = final_teams[1] if final_teams[0] == 'Germany' else final_teams[0] if final_teams[1] == 'Germany' else None + if focal_team in final_teams: + opp = final_teams[1] if final_teams[0] == focal_team else final_teams[0] if final_teams[1] == focal_team else None rounds['Final'] = opp return { @@ -843,7 +864,7 @@ def parse_group_place(route): return None -def run_simulations(groups, n, seed=0, annex_mapping=None, team_rankings=None, strengths=None): +def run_simulations(groups, n, seed=0, annex_mapping=None, team_rankings=None, strengths=None, focal_team='Germany'): rng = np.random.default_rng(seed) counters = {r: Counter() for r in ['R32', 'R16', 'QF', 'SF', 'Final']} teams = [t for team_list in groups.values() for t in team_list] @@ -866,17 +887,17 @@ def run_simulations(groups, n, seed=0, annex_mapping=None, team_rankings=None, s } for _ in range(n): - result = run_single_tournament(groups, rng, strengths, annex_mapping=annex_mapping, team_rankings=team_rankings) + result = run_single_tournament(groups, rng, strengths, annex_mapping=annex_mapping, team_rankings=team_rankings, focal_team=focal_team) for r, opp in result['rounds'].items(): if opp is not None: counters[r][opp] += 1 - meeting_stats['Germany'][opp][r] += 1 - meeting_stats[opp]['Germany'][r] += 1 - if result['positions']['Germany'] == 1 and result['positions'][opp] == 1: + meeting_stats[focal_team][opp][r] += 1 + meeting_stats[opp][focal_team][r] += 1 + if result['positions'][focal_team] == 1 and result['positions'][opp] == 1: both_group_winner_meetings[opp] += 1 - germany_route = summarize_meeting_route(result['team_info']['Germany'], r) + focal_route = summarize_meeting_route(result['team_info'][focal_team], r) opp_route = summarize_meeting_route(result['team_info'][opp], r) - meeting_paths[opp][r][(germany_route, opp_route)] += 1 + meeting_paths[opp][r][(focal_route, opp_route)] += 1 for team, pos in result['positions'].items(): team_stats[team]['group_positions'][pos] += 1 for team, record in result['group_records'].items(): @@ -961,11 +982,21 @@ def main(): list(LANGUAGES.keys()), format_func=lambda code: LANGUAGES[code], index=0, + key='lang_select', ) st.title(translate('title', lang)) teams = load_teams() strengths = compute_team_strengths(TEAM_STAGE_ODDS) + # choose focal team for KO analysis (defaults to Germany if present) + focus_default = 'Germany' if 'Germany' in teams else teams[0] + focus_team = st.sidebar.selectbox( + translate('select_focus_team_label', lang), + teams, + index=teams.index(focus_default), + format_func=lambda t: display_team_name(t, lang), + key=f'focus_team_{lang}', + ) st.sidebar.header(translate('sidebar_header', lang)) n = st.sidebar.slider(translate('sidebar_n', lang), min_value=100, max_value=20000, value=2000, step=100) @@ -999,6 +1030,7 @@ def main(): annex_mapping=None, team_rankings=strengths, strengths=strengths, + focal_team=focus_team, ) if st.session_state.simulation_results is not None: @@ -1006,25 +1038,26 @@ def main(): st.success(translate('done_message', lang)) counters, team_stats, meeting_stats, meeting_paths, both_group_winner_meetings = st.session_state.simulation_results - germany = team_stats['Germany'] + focal_stats = team_stats[focus_team] tab1, tab2 = st.tabs([translate('tab_opponent_details', lang), translate('tab_results', lang)]) with tab1: st.subheader(translate('opponent_details_header', lang)) possible_opponents = sorted( - [t for t in teams if t != 'Germany'], - key=lambda x: sum(meeting_stats['Germany'][x].values()), + [t for t in teams if t != focus_team], + key=lambda x: sum(meeting_stats[focus_team][x].values()), reverse=True, ) - opponent_options = ['Germany'] + [t for t in sorted(teams) if t != 'Germany'] + opponent_options = [focus_team] + [t for t in sorted(teams) if t != focus_team] opponent = st.selectbox( translate('select_opponent', lang), opponent_options, format_func=lambda t: display_team_name(t, lang), + key=f'opponent_select_{lang}', ) if opponent: - meet = meeting_stats['Germany'][opponent] + meet = meeting_stats[focus_team][opponent] total_meet = sum(meet.values()) st.markdown(translate('germany_vs', lang, opponent=display_team_name(opponent, lang))) if total_meet == 0: @@ -1061,20 +1094,20 @@ def main(): if meet[r] == 0: continue for (germany_route, opp_route), path_count in meeting_paths[opponent][r].most_common(): - germany_winner = is_group_winner_route(germany_route) + focal_winner = is_group_winner_route(germany_route) opp_winner = is_group_winner_route(opp_route) - germany_place = parse_group_place(germany_route) + focal_place = parse_group_place(germany_route) opp_place = parse_group_place(opp_route) placement_rows.append({ 'Round': r, - 'Germany group place': germany_place, - f'{opponent} group place': opp_place, - 'Both group winners': germany_winner and opp_winner, + 'Focus group place': focal_place, + 'Opponent group place': opp_place, + 'Both group winners': focal_winner and opp_winner, 'Count': path_count, 'Share of round': f'{path_count / meet[r]:.1%}', 'Share of sims': f'{path_count / n:.1%}', - 'Germany path': ' → '.join(germany_route), - f'{opponent} path': ' → '.join(opp_route), + 'Focus path': ' → '.join(germany_route), + 'Opponent path': ' → '.join(opp_route), }) if placement_rows: @@ -1086,14 +1119,14 @@ def main(): [ 'Stage', 'Round', - 'Germany group place', - f'{opponent} group place', + 'Focus group place', + 'Opponent group place', 'Both group winners', 'Count', 'Share of round', 'Share of sims', - 'Germany path', - f'{opponent} path', + 'Focus path', + 'Opponent path', 'Round order', ] ] @@ -1102,7 +1135,12 @@ def main(): ascending=[False, True, False], ) placement_df = placement_df.drop(columns=['Round order']) - placement_df = placement_df.rename(columns=COLUMN_LABELS[lang]) + # prepare dynamic labels for the focus team + col_map = COLUMN_LABELS.get(lang, {}).copy() + # dynamic focus labels + col_map['Focus group place'] = f"{display_team_name(focus_team, lang)} {translate('group_place_phrase', lang)}" + col_map['Focus path'] = f"{display_team_name(focus_team, lang)} {translate('path_phrase', lang)}" + placement_df = placement_df.rename(columns=col_map) st.markdown(translate('flows_first_both_winners', lang)) def highlight_row(row): return [ @@ -1112,32 +1150,32 @@ def main(): st.dataframe(placement_df.style.apply(highlight_row, axis=1)) grouping = pd.DataFrame(placement_rows).groupby( - ['Germany group place', f'{opponent} group place'] + ['Focus group place', 'Opponent group place'] )['Count'].sum().reset_index() grouping['Probability of meeting'] = grouping['Count'] / total_meet grouping = grouping.rename( columns={ - 'Germany group place': label('Germany group place', lang), - f'{opponent} group place': label(f'{opponent} group place', lang), + 'Focus group place': f"{display_team_name(focus_team, lang)} {translate('group_place_phrase', lang)}", + 'Opponent group place': label('Opponent group place', lang), 'Count': label('Count', lang), 'Probability of meeting': label('Probability of meeting', lang), } ) st.subheader(translate('meeting_by_group_place', lang)) grouping = grouping.sort_values( - [label('Germany group place', lang), label(f'{opponent} group place', lang)] + [f"{display_team_name(focus_team, lang)} {translate('group_place_phrase', lang)}", label('Opponent group place', lang)] ) st.dataframe(grouping) st.subheader(translate('stage_meeting_phase', lang)) st.markdown( format_stage_line( - display_team_name('Germany', lang), - f'{team_stats['Germany']['R16'] / n:.1%}', - f'{team_stats['Germany']['QF'] / n:.1%}', - f'{team_stats['Germany']['SF'] / n:.1%}', - f'{team_stats['Germany']['Final'] / n:.1%}', - f'{team_stats['Germany']['Winner'] / n:.1%}', + display_team_name(focus_team, lang), + f"{team_stats[focus_team]['R16'] / n:.1%}", + f"{team_stats[focus_team]['QF'] / n:.1%}", + f"{team_stats[focus_team]['SF'] / n:.1%}", + f"{team_stats[focus_team]['Final'] / n:.1%}", + f"{team_stats[focus_team]['Winner'] / n:.1%}", lang=lang, ) ) @@ -1145,11 +1183,11 @@ def main(): st.markdown( format_stage_line( display_team_name(opponent, lang), - f'{opp_stats['R16'] / n:.1%}', - f'{opp_stats['QF'] / n:.1%}', - f'{opp_stats['SF'] / n:.1%}', - f'{opp_stats['Final'] / n:.1%}', - f'{opp_stats['Winner'] / n:.1%}', + f"{opp_stats['R16'] / n:.1%}", + f"{opp_stats['QF'] / n:.1%}", + f"{opp_stats['SF'] / n:.1%}", + f"{opp_stats['Final'] / n:.1%}", + f"{opp_stats['Winner'] / n:.1%}", lang=lang, ) ) @@ -1162,13 +1200,13 @@ def main(): translate( 'germany_reaches', lang, - team=display_team_name('Germany', lang), + team=display_team_name(focus_team, lang), stage=stage_label(r, lang), - value=f"{germany[r] / n:.1%}", + value=f"{team_stats[focus_team][r] / n:.1%}", ) ) - st.subheader(translate('possible_opponents', lang, team=display_team_name('Germany', lang))) + st.subheader(translate('possible_opponents', lang, team=display_team_name(focus_team, lang))) for r in ['R32', 'R16', 'QF', 'SF', 'Final']: cnt = counters[r] total = sum(cnt.values()) @@ -1215,11 +1253,12 @@ def main(): summary_df = pd.DataFrame(team_rows).sort_values(label('R16', lang), ascending=False) st.dataframe(summary_df) - team_options = ['Germany'] + [t for t in sorted(teams) if t != 'Germany'] + team_options = [focus_team] + [t for t in sorted(teams) if t != focus_team] selected_team = st.selectbox( translate('select_team', lang), team_options, format_func=lambda t: display_team_name(t, lang), + key=f'team_select_{lang}', ) if selected_team: selected = team_stats[selected_team] @@ -1264,6 +1303,3 @@ def main(): if __name__ == '__main__': main() - -if __name__ == '__main__': - main() diff --git a/docker-compose.yml b/docker-compose.yml index 3bfb8f9..6d64f5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: app: build: . diff --git a/gitea-deploy.sh b/gitea-deploy.sh new file mode 100755 index 0000000..ee8217a --- /dev/null +++ b/gitea-deploy.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Gitea / Git deployment runner script for Proxmox LXC container with Docker. +# +# Usage: +# ./gitea-deploy.sh +# +# Optional environment variables: +# REPO_DIR Path to the repository root (default: script directory) +# DEPLOY_BRANCH Git branch to deploy (default: main) +# COMPOSE_FILE Docker Compose file path (default: docker-compose.yml) +# DOCKER_CMD Docker command to use (auto-detected) +# GIT_CLEAN If set to 1, clean untracked files before deploy + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="${REPO_DIR:-$SCRIPT_DIR}" +DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" +GIT_CLEAN="${GIT_CLEAN:-0}" + +if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="docker-compose" +else + echo "ERROR: docker compose command not found. Install Docker Compose or use Docker v20+ with built-in compose." + exit 1 +fi + +cd "$REPO_DIR" + +echo "Deploying $REPO_DIR" +echo "Branch: $DEPLOY_BRANCH" +echo "Compose file: $COMPOSE_FILE" + +if ! command -v git >/dev/null 2>&1; then + echo "ERROR: git is not installed." + exit 1 +fi + +if [ ! -f "$COMPOSE_FILE" ]; then + echo "ERROR: Compose file '$COMPOSE_FILE' not found in $REPO_DIR." + exit 1 +fi + +# Ensure repository is on the correct branch and in sync with remote. + +git fetch --all --prune + +git checkout "$DEPLOY_BRANCH" +git reset --hard "origin/$DEPLOY_BRANCH" + +if [ "$GIT_CLEAN" = "1" ]; then + echo "Cleaning untracked files..." + git clean -fdx +fi + +# Build and deploy with Docker Compose. + +echo "Stopping existing containers and removing orphans..." +$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" down --remove-orphans + +echo "Building image..." +$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache + +echo "Starting containers..." +$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" up -d + +echo "Deployment finished. Current service status:" +$DOCKER_COMPOSE_CMD -f "$COMPOSE_FILE" ps + +exit 0 diff --git a/requirements.txt b/requirements.txt index 6fe0514..1b9eb04 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ streamlit pandas numpy +pypdf diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..d33551f --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,31 @@ +import pytest +import os +import sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from app import translate, display_team_name, COLUMN_LABELS, TRANSLATIONS + + +def test_display_team_name_localization(): + assert display_team_name('Germany', 'de') == 'Deutschland' + assert display_team_name('Germany', 'en') == 'Germany' + assert display_team_name('Spain', 'de') == 'Spain' + + +def test_dynamic_focus_label_creation(): + focus = 'Spain' + lang = 'en' + col_map = COLUMN_LABELS.get(lang, {}).copy() + col_map['Focus group place'] = f"{display_team_name(focus, lang)} {translate('group_place_phrase', lang)}" + col_map['Focus path'] = f"{display_team_name(focus, lang)} {translate('path_phrase', lang)}" + assert col_map['Focus group place'] == 'Spain group place' + assert col_map['Focus path'] == 'Spain path' + + +def test_no_untranslated_deutsch_in_non_de(): + # ensure no non-de translation contains the German word 'Deutschland' + bad = [] + for key, d in TRANSLATIONS.items(): + for lang, text in d.items(): + if lang != 'de' and 'Deutschland' in text: + bad.append((key, lang, text)) + assert not bad, f"Found German 'Deutschland' in non-de translations: {bad}"