Compare commits
12 Commits
edefce9703
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
e4db258bc5
|
|||
|
449adc60e0
|
|||
|
d68dc8411f
|
|||
|
60f776c56d
|
|||
|
7228dc4ec6
|
|||
|
e4989dcc8d
|
|||
|
3fdbfcf20e
|
|||
|
b17afbb5cd
|
|||
|
711a26c26c
|
|||
|
21c9bfd359
|
|||
|
3d720ff426
|
|||
|
d9dd1281c3
|
+2
-2
@@ -1,5 +1,5 @@
|
||||
# Use Python 3.13 slim image as base
|
||||
FROM python:3.13-slim AS builder
|
||||
FROM python:3.14-slim AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
@@ -17,7 +17,7 @@ RUN --mount=type=cache,id=pip,target=/root/.cache/pip \
|
||||
--target=/app/site-packages \
|
||||
-r requirements.txt
|
||||
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
COPY --from=builder /app/site-packages /app/site-packages
|
||||
|
||||
|
||||
+63
-137
@@ -1,12 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import random
|
||||
import os
|
||||
import io
|
||||
import boto3
|
||||
import zipfile
|
||||
import tempfile
|
||||
from botocore.exceptions import ClientError
|
||||
from typing import List
|
||||
from fastapi import FastAPI, Request, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||
@@ -19,6 +16,16 @@ import logging
|
||||
# Import the functions from gen_op.py
|
||||
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
|
||||
class HealthCheckFilter(logging.Filter):
|
||||
@@ -32,53 +39,9 @@ logging.getLogger("uvicorn.access").addFilter(HealthCheckFilter())
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# S3 Configuration
|
||||
S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", "math-exercises")
|
||||
|
||||
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
|
||||
try:
|
||||
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}")
|
||||
|
||||
|
||||
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):
|
||||
def header(self):
|
||||
self.set_font("Helvetica", "B", 16)
|
||||
@@ -168,6 +70,7 @@ class ExerciseRequest(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
class OperationExerciseRequest(BaseModel):
|
||||
@@ -175,7 +78,11 @@ class OperationExerciseRequest(BaseModel):
|
||||
|
||||
|
||||
def generate_exercises(
|
||||
min_table: int, max_table: int, num_exercises: int = 15, multiplication_only: bool = False
|
||||
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]:
|
||||
"""Génère des exercices de multiplication et division aléatoires mélangés sans doublons"""
|
||||
exercises: List[str] = []
|
||||
@@ -188,27 +95,28 @@ def generate_exercises(
|
||||
while len(exercises) < num_exercises and attempts < max_attempts:
|
||||
attempts += 1
|
||||
|
||||
# Choisir deux nombres aléatoires entre min_table et max_table
|
||||
a = random.randint(min_table, max_table)
|
||||
b = random.randint(min_table, max_table)
|
||||
result = a * b
|
||||
|
||||
# Déterminer le type d'exercice
|
||||
if multiplication_only:
|
||||
exercise_type = "multiplication"
|
||||
else:
|
||||
# Choisir aléatoirement le type d'exercice (seulement multiplication ou division)
|
||||
exercise_type = random.choice(["multiplication", "division"])
|
||||
|
||||
if exercise_type == "multiplication":
|
||||
# Pour les multiplications, un des opérandes est entre 1 et max_first_operand
|
||||
# et l'autre est entre min_table et max_table
|
||||
if multiplication_only or random.choice([True, False]):
|
||||
# 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:
|
||||
exercise = f"{a} · {b} = ____"
|
||||
exercises.append(exercise)
|
||||
used_operations.add(operation_key)
|
||||
elif not multiplication_only: # 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])
|
||||
operation_key = f"div_{result}_{divisor}" # Clé unique pour cette opération
|
||||
if operation_key not in used_operations:
|
||||
@@ -222,24 +130,32 @@ def generate_exercises(
|
||||
remaining = num_exercises - len(exercises)
|
||||
for _ in range(remaining):
|
||||
# Générer des variations avec des nombres légèrement différents
|
||||
# Pour les multiplications, un des opérandes est entre 1 et max_first_operand
|
||||
# et l'autre est entre min_table et max_table
|
||||
if multiplication_only or random.choice([True, False]):
|
||||
# Exercice de multiplication
|
||||
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
|
||||
exercise = f"{a} · {b} = ____"
|
||||
elif not multiplication_only: # division
|
||||
a = random.randint(min_table, max_table)
|
||||
b = random.randint(min_table, max_table)
|
||||
result = a * b
|
||||
|
||||
# Déterminer le type d'exercice
|
||||
if multiplication_only:
|
||||
exercise_type = "multiplication"
|
||||
else:
|
||||
# Choisir aléatoirement le type d'exercice
|
||||
exercise_type = random.choice(["multiplication", "division"])
|
||||
|
||||
if exercise_type == "multiplication":
|
||||
exercise = f"{a} · {b} = ____"
|
||||
elif not multiplication_only: # division
|
||||
divisor = random.choice([a, b])
|
||||
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)
|
||||
@@ -248,7 +164,11 @@ def generate_exercises(
|
||||
|
||||
|
||||
def create_math_exercises_pdf(
|
||||
min_table: int, max_table: int, num_exercises: int = 15, multiplication_only: bool = False
|
||||
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:
|
||||
"""Crée un fichier PDF avec des exercices de mathématiques mélangés en 3 colonnes et l'upload sur S3"""
|
||||
import datetime
|
||||
@@ -284,7 +204,9 @@ def create_math_exercises_pdf(
|
||||
pdf.ln(5)
|
||||
|
||||
# Générer les exercices
|
||||
exercises = generate_exercises(min_table, max_table, num_exercises, multiplication_only)
|
||||
exercises = generate_exercises(
|
||||
min_table, max_table, num_exercises, multiplication_only, max_first_operand
|
||||
)
|
||||
|
||||
# Pas d'en-têtes de colonnes
|
||||
|
||||
@@ -376,7 +298,11 @@ async def generate_exercises_endpoint(request: ExerciseRequest):
|
||||
return {"error": "Le nombre d'exercices doit être supérieur à 0"}
|
||||
|
||||
pdf_filename = create_math_exercises_pdf(
|
||||
request.min_table, request.max_table, request.num_exercises, request.multiplication_only
|
||||
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
|
||||
|
||||
+116
@@ -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
|
||||
+267
-7
@@ -5,17 +5,42 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<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 {
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
transition: 0.3s;
|
||||
margin-bottom: 20px;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
}
|
||||
.card:hover {
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||
}
|
||||
.exercise-result {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--bs-secondary-bg);
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin-top: 20px;
|
||||
@@ -25,9 +50,81 @@
|
||||
height: 3rem;
|
||||
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>
|
||||
</head>
|
||||
<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="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
@@ -61,9 +158,30 @@
|
||||
<div class="form-text">Nombre total d'exercices à générer (entre 1 et 100)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="multiplicationOnly">
|
||||
<label class="form-check-label" for="multiplicationOnly">Générer uniquement des multiplications</label>
|
||||
<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">
|
||||
@@ -230,7 +348,7 @@
|
||||
</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">
|
||||
<p>Générateur d'Exercices de Mathématiques © 2025</p>
|
||||
</div>
|
||||
@@ -238,10 +356,126 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></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
|
||||
let currentPage = 1;
|
||||
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)
|
||||
async function loadPdfList(page = 1) {
|
||||
const refreshBtn = document.getElementById('refreshListBtn');
|
||||
@@ -414,7 +648,23 @@
|
||||
|
||||
// Load PDF list on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 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
|
||||
@@ -513,10 +763,15 @@
|
||||
document.getElementById('exerciseForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Save form values to localStorage
|
||||
saveFormValues();
|
||||
|
||||
const minTable = parseInt(document.getElementById('minTable').value);
|
||||
const maxTable = parseInt(document.getElementById('maxTable').value);
|
||||
const numExercises = parseInt(document.getElementById('numExercises').value);
|
||||
const multiplicationOnly = document.getElementById('multiplicationOnly').checked;
|
||||
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 buttonText = document.getElementById('buttonText');
|
||||
@@ -537,7 +792,8 @@
|
||||
min_table: minTable,
|
||||
max_table: maxTable,
|
||||
num_exercises: numExercises,
|
||||
multiplication_only: multiplicationOnly
|
||||
multiplication_only: multiplicationOnly,
|
||||
max_first_operand: maxFirstOperand
|
||||
})
|
||||
});
|
||||
|
||||
@@ -594,8 +850,12 @@
|
||||
document.getElementById('operationExerciseForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Save form values to localStorage
|
||||
saveFormValues();
|
||||
|
||||
const numExercises = parseInt(document.getElementById('numOperationExercises').value);
|
||||
|
||||
|
||||
const generateBtn = document.getElementById('generateOperationBtn');
|
||||
const buttonText = document.getElementById('operationButtonText');
|
||||
const spinner = document.getElementById('operationSpinner');
|
||||
|
||||
@@ -57,8 +57,8 @@ spec:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 3
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
# 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:
|
||||
- name: math-exercises
|
||||
newName: harbor.cl1.parano.ch/library/math-exercice
|
||||
newTag: 1.0.9
|
||||
newTag: 1.4.0
|
||||
|
||||
# Production-specific labels
|
||||
|
||||
|
||||
+24
-24
@@ -1,32 +1,32 @@
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.10.0
|
||||
boto3==1.26.160
|
||||
boto3-stubs==1.40.23
|
||||
botocore==1.29.165
|
||||
botocore-stubs==1.40.23
|
||||
click==8.2.1
|
||||
anyio==4.12.0
|
||||
boto3==1.42.6
|
||||
boto3-stubs==1.42.6
|
||||
botocore==1.42.6
|
||||
botocore-stubs==1.42.6
|
||||
click==8.3.1
|
||||
defusedxml==0.7.1
|
||||
fastapi==0.116.1
|
||||
fonttools==4.59.2
|
||||
fpdf2==2.8.4
|
||||
fastapi==0.124.0
|
||||
fonttools==4.61.0
|
||||
fpdf2==2.8.5
|
||||
h11==0.16.0
|
||||
idna==3.10
|
||||
Jinja2==3.1.4
|
||||
idna==3.11
|
||||
Jinja2==3.1.6
|
||||
jmespath==1.0.1
|
||||
MarkupSafe==3.0.2
|
||||
pillow==11.3.0
|
||||
pydantic==2.11.7
|
||||
pydantic_core==2.33.2
|
||||
MarkupSafe==3.0.3
|
||||
pillow==12.0.0
|
||||
pydantic==2.12.5
|
||||
pydantic_core==2.41.5
|
||||
python-dateutil==2.9.0.post0
|
||||
python-multipart==0.0.9
|
||||
s3transfer==0.6.2
|
||||
python-multipart==0.0.20
|
||||
s3transfer==0.16.0
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
starlette==0.47.3
|
||||
types-awscrt==0.27.6
|
||||
types-fpdf2==2.8.4.20250822
|
||||
types-s3transfer==0.13.1
|
||||
typing-inspection==0.4.1
|
||||
starlette==0.50.0
|
||||
types-awscrt==0.29.2
|
||||
types-s3transfer==0.16.0
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
urllib3==1.26.20
|
||||
uvicorn==0.30.6
|
||||
urllib3==2.6.1
|
||||
uvicorn==0.38.0
|
||||
|
||||
Reference in New Issue
Block a user