36 Commits
Author SHA1 Message Date
herel ca5f7d7c61 build: Update dependencies and pre-commit hooks
Bump mypy to 2.3.1 and refresh all pre-commit hooks (ruff 0.16,
markdownlint 0.18). Fix issues surfaced by the new tooling:

- Use lowercase samesite cookie values (mypy 2.x)
- Remove no-op global declarations and address new ruff findings;
  ignore rules that would require behavior changes
- Add missing comma in AI system prompt (strings were concatenated)
- Wrap long README lines for MD013
2026-09-16 15:23:57 +02:00
herel 0269a65adc build: Upgrade dependencies to latest versions
Bump minimum versions for requests, typer, pypdf, rl-ai-tools,
fastapi and pyjwt, and update dev dependencies (mypy,
types-requests). Remove unused ipykernel from dev group.

Regenerate poetry.lock, re-export requirements.txt and sync
uv.lock accordingly. requirements.txt now also includes pyjwt
and its crypto dependencies which were missing from the
previous export.
2026-09-16 13:10:24 +02:00
herel dbbe2171c2 chore: update .gitignore 2026-06-29 23:11:51 +02:00
herel 09952e4dc1 fix: remove Cross-Account Data Leakage 2026-06-29 23:04:48 +02:00
herel c7990b0971 chore: remove debugging prints 2026-06-29 23:03:48 +02:00
herel a17c362c81 fix(frontend): Sanitize DOM rendering to prevent XSS via innerHTML
Replace unsafe innerHTML template literals with document.createElement()
and textContent for all user-conrolled data fields in index.html.

Event cards and modal details previously interpolated API responses
(event.name, event.title, player.fname, staff.role, etc.) directly into
innerHTML without escaping, allowing stored XSS if upstream MyIce data
is malicious (CWE-79 / vuln-0002).

All dynamic text now flows through textContent or createTextNode(),
while static HTML structures (SVG icons, strong labels) remain safe
hardcoded innerHTML assignments.

Refs vuln-0002
2026-06-29 20:05:45 +02:00
herel 77b2531106 fix(api): Send PKCE code_verifier in token exchange and secure OIDC cookies
The /exchange-token endpoint was not sending the code_verifier to the
token endpoint, rendering PKCE protection ineffective. Add the verifier
to the POST body so the IdP can validate the authorization code exchange.

Also add secure=True and samesite='Lax' to all OIDC cookies on /login
to improve cookie security per the recommended remediation.

Refs vuln-0004
2026-06-29 19:49:07 +02:00
herel 0d39efd034 fix: use tailwindcss cli 2026-06-29 18:02:10 +02:00
herel 12aada4986 fix: better subgroup 2026-06-29 17:35:48 +02:00
herel 9795a4c813 fix: proper jwt verification 2026-06-29 17:35:36 +02:00
herel 46ae216e97 fix: IDC State/Nonce Not Validated 2026-06-29 16:53:14 +02:00
herel 19724b1f8b fix: add timeout for request 2026-06-29 16:52:35 +02:00
herel 9e5db51972 fix: Weak JWT Validation 2026-06-29 16:48:02 +02:00
herel c0bfa85688 fix: No static key fallback; reject non-OIDC tokens 2026-06-29 16:43:09 +02:00
herel ffe0a1cf18 fix: remove hard coded old secret 2026-06-29 16:42:51 +02:00
herel ff69806803 fix: Permissive CORS Configuration 2026-06-29 16:40:36 +02:00
herel 524c4db889 fix: Missing Authentication Checks 2026-06-29 16:37:39 +02:00
herel 6b2965c18a fix(security): Authentication Bypass via Broken JWT Validation in AuthHeaders.validate_oidc_token 2026-06-29 16:33:21 +02:00
herel 9363a1f7ef chore: add some debug 2026-06-29 16:30:35 +02:00
herel 449f5450fe chore: update categories 2026-06-29 16:28:15 +02:00
herel f302357545 fix: prevent OS Command Injection 2026-06-29 16:19:33 +02:00
herel b1a792502f fix(schedule): Use midnight as schedule start date
Change date_start from current time to today at midnight so the
schedule fetch query properly covers the full day.
2026-06-05 15:12:33 +02:00
herel 38e3564524 chore: update Dockerfile 2025-11-11 11:06:40 +01:00
herel 6a240c5773 feat: add ESC key support to close event details modal and bump version to v0.6.0 2025-11-11 08:51:58 +01:00
herel b04e8e2900 docs: improve AGENTS.md with better build/lint/test commands and code style guidelines 2025-11-11 08:46:13 +01:00
herel f33bb46d73 chore(docker): use docker cache mount for pip install step 2025-10-15 16:03:01 +02:00
herel cd32efb657 feat: redesign UI with Tailwind CSS and improve event display 2025-10-15 15:59:57 +02:00
herel 747e9db582 chore: use python image 3.13-slim instead of 3.13-alpine 2025-10-01 16:22:48 +02:00
herel 768ac8375b feat: slim down Docker image size by 8x using Alpine Linux base and multi-stage build 2025-09-30 09:22:26 +02:00
herel 4a5b2a5991 chore: update fastapi and dependencies to latest versions 2025-09-30 08:37:56 +02:00
herel c099f1328b chore: migrate to python 3.13 and update dependencies
Migrate from Python 3.11 to 3.13 with updated dependencies. Switch from PyPDF2 to pypdf library for better PDF processing. Add new U14 age groups and extract-pdf utility script.
2025-09-29 23:05:48 +02:00
herel fe8a4e5d09 refactor: migrate to distroless multi-stage Docker build 2025-09-29 22:07:11 +02:00
herel 781d7000cd feat: display staff information and improve empty state handling in game details modal 2025-09-29 20:17:23 +02:00
herel 6903dab06b doc: fix markdownlint issues in AGENTS.md 2025-09-23 09:33:44 +02:00
herel abfad23a7a chore: bump version to v0.5.7 2025-08-20 11:10:20 +02:00
herel 68ecce7d3a feat: layout filters horizontally 2025-08-20 11:10:06 +02:00
17 changed files with 4208 additions and 1731 deletions

No files matched your search

