Compare commits

...

15 Commits

Author SHA1 Message Date
herel e4db258bc5 fix: update version 2025-12-10 11:38:16 +01:00
herel 449adc60e0 feat: dark mode and python 3.14 2025-12-10 11:36:40 +01:00
herel d68dc8411f chore: speedup readyness 2025-10-15 17:25:08 +02:00
herel 60f776c56d chore: service internal traffic policy 2025-10-15 17:24:13 +02:00
herel 7228dc4ec6 chore: add scale-to-zero overlay 2025-10-15 17:11:30 +02:00
herel e4989dcc8d chore: bump production image tag to 1.2.0 2025-10-07 08:47:52 +02:00
herel 3fdbfcf20e feat: replace checkbox with radio buttons for exercise type selection
- Replace the checkbox "Générer uniquement des multiplications" with radio buttons for "Multiplications uniquement" and "Multiplications ET divisions"
- Update JavaScript code to handle radio button values instead of checkbox
- Maintain backward compatibility with old localStorage format
- Ensure all functionality remains intact
2025-10-07 08:46:38 +02:00
herel b17afbb5cd chore: bump production image tag to 1.1.0 2025-10-07 08:34:21 +02:00
herel 711a26c26c chore: bump production image tag to 1.0.10 2025-10-07 08:33:13 +02:00
herel 21c9bfd359 feat: implement localStorage for form parameter persistence
- Add saveFormValues() function to store all form parameters in localStorage
- Add restoreFormValues() function to retrieve form parameters from localStorage
- Modify DOMContentLoaded event to restore form values on page load
- Add calls to saveFormValues() in both form submission handlers
- Add event listeners to save form values whenever any input changes

