11 Commits

Author SHA1 Message Date
herel a39899ebb1 chore: myice.py -> Added file parameter to save_cookies function and used Path object for file handling. 2024-11-01 14:02:10 +01:00
herel 320ec291e6 🟢 pyproject.toml: Added Poetry project configuration file
🛠️ pyproject.toml -> Updated version from 'v0.1.5' to 'v0.1.6'
2024-11-01 13:18:38 +01:00
herel 85f84b540a 🟢 README.md -> added installation instructions and introduction section
🛠️ .mdlrc -> set style path
🛠️ mdl.rb -> created a new ruby script
🔴 RECUPE\_SCHEDULE.sh -> removed shell script
2024-11-01 13:15:35 +01:00
herel 7939414995 🛠️ myice.py -> updated error messages to redirect to stderr
🛠️ myice.py -> changed login and get\_userid prints to write to stderr
🔴 pyproject.toml -> removed version v0.1.3
🟢 pyproject.toml -> added version v0.1.5
2024-11-01 11:23:05 +01:00
herel b2baa07371 🛠️ myice/myice.py -> updated function get_login by simplifying config reading; renamed functions open, get_game_pdf, get_practice_pdf to os_open, get_game_pdf_os, get_practice_pdf_os respectively
🔴 pyproject.toml -> removed version 'v0.1.2', now at 'v0.1.3'
2024-11-01 11:17:26 +01:00
herel 56e7ba92e8 🛠️ myice/myice.py -> added EventType enum and updated search command to support optional event type filtering
- Added `EventType` enum with two values: 'game' and 'practice'.
- Updated `parse_schedule` function to accept an optional `event_type_filter` argument which filters displayed events based on their type.
- Changed `age_group` parameter in `parse_schedule` to allow `None`.
2024-11-01 11:06:10 +01:00
herel a3e1d9ccbf 🛠️ pyproject.toml -> updated project dependencies and version number from 0.1.0 to v0.1.1
🛠️ poetry.lock -> content-hash changed from e11a99... to 32eb77...
2024-11-01 10:53:58 +01:00
herel 3b2351efc6 🟢 LICENSE.txt (New MIT license added)
🛠️ pyproject.toml -> Updated poetry dependencies, authors email changed, added license info
2024-11-01 10:41:39 +01:00
herel e313b824d7 🟢 README.md: Updated command to retrieve schedule and added new commands for searching events by age group and retrieving match details
🛠️ myice/myice.py: Implemented a function to parse the schedule JSON file based on given age groups, improved error handling, enhanced formatting when printing results, added a new command `myice search`
2024-11-01 10:38:43 +01:00
herel 11bbfdfc10 chore: add from Python.gitignore 2024-11-01 10:07:04 +01:00
herel 1644523d40 initial import 2024-11-01 09:59:45 +01:00
11 changed files with 276 additions and 2911 deletions
+9 -9
View File
@@ -3,7 +3,7 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: check-case-conflict
@@ -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.6.8
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: v1.11.2
hooks:
- id: mypy
exclude: ^(docs/|example-plugin/)
args: [--ignore-missing-imports]
additional_dependencies: [types-requests, PyPDF2]
additional_dependencies: [types-requests]
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.37.1
rev: v1.35.1
hooks:
- id: yamllint
args: [--strict]
- repo: https://github.com/markdownlint/markdownlint
rev: v0.13.0
rev: v0.12.0
hooks:
- id: markdownlint
exclude: "^.github|(^docs/_sidebar\\.md$)"
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
rev: v0.10.0.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.6.0
hooks:
- id: misspell
args: ["-i", "charactor"]
- repo: https://github.com/python-poetry/poetry
rev: "2.1.4"
rev: "1.8.0"
hooks:
- id: poetry-check
- id: poetry-lock
-93
View File
@@ -1,93 +0,0 @@
# Development Guide for Agentic Coding Agents
## Build/Lint/Test Commands
### Setup
```bash
# Install dependencies with Poetry
poetry install
# Install pre-commit hooks
pre-commit install
```
### Linting and Formatting
```bash
# Run all pre-commit checks (linting, formatting, type checking)
pre-commit run --all-files
# Run specific linters
ruff check . # Python linting
ruff format . # Python formatting
mypy . # Type checking
yamllint . # YAML linting
markdownlint . # Markdown linting
```
### Running Tests
```bash
# 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
### Imports
- Standard library imports first, then third-party, then local imports
- Use explicit imports rather than wildcard imports
- Group imports logically with blank lines between groups
### Formatting
- Use ruff-format for automatic formatting
- Follow PEP 8 style guide
- Maximum line length: 88 characters (default ruff setting)
- Use 4 spaces for indentation
### Types
- Use type hints for function parameters and return values
- Prefer built-in types (str, int, list, dict) over typing aliases when possible
- Use typing.Annotated for Typer command options
### Naming Conventions
- Variables and functions: snake_case
- Classes: PascalCase
- Constants: UPPER_SNAKE_CASE
- Private members: prefixed with underscore (_private)
### Error Handling
- Use try/except blocks for expected exceptions
- Raise appropriate HTTPException for API errors
- Include descriptive error messages
- Use sys.exit(1) for command-line tool errors
### Frameworks and Libraries
- Typer for CLI interface
- FastAPI for web API
- requests for HTTP requests
- PyPDF2 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
-20
View File
@@ -1,20 +0,0 @@
FROM python:3.11
RUN install -o www-data -g www-data -d -m 0755 /var/www
USER www-data
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH=/var/www/.local/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
COPY README.md pyproject.toml poetry.lock docker-entrypoint.sh index.html favicon.ico /var/www/
COPY myice /var/www/myice
WORKDIR /var/www
RUN poetry install && . $(poetry env info -p)
EXPOSE 8000
ENTRYPOINT [ "/var/www/docker-entrypoint.sh" ]
+9 -141
View File
@@ -1,9 +1,9 @@
# myice
## introduction
## intro
With this tool, you can fetch kids' schedules from MyIce and generate
the PDFs you need.
Avec tout ça on va aller chercher sur MyIce les planning des gamins et générer
les pdf qu'on veut
## install
@@ -19,121 +19,17 @@ with [pipx](https://pipx.pypa.io/stable/installation/):
pipx install --extra-index-url https://gitea.parano.ch/api/packages/herel/pypi/simple/ myice
```
## configuration
Create a `myice.ini` file with your credentials:
```ini
[default]
username = your_email@example.com
password = your_password
userid = 12345
token = your_api_token
club_id = 172
```
You can also create multiple sections for different users:
```ini
[default]
username = user1@example.com
password = password1
userid = 12345
token = token1
club_id = 172
[second_kid]
username = charlie@example.com
password = password2
userid = 67890
token = token2
club_id = 186
```
Note: The `userid`, `token`, and `club_id` fields are optional. If you don't
have them, the tool can fetch them automatically using the mobile-login
command (see below).
To fetch the `token` and `club_id` using mobile-login:
```shell
myice mobile-login
```
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:
```shell
uv run fastapi run myice/webapi.py
```
Then open your browser at `http://localhost:8000`. The web interface allows you to:
- Select between different configured accounts
- View upcoming games and practices
- See detailed information about events including player rosters
### Authentication
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.
2. **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_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")
The web API provides the following endpoints:
- `/schedule` - Get the schedule for a specific account
- `/game/{game_id}` - Get details for a specific game
- `/accounts` - Get a list of available accounts
- `/health` - Health check endpoint
- `/login` - Initiate OIDC login flow
- `/callback` - Handle OIDC callback
- `/userinfo` - Get user information
All endpoints (except `/health`, `/login`, and `/callback`) require an Authorization header with a Bearer token.
## mobile functions
The tool includes several mobile API functions that interact with MyIce's
mobile API:
- `myice mobile-login` - Authenticate and get authentication tokens
- `myice mobile` - Fetch game data using the mobile API
- `myice mobile-game {game_id}` - Get detailed information about a specific game
These functions can be useful for debugging or when the regular web
interface is not available.
## fetch schedule
## récupérer le schedule
```shell
myice schedule -o schedule.json
```
To use a specific configuration section:
```shell
myice schedule -o schedule.json --config-section isaac
```
## data
### listing
To fetch events for U13 Elite for example:
Pour récupérer les event des U13 Elite par exemple:
```shell
myice search "U13 (Elite)"
@@ -159,11 +55,11 @@ practice: On-Ice Patinoire des Vernets - Patinoire Extérieure
game: Saison HC Ajoie
```
And we retrieve the match roster:
Et on récupère la convoc du match:
### match
To get the roster for the match against Ajoie, the ID is 117015:
alors pour avoir la convocation du match contre Ajoie, l'id c'est 117015:
```shell
myice game 117015
@@ -171,40 +67,12 @@ Opening file game_117015.pdf
```
To use a specific configuration section:
### entraînement
```shell
myice game 117015 --config-section isaac
```
### practice
And for a practice roster:
et pour la convoc d'un entraînement:
```shell
myice practice 561855
Opening file practice_561855.pdf
```
To use a specific configuration section:
```shell
myice practice 561855 --config-section isaac
```
### AI
```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.
> 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.
```
To use a specific configuration section:
```shell
myice ai --config-section isaac
```
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
exec /var/www/.local/bin/poetry run fastapi run myice/webapi.py
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

