feat: migrate to S3 storage with automatic download and timestamped filenames

- Replace local file storage with S3-compatible object storage

- Add automatic PDF download after generation

- Include timestamps in filenames to ensure uniqueness

- Remove unused static volume from Kubernetes deployment

- Update ConfigMap to remove unused variables and add S3 configuration

- Configure S3 credentials via Kubernetes secrets for both dev and prod environments

- Add boto3 dependency for S3 integration
This commit is contained in:
2025-09-03 22:41:16 +02:00
parent a67db405f7
commit 75548dab2b
10 changed files with 164 additions and 67 deletions

View File

@@ -1,20 +1,96 @@
#!/usr/bin/env python3
import random
import os
import io
import boto3
from botocore.exceptions import ClientError
from typing import List
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from fpdf import FPDF
app = FastAPI()
# Mount static files and templates
app.mount("/static", StaticFiles(directory="app/static"), name="static")
# 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')
s3_host_bucket = os.environ.get('S3_HOST_BUCKET')
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)
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
class MathExercisesPDF(FPDF):
def header(self):
self.set_font('Helvetica', 'B', 16)
@@ -91,16 +167,23 @@ def generate_exercises(min_table: int, max_table: int, num_exercises: int = 15)
return exercises
def create_math_exercises_pdf(min_table: int, max_table: int, num_exercises: int = 15) -> str:
"""Crée un fichier PDF avec des exercices de mathématiques mélangés en 3 colonnes"""
pdf = MathExercisesPDF()
pdf.add_page()
pdf.set_font('Helvetica', '', 12)
"""Crée un fichier PDF avec des exercices de mathématiques mélangés en 3 colonnes et l'upload sur S3"""
import datetime
# Add timestamp to filename
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
# Ajouter des informations sur la plage de tables
if 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'
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.add_page()
pdf.set_font('Helvetica', '', 12)
pdf.cell(0, 10, table_info, 0, 1, 'C', new_x="LMARGIN", new_y="NEXT")
pdf.ln(5)
@@ -146,18 +229,23 @@ def create_math_exercises_pdf(min_table: int, max_table: int, num_exercises: int
pdf.cell(60, 10, col2_text, 0, 0, 'L', new_x="RIGHT", new_y="TOP")
pdf.cell(60, 10, col3_text, 0, 1, 'L', new_x="LMARGIN", new_y="NEXT")
# Sauvegarder le PDF
if min_table == max_table:
filename = f'app/static/exercices_mathematiques_table_{min_table}_{num_exercises}_exercices.pdf'
else:
filename = f'app/static/exercices_mathematiques_tables_{min_table}_a_{max_table}_{num_exercises}_exercices.pdf'
pdf.output(filename)
return filename
# Sauvegarder le PDF en mémoire
pdf_data = pdf.output()
# Upload to S3
upload_success = upload_to_s3(pdf_data, S3_BUCKET_NAME, pdf_filename)
if not upload_success:
raise Exception("Failed to upload PDF to S3")
return pdf_filename
@app.get("/", response_class=HTMLResponse)
async def read_root(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
from fastapi.responses import RedirectResponse
@app.post("/generate")
async def generate_exercises_endpoint(request: ExerciseRequest):
try:
@@ -168,35 +256,32 @@ async def generate_exercises_endpoint(request: ExerciseRequest):
if request.num_exercises < 1:
return {"error": "Le nombre d'exercices doit être supérieur à 0"}
filename = create_math_exercises_pdf(
pdf_filename = create_math_exercises_pdf(
request.min_table,
request.max_table,
request.num_exercises
)
# Extract just the filename for the download link
pdf_filename = os.path.basename(filename)
# Return redirect to automatically download the file
return RedirectResponse(url=f"/download/{pdf_filename}", status_code=303)
return {
"message": "PDF généré avec succès!",
"pdf_filename": pdf_filename,
"min_table": request.min_table,
"max_table": request.max_table,
"num_exercises": request.num_exercises
}
except Exception as e:
return {"error": f"Erreur lors de la création du PDF: {str(e)}"}
@app.get("/download/{filename}")
async def download_pdf(filename: str):
file_path = f"app/static/{filename}"
if os.path.exists(file_path):
return FileResponse(
path=file_path,
media_type='application/pdf',
filename=filename
)
return {"error": "File not found"}
# Download file from S3
file_data = download_from_s3(S3_BUCKET_NAME, filename)
if file_data is None:
return {"error": "File not found"}
# Return streaming response with PDF data
return StreamingResponse(
io.BytesIO(file_data),
media_type='application/pdf',
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
)
if __name__ == "__main__":
import uvicorn

View File

@@ -176,31 +176,37 @@
})
});
const result = await response.json();
const resultContainer = document.getElementById('resultContainer');
const resultMessage = document.getElementById('resultMessage');
const downloadLinkContainer = document.getElementById('downloadLinkContainer');
const downloadLink = document.getElementById('downloadLink');
if (result.error) {
resultMessage.innerHTML = `<div class="alert alert-danger">${result.error}</div>`;
downloadLinkContainer.classList.add('d-none');
} else {
// Check if it's a redirect response (for automatic download)
if (response.redirected) {
// Automatically trigger download
window.location.href = response.url;
// Show success message
const resultContainer = document.getElementById('resultContainer');
const resultMessage = document.getElementById('resultMessage');
resultMessage.innerHTML = `
<div class="alert alert-success">
<h6>${result.message}</h6>
<h6>PDF généré avec succès!</h6>
<p>Le téléchargement devrait commencer automatiquement.</p>
<ul class="mb-0">
<li>Tables de ${result.min_table} à ${result.max_table}</li>
<li>${result.num_exercises} exercices générés</li>
<li>Tables de ${minTable} à ${maxTable}</li>
<li>${numExercises} exercices générés</li>
</ul>
</div>
`;
downloadLink.href = `/download/${result.pdf_filename}`;
downloadLinkContainer.classList.remove('d-none');
resultContainer.classList.remove('d-none');
} else {
// Handle JSON response (error case)
const result = await response.json();
const resultContainer = document.getElementById('resultContainer');
const resultMessage = document.getElementById('resultMessage');
if (result.error) {
resultMessage.innerHTML = `<div class="alert alert-danger">${result.error}</div>`;
}
resultContainer.classList.remove('d-none');
}
resultContainer.classList.remove('d-none');
} catch (error) {
const resultContainer = document.getElementById('resultContainer');
const resultMessage = document.getElementById('resultMessage');

View File

@@ -7,8 +7,4 @@ data:
PORT: "8000"
LOG_LEVEL: "INFO"
MAX_REQUEST_SIZE: "10mb"
# Security configuration
SECURE_COOKIES: "true"
CORS_ORIGINS: "math-exercises.local"
REQUEST_TIMEOUT: "30s"

View File

@@ -65,11 +65,3 @@ spec:
envFrom:
- configMapRef:
name: math-exercises-config
# Volume mounts for writable directories
volumeMounts:
- name: static-volume
mountPath: /app/app/static
# Volumes
volumes:
- name: static-volume
emptyDir: {}

View File

@@ -16,6 +16,10 @@ spec:
value: development
- name: DEBUG
value: "false"
# Environment variables from S3 credentials secret
envFrom:
- secretRef:
name: s3-credentials
# Reduce resource consumption in development
resources:
requests:

View File

@@ -17,4 +17,9 @@ images:
# Development-specific labels
commonLabels:
environment: development
security-level: standard
security-level: standard
secretGenerator:
- name: s3-credentials
envs:
- s3-credentials.env

View File

@@ -8,7 +8,6 @@ metadata:
# Security annotations
seccomp.security.alpha.kubernetes.io/pod: docker/default
spec:
replicas: 1
template:
spec:
containers:

View File

@@ -22,4 +22,9 @@ images:
# Production-specific labels
commonLabels:
environment: production
security-level: high
security-level: high
secretGenerator:
- name: s3-credentials
envs:
- s3-credentials.env

View File

@@ -24,4 +24,8 @@ spec:
drop:
- ALL
add:
- NET_BIND_SERVICE
- NET_BIND_SERVICE
# Environment variables from S3 credentials secret
envFrom:
- secretRef:
name: s3-credentials

View File

@@ -6,3 +6,4 @@ fastapi==0.115.0
uvicorn==0.30.6
jinja2==3.1.4
python-multipart==0.0.9
boto3==1.34.0