This enhancement improves user experience by preserving form settings between visits.
2025-10-07 08:29:42 +02:00
herel 3d720ff426 feat: add configurable max_first_operand for multiplication exercises with randomized operand order 2025-10-07 08:23:34 +02:00
herel d9dd1281c3 refactor: move S3 functions to separate file for clarity 2025-10-07 08:12:54 +02:00
herel edefce9703 chore: use cache for pip 2025-10-07 08:09:42 +02:00
herel eb6da3d9cf feat: multi-stage build for slim image 2025-10-07 07:58:08 +02:00
herel bc55b95bda feat: permit to generate only multiplications 2025-10-07 07:55:18 +02:00
11 changed files with 618 additions and 196 deletions
+19 -23
View File
@@ -1,28 +1,25 @@
# Use Python 3.13 slim image as base # Use Python 3.13 slim image as base
FROM python:3.13-slim FROM python:3.14-slim AS builder
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Set environment variables # Create and set pip cache directory
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PIP_CACHE_DIR=/root/.cache/pip
PYTHONUNBUFFERED=1 \
POETRY_NO_INTERACTION=1 \
POETRY_VENV_IN_PROJECT=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements file # Copy requirements file
COPY requirements.txt . COPY requirements.txt .
# Install Python dependencies # Install Python dependencies with bind-mounted cache
RUN pip install --no-cache-dir --upgrade pip && \ RUN --mount=type=cache,id=pip,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt pip install --no-deps \
--disable-pip-version-check \
--target=/app/site-packages \
-r requirements.txt
FROM python:3.14-slim
COPY --from=builder /app/site-packages /app/site-packages
# Create a non-root user # Create a non-root user
RUN adduser --disabled-password --gecos '' appuser RUN adduser --disabled-password --gecos '' appuser
@@ -31,8 +28,11 @@ RUN adduser --disabled-password --gecos '' appuser
COPY ./app /app/app COPY ./app /app/app
COPY ./generate_math_exercises.py /app/ COPY ./generate_math_exercises.py /app/
# Change ownership of the app directory to the non-root user ENV PYTHONPATH=/app/site-packages
RUN chown -R appuser:appuser /app ENV PATH=/app/site-packages/bin:/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Set working directory
WORKDIR /app
# Switch to non-root user # Switch to non-root user
USER appuser USER appuser
@@ -40,9 +40,5 @@ USER appuser
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run the application # Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+87 -139
View File
@@ -1,12 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import random import random
import os
import io import io
import boto3
import zipfile import zipfile
import tempfile import tempfile
from botocore.exceptions import ClientError
from typing import List from typing import List
from fastapi import FastAPI, Request, Form from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
@@ -19,6 +16,16 @@ import logging
# Import the functions from gen_op.py # Import the functions from gen_op.py
from app.gen_op import generer_pdf_exercices_en_memoire from app.gen_op import generer_pdf_exercices_en_memoire
# Import S3 functions
from app.s3_utils import (
S3_BUCKET_NAME,
create_bucket_if_not_exists,
upload_to_s3,
download_from_s3,
list_objects_in_s3,
delete_from_s3,
)
# Custom filter to exclude health check logs # Custom filter to exclude health check logs
class HealthCheckFilter(logging.Filter): class HealthCheckFilter(logging.Filter):
@@ -32,53 +39,9 @@ logging.getLogger("uvicorn.access").addFilter(HealthCheckFilter())
app = FastAPI() app = FastAPI()
# S3 Configuration
S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", "math-exercises")
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
def get_s3_client():
"""Create and return an S3 client using environment variables"""
s3_access_key = os.environ.get("S3_ACCESS_KEY")
s3_secret_key = os.environ.get("S3_SECRET_KEY")
s3_host_base = os.environ.get("S3_HOST_BASE")
if not all([s3_access_key, s3_secret_key, s3_host_base]):
raise ValueError("S3 environment variables not properly set")
s3 = boto3.client(
"s3",
aws_access_key_id=s3_access_key,
aws_secret_access_key=s3_secret_key,
endpoint_url=s3_host_base,
region_name="us-east-1", # Required but unused for Infomaniak
)
return s3
def create_bucket_if_not_exists(bucket_name):
"""Create S3 bucket if it doesn't exist"""
s3_client = get_s3_client()
try:
s3_client.head_bucket(Bucket=bucket_name)
except ClientError as e:
error_code = int(e.response["Error"]["Code"])
if error_code == 404:
# Bucket doesn't exist, create it
try:
s3_client.create_bucket(Bucket=bucket_name)
print(f"Bucket {bucket_name} created successfully")
except ClientError as create_error:
print(f"Error creating bucket: {create_error}")
raise
else:
# Some other error
print(f"Error checking bucket: {e}")
raise
# Create bucket on startup # Create bucket on startup
try: try:
create_bucket_if_not_exists(S3_BUCKET_NAME) create_bucket_if_not_exists(S3_BUCKET_NAME)
@@ -86,67 +49,6 @@ except Exception as e:
print(f"Warning: Could not create/check S3 bucket: {e}") print(f"Warning: Could not create/check S3 bucket: {e}")
def upload_to_s3(file_data, bucket_name, object_name, content_type="application/pdf"):
"""Upload file data to S3 bucket"""
s3_client = get_s3_client()
try:
s3_client.put_object(
Bucket=bucket_name,
Key=object_name,
Body=file_data,
ContentType=content_type,
)
return True
except ClientError as e:
print(f"Error uploading to S3: {e}")
return False
def download_from_s3(bucket_name, object_name):
"""Download file data from S3 bucket"""
s3_client = get_s3_client()
try:
response = s3_client.get_object(Bucket=bucket_name, Key=object_name)
return response["Body"].read()
except ClientError as e:
print(f"Error downloading from S3: {e}")
return None
def list_objects_in_s3(bucket_name):
"""List all objects in S3 bucket (sorted from newest to oldest)"""
s3_client = get_s3_client()
try:
response = s3_client.list_objects_v2(Bucket=bucket_name)
if "Contents" in response:
# Filter for PDF files only and sort by last modified (newest first)
pdf_files = [
obj for obj in response["Contents"] if obj["Key"].endswith(".pdf")
]
pdf_files.sort(key=lambda x: x["LastModified"], reverse=True)
return pdf_files
else:
return []
except ClientError as e:
print(f"Error listing objects in S3: {e}")
return []
def delete_from_s3(bucket_name, object_name):
"""Delete file from S3 bucket"""
s3_client = get_s3_client()
try:
s3_client.delete_object(Bucket=bucket_name, Key=object_name)
return True
except ClientError as e:
print(f"Error deleting from S3: {e}")
return False
class MathExercisesPDF(FPDF): class MathExercisesPDF(FPDF):
def header(self): def header(self):
self.set_font("Helvetica", "B", 16) self.set_font("Helvetica", "B", 16)
@@ -167,6 +69,8 @@ class ExerciseRequest(BaseModel):
min_table: int min_table: int
max_table: int max_table: int
num_exercises: int = 15 num_exercises: int = 15
multiplication_only: bool = False
max_first_operand: int = 12 # New parameter for the maximum value of the first operand
class OperationExerciseRequest(BaseModel): class OperationExerciseRequest(BaseModel):
@@ -174,7 +78,11 @@ class OperationExerciseRequest(BaseModel):
def generate_exercises( def generate_exercises(
min_table: int, max_table: int, num_exercises: int = 15 min_table: int,
max_table: int,
num_exercises: int = 15,
multiplication_only: bool = False,
max_first_operand: int = 12, # New parameter for the maximum value of the first operand
) -> List[str]: ) -> List[str]:
"""Génère des exercices de multiplication et division aléatoires mélangés sans doublons""" """Génère des exercices de multiplication et division aléatoires mélangés sans doublons"""
exercises: List[str] = [] exercises: List[str] = []
@@ -187,23 +95,28 @@ def generate_exercises(
while len(exercises) < num_exercises and attempts < max_attempts: while len(exercises) < num_exercises and attempts < max_attempts:
attempts += 1 attempts += 1
# Choisir deux nombres aléatoires entre min_table et max_table # Pour les multiplications, un des opérandes est entre 1 et max_first_operand
a = random.randint(min_table, max_table) # et l'autre est entre min_table et max_table
b = random.randint(min_table, max_table) if multiplication_only or random.choice([True, False]):
result = a * b
# Choisir aléatoirement le type d'exercice (seulement multiplication ou division)
exercise_type = random.choice(["multiplication", "division"])
if exercise_type == "multiplication":
# Exercice de multiplication # Exercice de multiplication
operation_key = f"mult_{a}_{b}" # Clé unique pour cette opération a = random.randint(1, max_first_operand) # Premier opérande entre 1 et max_first_operand
b = random.randint(min_table, max_table) # Second opérande entre min_table et max_table
# Randomize the order of operands
if random.choice([True, False]):
a, b = b, a
result = a * b
operation_key = f"mult_{min(a,b)}_{max(a,b)}" # Clé unique pour cette opération (order-independent)
if operation_key not in used_operations: if operation_key not in used_operations:
exercise = f"{a} · {b} = ____" exercise = f"{a} · {b} = ____"
exercises.append(exercise) exercises.append(exercise)
used_operations.add(operation_key) used_operations.add(operation_key)
else: # division elif not multiplication_only: # division
# Exercice de division # Exercice de division
a = random.randint(min_table, max_table)
b = random.randint(min_table, max_table)
result = a * b
divisor = random.choice([a, b]) divisor = random.choice([a, b])
operation_key = f"div_{result}_{divisor}" # Clé unique pour cette opération operation_key = f"div_{result}_{divisor}" # Clé unique pour cette opération
if operation_key not in used_operations: if operation_key not in used_operations:
@@ -217,18 +130,33 @@ def generate_exercises(
remaining = num_exercises - len(exercises) remaining = num_exercises - len(exercises)
for _ in range(remaining): for _ in range(remaining):
# Générer des variations avec des nombres légèrement différents # Générer des variations avec des nombres légèrement différents
a = random.randint(min_table, max_table) # Pour les multiplications, un des opérandes est entre 1 et max_first_operand
b = random.randint(min_table, max_table) # et l'autre est entre min_table et max_table
result = a * b if multiplication_only or random.choice([True, False]):
# Exercice de multiplication
# Choisir aléatoirement le type d'exercice a = random.randint(1, max_first_operand) # Premier opérande entre 1 et max_first_operand
exercise_type = random.choice(["multiplication", "division"]) b = random.randint(min_table, max_table) # Second opérande entre min_table et max_table
if exercise_type == "multiplication": # Randomize the order of operands
if random.choice([True, False]):
a, b = b, a
result = a * b
exercise = f"{a} · {b} = ____" exercise = f"{a} · {b} = ____"
else: # division elif not multiplication_only: # division
a = random.randint(min_table, max_table)
b = random.randint(min_table, max_table)
result = a * b
divisor = random.choice([a, b]) divisor = random.choice([a, b])
exercise = f"{result} : {divisor} = ____" exercise = f"{result} : {divisor} = ____"
else:
# Fallback to multiplication if multiplication_only is True
a = random.randint(1, max_first_operand)
b = random.randint(min_table, max_table)
# Randomize the order of operands
if random.choice([True, False]):
a, b = b, a
exercise = f"{a} · {b} = ____"
exercises.append(exercise) exercises.append(exercise)
@@ -236,7 +164,11 @@ def generate_exercises(
def create_math_exercises_pdf( def create_math_exercises_pdf(
min_table: int, max_table: int, num_exercises: int = 15 min_table: int,
max_table: int,
num_exercises: int = 15,
multiplication_only: bool = False,
max_first_operand: int = 12, # New parameter for the maximum value of the first operand
) -> str: ) -> str:
"""Crée un fichier PDF avec des exercices de mathématiques mélangés en 3 colonnes et l'upload sur S3""" """Crée un fichier PDF avec des exercices de mathématiques mélangés en 3 colonnes et l'upload sur S3"""
import datetime import datetime
@@ -245,14 +177,24 @@ def create_math_exercises_pdf(
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
# Ajouter des informations sur la plage de tables # Ajouter des informations sur la plage de tables
if min_table == max_table: if multiplication_only:
table_info = f"Tables de multiplication et division pour {min_table}" if min_table == max_table:
pdf_filename = f"exercices_mathematiques_table_{min_table}_{num_exercises}_exercices_{timestamp}.pdf" table_info = f"Tables de multiplication seulement pour {min_table}"
pdf_filename = f"exercices_multiplication_seulement_table_{min_table}_{num_exercises}_exercices_{timestamp}.pdf"
else:
table_info = (
f"Tables de multiplication seulement de {min_table} à {max_table}"
)
pdf_filename = f"exercices_multiplication_seulement_tables_{min_table}_a_{max_table}_{num_exercises}_exercices_{timestamp}.pdf"
else: else:
table_info = ( if min_table == max_table:
f"Tables de multiplication et division de {min_table} à {max_table}" table_info = f"Tables de multiplication et division pour {min_table}"
) pdf_filename = f"exercices_mathematiques_table_{min_table}_{num_exercises}_exercices_{timestamp}.pdf"
pdf_filename = f"exercices_mathematiques_tables_{min_table}_a_{max_table}_{num_exercises}_exercices_{timestamp}.pdf" else:
table_info = (
f"Tables de multiplication et division de {min_table} à {max_table}"
)
pdf_filename = f"exercices_mathematiques_tables_{min_table}_a_{max_table}_{num_exercises}_exercices_{timestamp}.pdf"
pdf = MathExercisesPDF() pdf = MathExercisesPDF()
pdf.add_page() pdf.add_page()
@@ -262,7 +204,9 @@ def create_math_exercises_pdf(
pdf.ln(5) pdf.ln(5)
# Générer les exercices # Générer les exercices
exercises = generate_exercises(min_table, max_table, num_exercises) exercises = generate_exercises(
min_table, max_table, num_exercises, multiplication_only, max_first_operand
)
# Pas d'en-têtes de colonnes # Pas d'en-têtes de colonnes
@@ -354,7 +298,11 @@ async def generate_exercises_endpoint(request: ExerciseRequest):
return {"error": "Le nombre d'exercices doit être supérieur à 0"} return {"error": "Le nombre d'exercices doit être supérieur à 0"}
pdf_filename = create_math_exercises_pdf( pdf_filename = create_math_exercises_pdf(
request.min_table, request.max_table, request.num_exercises request.min_table,
request.max_table,
request.num_exercises,
request.multiplication_only,
request.max_first_operand, # Pass the new parameter
) )
# Return redirect to automatically download the file # Return redirect to automatically download the file
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
import os
import boto3
from botocore.exceptions import ClientError
from typing import List, Optional
# S3 Configuration
S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", "math-exercises")
def get_s3_client():
"""Create and return an S3 client using environment variables"""
s3_access_key = os.environ.get("S3_ACCESS_KEY")
s3_secret_key = os.environ.get("S3_SECRET_KEY")
s3_host_base = os.environ.get("S3_HOST_BASE")
if not all([s3_access_key, s3_secret_key, s3_host_base]):
raise ValueError("S3 environment variables not properly set")
s3 = boto3.client(
"s3",
aws_access_key_id=s3_access_key,
aws_secret_access_key=s3_secret_key,
endpoint_url=s3_host_base,
region_name="us-east-1", # Required but unused for Infomaniak
)
return s3
def create_bucket_if_not_exists(bucket_name: str):
"""Create S3 bucket if it doesn't exist"""
s3_client = get_s3_client()
try:
s3_client.head_bucket(Bucket=bucket_name)
except ClientError as e:
error_code = int(e.response["Error"]["Code"])
if error_code == 404:
# Bucket doesn't exist, create it
try:
s3_client.create_bucket(Bucket=bucket_name)
print(f"Bucket {bucket_name} created successfully")
except ClientError as create_error:
print(f"Error creating bucket: {create_error}")
raise
else:
# Some other error
print(f"Error checking bucket: {e}")
raise
def upload_to_s3(
file_data: bytes,
bucket_name: str,
object_name: str,
content_type: str = "application/pdf",
) -> bool:
"""Upload file data to S3 bucket"""
s3_client = get_s3_client()
try:
s3_client.put_object(
Bucket=bucket_name,
Key=object_name,
Body=file_data,
ContentType=content_type,
)
return True
except ClientError as e:
print(f"Error uploading to S3: {e}")
return False
def download_from_s3(bucket_name: str, object_name: str) -> Optional[bytes]:
"""Download file data from S3 bucket"""
s3_client = get_s3_client()
try:
response = s3_client.get_object(Bucket=bucket_name, Key=object_name)
return response["Body"].read()
except ClientError as e:
print(f"Error downloading from S3: {e}")
return None
def list_objects_in_s3(bucket_name: str) -> List[dict]:
"""List all objects in S3 bucket (sorted from newest to oldest)"""
s3_client = get_s3_client()
try:
response = s3_client.list_objects_v2(Bucket=bucket_name)
if "Contents" in response:
# Filter for PDF files only and sort by last modified (newest first)
pdf_files = [
obj for obj in response["Contents"] if obj["Key"].endswith(".pdf")
]
pdf_files.sort(key=lambda x: x["LastModified"], reverse=True)
return pdf_files
else:
return []
except ClientError as e:
print(f"Error listing objects in S3: {e}")
return []
def delete_from_s3(bucket_name: str, object_name: str) -> bool:
"""Delete file from S3 bucket"""
s3_client = get_s3_client()
try:
s3_client.delete_object(Bucket=bucket_name, Key=object_name)
return True
except ClientError as e:
print(f"Error deleting from S3: {e}")
return False
+275 -7
View File
@@ -5,17 +5,42 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Générateur d'Exercices de Mathématiques</title> <title>Générateur d'Exercices de Mathématiques</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style> <style>
:root {
--bs-body-bg: #ffffff;
--bs-body-color: #212529;
--bs-border-color: #dee2e6;
--bs-secondary-bg: #f8f9fa;
--bs-tertiary-bg: #ffffff;
}
[data-bs-theme="dark"] {
--bs-body-bg: #121212;
--bs-body-color: #e9ecef;
--bs-border-color: #495057;
--bs-secondary-bg: #212529;
--bs-tertiary-bg: #343a40;
}
body {
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card { .card {
box-shadow: 0 4px 8px rgba(0,0,0,0.1); box-shadow: 0 4px 8px rgba(0,0,0,0.1);
transition: 0.3s; transition: 0.3s;
margin-bottom: 20px; margin-bottom: 20px;
background-color: var(--bs-tertiary-bg);
border-color: var(--bs-border-color);
} }
.card:hover { .card:hover {
box-shadow: 0 8px 16px rgba(0,0,0,0.2); box-shadow: 0 8px 16px rgba(0,0,0,0.2);
} }
.exercise-result { .exercise-result {
background-color: #f8f9fa; background-color: var(--bs-secondary-bg);
border-radius: 5px; border-radius: 5px;
padding: 15px; padding: 15px;
margin-top: 20px; margin-top: 20px;
@@ -25,9 +50,81 @@
height: 3rem; height: 3rem;
margin-right: 0.75rem; margin-right: 0.75rem;
} }
.dark-mode-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
border-radius: 50%;
width: 50px;
height: 50px;
border: none;
background-color: var(--bs-tertiary-bg);
color: var(--bs-body-color);
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
transition: all 0.3s ease;
}
.dark-mode-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
[data-bs-theme="dark"] .bg-light {
background-color: var(--bs-secondary-bg) !important;
}
[data-bs-theme="dark"] .text-muted {
color: var(--bs-body-color) !important;
opacity: 0.7;
}
[data-bs-theme="dark"] .table {
--bs-table-bg: var(--bs-tertiary-bg);
--bs-table-striped-bg: var(--bs-secondary-bg);
--bs-table-hover-bg: var(--bs-border-color);
}
[data-bs-theme="dark"] .alert {
border-color: var(--bs-border-color);
}
[data-bs-theme="dark"] .form-control,
[data-bs-theme="dark"] .form-select {
background-color: var(--bs-tertiary-bg);
border-color: var(--bs-border-color);
color: var(--bs-body-color);
}
[data-bs-theme="dark"] .form-control:focus,
[data-bs-theme="dark"] .form-select:focus {
background-color: var(--bs-tertiary-bg);
border-color: var(--bs-primary);
color: var(--bs-body-color);
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
[data-bs-theme="dark"] .form-check-input {
background-color: var(--bs-tertiary-bg);
border-color: var(--bs-border-color);
}
[data-bs-theme="dark"] .form-check-input:checked {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
}
.transition-all {
transition: all 0.3s ease;
}
</style> </style>
</head> </head>
<body> <body>
<!-- Dark Mode Toggle Button -->
<button class="dark-mode-toggle" id="darkModeToggle" title="Basculer le mode sombre" aria-label="Basculer le mode sombre">
<i class="bi bi-moon-fill" id="darkModeIcon"></i>
</button>
<div class="container mt-5 mb-5"> <div class="container mt-5 mb-5">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-8"> <div class="col-lg-8">
@@ -61,6 +158,32 @@
<div class="form-text">Nombre total d'exercices à générer (entre 1 et 100)</div> <div class="form-text">Nombre total d'exercices à générer (entre 1 et 100)</div>
</div> </div>
<div class="mb-3">
<label class="form-label">Types d'exercices</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="exerciseType" id="multiplicationOnly" value="multiplication" checked>
<label class="form-check-label" for="multiplicationOnly">
Multiplications uniquement
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="exerciseType" id="multiplicationAndDivision" value="both">
<label class="form-check-label" for="multiplicationAndDivision">
Multiplications ET divisions
</label>
</div>
</div>
<div class="mb-3">
<label for="maxFirstOperand" class="form-label">Maximum du premier opérande</label>
<select class="form-select" id="maxFirstOperand">
<option value="9">9 (Tables jusqu'à 9)</option>
<option value="10" selected>10 (Tables jusqu'à 10)</option>
<option value="12">12 (Tables jusqu'à 12)</option>
</select>
<div class="form-text">La valeur maximale pour le premier opérande dans les multiplications</div>
</div>
<div class="d-grid"> <div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg" id="generateBtn"> <button type="submit" class="btn btn-primary btn-lg" id="generateBtn">
<span id="buttonText">Générer le PDF (Mult/Div)</span> <span id="buttonText">Générer le PDF (Mult/Div)</span>
@@ -225,7 +348,7 @@
</div> </div>
</div> </div>
<footer class="bg-light text-center text-muted py-4 mt-5"> <footer class="bg-light text-center text-muted py-4 mt-5 transition-all">
<div class="container"> <div class="container">
<p>Générateur d'Exercices de Mathématiques &copy; 2025</p> <p>Générateur d'Exercices de Mathématiques &copy; 2025</p>
</div> </div>
@@ -233,9 +356,125 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
// Dark Mode Theme Management
class ThemeManager {
constructor() {
this.init();
}
init() {
// Load saved theme or detect system preference
const savedTheme = localStorage.getItem('theme');
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const theme = savedTheme || systemTheme;
this.setTheme(theme);
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
this.setTheme(e.matches ? 'dark' : 'light');
}
});
// Setup toggle button
this.setupToggleButton();
}
setTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
localStorage.setItem('theme', theme);
this.updateToggleIcon(theme);
}
toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-bs-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
this.setTheme(newTheme);
}
updateToggleIcon(theme) {
const icon = document.getElementById('darkModeIcon');
if (theme === 'dark') {
icon.className = 'bi bi-sun-fill';
} else {
icon.className = 'bi bi-moon-fill';
}
}
setupToggleButton() {
const toggleButton = document.getElementById('darkModeToggle');
toggleButton.addEventListener('click', () => {
this.toggleTheme();
this.animateToggle();
});
}
animateToggle() {
const button = document.getElementById('darkModeToggle');
button.style.transform = 'scale(0.9)';
setTimeout(() => {
button.style.transform = '';
}, 150);
}
}
// Initialize theme manager
const themeManager = new ThemeManager();
// Current page for pagination // Current page for pagination
let currentPage = 1; let currentPage = 1;
const pageSize = 10; const pageSize = 10;
// Save form values to localStorage
function saveFormValues() {
const exerciseFormValues = {
minTable: document.getElementById('minTable').value,
maxTable: document.getElementById('maxTable').value,
numExercises: document.getElementById('numExercises').value,
exerciseType: document.querySelector('input[name="exerciseType"]:checked').value,
maxFirstOperand: document.getElementById('maxFirstOperand').value
};
const operationFormValues = {
numOperationExercises: document.getElementById('numOperationExercises').value
};
localStorage.setItem('exerciseFormValues', JSON.stringify(exerciseFormValues));
localStorage.setItem('operationFormValues', JSON.stringify(operationFormValues));
}
// Restore form values from localStorage
function restoreFormValues() {
// Restore exercise form values
const savedExerciseValues = localStorage.getItem('exerciseFormValues');
if (savedExerciseValues) {
const exerciseValues = JSON.parse(savedExerciseValues);
document.getElementById('minTable').value = exerciseValues.minTable || 1;
document.getElementById('maxTable').value = exerciseValues.maxTable || 10;
document.getElementById('numExercises').value = exerciseValues.numExercises || 15;
// Restore radio button selection
if (exerciseValues.exerciseType) {
const radio = document.querySelector(`input[name="exerciseType"][value="${exerciseValues.exerciseType}"]`);
if (radio) {
radio.checked = true;
}
} else if (exerciseValues.multiplicationOnly !== undefined) {
// Handle legacy storage format
document.getElementById('multiplicationOnly').checked = exerciseValues.multiplicationOnly;
}
document.getElementById('maxFirstOperand').value = exerciseValues.maxFirstOperand || 10;
}
// Restore operation form values
const savedOperationValues = localStorage.getItem('operationFormValues');
if (savedOperationValues) {
const operationValues = JSON.parse(savedOperationValues);
document.getElementById('numOperationExercises').value = operationValues.numOperationExercises || 20;
}
}
// Function to load and display existing PDFs with pagination (sorted from newest to oldest) // Function to load and display existing PDFs with pagination (sorted from newest to oldest)
async function loadPdfList(page = 1) { async function loadPdfList(page = 1) {
@@ -409,7 +648,23 @@
// Load PDF list on page load // Load PDF list on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadPdfList(currentPage); // Ensure theme is initialized first
setTimeout(() => {
restoreFormValues();
loadPdfList(currentPage);
}, 100);
});
// Add event listeners to save form values on change
document.getElementById('minTable').addEventListener('change', saveFormValues);
document.getElementById('maxTable').addEventListener('change', saveFormValues);
document.getElementById('numExercises').addEventListener('change', saveFormValues);
document.getElementById('maxFirstOperand').addEventListener('change', saveFormValues);
document.getElementById('numOperationExercises').addEventListener('change', saveFormValues);
// Add event listeners for radio buttons
document.querySelectorAll('input[name="exerciseType"]').forEach(radio => {
radio.addEventListener('change', saveFormValues);
}); });
// Event listener for select all button // Event listener for select all button
@@ -508,9 +763,15 @@
document.getElementById('exerciseForm').addEventListener('submit', async function(e) { document.getElementById('exerciseForm').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
// Save form values to localStorage
saveFormValues();
const minTable = parseInt(document.getElementById('minTable').value); const minTable = parseInt(document.getElementById('minTable').value);
const maxTable = parseInt(document.getElementById('maxTable').value); const maxTable = parseInt(document.getElementById('maxTable').value);
const numExercises = parseInt(document.getElementById('numExercises').value); const numExercises = parseInt(document.getElementById('numExercises').value);
const exerciseType = document.querySelector('input[name="exerciseType"]:checked').value;
const multiplicationOnly = (exerciseType === 'multiplication');
const maxFirstOperand = parseInt(document.getElementById('maxFirstOperand').value);
const generateBtn = document.getElementById('generateBtn'); const generateBtn = document.getElementById('generateBtn');
const buttonText = document.getElementById('buttonText'); const buttonText = document.getElementById('buttonText');
@@ -530,7 +791,9 @@
body: JSON.stringify({ body: JSON.stringify({
min_table: minTable, min_table: minTable,
max_table: maxTable, max_table: maxTable,
num_exercises: numExercises num_exercises: numExercises,
multiplication_only: multiplicationOnly,
max_first_operand: maxFirstOperand
}) })
}); });
@@ -545,13 +808,14 @@
// Show success message // Show success message
const resultContainer = document.getElementById('resultContainer'); const resultContainer = document.getElementById('resultContainer');
const resultMessage = document.getElementById('resultMessage'); const resultMessage = document.getElementById('resultMessage');
const exerciseType = multiplicationOnly ? "multiplications uniquement" : "multiplications et divisions";
resultMessage.innerHTML = ` resultMessage.innerHTML = `
<div class="alert alert-success"> <div class="alert alert-success">
<h6>PDF généré avec succès!</h6> <h6>PDF généré avec succès!</h6>
<p>Le téléchargement devrait commencer automatiquement.</p> <p>Le téléchargement devrait commencer automatiquement.</p>
<ul class="mb-0"> <ul class="mb-0">
<li>Tables de ${minTable} à ${maxTable}</li> <li>Tables de ${minTable} à ${maxTable}</li>
<li>${numExercises} exercices générés</li> <li>${numExercises} exercices générés (${exerciseType})</li>
</ul> </ul>
</div> </div>
`; `;
@@ -580,14 +844,18 @@
spinner.classList.add('d-none'); spinner.classList.add('d-none');
generateBtn.disabled = false; generateBtn.disabled = false;
} }
}); });
// Handle operation exercises form submission // Handle operation exercises form submission
document.getElementById('operationExerciseForm').addEventListener('submit', async function(e) { document.getElementById('operationExerciseForm').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
// Save form values to localStorage
saveFormValues();
const numExercises = parseInt(document.getElementById('numOperationExercises').value); const numExercises = parseInt(document.getElementById('numOperationExercises').value);
const generateBtn = document.getElementById('generateOperationBtn'); const generateBtn = document.getElementById('generateOperationBtn');
const buttonText = document.getElementById('operationButtonText'); const buttonText = document.getElementById('operationButtonText');
const spinner = document.getElementById('operationSpinner'); const spinner = document.getElementById('operationSpinner');
+2 -2
View File
@@ -57,8 +57,8 @@ spec:
httpGet: httpGet:
path: /health path: /health
port: 8000 port: 8000
initialDelaySeconds: 5 initialDelaySeconds: 2
periodSeconds: 10 periodSeconds: 3
timeoutSeconds: 3 timeoutSeconds: 3
failureThreshold: 3 failureThreshold: 3
# Environment variables from ConfigMap # Environment variables from ConfigMap
@@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
creationTimestamp: null
labels:
app: keda-add-ons-http-interceptor-proxy
name: keda-add-ons-http-interceptor-proxy
spec:
externalName: keda-add-ons-http-interceptor-proxy.keda
selector:
app: keda-add-ons-http-interceptor-proxy
type: ExternalName
status:
loadBalancer: {}
@@ -0,0 +1,24 @@
kind: HTTPScaledObject
apiVersion: http.keda.sh/v1alpha1
metadata:
name: math-exercises
spec:
hosts:
- "math-tables.cl1.parano.ch"
pathPrefixes:
- /
scaleTargetRef:
name: math-exercises-app
kind: Deployment
apiVersion: apps/v1
service: math-exercises-service
portName: http
replicas:
min: 0
max: 3
scaledownPeriod: 60
scalingMetric:
requestRate:
granularity: 1s
targetValue: 100
window: 1m
@@ -0,0 +1,51 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: math-tables
resources:
- ../production
- external-service.yaml
- http-scaled-object.yaml
patches:
- patch: |
[
{
"op": "remove",
"path": "/spec/replicas"
}
]
target:
group: apps
version: v1
kind: Deployment
name: math-exercises-app
- path: remove-pdb.yaml
- patch: |
[
{
"op": "replace",
"path": "/spec/rules/0/http/paths/0/backend/service/port/number",
"value": 8080
},
{
"op": "replace",
"path": "/spec/rules/0/http/paths/0/backend/service/name",
"value": "keda-add-ons-http-interceptor-proxy"
}
]
target:
group: networking.k8s.io
version: v1
kind: Ingress
name: math-exercises-ingress
- patch: |
[
{
"op": "replace",
"path": "/spec/internalTrafficPolicy",
"value": "Cluster"
}
]
target:
version: v1
kind: Service
name: math-exercises-service
@@ -0,0 +1,5 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: math-exercises-pdb
$patch: delete
@@ -13,7 +13,7 @@ resources:
images: images:
- name: math-exercises - name: math-exercises
newName: harbor.cl1.parano.ch/library/math-exercice newName: harbor.cl1.parano.ch/library/math-exercice
newTag: 1.0.9 newTag: 1.4.0
# Production-specific labels # Production-specific labels
+24 -24
View File
@@ -1,32 +1,32 @@
annotated-doc==0.0.4
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.10.0 anyio==4.12.0
boto3==1.26.160 boto3==1.42.6
boto3-stubs==1.40.23 boto3-stubs==1.42.6
botocore==1.29.165 botocore==1.42.6
botocore-stubs==1.40.23 botocore-stubs==1.42.6
click==8.2.1 click==8.3.1
defusedxml==0.7.1 defusedxml==0.7.1
fastapi==0.116.1 fastapi==0.124.0
fonttools==4.59.2 fonttools==4.61.0
fpdf2==2.8.4 fpdf2==2.8.5
h11==0.16.0 h11==0.16.0
idna==3.10 idna==3.11
Jinja2==3.1.4 Jinja2==3.1.6
jmespath==1.0.1 jmespath==1.0.1
MarkupSafe==3.0.2 MarkupSafe==3.0.3
pillow==11.3.0 pillow==12.0.0
pydantic==2.11.7 pydantic==2.12.5
pydantic_core==2.33.2 pydantic_core==2.41.5
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-multipart==0.0.9 python-multipart==0.0.20
s3transfer==0.6.2 s3transfer==0.16.0
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
starlette==0.47.3 starlette==0.50.0
types-awscrt==0.27.6 types-awscrt==0.29.2
types-fpdf2==2.8.4.20250822 types-s3transfer==0.16.0
types-s3transfer==0.13.1 typing-inspection==0.4.2
typing-inspection==0.4.1
typing_extensions==4.15.0 typing_extensions==4.15.0
urllib3==1.26.20 urllib3==2.6.1
uvicorn==0.30.6 uvicorn==0.38.0