+6 -1
View File
@@ -3,7 +3,11 @@ myice.ini
schedule.json
.envrc
cookies.txt
# Byte-compiled / optimized / DLL files
# Tailwind CSS generated artifacts
style.css
tailwindcss
# Python bytecode
__pycache__/
*.py[cod]
*$py.class
@@ -165,3 +169,4 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
strix_runs/*
+8 -8
View File
@@ -15,7 +15,7 @@ repos:
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.9
rev: v0.16.7
hooks:
# Run the linter.
- id: ruff
@@ -24,26 +24,26 @@ repos:
- id: ruff-format
args: [--diff, --target-version, py312]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.17.1
rev: v2.3.1
hooks:
- id: mypy
exclude: ^(docs/|example-plugin/)
args: [--ignore-missing-imports]
additional_dependencies: [types-requests, PyPDF2]
additional_dependencies: [types-requests, pypdf]
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.37.1
rev: v1.38.0
hooks:
- id: yamllint
args: [--strict]
- repo: https://github.com/markdownlint/markdownlint
rev: v0.13.0
rev: v0.18.1
hooks:
- id: markdownlint
exclude: "^.github|(^docs/_sidebar\\.md$)"
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
rev: v0.11.0.1-1
hooks:
- id: shellcheck
args: ["--severity=error"]
@@ -51,12 +51,12 @@ repos:
files: "\\.sh$"
- repo: https://github.com/golangci/misspell
rev: v0.7.0
rev: v0.8.0
hooks:
- id: misspell
args: ["-i", "charactor"]
- repo: https://github.com/python-poetry/poetry
rev: "2.1.4"
rev: "2.4.1"
hooks:
- id: poetry-check
- id: poetry-lock
+63 -4
View File
@@ -26,11 +26,51 @@ yamllint . # YAML linting
markdownlint . # Markdown linting
```
### Testing
### Running Tests
```bash
# Run tests (no specific test framework configured)
# Project uses manual testing with example PDF files in repository
# Manual testing approach - run individual commands to test functionality
# Test schedule fetching
myice schedule --days 7
# Test mobile login
myice mobile-login
# Test specific command help
myice search --help
# Test web API (run in background)
poetry run fastapi run myice/webapi.py &
# Run a single pre-commit hook on specific file
pre-commit run ruff --files myice/myice.py
pre-commit run ruff-format --files myice/webapi.py
pre-commit run mypy --files myice/myice.py
```
### CSS/Frontend Build
When modifying `index.html` or the Tailwind CSS setup:
```bash
# One-off build of style.css
./build-css.sh
# Watch mode for development
./build-css.sh --watch
# Or manually with the standalone CLI (if already downloaded)
./tailwindcss -i ./style-input.css -o ./style.css --minify
```
### Running the Web API
```bash
# Run with poetry
poetry run fastapi run myice/webapi.py --host 127.0.0.1
# Run with uv (alternative)
uv run fastapi run myice/webapi.py --host 127.0.0.1
```
## Code Style Guidelines
@@ -73,5 +113,24 @@ markdownlint . # Markdown linting
- Typer for CLI interface
- FastAPI for web API
- requests for HTTP requests
- PyPDF2 for PDF processing
- pypdf for PDF processing
- Use rich for enhanced console output
- Custom rl_ai_tools package for AI functionalities
### Git Commit Messages
- Use conventional commits format
- Never mention Claude in commit messages
- Be descriptive but concise
- Use present tense ("add feature" not "added feature")
### Additional Rules
- Always use ddg-mcp to perform Web Search functionality
- Follow the existing code patterns in myice/myice.py and myice/webapi.py
- Maintain backward compatibility when modifying existing APIs
- Document new features in README.md
- Always run ruff format and ruff check after editing a python file
- Use conventional commit messages
- When fixing JSON parsing issues, follow the pattern established in
sanitize_json_response function
+51 -10
View File
@@ -1,20 +1,61 @@
FROM python:3.11
# Multi-stage build to create a minimal image
FROM python:3.13-slim AS builder
RUN install -o www-data -g www-data -d -m 0755 /var/www
# Create working directory
WORKDIR /app
USER www-data
# Install tools needed for Tailwind CSS build
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
# Copy dependency files
COPY requirements.txt ./
ENV PATH=/var/www/.local/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Install dependencies to a target directory
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-deps --disable-pip-version-check -r requirements.txt
COPY README.md pyproject.toml poetry.lock docker-entrypoint.sh index.html favicon.ico /var/www/
COPY myice /var/www/myice
# Build Tailwind CSS
COPY index.html style-input.css tailwind.config.js ./
RUN curl -sL https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-linux-x64 -o tailwindcss \
&& chmod +x tailwindcss \
&& ./tailwindcss -i ./style-input.css -o ./style.css --minify
WORKDIR /var/www
# Runtime stage
FROM python:3.13-slim AS runtime
RUN poetry install && . $(poetry env info -p)
# Create working directory
WORKDIR /app
# Install only runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user for security
RUN useradd --home-dir /app --no-create-home --uid 1000 myice
# Copy installed packages from builder stage
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
# Copy application code and compiled assets
COPY index.html favicon.ico ./
COPY --from=builder /app/style.css ./style.css
COPY myice ./myice
# Change ownership of copied files
RUN chown -R myice:myice /app
# Switch to non-root user
USER myice
# Bytecompile Python files for faster first load
RUN python -m compileall -q ./myice
# Expose port
EXPOSE 8000
ENTRYPOINT [ "/var/www/docker-entrypoint.sh" ]
# Run the application
ENTRYPOINT ["python", "-m", "uvicorn", "myice.webapi:app", "--host", "0.0.0.0", "--port", "8000"]
+33 -13
View File
@@ -10,13 +10,17 @@ the PDFs you need.
with [uv](https://docs.astral.sh/uv/getting-started/installation/):
```shell
uv tool install --extra-index-url https://gitea.parano.ch/api/packages/herel/pypi/simple/ myice
uv tool install \
--extra-index-url https://gitea.parano.ch/api/packages/herel/pypi/simple/ \
myice
```
with [pipx](https://pipx.pypa.io/stable/installation/):
```shell
pipx install --extra-index-url https://gitea.parano.ch/api/packages/herel/pypi/simple/ myice
pipx install \
--extra-index-url https://gitea.parano.ch/api/packages/herel/pypi/simple/ \
myice
```
## configuration
@@ -60,17 +64,20 @@ To fetch the `token` and `club_id` using mobile-login:
myice mobile-login
```
This will output the necessary information that you can add to your `myice.ini` file.
This will output the necessary information that you can add to your
`myice.ini` file.
## web interface
The tool includes a web interface that can be accessed by running the web API server:
The tool includes a web interface that can be accessed by running the web
API server:
```shell
uv run fastapi run myice/webapi.py
```
Then open your browser at `http://localhost:8000`. The web interface allows you to:
Then open your browser at `http://localhost:8000`. The web interface allows
you to:
- Select between different configured accounts
- View upcoming games and practices
@@ -80,18 +87,23 @@ Then open your browser at `http://localhost:8000`. The web interface allows you
The web interface supports two authentication methods:
1. **Infomaniak OpenID Connect (Recommended)**: Click the "Se connecter avec Infomaniak" button to authenticate using Infomaniak's OIDC provider. Only users in the allowed list will be granted access.
1. **Infomaniak OpenID Connect (Recommended)**: Click the "Se connecter avec
Infomaniak" button to authenticate using Infomaniak's OIDC provider. Only
users in the allowed list will be granted access.
2. **Static API Key**: For development purposes, you can still use `abc` as the token.
1. **Static API Key**: For development purposes, you can still use `abc` as
the token.
### Environment Variables
To configure OIDC authentication, set the following environment variables:
- `CLIENT_ID`: Your OIDC client ID (default: 8ea04fbb-4237-4b1d-a895-0b3575a3af3f)
- `CLIENT_ID`: Your OIDC client ID
(default: 8ea04fbb-4237-4b1d-a895-0b3575a3af3f)
- `CLIENT_SECRET`: Your OIDC client secret
- `REDIRECT_URI`: The redirect URI (default: http://localhost:8000/callback)
- `ALLOWED_USERS`: Comma-separated list of allowed email addresses (e.g., "user1@example.com,user2@example.com")
- `REDIRECT_URI`: The redirect URI (default: `http://localhost:8000/callback`)
- `ALLOWED_USERS`: Comma-separated list of allowed email addresses
(e.g., `"user1@example.com,user2@example.com"`)
The web API provides the following endpoints:
@@ -103,7 +115,8 @@ The web API provides the following endpoints:
- `/callback` - Handle OIDC callback
- `/userinfo` - Get user information
All endpoints (except `/health`, `/login`, and `/callback`) require an Authorization header with a Bearer token.
All endpoints (except `/health`, `/login`, and `/callback`) require an
Authorization header with a Bearer token.
## mobile functions
@@ -198,9 +211,16 @@ To use a specific configuration section:
```text
❯ myice ai
> prochain match u13 top ?
< Le prochain match de l'équipe U13 Top se déroulera le dimanche 10 novembre 2024 contre HC Ajoie à la Raffeisen Arena de Porrentruy. Le match débutera à 14h00 et se terminera à 16h15.
< Le prochain match de l'équipe U13 Top se déroulera le dimanche 10 novembre
2024 contre HC Ajoie à la Raffeisen Arena de Porrentruy. Le match débutera
à 14h00 et se terminera à 16h15.
> et les u13 a ?
< Le prochain match de l'équipe U13 A se déroulera le samedi 9 novembre 2024 contre HC Vallorbe à P. du Frézillon, 1337 Vallorbe VD. Le match débutera à 13h00 et se terminera à 15h00. Le prochain match à domicile de l'équipe U13 A se déroulera le dimanche 10 novembre 2024 contre CP Meyrin à Les Vernets, Glace extérieure, 1227 Les Acacias GE. Le match débutera à 13h00 et se terminera à 15h00.
< Le prochain match de l'équipe U13 A se déroulera le samedi 9 novembre 2024
contre HC Vallorbe à P. du Frézillon, 1337 Vallorbe VD. Le match débutera à
13h00 et se terminera à 15h00. Le prochain match à domicile de l'équipe
U13 A se déroulera le dimanche 10 novembre 2024 contre CP Meyrin à
Les Vernets, Glace extérieure, 1227 Les Acacias GE. Le match débutera
à 13h00 et se terminera à 15h00.
```
To use a specific configuration section:
Executable
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
set -euo pipefail
# Build CSS using Tailwind CSS standalone CLI
# For local development, run:
# ./build-css.sh # one-off build
# ./build-css.sh --watch # watch mode
version="v3.4.17"
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Linux*) platform="linux";;
Darwin*) platform="macos";;
*) echo "Unsupported OS: $os"; exit 1;;
esac
case "$arch" in
x86_64) arch="x64";;
arm64) arch="arm64";;
aarch64) arch="arm64";;
*) echo "Unsupported architecture: $arch"; exit 1;;
esac
binary="tailwindcss-${platform}-${arch}"
url="https://github.com/tailwindlabs/tailwindcss/releases/download/${version}/${binary}"
if [ ! -f "tailwindcss" ]; then
echo "Downloading Tailwind CSS standalone CLI (${version} ${platform}/${arch})..."
curl -sL "$url" -o tailwindcss
chmod +x tailwindcss
fi
if [ "${1:-}" == "--watch" ]; then
echo "Building CSS in watch mode..."
./tailwindcss -i ./style-input.css -o ./style.css --watch
else
echo "Building CSS..."
./tailwindcss -i ./style-input.css -o ./style.css --minify
fi
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
REGISTRY="harbor.cl1.parano.ch/library"
IMAGE="myice"
current_commit=$(git rev-parse HEAD)
git_tag=$(git tag --points-at "$current_commit")
if [[ -z $git_tag ]]; then
echo "Build only works on a git tag" >&2
exit 1
fi
docker build --platform linux/amd64 -t "$REGISTRY/$IMAGE:$git_tag" .
docker push "$REGISTRY/$IMAGE:$git_tag"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
exec /var/www/.local/bin/poetry run fastapi run myice/webapi.py
+305 -97
View File
@@ -5,70 +5,95 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MyIce - Games</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<link rel="stylesheet" href="/style.css">
<link rel="icon" href="favicon.ico" type="image/x-icon">
</head>
<body>
<div id="connectedUser" class="position-fixed top-0 end-0 m-2 p-2 bg-light border rounded"
style="display: none; z-index: 1000; font-size: 0.9rem;">
<span>Connecté: <span id="userName"></span></span>
<button id="disconnect" class="btn btn-danger btn-sm ms-2"
style="font-size: 0.8rem; padding: 0.1rem 0.3rem;">Déco</button>
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen">
<!-- Connected User Panel -->
<div id="connectedUser" class="fixed top-4 right-4 bg-white bg-opacity-80 backdrop-blur-sm rounded-xl shadow-lg p-4 flex items-center hidden z-50">
<span class="text-indigo-700 font-medium">Connecté: <span id="userName" class="font-semibold"></span></span>
<button id="disconnect" class="ml-3 bg-rose-500 hover:bg-rose-600 text-white text-sm px-3 py-1 rounded-lg transition duration-200">Déco</button>
</div>
<div class="container-fluid d-flex align-items-center justify-content-center">
<!-- Login View -->
<div class="min-h-screen flex items-center justify-center p-4">
<div class="text-center">
<h1 id="loginTitle" class="mb-4">MyIce - Games</h1>
<h1 id="loginTitle" class="text-4xl md:text-5xl font-bold text-indigo-800 mb-8">MyIce - Games</h1>
<div id="loginContainer" class="mb-3">
<div id="loginContainer" class="mb-6">
<div id="oidcLoginSection">
<button id="oidcLoginBtn" class="btn btn-primary btn-md px-4 py-3 shadow">Se connecter</button>
<button id="oidcLoginBtn" class="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white font-semibold text-lg px-8 py-4 rounded-2xl shadow-xl transform hover:scale-105 transition-all duration-300">
Se connecter
</button>
</div>
</div>
<div class="mt-8 max-w-md mx-auto">
<p class="text-gray-600 text-sm">
Connectez-vous pour accéder aux événements et informations de votre équipe
</p>
</div>
</div>
</div>
<div class="container mt-2" id="mainContent" style="display: none;">
<div id="eventFilters" style="display: none;">
<div class="mb-3">
<label for="account" class="form-label">Compte</label>
<select id="account" class="form-select">
<!-- Main Content -->
<div id="mainContent" class="container mx-auto px-4 py-6 hidden">
<!-- Filters Section -->
<div id="eventFilters" class="bg-white rounded-2xl shadow-lg p-6 mb-8 hidden">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label for="account" class="block text-sm font-medium text-gray-700 mb-1">Compte</label>
<select id="account" class="w-full rounded-lg border border-gray-300 bg-white py-2 px-3 text-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-300">
<option value="default">Défaut</option>
</select>
<label for="agegroup" class="form-label">Âge</label>
<select id="agegroup" class="form-select">
</div>
<div>
<label for="agegroup" class="block text-sm font-medium text-gray-700 mb-1">Âge</label>
<select id="agegroup" class="w-full rounded-lg border border-gray-300 bg-white py-2 px-3 text-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-300">
<option value="">Tous</option>
</select>
<label for="subgroup" class="form-label">Sous-groupe</label>
<select id="subgroup" class="form-select">
</div>
<div>
<label for="subgroup" class="block text-sm font-medium text-gray-700 mb-1">Sous-groupe</label>
<select id="subgroup" class="w-full rounded-lg border border-gray-300 bg-white py-2 px-3 text-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-300">
<option value="">Tous</option>
</select>
<button id="fetchEvents" class="btn btn-primary" style="margin-top: 1.2rem;">Charger</button>
</div>
<div class="flex items-end">
<button id="fetchEvents" class="w-full bg-indigo-500 hover:bg-indigo-600 text-white font-medium py-2 px-4 rounded-lg transition duration-200">
Charger
</button>
</div>
</div>
</div>
<div id="loadingIndicator" class="text-center my-3" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2">Chargement des événements...</p>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-12 hidden">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500 mb-4"></div>
<p class="text-indigo-700 font-medium">Chargement des événements...</p>
</div>
<div id="eventList" class="row mt-2"></div>
<!-- Events List -->
<div id="eventList" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"></div>
</div>
<!-- Modal pour afficher les détails d'un événement -->
<div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-labelledby="eventDetailsLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="eventDetailsLabel">Détails de l'événement</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="eventDetailsContent">Chargement...</div>
<!-- Event Details Modal -->
<div id="eventDetailsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50 hidden">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="border-b border-gray-200 p-6 flex justify-between items-center">
<h5 id="eventDetailsLabel" class="text-2xl font-bold text-gray-800">Détails de l'événement</h5>
<button id="closeModal" class="text-gray-500 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div id="eventDetailsContent" class="p-6">
Chargement...
</div>
</div>
</div>
@@ -85,6 +110,8 @@
const eventList = document.getElementById("eventList");
const fetchButton = document.getElementById("fetchEvents");
const eventDetailsContent = document.getElementById("eventDetailsContent");
const eventDetailsModal = document.getElementById("eventDetailsModal");
const closeModal = document.getElementById("closeModal");
const apiBaseUrl = window.location.origin;
let storedApiKey = localStorage.getItem("apikey");
@@ -92,6 +119,25 @@
let storedAccount = localStorage.getItem("account") || "default";
let userInfo = JSON.parse(localStorage.getItem("userInfo") || "null");
// Close modal when close button is clicked
closeModal.addEventListener("click", () => {
eventDetailsModal.classList.add("hidden");
});
// Close modal when clicking outside the content
eventDetailsModal.addEventListener("click", (e) => {
if (e.target === eventDetailsModal) {
eventDetailsModal.classList.add("hidden");
}
});
// Close modal when pressing ESC key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !eventDetailsModal.classList.contains("hidden")) {
eventDetailsModal.classList.add("hidden");
}
});
// If we have an API key but no userInfo, fetch it from the server
if (storedApiKey && !userInfo) {
fetch(`${apiBaseUrl}/userinfo`, {
@@ -194,18 +240,18 @@
function renderLoginSection() {
const mainContent = document.getElementById("mainContent");
const loginView = document.querySelector(".container-fluid");
const loginView = document.querySelector(".min-h-screen.flex");
const connectedUser = document.getElementById("connectedUser");
const loginTitle = document.getElementById("loginTitle");
if (storedApiKey || userInfo) {
// User is logged in
loginView.style.display = "none";
mainContent.style.display = "block";
loginTitle.style.display = "none";
loginView.classList.add("hidden");
mainContent.classList.remove("hidden");
loginTitle.classList.add("hidden");
connectedUser.style.display = "block";
oidcLoginSection.style.display = "none";
connectedUser.classList.remove("hidden");
oidcLoginSection.classList.add("hidden");
const userNameElement = document.getElementById("userName");
if (userInfo && userInfo.email) {
@@ -214,17 +260,17 @@
userNameElement.textContent = "Utilisateur";
}
eventFilters.style.display = "block";
eventFilters.classList.remove("hidden");
updateAccountOptionsAndLoadEvents();
} else {
// User is not logged in
loginView.style.display = "flex";
mainContent.style.display = "none";
loginTitle.style.display = "block";
loginView.classList.remove("hidden");
mainContent.classList.add("hidden");
loginTitle.classList.remove("hidden");
connectedUser.style.display = "none";
oidcLoginSection.style.display = "block";
eventFilters.style.display = "none";
connectedUser.classList.add("hidden");
oidcLoginSection.classList.remove("hidden");
eventFilters.classList.add("hidden");
// Remove any existing event listeners to prevent duplicates
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
@@ -353,7 +399,7 @@
// Show loading indicator
const loadingIndicator = document.getElementById("loadingIndicator");
const eventList = document.getElementById("eventList");
loadingIndicator.style.display = "block";
loadingIndicator.classList.remove("hidden");
eventList.innerHTML = ""; // Clear previous events
fetch(`${apiBaseUrl}/schedule?account=${account}`, {
@@ -392,7 +438,7 @@
})
.then(data => {
// Hide loading indicator
loadingIndicator.style.display = "none";
loadingIndicator.classList.add("hidden");
if (data) {
// Check if data is an array
@@ -408,7 +454,7 @@
})
.catch(error => {
// Hide loading indicator on error
loadingIndicator.style.display = "none";
loadingIndicator.classList.add("hidden");
console.error("Erreur lors du chargement des événements:", error);
alert("Erreur lors du chargement des événements: " + error.message);
@@ -429,6 +475,17 @@
subgroupSelect.innerHTML = '<option value="">Tous</option>';
}
function extractSubgroup(name) {
// Extract subgroup from name by removing opponent part after " - "
if (!name) return "";
const parts = name.split(" - ");
if (parts.length > 1) {
parts.pop(); // Remove opponent
return parts.join(" - ");
}
return name;
}
function updateSubgroupOptions(selectedAgegroup, events) {
// Reset subgroup options
subgroupSelect.innerHTML = '<option value="">Tous</option>';
@@ -447,10 +504,9 @@
events
.filter(event => event.agegroup === selectedAgegroup)
.forEach(event => {
// Extract subgroup from event.name or event.title
// This assumes the subgroup is part of the name field
// Extract subgroup from event.name by removing opponent
if (event.name && event.name !== selectedAgegroup) {
subgroups.add(event.name);
subgroups.add(extractSubgroup(event.name));
}
});
@@ -475,30 +531,63 @@
if (selectedAgegroup !== "" && event.agegroup !== selectedAgegroup) return false;
// Filter by subgroup
if (selectedSubgroup !== "" && event.name !== selectedSubgroup) return false;
let eventSubgroup = extractSubgroup(event.name);
if (selectedSubgroup !== "" && eventSubgroup !== selectedSubgroup) return false;
return true;
});
if (filteredEvents.length === 0) {
eventList.innerHTML = "<p class='text-muted'>Aucun événement 'Jeu' trouvé.</p>";
eventList.innerHTML = "<p class='text-gray-500 text-center py-8'>Aucun événement 'Jeu' trouvé.</p>";
return;
}
filteredEvents.forEach(event => {
const eventCard = document.createElement("div");
eventCard.className = "col-md-4 mb-3";
eventCard.innerHTML = `
<div class="card" style="border-left: 5px solid ${event.color}" data-id="${event.id_event}">
<div class="card-body">
<h5 class="card-title">${event.agegroup} - ${event.name}</h5>
<p class="card-text">${event.title}</p>
<p class="card-text"><strong>Adversaire:</strong> ${event.opponent}</p>
<p class="card-text"><strong>Lieu:</strong> ${event.place}</p>
<p class="card-text"><strong>Heure:</strong> ${event.start} - ${event.end}</p>
</div>
</div>
`;
eventCard.className = "bg-white rounded-2xl shadow-lg overflow-hidden transform hover:scale-105 transition-all duration-300 cursor-pointer";
const card = document.createElement("div");
card.className = "p-6 border-l-4";
card.style.borderColor = event.color || "#818cf8";
const inner = document.createElement("div");
inner.className = "flex justify-between items-start";
const innerDiv = document.createElement("div");
const h3 = document.createElement("h3");
h3.className = "text-xl font-bold text-gray-800";
h3.textContent = event.agegroup + " - " + event.name;
const subtitle = document.createElement("p");
subtitle.className = "text-gray-600 mt-1";
subtitle.textContent = event.title;
innerDiv.appendChild(h3);
innerDiv.appendChild(subtitle);
inner.appendChild(innerDiv);
card.appendChild(inner);
const detailsDiv = document.createElement("div");
detailsDiv.className = "mt-4 space-y-2";
const p1 = document.createElement("p");
p1.className = "flex items-center text-gray-700";
p1.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg><strong>Adversaire:</strong> ';
p1.appendChild(document.createTextNode(event.opponent));
detailsDiv.appendChild(p1);
const p2 = document.createElement("p");
p2.className = "flex items-center text-gray-700";
p2.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg><strong>Lieu:</strong> ';
p2.appendChild(document.createTextNode(event.place));
detailsDiv.appendChild(p2);
const p3 = document.createElement("p");
p3.className = "flex items-center text-gray-700";
p3.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg><strong>Heure:</strong> ';
p3.appendChild(document.createTextNode(event.start + " - " + event.end));
detailsDiv.appendChild(p3);
card.appendChild(detailsDiv);
eventCard.appendChild(card);
eventCard.addEventListener("click", () => fetchEventDetails(event.id_event));
eventList.appendChild(eventCard);
});
@@ -511,10 +600,10 @@
})
.then(response => response.json())
.then(data => {
const sortedPlayers = data.convocation.available
const availablePlayers = data.convocation.available || [];
const sortedPlayers = availablePlayers
.sort((a, b) => (a.number || 0) - (b.number || 0));
// Calculate player statistics
const totalPlayers = sortedPlayers.length;
const positionCount = {};
sortedPlayers.forEach(player => {
@@ -522,41 +611,160 @@
positionCount[position] = (positionCount[position] || 0) + 1;
});
// Generate position breakdown
const positionBreakdown = Object.entries(positionCount)
.map(([position, count]) => `${position}: ${count}`)
.join(', ');
.map(([position, count]) => position + ": " + count)
.join(", ");
// Sort players by position first, then by number
const playersByPosition = [...sortedPlayers].sort((a, b) => {
// Sort by position first
const positionA = a.position || "ZZZ"; // Put undefined positions at the end
const positionA = a.position || "ZZZ";
const positionB = b.position || "ZZZ";
if (positionA !== positionB) {
return positionA.localeCompare(positionB);
}
// If positions are the same, sort by number
const numA = parseInt(a.number) || 0;
const numB = parseInt(b.number) || 0;
return numA - numB;
});
eventDetailsContent.innerHTML = `
<h5>${data.title}</h5>
<p><strong>Type:</strong> ${data.type}</p>
<p><strong>Lieu:</strong> ${data.place}</p>
<p><strong>Heure:</strong> ${data.time_start} - ${data.time_end}</p>
<p><strong>Joueurs convoqués:</strong> ${totalPlayers} joueur${totalPlayers > 1 ? 's' : ''} (${positionBreakdown})</p>
<h6>Liste des joueurs:</h6>
<ul>${playersByPosition.map(player => {
let number = player.number ? player.number : "N/A";
let position = player.position ? player.position : "N/A";
return `<li>[${position}] ${number} - ${player.fname} ${player.lname} (${player.dob})</li>`;
}).join('')}</ul>
`;
new bootstrap.Modal(document.getElementById('eventDetailsModal')).show();
const staffList = data.convocation.staff || [];
const totalStaff = staffList.length;
eventDetailsContent.innerHTML = "";
if (totalPlayers === 0 && totalStaff === 0) {
const wrapper = document.createElement("div");
wrapper.className = "rounded-xl border border-amber-300 bg-amber-50 p-6";
const centerDiv = document.createElement("div");
centerDiv.className = "text-center";
const h5 = document.createElement("h5");
h5.className = "text-2xl font-bold text-gray-800 mb-4";
h5.textContent = data.title;
centerDiv.appendChild(h5);
const infoDiv = document.createElement("div");
infoDiv.className = "space-y-2 text-gray-700 mb-6";
const pType = document.createElement("p");
pType.innerHTML = "<strong>Type:</strong> ";
pType.appendChild(document.createTextNode(data.type));
infoDiv.appendChild(pType);
const pLieu = document.createElement("p");
pLieu.innerHTML = "<strong>Lieu:</strong> ";
pLieu.appendChild(document.createTextNode(data.place));
infoDiv.appendChild(pLieu);
const pHeure = document.createElement("p");
pHeure.innerHTML = "<strong>Heure:</strong> ";
pHeure.appendChild(document.createTextNode(data.time_start + " - " + data.time_end));
infoDiv.appendChild(pHeure);
centerDiv.appendChild(infoDiv);
const msgDiv = document.createElement("div");
msgDiv.className = "bg-amber-100 border border-amber-300 text-amber-800 px-4 py-3 rounded-lg";
const msgH6 = document.createElement("h6");
msgH6.className = "font-bold text-lg mb-1";
msgH6.textContent = "Aucun joueur ni personnel convoqué";
msgDiv.appendChild(msgH6);
const msgP = document.createElement("p");
msgP.textContent = "Il n'y a actuellement aucun joueur ni personnel convoqué pour ce match.";
msgDiv.appendChild(msgP);
centerDiv.appendChild(msgDiv);
wrapper.appendChild(centerDiv);
eventDetailsContent.appendChild(wrapper);
} else {
const container = document.createElement("div");
const h5 = document.createElement("h5");
h5.className = "text-2xl font-bold text-gray-800 mb-4";
h5.textContent = data.title;
container.appendChild(h5);
const infoDiv = document.createElement("div");
infoDiv.className = "space-y-2 text-gray-700 mb-6";
const pType = document.createElement("p");
pType.innerHTML = "<strong>Type:</strong> ";
pType.appendChild(document.createTextNode(data.type));
infoDiv.appendChild(pType);
const pLieu = document.createElement("p");
pLieu.innerHTML = "<strong>Lieu:</strong> ";
pLieu.appendChild(document.createTextNode(data.place));
infoDiv.appendChild(pLieu);
const pHeure = document.createElement("p");
pHeure.innerHTML = "<strong>Heure:</strong> ";
pHeure.appendChild(document.createTextNode(data.time_start + " - " + data.time_end));
infoDiv.appendChild(pHeure);
container.appendChild(infoDiv);
if (totalPlayers === 0) {
const noPlayersDiv = document.createElement("div");
noPlayersDiv.className = "bg-amber-100 border border-amber-300 text-amber-800 px-4 py-3 rounded-lg";
const noPlayersH6 = document.createElement("h6");
noPlayersH6.className = "font-bold mb-1";
noPlayersH6.textContent = "Aucun joueur convoqué";
noPlayersDiv.appendChild(noPlayersH6);
const noPlayersP = document.createElement("p");
noPlayersP.textContent = "Il n'y a actuellement aucun joueur convoqué pour ce match.";
noPlayersDiv.appendChild(noPlayersP);
container.appendChild(noPlayersDiv);
} else {
const playersP = document.createElement("p");
playersP.innerHTML = "<strong>Joueurs convoqués:</strong> ";
playersP.appendChild(document.createTextNode(totalPlayers + " joueur" + (totalPlayers > 1 ? "s" : "") + " (" + positionBreakdown + ")"));
infoDiv.appendChild(playersP);
const playersDiv = document.createElement("div");
const playersH6 = document.createElement("h6");
playersH6.className = "text-xl font-semibold text-gray-800 mb-3";
playersH6.textContent = "Liste des joueurs:";
playersDiv.appendChild(playersH6);
const playersUl = document.createElement("ul");
playersUl.className = "grid grid-cols-1 md:grid-cols-2 gap-3";
playersByPosition.forEach(player => {
const li = document.createElement("li");
li.className = "bg-indigo-50 rounded-lg p-3";
const number = player.number ? player.number : "N/A";
const position = player.position ? player.position : "N/A";
li.textContent = "[" + position + "] " + number + " - " + player.fname + " " + player.lname + " (" + player.dob + ")";
playersUl.appendChild(li);
});
playersDiv.appendChild(playersUl);
container.appendChild(playersDiv);
}
if (totalStaff > 0) {
const staffDiv = document.createElement("div");
staffDiv.className = "mt-6";
const staffH6 = document.createElement("h6");
staffH6.className = "text-xl font-semibold text-gray-800 mb-3";
staffH6.textContent = "Personnel (" + totalStaff + "):";
staffDiv.appendChild(staffH6);
const staffUl = document.createElement("ul");
staffUl.className = "space-y-2";
staffList.forEach(staff => {
const li = document.createElement("li");
li.className = "flex items-center";
const roleSpan = document.createElement("span");
roleSpan.className = "font-medium w-32";
roleSpan.textContent = staff.role + ":";
li.appendChild(roleSpan);
li.appendChild(document.createTextNode(" " + staff.fname + " " + staff.lname));
staffUl.appendChild(li);
});
staffDiv.appendChild(staffUl);
container.appendChild(staffDiv);
} else {
const noStaffDiv = document.createElement("div");
noStaffDiv.className = "mt-6 bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-lg";
const noStaffH6 = document.createElement("h6");
noStaffH6.className = "font-bold mb-1";
noStaffH6.textContent = "Aucun personnel convoqué";
noStaffDiv.appendChild(noStaffH6);
const noStaffP = document.createElement("p");
noStaffP.textContent = "Il n'y a actuellement aucun personnel convoqué pour ce match.";
noStaffDiv.appendChild(noStaffP);
container.appendChild(noStaffDiv);
}
eventDetailsContent.appendChild(container);
}
eventDetailsModal.classList.remove("hidden");
})
.catch(error => console.error("Erreur lors du chargement des détails de l'événement:", error));
}
+119 -39
View File
@@ -13,8 +13,8 @@ import tempfile
from enum import Enum
from pathlib import Path
from typing import Annotated
from typing import List, Tuple
import PyPDF2
import pypdf
import requests
import typer
from rich import print
@@ -112,8 +112,8 @@ def sanitize_json_response(text):
app = typer.Typer(no_args_is_help=True)
session: requests.Session
userid: int
session = requests.Session()
userid: int = 0
global_config_section: str = "default"
@@ -140,14 +140,17 @@ class AgeGroup(str, Enum):
u13a = "U13 (A)"
u13p = "U13 (Prép)"
u14ev = "U14 (Elite Vernets)"
u14sae = "U14 (Groupe SAE)"
u14esmv = "U14 (Elite Sous-Moulin/Vergers)"
u15t = "U15 (Top)"
u15a = "U15 (A)"
u16e = "U16 (Elite)"
u16e = "U16 (Elit)"
u16t = "U16 (Top GS Ass)"
u18e = "U18 (Elite)"
u18el = "U18 (Elit)"
u21e = "U21 (ELIT)"
u18e = "U18 (Elit)"
u21e = "U21 (Elit)"
u14t = "U14 (Top)"
u14t1 = "U14 (Top1)"
u14t2 = "U14 (Top2)"
def normalize_age_group(value: str) -> AgeGroup | None:
@@ -242,7 +245,6 @@ def get_login(
def select_club(club_id: int = 172):
"""Select a club by ID after login."""
global session
r = session.get(
f"https://app.myice.hockey/?cl={club_id}", headers={"User-Agent": user_agent}
)
@@ -250,14 +252,12 @@ def select_club(club_id: int = 172):
def do_login(config_section: str | None = None):
global session
global userid
global global_config_section
# Use provided config_section, or fall back to global one
section = config_section if config_section is not None else global_config_section
username, password, userid_tmp, token, club_id = get_login(config_section=section)
username, password, userid_tmp, _token, club_id = get_login(config_section=section)
if userid_tmp is not None:
userid = userid_tmp
r = session.get("https://app.myice.hockey/", headers={"User-Agent": user_agent})
@@ -284,7 +284,6 @@ def do_login(config_section: str | None = None):
def get_userid():
global session
r = session.get(
"https://app.myice.hockey/players/clubschedule/",
headers={
@@ -303,7 +302,7 @@ def get_userid():
def wrapper_session(func):
def wrapper(*args, **kwargs):
global session, userid, global_config_section
global session, userid
# Use the global config_section
config_section = global_config_section
@@ -327,11 +326,9 @@ def wrapper_session(func):
@wrapper_session
def get_schedule(num_days: int) -> str:
global session
global userid
assert session and userid
now = datetime.datetime.now()
date_start = now
date_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
date_end = date_start + datetime.timedelta(days=num_days)
r = session.post(
"https://app.myice.hockey/inc/processclubplanning.php",
@@ -358,7 +355,6 @@ def get_schedule(num_days: int) -> str:
@wrapper_session
def game_pdf(gameid: int, outfile: Path):
global session, userid
assert session and userid
r = session.get(
f"https://app.myice.hockey/pdfexports/playerplatgamepdfgenerator.php?id={gameid}",
@@ -375,7 +371,6 @@ def game_pdf(gameid: int, outfile: Path):
@wrapper_session
def practice_pdf(gameid: int, outfile: Path):
global session, userid
assert session and userid
r = session.get(
f"https://app.myice.hockey/pdfexports/playerplatpracticepdf.php?practice={gameid}",
@@ -403,7 +398,6 @@ def schedule(
"""
Fetch schedule as json
"""
global global_config_section
schedule = get_schedule(num_days)
if outfile:
with outfile.open("w") as f:
@@ -415,27 +409,31 @@ def schedule(
def os_open(file: str) -> None:
import subprocess
if os.uname().sysname == "Linux":
os.system(f"xdg-open {file}")
subprocess.run(["xdg-open", file])
else:
print(f"Opening file {file}", file=sys.stderr)
os.system(f"open {file}")
subprocess.run(["open", file])
def extract_players(pdf_file: Path) -> List[str]:
reader = PyPDF2.PdfReader(pdf_file)
def extract_players(pdf_file: Path) -> list[str]:
reader = pypdf.PdfReader(pdf_file)
page = reader.pages[0]
players = []
def visitor_body(text, cm, tm, fontDict, fontSize):
global last_text
if text:
last_text = text
(x, y) = (tm[4], tm[5])
# print(tm, text)
if x > 79 and x < 80 and y < 741.93:
# and y < 741.93 and y > 741.93 - 585.18:
players.append(text)
players.append(last_text.strip())
page.extract_text(visitor_text=visitor_body)
page.extract_text(visitor_text=visitor_body, extraction_mode="plain")
return players
@@ -447,7 +445,6 @@ def get_game_pdf(
"""
Genate the pdf for the game invitation
"""
global global_config_section
if open_file:
output_filename = f"game_{game_id}.pdf"
else:
@@ -468,7 +465,6 @@ def get_practice_pdf(
"""
Genate the pdf for the practice invitation
"""
global global_config_section
output_filename = f"practice_{game_id}.pdf"
practice_pdf(game_id, Path(output_filename))
os_open(output_filename)
@@ -488,7 +484,6 @@ def parse_schedule(
"""
Parse schedule.json to look for specific games or practices
"""
global global_config_section
try:
with schedule_file.open("r") as f:
data = json.load(f)
@@ -538,7 +533,6 @@ def check_with_ai(
"""
Search through the schedule with natural language using Infomaniak LLM API
"""
global global_config_section
if not utils.init():
sys.exit(1)
with schedule_file.open("r") as f:
@@ -553,7 +547,7 @@ def check_with_ai(
system = "\n".join(
[
"Tu es une IA connaissant bien les données suivantes, qui décrivent les match et entraînements d'équipes de hockey sur glace.",
f"aujourd'hui, nous sommes le {when}"
f"aujourd'hui, nous sommes le {when}",
"attention: ce qu'il y a entre parenthèse après la catégorie est une catégorie à part entière, example, u13 a = U13 (A) et ça correspond aux agegroup",
"assure-toi de ne pas confondre les catégories d'age dans tes réponses. ne donne pas une réponse pour la mauvaise équipe",
"ne confond pas top, elite, a, prép",
@@ -562,7 +556,7 @@ def check_with_ai(
"```",
],
)
history: List[Tuple[str, str]] = []
history: list[tuple[str, str]] = []
while True:
try:
question = input("> ")
@@ -587,8 +581,19 @@ mobile_headers = {
}
# def curl_for_mobile_login(username: str, password: str) -> str:
# """Generate curl command to replicate mobile_login request."""
# import base64
# headers_str = " ".join([f"-H '{k}: {v}'" for k, v in mobile_headers.items()])
# encoded_username = base64.b64encode(username.encode()).decode()
# encoded_password = base64.b64encode(password.encode()).decode()
# return f"""curl -X POST "https://app.myice.hockey/api/mobilerest/login" \
# {headers_str} \
# -d 'login_email={encoded_username}&login_password={encoded_password}&language=FR&v=2'"""
def mobile_login(config_section: str | None = None):
global global_config_section
import base64
# Use provided config_section, or fall back to global one
@@ -599,10 +604,13 @@ def mobile_login(config_section: str | None = None):
return {"id": 0, "token": token, "id_club": club_id}
print("Requesting token", file=sys.stderr)
# Print curl equivalent for debugging
# print(curl_for_mobile_login(username, password), file=sys.stderr)
with requests.post(
"https://app.myice.hockey/api/mobilerest/login",
headers=mobile_headers,
data=f"login_email={base64.b64encode(username.encode()).decode()}&login_password={base64.b64encode(password.encode()).decode()}&language=FR&v=2",
timeout=(5, 30),
# verify=False,
) as r:
r.raise_for_status()
@@ -621,11 +629,26 @@ userdata = {
}
def refresh_data():
# def curl_for_refresh_data(token: str, club_id: int) -> str:
# """Generate curl command to replicate refresh_data request."""
# headers_str = " ".join([f"-H '{k}: {v}'" for k, v in mobile_headers.items()])
# return f"""curl -X POST "https://app.myice.hockey/api/mobilerest/refreshdata" \
# {headers_str} \
# -d 'token={token}&id_club={club_id}&language=FR'"""
def refresh_data(user_data=None):
# Use provided user_data or fall back to global userdata for backward compatibility
if user_data is None:
user_data = userdata
# Print curl equivalent for debugging
# print(
# curl_for_refresh_data(user_data["token"], user_data["id_club"]), file=sys.stderr
# )
with requests.post(
"https://app.myice.hockey/api/mobilerest/refreshdata",
headers=mobile_headers,
data=f"token={userdata['token']}&id_club={userdata['id_club']}&language=FR",
data=f"token={user_data['token']}&id_club={user_data['id_club']}&language=FR",
# verify=False,
) as r:
r.raise_for_status()
@@ -649,14 +672,14 @@ def refresh_data():
@app.command("mobile-login")
def do_mobile_login():
global userdata, global_config_section
global userdata
userdata = mobile_login(config_section=global_config_section)
print(json.dumps(userdata, indent=2))
@app.command("mobile")
def mobile():
global userdata, global_config_section
global userdata
userdata = mobile_login(config_section=global_config_section)
games = [x for x in refresh_data().get("club_games")]
# Use built-in print to avoid rich formatting issues
@@ -665,12 +688,22 @@ def mobile():
builtins.print(json.dumps(games, indent=2, ensure_ascii=False))
@app.command("mobile-all")
def mobile_all():
global userdata
userdata = mobile_login(config_section=global_config_section)
# Use built-in print to avoid rich formatting issues
import builtins
builtins.print(json.dumps(refresh_data(), indent=2, ensure_ascii=False))
@app.command("mobile-game")
def mobile_game(
game_id: Annotated[int, typer.Argument(help="game id")],
raw: Annotated[bool, typer.Option(help="display raw output")] = False,
):
global userdata, global_config_section
global userdata
userdata = mobile_login(config_section=global_config_section)
with requests.post(
@@ -688,6 +721,7 @@ def mobile_game(
),
# verify=False,
) as r:
try:
data = json.loads(sanitize_json_response(r.text))["eventData"]
players = data["convocation"]["available"]
@@ -707,6 +741,52 @@ def mobile_game(
print(
f"[{player['position']}] {player['fname']} {player['lname']} ({player['dob']})"
)
except KeyError:
print(sanitize_json_response(r.text))
@app.command("mobile-practice")
def mobile_practice(
game_id: Annotated[int, typer.Argument(help="game id")],
raw: Annotated[bool, typer.Option(help="display raw output")] = False,
):
global userdata
userdata = mobile_login(config_section=global_config_section)
with requests.post(
"https://app.myice.hockey/api/mobilerest/getevent",
headers=mobile_headers,
data="&".join(
[
f"token={userdata['token']}",
f"id_event={game_id}",
"type=practices",
f"id_player={userdata['id']}",
f"id_club={userdata['id_club']}",
"language=FR",
]
),
# verify=False,
) as r:
data = json.loads(sanitize_json_response(r.text))["eventData"]
players = data["convocation"]["available"]
if raw:
print(data)
print(
f"Game {game_id}: {data['title']} ({data['type']}, {data.get('is_away')}) at {data['time_start']}"
)
print(f"{len(players)} players")
for player in sorted(
players,
key=lambda x: (
x["position"] if x["position"] else "none",
x["dob"],
),
):
print(
f"[{player['position']}] {player['fname']} {player['lname']} ({player['dob']})"
)
if __name__ == "__main__":
+142 -72
View File
@@ -1,19 +1,24 @@
import base64
import hashlib
import logging
import requests
import os
from typing import Annotated
from fastapi import FastAPI, Header, HTTPException, status, Request
import requests
from fastapi import FastAPI, Header, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
from . import myice
# OIDC Configuration
CLIENT_ID = os.environ.get("CLIENT_ID", "8ea04fbb-4237-4b1d-a895-0b3575a3af3f")
CLIENT_SECRET = os.environ.get(
"CLIENT_SECRET", "REDACTED_CLIENT_SECRET"
)
CLIENT_SECRET = os.environ.get("CLIENT_SECRET")
if not CLIENT_SECRET:
raise RuntimeError("CLIENT_SECRET environment variable must be set")
REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:8000/callback")
SECRET_KEY = os.environ.get("SECRET_KEY", "change_me_in_production")
AUTHORIZATION_ENDPOINT = "https://login.infomaniak.com/authorize"
TOKEN_ENDPOINT = "https://login.infomaniak.com/token"
USERINFO_ENDPOINT = "https://login.infomaniak.com/oauth2/userinfo"
@@ -26,16 +31,20 @@ ALLOWED_USERS = (
else []
)
origins = ["*"]
origins = (
os.environ.get("ALLOWED_ORIGINS", "").split(",")
if os.environ.get("ALLOWED_ORIGINS")
else []
)
logger = logging.getLogger(__name__)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
)
block_endpoints = ["/health"]
@@ -43,10 +52,9 @@ block_endpoints = ["/health"]
class LogFilter(logging.Filter):
def filter(self, record):
if record.args and len(record.args) >= 3:
if record.args[2] in block_endpoints:
return False
return True
return not (
record.args and len(record.args) >= 3 and record.args[2] in block_endpoints
)
uvicorn_logger = logging.getLogger("uvicorn.access")
@@ -67,13 +75,8 @@ class AuthHeaders(BaseModel):
if not token:
return False
# First check if it's a valid OIDC token
if self.validate_oidc_token():
return True
# Fallback to the old static key for compatibility
if token == "abc":
return True
return False
# No static key fallback; only valid OIDC tokens are accepted
return self.validate_oidc_token()
def validate_oidc_token(self) -> bool:
try:
@@ -81,39 +84,39 @@ class AuthHeaders(BaseModel):
if not token:
return False
# Basic validation - in production, you would validate the JWT signature
# and check issuer, audience, expiration, etc.
import base64
import json
# Try JWT validation first (for id_token-style tokens)
if token.count(".") == 2:
import jwt
from jwt import PyJWKClient
# Decode JWT header and payload (without verification for simplicity in this example)
parts = token.split(".")
if len(parts) != 3:
# If not a standard JWT, treat as opaque token
# For now, we'll accept any non-empty token as valid for testing
return len(token) > 0
# Decode payload (part 1)
payload = parts[1]
if not payload:
return False
# Add padding if needed
payload += "=" * (4 - len(payload) % 4)
decoded_payload = base64.urlsafe_b64decode(payload)
payload_data = json.loads(decoded_payload)
# Check if user is in allowed list
jwks_client = PyJWKClient(JWKS_URI)
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload_data = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=CLIENT_ID,
issuer="https://login.infomaniak.com/",
)
user_email = payload_data.get("email")
if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS:
return not (
ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS
)
# Fallback: validate opaque tokens by calling userinfo endpoint
userinfo_response = requests.get(
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {token}"}
)
if userinfo_response.status_code != 200:
return False
return True
except Exception as e:
print(f"Token validation error: {e}")
# Even if we can't validate the token, if it exists, we'll accept it for now
# This is for development/testing purposes only
return len(self.token()) > 0
userinfo = userinfo_response.json()
user_email = userinfo.get("email")
return not (
ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS
)
except Exception:
return False
class HealthCheck(BaseModel):
@@ -136,6 +139,11 @@ def get_health() -> HealthCheck:
return HealthCheck(status="OK")
@app.get("/style.css")
async def style_css():
return FileResponse("style.css")
@app.get("/favicon.ico")
async def favico():
return FileResponse("favicon.ico")
@@ -144,13 +152,19 @@ async def favico():
@app.get("/login")
async def login(request: Request):
"""Redirect to Infomaniak OIDC login"""
import secrets
import urllib.parse
state = "random_state_string" # In production, use a proper random state
nonce = "random_nonce_string" # In production, use a proper random nonce
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
# Store state and nonce in session (simplified for this example)
# In production, use proper session management
# Generate PKCE parameters for public clients / Infomaniak requirement
code_verifier = secrets.token_urlsafe(32)
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
.decode("ascii")
.rstrip("=")
)
auth_url = (
f"{AUTHORIZATION_ENDPOINT}"
@@ -160,14 +174,41 @@ async def login(request: Request):
f"&scope=openid email profile"
f"&state={state}"
f"&nonce={nonce}"
f"&code_challenge={code_challenge}"
f"&code_challenge_method=S256"
)
return RedirectResponse(auth_url)
response = RedirectResponse(auth_url)
# Store state and nonce in secure http-only cookies for validation on callback
response.set_cookie(
"oidc_state", state, httponly=True, secure=True, samesite="lax", max_age=300
)
response.set_cookie(
"oidc_nonce", nonce, httponly=True, secure=True, samesite="lax", max_age=300
)
response.set_cookie(
"oidc_verifier",
code_verifier,
httponly=True,
secure=True,
samesite="lax",
max_age=300,
)
return response
@app.get("/callback")
async def callback(code: str, state: str):
async def callback(request: Request, code: str, state: str):
"""Handle OIDC callback and exchange code for token"""
expected_state = request.cookies.get("oidc_state")
code_verifier = request.cookies.get("oidc_verifier")
# We should also delete state/nonce cookies once read
if not expected_state or state != expected_state:
response = RedirectResponse("/?error=Invalid state parameter")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
return response
# Exchange authorization code for access token
token_data = {
"grant_type": "authorization_code",
@@ -175,18 +216,24 @@ async def callback(code: str, state: str):
"client_secret": CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier,
}
response = requests.post(TOKEN_ENDPOINT, data=token_data)
token_response = response.json()
token_response = requests.post(TOKEN_ENDPOINT, data=token_data).json()
if "access_token" not in token_response:
# Log the error for debugging
logger.error("Token exchange failed: %s", token_response)
# Redirect back to frontend with error
frontend_url = os.environ.get("FRONTEND_URL", "/")
error_detail = token_response.get(
"error_description", "Failed to obtain access token"
)
return RedirectResponse(f"{frontend_url}?error={error_detail}")
error_response = RedirectResponse(f"{frontend_url}?error={error_detail}")
error_response.delete_cookie("oidc_state")
error_response.delete_cookie("oidc_nonce")
error_response.delete_cookie("oidc_verifier")
return error_response
access_token = token_response["access_token"]
@@ -202,16 +249,32 @@ async def callback(code: str, state: str):
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
# Redirect back to frontend with error
frontend_url = os.environ.get("FRONTEND_URL", "/")
return RedirectResponse(f"{frontend_url}?error=User not authorized")
response = RedirectResponse(f"{frontend_url}?error=User not authorized")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
return response
# Redirect back to frontend with access token and user info
frontend_url = os.environ.get("FRONTEND_URL", "/")
return RedirectResponse(f"{frontend_url}#access_token={access_token}")
response = RedirectResponse(f"{frontend_url}#access_token={access_token}")
response.delete_cookie("oidc_state")
response.delete_cookie("oidc_nonce")
response.delete_cookie("oidc_verifier")
return response
@app.get("/exchange-token")
async def exchange_token(code: str):
async def exchange_token(
request: Request,
code: str,
headers: Annotated[AuthHeaders, Header()],
):
"""Exchange authorization code for access token"""
if not headers.authorized():
raise HTTPException(401, detail="get out")
code_verifier = request.cookies.get("oidc_verifier")
# Exchange authorization code for access token
token_data = {
"grant_type": "authorization_code",
@@ -219,6 +282,7 @@ async def exchange_token(code: str):
"client_secret": CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier,
}
response = requests.post(TOKEN_ENDPOINT, data=token_data)
@@ -254,6 +318,9 @@ async def exchange_token(code: str):
@app.get("/userinfo")
async def userinfo(headers: Annotated[AuthHeaders, Header()]):
"""Get user info from access token"""
if not headers.authorized():
raise HTTPException(401, detail="get out")
access_token = headers.token()
userinfo_response = requests.get(
@@ -272,26 +339,26 @@ async def schedule(
raise HTTPException(401, detail="get out")
try:
username, password, userid, existing_token, club_id = myice.get_login(
_username, _password, userid, existing_token, club_id = myice.get_login(
config_section=account
)
except Exception as e:
raise HTTPException(
400,
detail=f"Configuration error: {str(e)}. Available accounts: isaac, leonard",
detail=f"Configuration error: {e!s}. Available accounts: isaac, leonard",
)
try:
if existing_token:
myice.userdata = {
user_data = {
"id": userid,
"id_club": club_id or 186,
"token": existing_token,
}
else:
myice.userdata = myice.mobile_login(config_section=account)
user_data = myice.mobile_login(config_section=account)
data = myice.refresh_data()
data = myice.refresh_data(user_data=user_data)
if "club_games" in data:
return data["club_games"]
else:
@@ -357,17 +424,20 @@ async def game(
game_id: int,
account: str = "default",
):
username, password, userid, existing_token, club_id = myice.get_login(
if not headers.authorized():
raise HTTPException(401, detail="get out")
_username, _password, userid, existing_token, club_id = myice.get_login(
config_section=account
)
if existing_token:
myice.userdata = {
user_data = {
"id": userid,
"id_club": club_id or 186,
"token": existing_token,
}
else:
myice.userdata = myice.mobile_login(config_section=account)
user_data = myice.mobile_login(config_section=account)
# data = refresh_data()
with requests.post(
@@ -375,11 +445,11 @@ async def game(
headers=myice.mobile_headers,
data="&".join(
[
f"token={myice.userdata['token']}",
f"token={user_data['token']}",
f"id_event={game_id}",
"type=games",
f"id_player={myice.userdata['id']}",
f"id_club={myice.userdata['id_club']}",
f"id_player={user_data['id']}",
f"id_club={user_data['id_club']}",
"language=FR",
]
),
Generated
+2020 -1474
View File
File diff suppressed because it is too large. Load diff
+19 -10
View File
@@ -1,29 +1,29 @@
[project]
name = "myice"
version = "v0.5.6"
version = "v0.6.1"
description = "myice parsing"
authors = [
{ name = "Rene Luria", "email" = "<rene@luria.ch>"},
]
license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
requires-python = ">=3.13"
dependencies = [
"requests (>=2.32.3,<2.33.0)",
"typer (>=0.15.1,<0.16.0)",
"pypdf2 (>=3.0.1)",
"rl-ai-tools >=1.9.0",
"fastapi[standard] (>=0.115.11,<0.116.0)",
"requests (>=2.34.2)",
"typer (>=0.27.2)",
"pypdf (>=6.19.0)",
"rl-ai-tools >=1.17.3",
"fastapi[standard] (>=0.141.1)",
"pyjwt[crypto] (>=2.14.0)",
]
[tool.poetry.dependencies]
rl-ai-tools = { source = "infomaniak" }
[tool.poetry.group.dev.dependencies]
ipykernel = "^6.29.5"
types-requests = "^2.32.0.20241016"
mypy = "^1.15.0"
types-requests = "^2.33.0.20260906"
mypy = "^2.3.1"
[[tool.poetry.source]]
@@ -31,6 +31,15 @@ name = "infomaniak"
url = "https://pypi.purple.infomaniak.ch"
priority = "supplemental"
[tool.ruff.lint]
ignore = [
"ASYNC210",
"BLE001",
"DTZ005",
"PLW1510",
"SIM115",
]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
+55
View File
@@ -0,0 +1,55 @@
--extra-index-url https://pypi.purple.infomaniak.ch
agent-detector==2.0.0 ; python_version >= "3.13"
annotated-doc==0.0.5 ; python_version >= "3.13"
annotated-types==0.8.0 ; python_version >= "3.13"
anyio==4.15.1 ; python_version >= "3.13"
certifi==2026.7.22 ; python_version >= "3.13"
cffi==2.1.1 ; python_version >= "3.13" and platform_python_implementation != "PyPy"
charset-normalizer==3.5.1 ; python_version >= "3.13"
click==8.5.0 ; python_version >= "3.13"
colorama==0.4.6 ; python_version >= "3.13" and platform_system == "Windows"
cryptography==50.0.1 ; python_version >= "3.13"
detect-installer==0.2.1 ; python_version >= "3.13"
dnspython==2.8.0 ; python_version >= "3.13"
email-validator==2.3.0 ; python_version >= "3.13"
fastapi-cli==0.0.32 ; python_version >= "3.13"
fastapi-cloud-cli==0.26.0 ; python_version >= "3.13"
fastapi==0.141.1 ; python_version >= "3.13"
fastar==0.12.0 ; python_version >= "3.13"
h11==0.16.0 ; python_version >= "3.13"
httpcore==1.0.9 ; python_version >= "3.13"
httptools==0.8.0 ; python_version >= "3.13"
httpx==0.28.1 ; python_version >= "3.13"
idna==3.19 ; python_version >= "3.13"
jinja2==3.1.6 ; python_version >= "3.13"
markdown-it-py==4.2.0 ; python_version >= "3.13"
markupsafe==3.0.3 ; python_version >= "3.13"
mdurl==0.1.2 ; python_version >= "3.13"
pycparser==3.0 ; implementation_name != "PyPy" and platform_python_implementation != "PyPy" and python_version >= "3.13"
pydantic-core==2.46.5 ; python_version >= "3.13"
pydantic-extra-types==2.11.1 ; python_version >= "3.13"
pydantic-settings==2.15.0 ; python_version >= "3.13"
pydantic==2.13.5 ; python_version >= "3.13"
pygments==2.21.0 ; python_version >= "3.13"
pyjwt==2.14.0 ; python_version >= "3.13"
pypdf==6.19.0 ; python_version >= "3.13"
python-dotenv==1.2.3 ; python_version >= "3.13"
python-multipart==0.0.32 ; python_version >= "3.13"
pyyaml==6.0.3 ; python_version >= "3.13"
requests==2.34.2 ; python_version >= "3.13"
rich-toolkit==0.20.5 ; python_version >= "3.13"
rich==15.0.0 ; python_version >= "3.13"
rignore==0.8.1 ; python_version >= "3.13"
rl-ai-tools==1.17.3 ; python_version >= "3.13"
sentry-sdk==2.69.2 ; python_version >= "3.13"
shellingham==1.5.4 ; python_version >= "3.13"
starlette==1.6.0 ; python_version >= "3.13"
typer==0.27.2 ; python_version >= "3.13"
typing-extensions==4.16.0 ; python_version >= "3.13"
typing-inspection==0.4.4 ; python_version >= "3.13"
urllib3==2.8.0 ; python_version >= "3.13"
uvicorn==0.53.0 ; python_version >= "3.13"
uvloop==0.22.1 ; platform_python_implementation != "PyPy" and sys_platform != "win32" and sys_platform != "cygwin" and python_version >= "3.13"
watchfiles==1.2.0 ; python_version >= "3.13"
websockets==17.1 ; python_version >= "3.13"
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html"],
theme: {
extend: {},
},
plugins: [],
}
Generated
+1317
View File
File diff suppressed because it is too large. Load diff