11 Commits

Author SHA1 Message Date
herel c21afdebc0 chore: use python image 3.13-slim instead of 3.13-alpine 2025-10-01 16:22:48 +02:00
herel 11d9aa0290 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 33d3dee358 chore: update fastapi and dependencies to latest versions 2025-09-30 08:37:56 +02:00
herel 8ae1c33b3a 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 ce42f489bf refactor: migrate to distroless multi-stage Docker build 2025-09-29 22:07:11 +02:00
herel e7615de98b feat: display staff information and improve empty state handling in game details modal 2025-09-29 20:17:23 +02:00
herel 394d71f59c doc: fix markdownlint issues in AGENTS.md 2025-09-23 09:33:44 +02:00
herel b016d58d84 chore: bump version to v0.5.7 2025-08-20 11:10:20 +02:00
herel 6232e91925 feat: layout filters horizontally 2025-08-20 11:10:06 +02:00
herel 7ce4fbd756 chore: bump version to v0.5.6 2025-08-20 10:17:08 +02:00
herel bb62acfc7f feat: add secondary agegroup selector for subgroup filtering
- Added subgroup dropdown selector to filter events by team within agegroup
- Implemented logic to populate subgroup options based on selected agegroup
- Updated event filtering to support both agegroup and subgroup criteria
- Added event listeners for real-time filtering when selectors change
2025-08-20 10:17:00 +02:00
10 changed files with 1501 additions and 944 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ repos:
- 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
hooks:
+31 -3
View File
@@ -26,11 +26,22 @@ yamllint . # YAML linting
markdownlint . # Markdown linting
```
### Testing
### Running Tests
```bash
# Run tests (no specific test framework configured)
# No formal test framework configured
# Project uses manual testing with example PDF files in repository
# To test individual functions, run the CLI commands directly:
# myice schedule --days 7
# myice mobile-login
# myice search --help
```
### Running the Web API
```bash
# Or with poetry
poetry run fastapi run myice/webapi.py --host 127.0.0.1
```
## Code Style Guidelines
@@ -73,5 +84,22 @@ 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
+28 -10
View File
@@ -1,20 +1,38 @@
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
# poetry export -f requirements.txt --output requirements.txt --without-hashes
# Copy dependency files
COPY requirements.txt ./
RUN curl -sSL https://install.python-poetry.org | python3 -
# Install dependencies to a target directory
RUN pip install --no-cache-dir --no-deps --disable-pip-version-check --target=/app/site-packages -r requirements.txt
ENV PATH=/var/www/.local/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Use Alpine as the base image for a much smaller footprint
FROM python:3.13-slim
COPY README.md pyproject.toml poetry.lock docker-entrypoint.sh index.html favicon.ico /var/www/
COPY myice /var/www/myice
# Copy installed packages from builder stage
COPY --from=builder /app/site-packages /app/site-packages
WORKDIR /var/www
# Copy application code
COPY index.html favicon.ico /app/
COPY myice /app/myice
RUN poetry install && . $(poetry env info -p)
# Set PYTHONPATH so Python can find our installed packages
ENV PYTHONPATH=/app/site-packages
# Set working directory
WORKDIR /app
# Create a non-root user for security
RUN useradd --home-dir /app --no-create-home --uid 1000 myice
USER 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"]
+23 -9
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
@@ -80,9 +84,11 @@ 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
@@ -90,8 +96,8 @@ To configure OIDC authentication, set the following environment variables:
- `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 +109,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 +205,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:
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
exec /var/www/.local/bin/poetry run fastapi run myice/webapi.py
+151 -27
View File
@@ -32,16 +32,28 @@
<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">
<option value="default">Défaut</option>
</select>
<label for="agegroup" class="form-label">Âge</label>
<select id="agegroup" class="form-select">
<option value="">Tous</option>
</select>
<button id="fetchEvents" class="btn btn-primary" style="margin-top: 1.2rem;">Charger</button>
<div class="mb-3 row">
<div class="col-md-3">
<label for="account" class="form-label">Compte</label>
<select id="account" class="form-select">
<option value="default">Défaut</option>
</select>
</div>
<div class="col-md-3">
<label for="agegroup" class="form-label">Âge</label>
<select id="agegroup" class="form-select">
<option value="">Tous</option>
</select>
</div>
<div class="col-md-3">
<label for="subgroup" class="form-label">Sous-groupe</label>
<select id="subgroup" class="form-select">
<option value="">Tous</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-end">
<button id="fetchEvents" class="btn btn-primary">Charger</button>
</div>
</div>
</div>
@@ -77,6 +89,7 @@
const eventFilters = document.getElementById("eventFilters");
const accountSelect = document.getElementById("account");
const agegroupSelect = document.getElementById("agegroup");
const subgroupSelect = document.getElementById("subgroup");
const eventList = document.getElementById("eventList");
const fetchButton = document.getElementById("fetchEvents");
const eventDetailsContent = document.getElementById("eventDetailsContent");
@@ -335,6 +348,12 @@
});
agegroupSelect.addEventListener("change", () => {
// Update subgroup options based on selected agegroup
updateSubgroupOptions(agegroupSelect.value, lastFetchedEvents);
displayEvents(lastFetchedEvents);
});
subgroupSelect.addEventListener("change", () => {
displayEvents(lastFetchedEvents);
});
@@ -413,12 +432,61 @@
option.textContent = group;
agegroupSelect.appendChild(option);
});
// Reset subgroup selector
subgroupSelect.innerHTML = '<option value="">Tous</option>';
}
function updateSubgroupOptions(selectedAgegroup, events) {
// Reset subgroup options
subgroupSelect.innerHTML = '<option value="">Tous</option>';
if (selectedAgegroup === "") {
// If no agegroup is selected, disable subgroup selector
subgroupSelect.disabled = true;
return;
}
// Enable subgroup selector
subgroupSelect.disabled = false;
// Extract subgroups from events matching the selected agegroup
let subgroups = new Set();
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
if (event.name && event.name !== selectedAgegroup) {
subgroups.add(event.name);
}
});
// Add subgroups to the selector
Array.from(subgroups).sort().forEach(subgroup => {
const option = document.createElement("option");
option.value = subgroup;
option.textContent = subgroup;
subgroupSelect.appendChild(option);
});
}
function displayEvents(events) {
eventList.innerHTML = "";
let selectedAgegroup = agegroupSelect.value;
let filteredEvents = events.filter(event => event.event === "Jeu" && (selectedAgegroup === "" || event.agegroup === selectedAgegroup));
let selectedSubgroup = subgroupSelect.value;
let filteredEvents = events.filter(event => {
// Filter by event type
if (event.event !== "Jeu") return false;
// Filter by agegroup
if (selectedAgegroup !== "" && event.agegroup !== selectedAgegroup) return false;
// Filter by subgroup
if (selectedSubgroup !== "" && event.name !== selectedSubgroup) return false;
return true;
});
if (filteredEvents.length === 0) {
eventList.innerHTML = "<p class='text-muted'>Aucun événement 'Jeu' trouvé.</p>";
@@ -450,9 +518,11 @@
headers: { "Authorization": `Bearer ${storedApiKey}` }
})
.then(response => response.json())
.then(data => {
const sortedPlayers = data.convocation.available
.sort((a, b) => (a.number || 0) - (b.number || 0));
.then(data => {
// Check if available players data exists
const availablePlayers = data.convocation.available || [];
const sortedPlayers = availablePlayers
.sort((a, b) => (a.number || 0) - (b.number || 0));
// Calculate player statistics
const totalPlayers = sortedPlayers.length;
@@ -483,19 +553,73 @@
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>
`;
// Process staff data
const staffList = data.convocation.staff || [];
const totalStaff = staffList.length;
// Check if there are no players
if (totalPlayers === 0 && totalStaff === 0) {
eventDetailsContent.innerHTML = `
<div class="card border-warning">
<div class="card-body text-center">
<h5 class="card-title">${data.title}</h5>
<p class="card-text"><strong>Type:</strong> ${data.type}</p>
<p class="card-text"><strong>Lieu:</strong> ${data.place}</p>
<p class="card-text"><strong>Heure:</strong> ${data.time_start} - ${data.time_end}</p>
<div class="alert alert-warning" role="alert">
<h6 class="alert-heading">Aucun joueur ni personnel convoqué</h6>
<p>Il n'y a actuellement aucun joueur ni personnel convoqué pour ce match.</p>
</div>
</div>
</div>
`;
} else {
let staffHtml = '';
if (totalStaff > 0) {
staffHtml = `
<h6>Personnel (${totalStaff}):</h6>
<ul>${staffList.map(staff => {
return `<li><strong>${staff.role}:</strong> ${staff.fname} ${staff.lname}</li>`;
}).join('')}</ul>
`;
} else {
staffHtml = `
<div class="alert alert-info" role="alert">
<h6>Aucun personnel convoqué</h6>
<p>Il n'y a actuellement aucun personnel convoqué pour ce match.</p>
</div>
`;
}
if (totalPlayers === 0) {
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>
<div class="alert alert-warning" role="alert">
<h6 class="alert-heading">Aucun joueur convoqué</h6>
<p>Il n'y a actuellement aucun joueur convoqué pour ce match.</p>
</div>
${staffHtml}
`;
} else {
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>
${staffHtml}
`;
}
}
new bootstrap.Modal(document.getElementById('eventDetailsModal')).show();
})
.catch(error => console.error("Erreur lors du chargement des détails de l'événement:", error));
+10 -5
View File
@@ -14,7 +14,7 @@ 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
@@ -148,6 +148,9 @@ class AgeGroup(str, Enum):
u18e = "U18 (Elite)"
u18el = "U18 (Elit)"
u21e = "U21 (ELIT)"
u14t = "U14 (Top)"
u14t1 = "U14 (Top1)"
u14t2 = "U14 (Top2)"
def normalize_age_group(value: str) -> AgeGroup | None:
@@ -423,19 +426,21 @@ def os_open(file: str) -> None:
def extract_players(pdf_file: Path) -> List[str]:
reader = PyPDF2.PdfReader(pdf_file)
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
Generated
+1205 -880
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,20 +1,20 @@
[project]
name = "myice"
version = "v0.5.5"
version = "v0.5.8"
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)",
"requests (>=2.32.3)",
"typer (>=0.15.1)",
"pypdf (>=6.0.0)",
"rl-ai-tools >=1.9.0",
"fastapi[standard] (>=0.115.11,<0.116.0)",
"fastapi[standard] (>=0.115.11)",
]
[tool.poetry.dependencies]
+46
View File
@@ -0,0 +1,46 @@
--extra-index-url https://pypi.purple.infomaniak.ch
annotated-types==0.7.0 ; python_version >= "3.13"
anyio==4.11.0 ; python_version >= "3.13"
certifi==2025.8.3 ; python_version >= "3.13"
charset-normalizer==3.4.3 ; python_version >= "3.13"
click==8.1.8 ; python_version >= "3.13"
colorama==0.4.6 ; (platform_system == "Windows" or sys_platform == "win32") and 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.13 ; python_version >= "3.13"
fastapi-cloud-cli==0.2.1 ; python_version >= "3.13"
fastapi==0.118.0 ; python_version >= "3.13"
h11==0.16.0 ; python_version >= "3.13"
httpcore==1.0.9 ; python_version >= "3.13"
httptools==0.6.4 ; python_version >= "3.13"
httpx==0.28.1 ; python_version >= "3.13"
idna==3.10 ; python_version >= "3.13"
jinja2==3.1.6 ; python_version >= "3.13"
markdown-it-py==4.0.0 ; python_version >= "3.13"
markupsafe==3.0.3 ; python_version >= "3.13"
mdurl==0.1.2 ; python_version >= "3.13"
pydantic-core==2.33.2 ; python_version >= "3.13"
pydantic==2.11.9 ; python_version >= "3.13"
pygments==2.19.2 ; python_version >= "3.13"
pypdf==6.1.1 ; python_version >= "3.13"
python-dotenv==1.1.1 ; python_version >= "3.13"
python-multipart==0.0.20 ; python_version >= "3.13"
pyyaml==6.0.3 ; python_version >= "3.13"
requests==2.32.5 ; python_version >= "3.13"
rich-toolkit==0.15.1 ; python_version >= "3.13"
rich==14.1.0 ; python_version >= "3.13"
rignore==0.6.4 ; python_version >= "3.13"
rl-ai-tools==1.15.0 ; python_version >= "3.13"
sentry-sdk==2.39.0 ; python_version >= "3.13"
shellingham==1.5.4 ; python_version >= "3.13"
sniffio==1.3.1 ; python_version >= "3.13"
starlette==0.48.0 ; python_version >= "3.13"
typer==0.15.4 ; python_version >= "3.13"
typing-extensions==4.15.0 ; python_version >= "3.13"
typing-inspection==0.4.1 ; python_version >= "3.13"
urllib3==2.5.0 ; python_version >= "3.13"
uvicorn==0.37.0 ; python_version >= "3.13"
uvloop==0.21.0 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.13"
watchfiles==1.1.0 ; python_version >= "3.13"
websockets==15.0.1 ; python_version >= "3.13"