-575
View File
@@ -1,575 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<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>
</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>
</div>
<div class="container-fluid d-flex align-items-center justify-content-center">
<div class="text-center">
<h1 id="loginTitle" class="mb-4">MyIce - Games</h1>
<div id="loginContainer" class="mb-3">
<div id="oidcLoginSection">
<button id="oidcLoginBtn" class="btn btn-primary btn-md px-4 py-3 shadow">Se connecter</button>
</div>
</div>
</div>
</div>
<div class="container mt-2" id="mainContent" style="display: none;">
<div id="eventFilters" style="display: none;">
<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>
<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>
</div>
<div id="eventList" class="row mt-2"></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>
</div>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const loginContainer = document.getElementById("loginContainer");
const oidcLoginSection = document.getElementById("oidcLoginSection");
const connectedUser = document.getElementById("connectedUser");
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");
const apiBaseUrl = window.location.origin;
let storedApiKey = localStorage.getItem("apikey");
let lastFetchedEvents = [];
let storedAccount = localStorage.getItem("account") || "default";
let userInfo = JSON.parse(localStorage.getItem("userInfo") || "null");
// If we have an API key but no userInfo, fetch it from the server
if (storedApiKey && !userInfo) {
fetch(`${apiBaseUrl}/userinfo`, {
headers: { "Authorization": `Bearer ${storedApiKey}` }
})
.then(response => response.json())
.then(userData => {
userInfo = { email: userData.email || "Utilisateur connecté" };
localStorage.setItem("userInfo", JSON.stringify(userInfo));
renderLoginSection();
})
.catch(e => {
console.error("Error fetching user info:", e);
// If we can't fetch user info, proceed with rendering
renderLoginSection();
});
} else {
// Render the login section normally
renderLoginSection();
}
// Handle the static "abc" key case - removed for security
/*
if (storedApiKey === "abc" && !userInfo) {
userInfo = {email: "utilisateur@example.com"};
localStorage.setItem("userInfo", JSON.stringify(userInfo));
}
*/
// Handle OIDC callback
const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get('error');
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const accessToken = hashParams.get('access_token');
// Display error message if present
if (error) {
alert("Erreur d'authentification: " + decodeURIComponent(error));
// Remove error from URL
window.history.replaceState({}, document.title, "/");
}
// Handle access token in URL fragment
if (accessToken) {
// Store the access token
localStorage.setItem("apikey", accessToken);
storedApiKey = accessToken;
// Get user info from the userinfo endpoint
fetch(`${apiBaseUrl}/userinfo`, {
headers: { "Authorization": `Bearer ${accessToken}` }
})
.then(response => response.json())
.then(userData => {
userInfo = { email: userData.email || "Utilisateur connecté" };
localStorage.setItem("userInfo", JSON.stringify(userInfo));
// Update UI
renderLoginSection();
})
.catch(e => {
console.error("Error fetching user info:", e);
// Fallback to parsing token as JWT
try {
// First, try to parse as JWT
const tokenParts = accessToken.split('.');
let userData = {};
if (tokenParts.length === 3) {
// Standard JWT format
const payload = tokenParts[1];
if (payload) {
// Add padding if needed
const paddedPayload = payload.padEnd(payload.length + (4 - payload.length % 4) % 4, '=');
const decodedPayload = atob(paddedPayload);
userData = JSON.parse(decodedPayload);
}
} else {
// Non-JWT token, treat as opaque token
console.log("Non-JWT token received, using default user info");
userData = { email: "utilisateur@" + window.location.hostname };
}
userInfo = { email: userData.email || "Utilisateur connecté" };
localStorage.setItem("userInfo", JSON.stringify(userInfo));
} catch (parseError) {
console.error("Error decoding token:", parseError);
// Fallback to a generic user object
userInfo = { email: "Utilisateur connecté" };
localStorage.setItem("userInfo", JSON.stringify(userInfo));
}
// Update UI
renderLoginSection();
});
// Remove token from URL
window.history.replaceState({}, document.title, "/");
}
function renderLoginSection() {
const mainContent = document.getElementById("mainContent");
const loginView = document.querySelector(".container-fluid");
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";
connectedUser.style.display = "block";
oidcLoginSection.style.display = "none";
const userNameElement = document.getElementById("userName");
if (userInfo && userInfo.email) {
userNameElement.textContent = userInfo.email;
} else {
userNameElement.textContent = "Utilisateur";
}
eventFilters.style.display = "block";
updateAccountOptionsAndLoadEvents();
} else {
// User is not logged in
loginView.style.display = "flex";
mainContent.style.display = "none";
loginTitle.style.display = "block";
connectedUser.style.display = "none";
oidcLoginSection.style.display = "block";
eventFilters.style.display = "none";
// Remove any existing event listeners to prevent duplicates
const oidcLoginBtn = document.getElementById("oidcLoginBtn");
const newOidcLoginBtn = oidcLoginBtn.cloneNode(true);
oidcLoginBtn.parentNode.replaceChild(newOidcLoginBtn, oidcLoginBtn);
newOidcLoginBtn.addEventListener("click", initiateOIDCLogin);
}
// Add disconnect handler
const disconnectBtn = document.getElementById("disconnect");
if (disconnectBtn) {
// Remove any existing event listeners to prevent duplicates
const newDisconnectBtn = disconnectBtn.cloneNode(true);
disconnectBtn.parentNode.replaceChild(newDisconnectBtn, disconnectBtn);
newDisconnectBtn.addEventListener("click", logout);
}
}
function initiateOIDCLogin() {
// Redirect to backend login endpoint
window.location.href = `${apiBaseUrl}/login`;
}
function logout() {
localStorage.removeItem("apikey");
localStorage.removeItem("account");
localStorage.removeItem("userInfo");
storedApiKey = null;
userInfo = null;
location.reload();
}
function updateAccountOptionsAndLoadEvents() {
// Fetch available accounts from the server
fetch(`${apiBaseUrl}/accounts`, {
headers: { "Authorization": `Bearer ${storedApiKey}` }
})
.then(response => {
if (!response.ok) {
return response.json().then(errorData => {
console.error("Accounts error response:", errorData);
throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.detail || 'Unknown error'}`);
});
}
return response.json();
})
.then(accounts => {
// Check if accounts is actually an array
if (!Array.isArray(accounts)) {
console.error("Accounts data is not an array:", accounts);
throw new Error("Invalid accounts data format");
}
accountSelect.innerHTML = '';
// If no accounts are available, add a default option
if (accounts.length === 0) {
const option = document.createElement("option");
option.value = "default";
option.textContent = "Default";
accountSelect.appendChild(option);
return;
}
// Add all available accounts
accounts.forEach(account => {
const option = document.createElement("option");
option.value = account.name;
option.textContent = account.label;
accountSelect.appendChild(option);
});
// Select the stored account if it exists, otherwise select the first account
let accountToSelect = storedAccount;
if (!accounts.some(account => account.name === storedAccount)) {
accountToSelect = accounts[0].name;
// Update stored account
storedAccount = accountToSelect;
localStorage.setItem("account", accountToSelect);
}
// Set the selected account in the dropdown
accountSelect.value = accountToSelect;
// Automatically fetch events for the selected account
fetchEvents(storedApiKey, accountToSelect);
})
.catch(error => {
console.error("Erreur lors du chargement des comptes:", error);
alert("Accès refusé. Vous n'êtes pas autorisé à accéder à cette application. Veuillez contacter l'administrateur.");
logout();
});
}
renderLoginSection();
accountSelect.addEventListener("change", () => {
const selectedAccount = accountSelect.value;
localStorage.setItem("account", selectedAccount);
if (storedApiKey) {
fetchEvents(storedApiKey, selectedAccount);
}
});
fetchButton.addEventListener("click", () => {
if (!storedApiKey) {
alert("Veuillez vous connecter");
return;
}
const selectedAccount = accountSelect.value;
fetchEvents(storedApiKey, selectedAccount);
});
agegroupSelect.addEventListener("change", () => {
// Update subgroup options based on selected agegroup
updateSubgroupOptions(agegroupSelect.value, lastFetchedEvents);
displayEvents(lastFetchedEvents);
});
subgroupSelect.addEventListener("change", () => {
displayEvents(lastFetchedEvents);
});
function fetchEvents(apiKey, account) {
// Show loading indicator
const loadingIndicator = document.getElementById("loadingIndicator");
const eventList = document.getElementById("eventList");
loadingIndicator.style.display = "block";
eventList.innerHTML = ""; // Clear previous events
fetch(`${apiBaseUrl}/schedule?account=${account}`, {
headers: { "Authorization": `Bearer ${apiKey}` }
})
.then(response => {
if (response.status === 401) {
// Token expired or invalid
alert("Votre session a expiré. Veuillez vous reconnecter.");
logout();
return;
}
if (!response.ok) {
// Try to parse error response as JSON, but handle plain text as well
return response.text().then(errorText => {
let errorMessage = 'Unknown error';
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.detail || errorData.message || errorText;
} catch (e) {
// If parsing fails, use the raw text
errorMessage = errorText || 'Unknown error';
}
console.error("Schedule error response:", errorText);
throw new Error(`HTTP error! status: ${response.status}, message: ${errorMessage}`);
});
}
return response.text().then(text => {
try {
return JSON.parse(text);
} catch (e) {
console.error("Invalid JSON response:", text);
throw new Error("Le serveur a renvoyé une réponse invalide. Veuillez réessayer.");
}
});
})
.then(data => {
// Hide loading indicator
loadingIndicator.style.display = "none";
if (data) {
// Check if data is an array
if (!Array.isArray(data)) {
console.error("Schedule data is not an array:", data);
throw new Error("Invalid schedule data format");
}
lastFetchedEvents = data.filter(event => event.event === "Jeu");
updateAgeGroupOptions(lastFetchedEvents);
displayEvents(lastFetchedEvents);
}
})
.catch(error => {
// Hide loading indicator on error
loadingIndicator.style.display = "none";
console.error("Erreur lors du chargement des événements:", error);
alert("Erreur lors du chargement des événements: " + error.message);
});
}
function updateAgeGroupOptions(events) {
let agegroups = new Set(events.map(event => event.agegroup));
agegroupSelect.innerHTML = '<option value="">Tous</option>';
Array.from(agegroups).sort().forEach(group => {
const option = document.createElement("option");
option.value = group;
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 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>";
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.addEventListener("click", () => fetchEventDetails(event.id_event));
eventList.appendChild(eventCard);
});
}
function fetchEventDetails(eventId) {
const selectedAccount = accountSelect.value;
fetch(`${apiBaseUrl}/game/${eventId}?account=${selectedAccount}`, {
headers: { "Authorization": `Bearer ${storedApiKey}` }
})
.then(response => response.json())
.then(data => {
const sortedPlayers = data.convocation.available
.sort((a, b) => (a.number || 0) - (b.number || 0));
// Calculate player statistics
const totalPlayers = sortedPlayers.length;
const positionCount = {};
sortedPlayers.forEach(player => {
const position = player.position || "N/A";
positionCount[position] = (positionCount[position] || 0) + 1;
});
// Generate position breakdown
const positionBreakdown = Object.entries(positionCount)
.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 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();
})
.catch(error => console.error("Erreur lors du chargement des détails de l'événement:", error));
}
});
</script>
</body>
</html>
+26 -438
View File
@@ -9,177 +9,26 @@ import json
import os
import re
import sys
import tempfile
from enum import Enum
from pathlib import Path
from typing import Annotated
from typing import List, Tuple
import PyPDF2
import requests
import typer
from rich import print
from rl_ai_tools import utils # type: ignore
user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0"
def sanitize_json_response(text):
"""Sanitize JSON response from MyIce API using our proven fix_schedule_json.py approach"""
try:
# First, try to parse as-is
data = json.loads(text)
return json.dumps(data, ensure_ascii=False, indent=2, separators=(",", ": "))
except json.JSONDecodeError:
# Use the exact same approach as our working fix_schedule_json.py script
# but implemented directly in code
# Split into lines to process
lines = text.split("\n")
# Fix line issues (remove line numbers if present)
fixed_lines = []
for line in lines:
# Remove line numbers prefix if present (from cat -n format)
line = re.sub(r"^\s*\d+\|\s*", "", line)
fixed_lines.append(line)
# Join all lines back together
content = "".join(fixed_lines)
# Apply comprehensive sanitization that we know works
# 1. Escape literal newlines, carriage returns, and tabs everywhere
content = content.replace("\n", "\\n")
content = content.replace("\r", "\\r")
content = content.replace("\t", "\\t")
# 2. Find the main JSON array structure
start = content.find("[")
end = content.rfind("]")
if start != -1 and end != -1 and end > start:
array_content = content[start : end + 1]
else:
# Fallback: try to reconstruct a valid array
array_content = "[" + content + "]"
# 3. Fix common JSON formatting issues
# Remove trailing commas before closing brackets/braces
array_content = re.sub(r",(\s*[}\]])", r"\1", array_content)
# 4. Try to parse the sanitized content
try:
data = json.loads(array_content)
return json.dumps(
data, ensure_ascii=False, indent=2, separators=(",", ": ")
)
except json.JSONDecodeError:
# If parsing still fails, try one more aggressive approach
# Remove any remaining control characters that might be causing issues
# This is the key fix - we need to escape control characters rather than remove them
def escape_control_chars(match):
char = match.group(0)
# Handle specific control characters that might cause issues
if char == "\n":
return "\\n"
elif char == "\r":
return "\\r"
elif char == "\t":
return "\\t"
else:
# For other control characters, use Unicode escape
return f"\\u{ord(char):04x}"
# More aggressive approach: escape ALL control characters
# This handles the case where line breaks occur within JSON string values
# We need to escape them before they break the JSON structure
# First, let's try to detect if there are line breaks within quoted strings
# and escape them properly
# Simple approach: escape all control characters
array_content = re.sub(r"[\x00-\x1f]", escape_control_chars, array_content)
try:
data = json.loads(array_content)
return json.dumps(
data, ensure_ascii=False, indent=2, separators=(",", ": ")
)
except json.JSONDecodeError:
# Final fallback - return a minimal valid JSON array
return json.dumps(
[], ensure_ascii=False, indent=2, separators=(",", ": ")
)
app = typer.Typer(no_args_is_help=True)
session: requests.Session
userid: int
global_config_section: str = "default"
# Add global option for config section
@app.callback()
def main(
config_section: Annotated[
str,
typer.Option(
"--config-section", "-c", help="Configuration section to use from INI file"
),
] = "default",
):
"""My Ice Hockey schedule tool"""
# Store the config_section in a global variable so it can be accessed by commands
global global_config_section
global_config_section = config_section
class AgeGroup(str, Enum):
u111 = "U11 (1)"
u13e = "U13 (Elite)"
u13t = "U13 (Top)"
u13a = "U13 (A)"
u13p = "U13 (Prép)"
u14ev = "U14 (Elite Vernets)"
u14esmv = "U14 (Elite Sous-Moulin/Vergers)"
u15t = "U15 (Top)"
u15a = "U15 (A)"
u16e = "U16 (Elite)"
u16t = "U16 (Top GS Ass)"
u18e = "U18 (Elite)"
u18el = "U18 (Elit)"
u21e = "U21 (ELIT)"
def normalize_age_group(value: str) -> AgeGroup | None:
"""Normalize age group string to handle case and spelling variations."""
import re
if not isinstance(value, str):
return None
def normalize_spelling(text: str) -> str:
"""Normalize spelling variations of 'elit' to 'elite'."""
# Replace accented versions first
text = text.replace("élit", "elite")
# Use regex to replace "elit" with "elite" only when it's a complete word
# This avoids replacing "elit" within "elite"
text = re.sub(r"\belit\b", "elite", text)
return text
# Convert to lowercase for case-insensitive comparison
input_lower = value.lower()
input_normalized = normalize_spelling(input_lower)
for member in AgeGroup:
# Convert enum value to lowercase and normalize
member_lower = member.value.lower()
member_normalized = normalize_spelling(member_lower)
# Check for match
if member_normalized == input_normalized:
return member
return None
class EventType(str, Enum):
@@ -202,10 +51,7 @@ def save_cookies(file: str = "cookies.txt"):
f.write(json.dumps(requests.utils.dict_from_cookiejar(session.cookies)))
def get_login(
local_file: str = "myice.ini",
config_section: str = "default",
) -> tuple[str, str, int | None, str | None, int | None]:
def get_login(local_file: str = "myice.ini") -> tuple[str, str, int]:
config = configparser.ConfigParser()
config.read(
[
@@ -214,52 +60,27 @@ def get_login(
local_file,
]
)
if config_section in config.sections():
selected_config = config[config_section]
elif "default" in config.sections():
# Fallback to default section if specified section doesn't exist
selected_config = config["default"]
if "default" in config.sections():
default_config = config["default"]
username = default_config.get("username")
password = default_config.get("password")
userid = default_config.getint("userid")
if not username or not password:
print(
f"Warning: Section '{config_section}' not found, using 'default' section",
file=sys.stderr,
"Error: please configure username/password in ini file", file=sys.stderr
)
sys.exit(1)
else:
print("Error: please configure username/password in ini file", file=sys.stderr)
sys.exit(1)
username = selected_config.get("username")
password = selected_config.get("password")
userid = selected_config.getint("userid", fallback=None)
token = selected_config.get("token", fallback=None)
club_id = selected_config.getint("club_id", fallback=None)
if not username or not password:
print("Error: please configure username/password in ini file", file=sys.stderr)
sys.exit(1)
return username, password, userid, token, club_id
return username, password, userid
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}
)
r.raise_for_status()
def do_login(config_section: str | None = None):
def do_login():
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)
if userid_tmp is not None:
userid = userid_tmp
username, password, userid = get_login()
r = session.get("https://app.myice.hockey/", headers={"User-Agent": user_agent})
r.raise_for_status()
form_data = {
@@ -278,9 +99,6 @@ def do_login(config_section: str | None = None):
},
)
r.raise_for_status()
# select the club we want
if club_id:
select_club(club_id)
def get_userid():
@@ -303,36 +121,31 @@ def get_userid():
def wrapper_session(func):
def wrapper(*args, **kwargs):
global session, userid, global_config_section
# Use the global config_section
config_section = global_config_section
global session, userid
session = requests.Session()
# session.verify = False
session.cookies = load_cookies()
session.cookies.clear_expired_cookies()
if not session.cookies.get("mih_v3_cookname"):
print("login...", file=sys.stderr)
do_login(config_section=None) # Use global config_section
do_login()
save_cookies()
_, _, userid, _, _ = get_login(config_section=config_section)
_, _, userid = get_login()
if not userid:
print("get userid...", file=sys.stderr)
userid = get_userid()
print(f"{userid=}", file=sys.stderr)
return func(*args, **kwargs)
return wrapper
@wrapper_session
def get_schedule(num_days: int) -> str:
def get_schedule() -> str:
global session
global userid
assert session and userid
now = datetime.datetime.now()
date_start = now
date_end = date_start + datetime.timedelta(days=num_days)
date_start = now + datetime.timedelta(days=1)
date_end = date_start + datetime.timedelta(days=7)
r = session.post(
"https://app.myice.hockey/inc/processclubplanning.php",
data={
@@ -345,15 +158,10 @@ def get_schedule(num_days: int) -> str:
headers={
"User-Agent": user_agent,
"Referer": "https://app.myice.hockey/players/clubschedule/",
"Accept": "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
},
)
r.raise_for_status()
# Debug: Save raw response to file for analysis
# with open("raw_response.txt", "w") as f:
# f.write(r.text)
return json.loads(sanitize_json_response(r.text))
return r.text
@wrapper_session
@@ -398,20 +206,16 @@ def schedule(
"--outfile", "-o", help="file to write result to, or stdout if none"
),
] = None,
num_days: Annotated[int, typer.Option("--days")] = 7,
):
"""
Fetch schedule as json
"""
global global_config_section
schedule = get_schedule(num_days)
schedule = get_schedule()
if outfile:
with outfile.open("w") as f:
f.write(json.dumps(schedule))
f.write(schedule)
else:
import builtins
builtins.print(json.dumps(schedule, indent=2))
print(schedule)
def os_open(file: str) -> None:
@@ -422,43 +226,16 @@ def os_open(file: str) -> None:
os.system(f"open {file}")
def extract_players(pdf_file: Path) -> List[str]:
reader = PyPDF2.PdfReader(pdf_file)
page = reader.pages[0]
players = []
def visitor_body(text, cm, tm, fontDict, fontSize):
(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)
page.extract_text(visitor_text=visitor_body)
return players
@app.command("game")
def get_game_pdf(
game_id: Annotated[int, typer.Argument(help="ID of game to gen pdf for")],
open_file: Annotated[bool, typer.Option("--open", "-o")] = False,
):
"""
Genate the pdf for the game invitation
"""
global global_config_section
if open_file:
output_filename = f"game_{game_id}.pdf"
else:
output_filename = tempfile.NamedTemporaryFile().name
game_pdf(game_id, Path(output_filename))
if open_file:
os_open(output_filename)
else:
players = extract_players(Path(output_filename))
print("Players:")
print("\n".join(players))
@app.command("practice")
@@ -468,7 +245,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)
@@ -476,7 +252,7 @@ def get_practice_pdf(
@app.command("search")
def parse_schedule(
age_group: Annotated[AgeGroup | None, typer.Option()] = None,
age_group: Annotated[AgeGroup | None, typer.Option(...)] = None,
event_type_filter: Annotated[
EventType | None,
typer.Option("--type", help="Only display events of this type"),
@@ -488,21 +264,13 @@ 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)
except json.JSONDecodeError:
# If JSON is malformed, try to sanitize it first
with schedule_file.open("r") as f:
content = f.read()
sanitized_content = sanitize_json_response(content)
data = json.loads(sanitized_content)
# age_group filter
if age_group:
events = [x for x in data if normalize_age_group(x["agegroup"]) == age_group]
events = [x for x in data if x["agegroup"] == age_group]
else:
events = [x for x in data if normalize_age_group(x["agegroup"]) is not None]
events = [x for x in data if x["agegroup"] in AgeGroup]
# event_type filter
if event_type_filter:
if event_type_filter.value == EventType.game:
@@ -529,185 +297,5 @@ def parse_schedule(
print(f"{event_type}: {title}\n")
@app.command("ai")
def check_with_ai(
schedule_file: Annotated[
Path, typer.Option(help="schedule json file to parse")
] = Path("schedule.json"),
):
"""
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:
schedule_data = json.load(f)
schedule_data = [
x for x in schedule_data if normalize_age_group(x["agegroup"]) is not None
]
for event in schedule_data:
event["team"] = event["agegroup"].replace("(", "").replace(")", "")
del event["agegroup"]
when = datetime.datetime.now().strftime("%d-%m-%Y et il est %H:%M")
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}"
"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",
"```json",
json.dumps(schedule_data),
"```",
],
)
history: List[Tuple[str, str]] = []
while True:
try:
question = input("> ")
except EOFError:
break
answer = utils.llm_inference(
question, history, system=system, model="mixtral8x22b"
)
print("<", answer)
history.append((question, answer))
mobile_headers = {
"Accept": "application/json, text/plain, */*",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
"Sec-Fetch-Site": "cross-site",
"Accept-Language": "fr-FR,fr;q=0.9",
"Sec-Fetch-Mode": "cors",
"Origin": "null",
"Sec-Fetch-Dest": "empty",
"Content-Type": "application/x-www-form-urlencoded",
}
def mobile_login(config_section: str | None = None):
global global_config_section
import base64
# 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, _, token, club_id = get_login(config_section=section)
if token and club_id:
return {"id": 0, "token": token, "id_club": club_id}
print("Requesting token", 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",
# verify=False,
) as r:
r.raise_for_status()
return {
"id": 0,
"token": r.json()["userinfo"]["token"],
"id_club": club_id
or r.json()["userinfo"]["id_club"], # Use configured club_id if available
}
userdata = {
"id": 0,
"id_club": 0,
"token": "",
}
def refresh_data():
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",
# verify=False,
) as r:
r.raise_for_status()
# Since the API returns valid JSON, we don't need to sanitize it
# Just parse it directly
try:
return r.json()
except json.JSONDecodeError:
# If direct parsing fails, try with sanitization
sanitized = sanitize_json_response(r.text)
try:
return json.loads(sanitized)
except json.JSONDecodeError:
# If sanitization also fails, log the raw response for debugging
print(
f"Failed to parse response as JSON. Raw response: {r.text[:500]}..."
)
# Return an empty dict to avoid breaking the API
return {}
@app.command("mobile-login")
def do_mobile_login():
global userdata, global_config_section
userdata = mobile_login(config_section=global_config_section)
print(json.dumps(userdata, indent=2))
@app.command("mobile")
def mobile():
global userdata, global_config_section
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
import builtins
builtins.print(json.dumps(games, 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
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=games",
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['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__":
app()
-389
View File
@@ -1,389 +0,0 @@
import logging
import requests
import os
from typing import Annotated
from fastapi import FastAPI, Header, HTTPException, status, Request
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", "5iXycu7aU6o9e17NNTOUeetQkRkBqQlomoD2hTyLGLTAcwj0dwkkLH3mz1IrZSmJ"
)
REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:8000/callback")
AUTHORIZATION_ENDPOINT = "https://login.infomaniak.com/authorize"
TOKEN_ENDPOINT = "https://login.infomaniak.com/token"
USERINFO_ENDPOINT = "https://login.infomaniak.com/oauth2/userinfo"
JWKS_URI = "https://login.infomaniak.com/oauth2/jwks"
# Allowed users (based on email claim)
ALLOWED_USERS = (
os.environ.get("ALLOWED_USERS", "").split(",")
if os.environ.get("ALLOWED_USERS")
else []
)
origins = ["*"]
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
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
uvicorn_logger = logging.getLogger("uvicorn.access")
uvicorn_logger.addFilter(LogFilter())
class AuthHeaders(BaseModel):
Authorization: str
def token(self) -> str:
parts = self.Authorization.split(" ")
if len(parts) == 2:
return parts[1]
return ""
def authorized(self) -> bool:
token = self.token()
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
def validate_oidc_token(self) -> bool:
try:
token = self.token()
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
# 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
user_email = payload_data.get("email")
if ALLOWED_USERS and user_email and user_email not in ALLOWED_USERS:
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
class HealthCheck(BaseModel):
"""Response model to validate and return when performing a health check."""
status: str = "OK"
@app.get("/")
async def home():
return FileResponse("index.html")
@app.get(
"/health",
response_model=HealthCheck,
status_code=status.HTTP_200_OK,
)
def get_health() -> HealthCheck:
return HealthCheck(status="OK")
@app.get("/favicon.ico")
async def favico():
return FileResponse("favicon.ico")
@app.get("/login")
async def login(request: Request):
"""Redirect to Infomaniak OIDC login"""
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
# Store state and nonce in session (simplified for this example)
# In production, use proper session management
auth_url = (
f"{AUTHORIZATION_ENDPOINT}"
f"?client_id={CLIENT_ID}"
f"&redirect_uri={urllib.parse.quote(REDIRECT_URI)}"
f"&response_type=code"
f"&scope=openid email profile"
f"&state={state}"
f"&nonce={nonce}"
)
return RedirectResponse(auth_url)
@app.get("/callback")
async def callback(code: str, state: str):
"""Handle OIDC callback and exchange code for token"""
# Exchange authorization code for access token
token_data = {
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
}
response = requests.post(TOKEN_ENDPOINT, data=token_data)
token_response = response.json()
if "access_token" not in 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}")
access_token = token_response["access_token"]
# Get user info
userinfo_response = requests.get(
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {access_token}"}
)
userinfo = userinfo_response.json()
# Check if user is in allowed list
user_email = userinfo.get("email")
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")
# 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}")
@app.get("/exchange-token")
async def exchange_token(code: str):
"""Exchange authorization code for access token"""
# Exchange authorization code for access token
token_data = {
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code": code,
"redirect_uri": REDIRECT_URI,
}
response = requests.post(TOKEN_ENDPOINT, data=token_data)
token_response = response.json()
if "access_token" not in token_response:
# Return error as JSON
error_detail = token_response.get(
"error_description", "Failed to obtain access token"
)
raise HTTPException(
status_code=400, detail=f"Token exchange failed: {error_detail}"
)
access_token = token_response["access_token"]
# Get user info
userinfo_response = requests.get(
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {access_token}"}
)
userinfo = userinfo_response.json()
# Check if user is in allowed list
user_email = userinfo.get("email")
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
raise HTTPException(status_code=403, detail="User not authorized")
# Return token and user info as JSON
return {"access_token": access_token, "user": userinfo}
@app.get("/userinfo")
async def userinfo(headers: Annotated[AuthHeaders, Header()]):
"""Get user info from access token"""
access_token = headers.token()
userinfo_response = requests.get(
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {access_token}"}
)
return userinfo_response.json()
@app.get("/schedule")
async def schedule(
headers: Annotated[AuthHeaders, Header()],
account: str = "default",
):
if not headers.authorized():
raise HTTPException(401, detail="get out")
try:
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",
)
try:
if existing_token:
myice.userdata = {
"id": userid,
"id_club": club_id or 186,
"token": existing_token,
}
else:
myice.userdata = myice.mobile_login(config_section=account)
data = myice.refresh_data()
if "club_games" in data:
return data["club_games"]
else:
# Return empty array if no games data found
return []
except Exception as e:
print(f"Error fetching schedule: {e}")
import traceback
traceback.print_exc()
# Return empty array instead of throwing an error
return []
@app.get("/accounts")
async def accounts(
headers: Annotated[AuthHeaders, Header()],
):
if not headers.authorized():
raise HTTPException(401, detail="get out")
# Import configparser to read the available sections
import configparser
from pathlib import Path
config = configparser.ConfigParser()
config_files = [
Path("~/.config/myice.ini").expanduser(),
Path("myice.ini"),
]
# Read all config files
for config_file in config_files:
if config_file.exists():
try:
config.read(config_file, encoding="utf-8")
except Exception:
# Try without specifying encoding
config.read(config_file)
# Get all sections (accounts) from the config
accounts = []
for section in config.sections():
if section != "DEFAULT": # Skip DEFAULT section
# Capitalize first letter for display
label = (
section[0].upper() + section[1:]
if len(section) > 1
else section.upper()
)
accounts.append({"name": section, "label": label})
# If no accounts found, return default
if not accounts:
accounts = [{"name": "default", "label": "Default"}]
return accounts
@app.get("/game/{game_id}")
async def game(
headers: Annotated[AuthHeaders, Header()],
game_id: int,
account: str = "default",
):
username, password, userid, existing_token, club_id = myice.get_login(
config_section=account
)
if existing_token:
myice.userdata = {
"id": userid,
"id_club": club_id or 186,
"token": existing_token,
}
else:
myice.userdata = myice.mobile_login(config_section=account)
# data = refresh_data()
with requests.post(
"https://app.myice.hockey/api/mobilerest/getevent",
headers=myice.mobile_headers,
data="&".join(
[
f"token={myice.userdata['token']}",
f"id_event={game_id}",
"type=games",
f"id_player={myice.userdata['id']}",
f"id_club={myice.userdata['id_club']}",
"language=FR",
]
),
# verify=False,
) as r:
data = r.json()["eventData"]
return data
Generated
+218 -1210
View File
File diff suppressed because it is too large Load Diff
+8 -27
View File
@@ -1,42 +1,23 @@
[project]
[tool.poetry]
name = "myice"
version = "v0.5.7"
version = "v0.1.6"
description = "myice parsing"
authors = [
{ name = "Rene Luria", "email" = "<rene@luria.ch>"},
]
authors = ["Rene Luria <rene@luria.ch>"]
license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
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)",
]
[tool.poetry.dependencies]
rl-ai-tools = { source = "infomaniak" }
python = "^3.12"
requests = "^2.32.3"
typer = "^0.12.5"
[tool.poetry.group.dev.dependencies]
ipykernel = "^6.29.5"
types-requests = "^2.32.0.20241016"
mypy = "^1.15.0"
[[tool.poetry.source]]
name = "infomaniak"
url = "https://pypi.purple.infomaniak.ch"
priority = "supplemental"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[project.scripts]
[tool.poetry.scripts]
myice = 'myice.myice:app'
[tool.poetry.requires-plugins]
poetry-plugin-export = ">=1.8"