Compare commits

...

3 Commits

Author SHA1 Message Date
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
3 changed files with 250 additions and 141 deletions
+62 -136
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)
@@ -168,6 +70,7 @@ class ExerciseRequest(BaseModel):
max_table: int max_table: int
num_exercises: int = 15 num_exercises: int = 15
multiplication_only: bool = False 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):
@@ -175,7 +78,11 @@ class OperationExerciseRequest(BaseModel):
def generate_exercises( 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]: ) -> 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] = []
@@ -188,27 +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
# 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":
# 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)
elif not multiplication_only: # 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:
@@ -222,24 +130,32 @@ 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
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
# Déterminer le type d'exercice # Randomize the order of operands
if multiplication_only: if random.choice([True, False]):
exercise_type = "multiplication" a, b = b, a
else:
# Choisir aléatoirement le type d'exercice
exercise_type = random.choice(["multiplication", "division"])
if exercise_type == "multiplication": result = a * b
exercise = f"{a} · {b} = ____" exercise = f"{a} · {b} = ____"
elif not multiplication_only: # 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: else:
# Fallback to multiplication if multiplication_only is True # 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} = ____" exercise = f"{a} · {b} = ____"
exercises.append(exercise) exercises.append(exercise)
@@ -248,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, 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: ) -> 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
@@ -284,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, multiplication_only) 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
@@ -376,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.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 # 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
+69 -2
View File
@@ -66,6 +66,16 @@
<label class="form-check-label" for="multiplicationOnly">Générer uniquement des multiplications</label> <label class="form-check-label" for="multiplicationOnly">Générer uniquement des multiplications</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>
@@ -242,6 +252,45 @@
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,
multiplicationOnly: document.getElementById('multiplicationOnly').checked,
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;
document.getElementById('multiplicationOnly').checked = exerciseValues.multiplicationOnly || false;
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) {
const refreshBtn = document.getElementById('refreshListBtn'); const refreshBtn = document.getElementById('refreshListBtn');
@@ -414,9 +463,18 @@
// Load PDF list on page load // Load PDF list on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
restoreFormValues();
loadPdfList(currentPage); loadPdfList(currentPage);
}); });
// 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('multiplicationOnly').addEventListener('change', saveFormValues);
document.getElementById('maxFirstOperand').addEventListener('change', saveFormValues);
document.getElementById('numOperationExercises').addEventListener('change', saveFormValues);
// Event listener for select all button // Event listener for select all button
document.getElementById('selectAllBtn').addEventListener('click', function() { document.getElementById('selectAllBtn').addEventListener('click', function() {
document.querySelectorAll('.file-checkbox').forEach(checkbox => { document.querySelectorAll('.file-checkbox').forEach(checkbox => {
@@ -513,10 +571,14 @@
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 multiplicationOnly = document.getElementById('multiplicationOnly').checked; const multiplicationOnly = document.getElementById('multiplicationOnly').checked;
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');
@@ -537,7 +599,8 @@
min_table: minTable, min_table: minTable,
max_table: maxTable, max_table: maxTable,
num_exercises: numExercises, num_exercises: numExercises,
multiplication_only: multiplicationOnly multiplication_only: multiplicationOnly,
max_first_operand: maxFirstOperand
}) })
}); });
@@ -588,14 +651,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');