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:
151
app/main.py
151
app/main.py
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
@@ -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: {}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -8,7 +8,6 @@ metadata:
|
||||
# Security annotations
|
||||
seccomp.security.alpha.kubernetes.io/pod: docker/default
|
||||
spec:
|
||||
replicas: 1
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user