feat: add pagination to file listing and improve UI

This commit is contained in:
2025-09-03 23:07:02 +02:00
parent 75548dab2b
commit 3c07c89f24
4 changed files with 454 additions and 4 deletions
+144 -1
View File
@@ -3,9 +3,11 @@ 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, Response
from fastapi import FastAPI, Request, Response, Form
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
@@ -91,6 +93,34 @@ def download_from_s3(bucket_name, object_name):
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)
@@ -283,6 +313,119 @@ async def download_pdf(filename: str):
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
)
@app.get("/list")
async def list_pdfs(page: int = 1, page_size: int = 10):
"""List PDF files in the S3 bucket with pagination (sorted from newest to oldest)"""
try:
# Validate page and page_size parameters
if page < 1:
page = 1
if page_size < 1 or page_size > 100:
page_size = 10
pdf_files = list_objects_in_s3(S3_BUCKET_NAME)
# Calculate pagination
total_files = len(pdf_files)
total_pages = (total_files + page_size - 1) // page_size # Ceiling division
# Slice the files for the current page
start_index = (page - 1) * page_size
end_index = start_index + page_size
paginated_files = pdf_files[start_index:end_index]
return {
"files": paginated_files,
"pagination": {
"current_page": page,
"page_size": page_size,
"total_files": total_files,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1
}
}
except Exception as e:
return {"error": f"Error listing files: {str(e)}"}
@app.delete("/delete/{filename}")
async def delete_pdf(filename: str):
"""Delete a PDF file from S3 bucket"""
try:
# Decode URL encoded filename
import urllib.parse
decoded_filename = urllib.parse.unquote(filename)
success = delete_from_s3(S3_BUCKET_NAME, decoded_filename)
if success:
return {"message": f"Fichier {decoded_filename} supprimé avec succès"}
else:
return {"error": f"Erreur lors de la suppression du fichier {decoded_filename}"}
except Exception as e:
return {"error": f"Error deleting file: {str(e)}"}
@app.post("/bulk-delete")
async def bulk_delete(filenames: str = Form(...)):
"""Delete multiple PDF files from S3 bucket"""
try:
import json
filename_list = json.loads(filenames)
deleted_files = []
errors = []
for filename in filename_list:
success = delete_from_s3(S3_BUCKET_NAME, filename)
if success:
deleted_files.append(filename)
else:
errors.append(filename)
if errors:
return {"message": f"Fichiers supprimés: {len(deleted_files)}", "errors": errors}
else:
return {"message": f"{len(deleted_files)} fichiers supprimés avec succès"}
except Exception as e:
return {"error": f"Error deleting files: {str(e)}"}
from fastapi import Form
@app.post("/bulk-download")
async def bulk_download(filenames: str = Form(...)):
"""Download multiple PDF files as a zip archive"""
try:
import zipfile
import tempfile
import json
# Parse the JSON string to get the list of filenames
filename_list = json.loads(filenames)
# Create a temporary zip file
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp_zip:
with zipfile.ZipFile(tmp_zip.name, 'w') as zipf:
for filename in filename_list:
file_data = download_from_s3(S3_BUCKET_NAME, filename)
if file_data:
zipf.writestr(filename, file_data)
# Read the zip file
with open(tmp_zip.name, 'rb') as f:
zip_data = f.read()
# Clean up temporary file
import os
os.unlink(tmp_zip.name)
# Return streaming response with zip data
zip_filename = f"math_exercises_{len(filename_list)}_files.zip"
return StreamingResponse(
io.BytesIO(zip_data),
media_type='application/zip',
headers={'Content-Disposition': f'attachment; filename="{zip_filename}"'}
)
except Exception as e:
return {"error": f"Error downloading files: {str(e)}"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
+308 -1
View File
@@ -36,6 +36,8 @@
<p class="lead">Créez des exercices de multiplication et division personnalisés en PDF</p>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">Paramètres des Exercices</h5>
@@ -68,6 +70,36 @@
</div>
</div>
<!-- Existing PDFs Section -->
<div class="card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0">Fichiers PDF Existants</h5>
<button id="refreshListBtn" class="btn btn-outline-primary btn-sm">
<span id="refreshText">Actualiser</span>
<span id="refreshSpinner" class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
</div>
<div class="d-flex gap-2 mb-3">
<button id="downloadSelectedBtn" class="btn btn-success btn-sm" disabled>
Télécharger Sélectionnés
</button>
<button id="deleteSelectedBtn" class="btn btn-danger btn-sm" disabled>
Supprimer Sélectionnés
</button>
<button id="selectAllBtn" class="btn btn-outline-secondary btn-sm">
Tout Sélectionner
</button>
<button id="clearSelectionBtn" class="btn btn-outline-secondary btn-sm" disabled>
Désélectionner
</button>
</div>
<div id="pdfListContainer" class="mt-3">
<p class="text-muted">Cliquez sur "Actualiser" pour charger la liste des fichiers existants.</p>
</div>
</div>
</div>
<div id="resultContainer" class="exercise-result d-none">
<h5>Résultat</h5>
<div id="resultMessage"></div>
@@ -145,8 +177,280 @@
</div>
</footer>
<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>
// Current page for pagination
let currentPage = 1;
const pageSize = 10;
// Function to load and display existing PDFs with pagination (sorted from newest to oldest)
async function loadPdfList(page = 1) {
const refreshBtn = document.getElementById('refreshListBtn');
const refreshText = document.getElementById('refreshText');
const refreshSpinner = document.getElementById('refreshSpinner');
const pdfListContainer = document.getElementById('pdfListContainer');
// Show loading state
refreshText.textContent = 'Chargement...';
refreshSpinner.classList.remove('d-none');
refreshBtn.disabled = true;
try {
const response = await fetch(`/list?page=${page}&page_size=${pageSize}`);
const result = await response.json();
if (result.error) {
pdfListContainer.innerHTML = `<div class="alert alert-danger">${result.error}</div>`;
} else {
if (result.files.length === 0) {
pdfListContainer.innerHTML = '<p class="text-muted">Aucun fichier PDF trouvé.</p>';
} else {
let html = '<div class="table-responsive"><table class="table table-striped table-hover">';
html += '<thead><tr><th><input type="checkbox" id="selectAllHeader"></th><th>Nom du fichier</th><th>Date de création</th><th>Taille</th><th>Actions</th></tr></thead><tbody>';
result.files.forEach(file => {
// Format date
const date = new Date(file.LastModified);
const formattedDate = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
// Format size
const sizeInKB = Math.round(file.Size / 1024);
html += '<tr>';
html += `<td><input type="checkbox" class="file-checkbox" data-filename="${file.Key}"></td>`;
html += `<td>${file.Key}</td>`;
html += `<td>${formattedDate}</td>`;
html += `<td>${sizeInKB} KB</td>`;
html += `<td>
<a href="/download/${encodeURIComponent(file.Key)}" class="btn btn-success btn-sm me-2">Télécharger</a>
<button class="btn btn-danger btn-sm delete-btn" data-filename="${file.Key}">Supprimer</button>
</td>`;
html += '</tr>';
});
html += '</tbody></table></div>';
// Add pagination controls if needed
if (result.pagination.total_pages > 1) {
html += '<nav aria-label="Pagination des fichiers"><ul class="pagination justify-content-center">';
// Previous button
if (result.pagination.has_prev) {
html += `<li class="page-item"><a class="page-link" href="#" data-page="${result.pagination.current_page - 1}">Précédent</a></li>`;
} else {
html += '<li class="page-item disabled"><span class="page-link">Précédent</span></li>';
}
// Page numbers
for (let i = 1; i <= result.pagination.total_pages; i++) {
if (i === result.pagination.current_page) {
html += `<li class="page-item active"><span class="page-link">${i}</span></li>`;
} else {
html += `<li class="page-item"><a class="page-link" href="#" data-page="${i}">${i}</a></li>`;
}
}
// Next button
if (result.pagination.has_next) {
html += `<li class="page-item"><a class="page-link" href="#" data-page="${result.pagination.current_page + 1}">Suivant</a></li>`;
} else {
html += '<li class="page-item disabled"><span class="page-link">Suivant</span></li>';
}
html += '</ul></nav>';
// Display total count
html += `<div class="text-center text-muted small">
Affichage ${(result.pagination.current_page - 1) * pageSize + 1}-${Math.min(result.pagination.current_page * pageSize, result.pagination.total_files)}
sur ${result.pagination.total_files} fichiers
</div>`;
}
pdfListContainer.innerHTML = html;
// Add event listeners to pagination links
document.querySelectorAll('.page-link[data-page]').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const page = parseInt(this.getAttribute('data-page'));
loadPdfList(page);
});
});
// Add event listeners to delete buttons
document.querySelectorAll('.delete-btn').forEach(button => {
button.addEventListener('click', async function() {
const filename = this.getAttribute('data-filename');
if (confirm(`Êtes-vous sûr de vouloir supprimer le fichier "${filename}" ?`)) {
try {
const response = await fetch(`/delete/${encodeURIComponent(filename)}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.error) {
alert(`Erreur: ${result.error}`);
} else {
// Refresh the list (stay on current page)
loadPdfList(currentPage);
alert(result.message);
}
} catch (error) {
alert(`Erreur lors de la suppression: ${error.message}`);
}
}
});
});
// Add event listener for select all checkbox in header
document.getElementById('selectAllHeader').addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.file-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
updateBulkButtons();
});
// Add event listeners to individual checkboxes
document.querySelectorAll('.file-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', updateBulkButtons);
});
// Update current page
currentPage = page;
}
}
} catch (error) {
pdfListContainer.innerHTML = `<div class="alert alert-danger">Erreur lors du chargement de la liste: ${error.message}</div>`;
} finally {
// Reset button state
refreshText.textContent = 'Actualiser';
refreshSpinner.classList.add('d-none');
refreshBtn.disabled = false;
}
}
// Event listener for refresh button
document.getElementById('refreshListBtn').addEventListener('click', function() {
loadPdfList(1); // Reset to first page on refresh
});
// Function to update bulk action buttons state
function updateBulkButtons() {
const selectedCount = document.querySelectorAll('.file-checkbox:checked').length;
document.getElementById('downloadSelectedBtn').disabled = selectedCount === 0;
document.getElementById('deleteSelectedBtn').disabled = selectedCount === 0;
document.getElementById('clearSelectionBtn').disabled = selectedCount === 0;
// Update select all header checkbox
const allChecked = selectedCount === document.querySelectorAll('.file-checkbox').length && selectedCount > 0;
document.getElementById('selectAllHeader').checked = allChecked;
}
// Load PDF list on page load
document.addEventListener('DOMContentLoaded', function() {
loadPdfList(currentPage);
});
// Event listener for select all button
document.getElementById('selectAllBtn').addEventListener('click', function() {
document.querySelectorAll('.file-checkbox').forEach(checkbox => {
checkbox.checked = true;
});
updateBulkButtons();
});
// Event listener for clear selection button
document.getElementById('clearSelectionBtn').addEventListener('click', function() {
document.querySelectorAll('.file-checkbox').forEach(checkbox => {
checkbox.checked = false;
});
updateBulkButtons();
});
// Event listener for download selected button
document.getElementById('downloadSelectedBtn').addEventListener('click', async function() {
const selectedFiles = Array.from(document.querySelectorAll('.file-checkbox:checked'))
.map(checkbox => checkbox.getAttribute('data-filename'));
if (selectedFiles.length === 0) {
alert('Veuillez sélectionner au moins un fichier à télécharger.');
return;
}
try {
// Create a form to submit the request (needed for file download)
const formData = new FormData();
formData.append('filenames', JSON.stringify(selectedFiles));
// Create a hidden form and submit it
const form = document.createElement('form');
form.method = 'POST';
form.action = '/bulk-download';
form.style.display = 'none';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'filenames';
input.value = JSON.stringify(selectedFiles);
form.appendChild(input);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
} catch (error) {
alert(`Erreur lors du téléchargement: ${error.message}`);
}
});
// Event listener for delete selected button
document.getElementById('deleteSelectedBtn').addEventListener('click', async function() {
const selectedFiles = Array.from(document.querySelectorAll('.file-checkbox:checked'))
.map(checkbox => checkbox.getAttribute('data-filename'));
if (selectedFiles.length === 0) {
alert('Veuillez sélectionner au moins un fichier à supprimer.');
return;
}
if (!confirm(`Êtes-vous sûr de vouloir supprimer ${selectedFiles.length} fichier(s) ?`)) {
return;
}
try {
// Create form data
const formData = new FormData();
formData.append('filenames', JSON.stringify(selectedFiles));
const response = await fetch('/bulk-delete', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.error) {
alert(`Erreur: ${result.error}`);
} else {
// Refresh the list (stay on current page)
loadPdfList(currentPage);
let message = result.message;
if (result.errors && result.errors.length > 0) {
message += `\nErreurs pour les fichiers: ${result.errors.join(', ')}`;
}
alert(message);
}
} catch (error) {
alert(`Erreur lors de la suppression: ${error.message}`);
}
});
document.getElementById('exerciseForm').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -181,6 +485,9 @@
// Automatically trigger download
window.location.href = response.url;
// Refresh the PDF list (reset to first page)
setTimeout(() => loadPdfList(1), 1000);
// Show success message
const resultContainer = document.getElementById('resultContainer');
const resultMessage = document.getElementById('resultMessage');
@@ -33,4 +33,4 @@ spec:
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
cpu: "1"
@@ -17,7 +17,7 @@ patchesStrategicMerge:
images:
- name: math-exercises
newName: harbor.cl1.parano.ch/library/math-exercice
newTag: 1.0.0
newTag: 1.0.1
# Production-specific labels
commonLabels: