From dd1249fb3523aec39a0113b011e151e3321eea43 Mon Sep 17 00:00:00 2001 From: Frederik Beimgraben Date: Mon, 1 Sep 2025 03:05:36 +0200 Subject: [PATCH] Attachments --- ATTACHMENT_FEATURE.md | 138 ++++++ .../ApplicationForm/ApplicationForm.tsx | 9 +- .../Attachments/AttachmentManager.tsx | 429 ++++++++++++++++++ .../LoadingSpinner/LoadingSpinner.tsx | 51 ++- frontend/src/pages/AdminApplicationView.tsx | 222 +++++---- frontend/src/pages/ViewApplicationPage.tsx | 218 +++++---- frontend/src/store/applicationStore.ts | 17 - src/migrations/add_attachments_tables.sql | 32 ++ .../alter_attachments_data_column.sql | 10 + src/pdf_to_struct.py | 2 +- src/service_api.py | 229 ++++++++++ 11 files changed, 1144 insertions(+), 213 deletions(-) create mode 100644 ATTACHMENT_FEATURE.md create mode 100644 frontend/src/components/Attachments/AttachmentManager.tsx create mode 100644 src/migrations/add_attachments_tables.sql create mode 100644 src/migrations/alter_attachments_data_column.sql diff --git a/ATTACHMENT_FEATURE.md b/ATTACHMENT_FEATURE.md new file mode 100644 index 00000000..cbfbcaba --- /dev/null +++ b/ATTACHMENT_FEATURE.md @@ -0,0 +1,138 @@ +# Attachment Feature Documentation + +## Overview + +The attachment feature allows users to upload and manage file attachments for their applications. Each application can have up to 30 attachments with a combined maximum size of 100MB. + +## Database Schema + +### `attachments` Table +Stores the actual file data and metadata: +- `id` (INT, PRIMARY KEY) - Unique identifier +- `filename` (VARCHAR 255) - Original filename +- `content_type` (VARCHAR 100) - MIME type of the file +- `size` (INT) - File size in bytes +- `data` (LONGTEXT) - Base64 encoded file content +- `created_at` (DATETIME) - Upload timestamp + +### `application_attachments` Table +Links attachments to applications (many-to-many relationship): +- `id` (INT, PRIMARY KEY) - Unique identifier +- `application_id` (INT) - References the application +- `attachment_id` (INT) - References the attachment +- `created_at` (DATETIME) - Link creation timestamp +- Unique constraint on (`application_id`, `attachment_id`) + +## API Endpoints + +### Upload Attachment +`POST /api/applications/{pa_id}/attachments` +- Requires authentication (PA-KEY or MASTER-KEY) +- Request body: multipart/form-data with file +- Returns: AttachmentUploadResponse with attachment ID, filename, and size +- Validates: + - Maximum 30 attachments per application + - Individual file size limit of 25MB per file + - Total size limit of 100MB across all attachments + +### List Attachments +`GET /api/applications/{pa_id}/attachments` +- Requires authentication (PA-KEY or MASTER-KEY) +- Returns: Array of AttachmentInfo objects + +### Download Attachment +`GET /api/applications/{pa_id}/attachments/{attachment_id}` +- Requires authentication (PA-KEY or MASTER-KEY) +- Returns: File content with appropriate content-type header + +### Delete Attachment +`DELETE /api/applications/{pa_id}/attachments/{attachment_id}` +- Requires authentication (PA-KEY or MASTER-KEY) +- Removes both the attachment and the link to the application + +## Frontend Component + +### AttachmentManager Component +Located at: `frontend/src/components/Attachments/AttachmentManager.tsx` + +Features: +- File upload with progress tracking +- List view with file type icons +- Download functionality +- Delete with confirmation dialog +- Real-time validation of limits (30 files, 100MB total) +- Responsive design with Material-UI + +Props: +- `paId` (string) - Application ID +- `paKey` (string, optional) - Application key for authentication +- `readOnly` (boolean) - Disable upload/delete actions +- `isAdmin` (boolean) - Use master key for authentication + +## Implementation Details + +### Security +- All endpoints require authentication via PA-KEY or MASTER-KEY headers +- Files are stored as Base64 encoded data in the database +- Application ownership is verified before allowing access to attachments + +### File Type Support +- All file types are supported +- Special icons for: + - Images (image/*) + - PDFs (application/pdf) + - Word documents (application/msword, .docx) + - Generic files (all others) + +### Performance Considerations +- Files are stored in the database as Base64 strings (increases size by ~33%) +- Individual files are limited to 25MB to prevent database issues +- Large files may impact database performance +- The data column uses MySQL LONGTEXT type (supports up to 4GB) +- Consider implementing file streaming for very large files +- Progress tracking uses XMLHttpRequest for better upload experience + +## Migration + +To add the attachment tables to an existing database, run: + +```sql +mysql -u your_user -p your_database < src/migrations/add_attachments_tables.sql +``` + +If you already created the tables but need to fix the data column type: + +```sql +mysql -u your_user -p your_database < src/migrations/alter_attachments_data_column.sql +``` + +## Usage Example + +### In ViewApplicationPage +```tsx + +``` + +### In AdminApplicationView +```tsx + +``` + +## Future Enhancements + +1. **File Preview**: Add preview functionality for images and PDFs +2. **Drag & Drop**: Implement drag-and-drop file upload +3. **Bulk Upload**: Allow multiple files to be uploaded at once +4. **File Compression**: Compress files before storing to save space +5. **External Storage**: Move file storage to S3 or similar service for better scalability +6. **Virus Scanning**: Integrate virus scanning for uploaded files +7. **File Versioning**: Track file versions/updates \ No newline at end of file diff --git a/frontend/src/components/ApplicationForm/ApplicationForm.tsx b/frontend/src/components/ApplicationForm/ApplicationForm.tsx index 71384dab..34422478 100644 --- a/frontend/src/components/ApplicationForm/ApplicationForm.tsx +++ b/frontend/src/components/ApplicationForm/ApplicationForm.tsx @@ -99,8 +99,6 @@ const ApplicationForm: React.FC = ({ exkursionGenehmigt: false, exkursionBezuschusst: false, }, - comparativeOffers: initialData.comparativeOffers ?? false, - fakultaet: initialData.fakultaet ?? false, }; } return { @@ -151,10 +149,6 @@ const ApplicationForm: React.FC = ({ exkursionGenehmigt: false, exkursionBezuschusst: false, }, - - // Attachments - comparativeOffers: false, - fakultaet: false, }; }); @@ -320,8 +314,7 @@ const ApplicationForm: React.FC = ({ if ( !formData.qsmFlags?.stellenfinanzierungen || !formData.qsmFlags?.studierende || - !formData.qsmFlags?.individuell || - !formData.fakultaet + !formData.qsmFlags?.individuell ) { setError("Bitte bestätigen Sie alle Pflichtangaben"); return; diff --git a/frontend/src/components/Attachments/AttachmentManager.tsx b/frontend/src/components/Attachments/AttachmentManager.tsx new file mode 100644 index 00000000..cc0d1c03 --- /dev/null +++ b/frontend/src/components/Attachments/AttachmentManager.tsx @@ -0,0 +1,429 @@ +import React, { useState, useRef } from "react"; +import { + Box, + Paper, + Typography, + Button, + IconButton, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + LinearProgress, + Alert, + Tooltip, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + CircularProgress, +} from "@mui/material"; +import { + CloudUpload, + Delete, + Download, + InsertDriveFile, + PictureAsPdf, + Image, + Description, +} from "@mui/icons-material"; +import { useApplicationStore } from "../../store/applicationStore"; + +interface Attachment { + id: number; + filename: string; + content_type: string; + size: number; + created_at: string; +} + +interface AttachmentManagerProps { + paId: string; + paKey?: string; + readOnly?: boolean; + isAdmin?: boolean; +} + +const AttachmentManager: React.FC = ({ + paId, + paKey, + readOnly = false, + isAdmin = false, +}) => { + const [attachments, setAttachments] = useState([]); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [error, setError] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [attachmentToDelete, setAttachmentToDelete] = useState( + null, + ); + const [loading, setLoading] = useState(false); + const fileInputRef = useRef(null); + + const { masterKey } = useApplicationStore(); + + // Helper function to format file size + const formatFileSize = (bytes: number) => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }; + + // Helper function to get file icon based on content type + const getFileIcon = (contentType: string) => { + if (contentType.startsWith("image/")) return ; + if (contentType === "application/pdf") return ; + if ( + contentType === "application/msword" || + contentType === + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) { + return ; + } + return ; + }; + + // Load attachments + const loadAttachments = async () => { + setLoading(true); + setError(null); + try { + const headers: HeadersInit = {}; + if (isAdmin && masterKey) { + headers["X-MASTER-KEY"] = masterKey; + } else if (paKey) { + headers["X-PA-KEY"] = paKey; + } + + const response = await fetch(`/api/applications/${paId}/attachments`, { + headers, + }); + + if (!response.ok) { + throw new Error("Failed to load attachments"); + } + + const data = await response.json(); + setAttachments(data); + } catch (err) { + setError("Fehler beim Laden der Anhänge"); + console.error("Error loading attachments:", err); + } finally { + setLoading(false); + } + }; + + // Load attachments on mount + React.useEffect(() => { + loadAttachments(); + }, [paId, paKey, isAdmin]); + + // Handle file upload + const handleFileUpload = async ( + event: React.ChangeEvent, + ) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + setError(null); + const file = files[0]; + + // Check individual file size limit (25MB per file) + const maxFileSize = 25 * 1024 * 1024; // 25MB + if (file.size > maxFileSize) { + setError( + `Die Datei ist zu groß. Maximale Dateigröße: ${formatFileSize(maxFileSize)}`, + ); + return; + } + + // Check file count limit + if (attachments.length >= 30) { + setError("Maximale Anzahl von 30 Anhängen erreicht"); + return; + } + + // Check total size limit (100MB) + const totalSize = attachments.reduce((sum, att) => sum + att.size, 0); + if (totalSize + file.size > 100 * 1024 * 1024) { + setError("Die Gesamtgröße aller Anhänge würde 100MB überschreiten"); + return; + } + + setUploading(true); + setUploadProgress(0); + + try { + const formData = new FormData(); + formData.append("file", file); + + const headers: HeadersInit = {}; + if (isAdmin && masterKey) { + headers["X-MASTER-KEY"] = masterKey; + } else if (paKey) { + headers["X-PA-KEY"] = paKey; + } + + const xhr = new XMLHttpRequest(); + + // Track upload progress + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) { + const percentComplete = (event.loaded / event.total) * 100; + setUploadProgress(percentComplete); + } + }); + + // Handle completion + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + loadAttachments(); + setUploading(false); + setUploadProgress(0); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } else { + throw new Error(`Upload failed with status ${xhr.status}`); + } + }); + + // Handle errors + xhr.addEventListener("error", () => { + throw new Error("Upload failed"); + }); + + // Send request + xhr.open("POST", `/api/applications/${paId}/attachments`); + Object.entries(headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value); + }); + xhr.send(formData); + } catch (err) { + setError("Fehler beim Hochladen der Datei"); + console.error("Error uploading file:", err); + setUploading(false); + setUploadProgress(0); + } + }; + + // Handle file download + const handleDownload = async (attachmentId: number, filename: string) => { + try { + const headers: HeadersInit = {}; + if (isAdmin && masterKey) { + headers["X-MASTER-KEY"] = masterKey; + } else if (paKey) { + headers["X-PA-KEY"] = paKey; + } + + const response = await fetch( + `/api/applications/${paId}/attachments/${attachmentId}`, + { headers }, + ); + + if (!response.ok) { + throw new Error("Download failed"); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (err) { + setError("Fehler beim Herunterladen der Datei"); + console.error("Error downloading file:", err); + } + }; + + // Handle file deletion + const handleDelete = async () => { + if (!attachmentToDelete) return; + + try { + const headers: HeadersInit = {}; + if (isAdmin && masterKey) { + headers["X-MASTER-KEY"] = masterKey; + } else if (paKey) { + headers["X-PA-KEY"] = paKey; + } + + const response = await fetch( + `/api/applications/${paId}/attachments/${attachmentToDelete}`, + { + method: "DELETE", + headers, + }, + ); + + if (!response.ok) { + throw new Error("Delete failed"); + } + + loadAttachments(); + setDeleteDialogOpen(false); + setAttachmentToDelete(null); + } catch (err) { + setError("Fehler beim Löschen der Datei"); + console.error("Error deleting file:", err); + } + }; + + // Calculate total size + const totalSize = attachments.reduce((sum, att) => sum + att.size, 0); + const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2); + + return ( + + + + Anhänge + + + = 30 ? "error" : "default"} + /> + = 100 * 1024 * 1024 ? "error" : "default"} + /> + + + + {error && ( + setError(null)}> + {error} + + )} + + {!readOnly && ( + + = 30} + /> + + + )} + + {uploading && ( + + + + Hochladen... {Math.round(uploadProgress)}% + + + )} + + {loading ? ( + + + + ) : attachments.length === 0 ? ( + + Keine Anhänge vorhanden + + ) : ( + + {attachments.map((attachment) => ( + + + {getFileIcon(attachment.content_type)} + + + + + + handleDownload(attachment.id, attachment.filename) + } + > + + + + {!readOnly && ( + + { + setAttachmentToDelete(attachment.id); + setDeleteDialogOpen(true); + }} + > + + + + )} + + + ))} + + )} + + {/* Delete confirmation dialog */} + setDeleteDialogOpen(false)} + > + Anhang löschen? + + + Möchten Sie diesen Anhang wirklich löschen? Diese Aktion kann nicht + rückgängig gemacht werden. + + + + + + + + + ); +}; + +export default AttachmentManager; diff --git a/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx index 288dfefd..dafe1ed1 100644 --- a/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx @@ -1,11 +1,11 @@ -import React from 'react'; +import React from "react"; import { Box, CircularProgress, Typography, Backdrop, - Paper -} from '@mui/material'; + Paper, +} from "@mui/material"; interface LoadingSpinnerProps { /** @@ -23,7 +23,7 @@ interface LoadingSpinnerProps { /** * Custom color */ - color?: 'primary' | 'secondary' | 'inherit'; + color?: "primary" | "secondary" | "inherit"; /** * Minimum height when not overlay */ @@ -36,22 +36,22 @@ interface LoadingSpinnerProps { const LoadingSpinner: React.FC = ({ overlay = false, - text = 'Wird geladen...', + text = "Wird geladen...", size = 40, - color = 'primary', + color = "primary", minHeight = 200, - backdrop = true + backdrop = true, }) => { const spinnerContent = ( @@ -62,7 +62,7 @@ const LoadingSpinner: React.FC = ({ sx={{ fontWeight: 500, maxWidth: 300, - lineHeight: 1.4 + lineHeight: 1.4, }} > {text} @@ -76,18 +76,23 @@ const LoadingSpinner: React.FC = ({ theme.zIndex.drawer + 1, - backgroundColor: backdrop ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.5)', - backdropFilter: backdrop ? 'blur(4px)' : 'none', + backgroundColor: backdrop + ? (theme) => + theme.palette.mode === "dark" + ? "rgba(0, 0, 0, 0.9)" + : "rgba(255, 255, 255, 0.9)" + : "rgba(0, 0, 0, 0.5)", + backdropFilter: backdrop ? "blur(4px)" : "none", }} > = ({ return ( {spinnerContent} diff --git a/frontend/src/pages/AdminApplicationView.tsx b/frontend/src/pages/AdminApplicationView.tsx index e53fc29e..b8de585d 100644 --- a/frontend/src/pages/AdminApplicationView.tsx +++ b/frontend/src/pages/AdminApplicationView.tsx @@ -30,6 +30,8 @@ import { VpnKey, ContentCopy, SwapVert, + CheckBox, + CheckBoxOutlineBlank, } from "@mui/icons-material"; import { useParams, useNavigate } from "react-router-dom"; import dayjs from "dayjs"; @@ -42,6 +44,7 @@ import { VSM_FINANCING_LABELS, QSM_FINANCING_LABELS } from "../types/api"; // Components import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner"; +import AttachmentManager from "../components/Attachments/AttachmentManager"; // Utils import { translateStatus, getStatusColor } from "../utils/statusTranslations"; @@ -151,6 +154,12 @@ const AdminApplicationView: React.FC = () => { } }; + // Calculate total from costs + const calculateTotal = () => { + if (!formData?.costs) return 0; + return formData.costs.reduce((sum, cost) => sum + (cost.amountEur || 0), 0); + }; + if (!isAdmin) { return null; // Will redirect } @@ -431,125 +440,167 @@ const AdminApplicationView: React.FC = () => { Beantragte Summe - - {formData.requestedAmountEur?.toLocaleString("de-DE", { + + {calculateTotal().toLocaleString("de-DE", { style: "currency", currency: "EUR", - }) || "0,00 €"} + })} {/* Financing Information */} - {(formData.vsmFinancing || formData.qsmFinancing) && ( + {(formData.vsmFinancing || + formData.qsmFinancing || + formData.financingCode) && ( - + {currentApplication.variant === "QSM" ? "Erfüllte Aufgabe der QSM" : "Erfüllte Aufgabe der VS nach §65 des Landeshochschulgesetzes"} - - {currentApplication.variant === "QSM" && - (formData.financingCode || formData.qsmFinancing) - ? QSM_FINANCING_LABELS[ - (formData.financingCode || - formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS - ] || - formData.financingCode || - formData.qsmFinancing - : currentApplication.variant === "VSM" && - (formData.financingCode || formData.vsmFinancing) - ? VSM_FINANCING_LABELS[ + + + theme.palette.mode === "dark" + ? theme.palette.grey[900] + : theme.palette.grey[50], + mb: 3, + }} + > + + {currentApplication.variant === "QSM" && + (formData.financingCode || formData.qsmFinancing) + ? QSM_FINANCING_LABELS[ (formData.financingCode || - formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS + formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS ] || formData.financingCode || - formData.vsmFinancing - : "-"} - + formData.qsmFinancing + : currentApplication.variant === "VSM" && + (formData.financingCode || formData.vsmFinancing) + ? VSM_FINANCING_LABELS[ + (formData.financingCode || + formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS + ] || + formData.financingCode || + formData.vsmFinancing + : "-"} + + {/* Display flags */} {formData.vsmFlags && currentApplication.variant === "VSM" && ( - + Zusätzliche Angaben: - - • Die Maßnahme erfüllt Aufgaben der verfassten - Studierendenschaft:{" "} - {formData.vsmFlags.aufgaben ? "Ja" : "Nein"} - - - • Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "} - {formData.vsmFlags.individuell ? "Ja" : "Nein"} - + + {formData.vsmFlags.aufgaben ? ( + + ) : ( + + )} + + Die Maßnahme erfüllt Aufgaben der verfassten + Studierendenschaft + + + + {formData.vsmFlags.individuell ? ( + + ) : ( + + )} + + Es werden keine Einzelpersonen von der Maßnahme gefördert + + )} {formData.qsmFlags && currentApplication.variant === "QSM" && ( - + Zusätzliche Angaben: - - • Es handelt sich um Stellenfinanzierungen:{" "} - {formData.qsmFlags.stellenfinanzierungen ? "Ja" : "Nein"} - - - • Die Studierenden werden an der Planung und Durchführung - der Maßnahme beteiligt:{" "} - {formData.qsmFlags.studierende ? "Ja" : "Nein"} - - - • Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "} - {formData.qsmFlags.individuell ? "Ja" : "Nein"} - - {formData.qsmFlags.exkursionGenehmigt !== undefined && ( - - • Die beantragte Exkursion wurde von den zuständigen - Stellen genehmigt:{" "} - {formData.qsmFlags.exkursionGenehmigt ? "Ja" : "Nein"} + + {formData.qsmFlags.stellenfinanzierungen ? ( + + ) : ( + + )} + + Es handelt sich um Stellenfinanzierungen + + + {formData.qsmFlags.studierende ? ( + + ) : ( + + )} + + Die Studierenden werden an der Planung und Durchführung + der Maßnahme beteiligt + + + + {formData.qsmFlags.individuell ? ( + + ) : ( + + )} + + Es werden keine Einzelpersonen von der Maßnahme gefördert + + + {formData.qsmFlags.exkursionGenehmigt !== undefined && ( + + {formData.qsmFlags.exkursionGenehmigt ? ( + + ) : ( + + )} + + Die beantragte Exkursion wurde von den zuständigen + Stellen genehmigt + + )} {formData.qsmFlags.exkursionBezuschusst !== undefined && ( - - • Die Exkursion wird bereits aus anderen Mitteln - bezuschusst:{" "} - {formData.qsmFlags.exkursionBezuschusst ? "Ja" : "Nein"} - + + {formData.qsmFlags.exkursionBezuschusst ? ( + + ) : ( + + )} + + Die Exkursion wird bereits aus anderen Mitteln + bezuschusst + + )} )} - - {/* Attachments */} - - - Anhänge: - - - • Vergleichsangebote liegen bei:{" "} - {formData.comparativeOffers ? "Ja" : "Nein"} - - {currentApplication.variant === "QSM" && ( - - • Die Fakultät ist über den Antrag informiert:{" "} - {formData.fakultaet ? "Ja" : "Nein"} - - )} - )} @@ -591,10 +642,10 @@ const AdminApplicationView: React.FC = () => { Gesamt: - {formData.requestedAmountEur?.toLocaleString("de-DE", { + {calculateTotal().toLocaleString("de-DE", { style: "currency", currency: "EUR", - }) || "0,00 €"} + })} @@ -614,10 +665,10 @@ const AdminApplicationView: React.FC = () => { setNewStatus(e.target.value)} - size="small" + sx={{ minWidth: "100px" }} > Neu In Prüfung @@ -728,6 +779,9 @@ const AdminApplicationView: React.FC = () => { + {/* Attachments */} + + {/* Delete Confirmation Dialog */} { Beantragte Summe - + {calculateTotal().toLocaleString("de-DE", { style: "currency", currency: "EUR", @@ -384,114 +396,152 @@ const ViewApplicationPage: React.FC = () => { {/* Financing Information */} - {(formData.vsmFinancing || formData.qsmFinancing) && ( + {(formData.vsmFinancing || + formData.qsmFinancing || + formData.financingCode) && ( - + {currentApplication.variant === "QSM" - ? "Erfüllte Aufgabe nach VWV zur Verwendung von QS-Mitteln" + ? "Erfüllte Aufgabe der QSM" : "Erfüllte Aufgabe der VS nach §65 des Landeshochschulgesetzes"} - - {currentApplication.variant === "QSM" && - (formData.financingCode || formData.qsmFinancing) - ? QSM_FINANCING_LABELS[ - (formData.financingCode || - formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS - ] || - formData.financingCode || - formData.qsmFinancing - : currentApplication.variant === "VSM" && - (formData.financingCode || formData.vsmFinancing) - ? VSM_FINANCING_LABELS[ + + + theme.palette.mode === "dark" + ? theme.palette.grey[900] + : theme.palette.grey[50], + mb: 3, + }} + > + + {currentApplication.variant === "QSM" && + (formData.financingCode || formData.qsmFinancing) + ? QSM_FINANCING_LABELS[ (formData.financingCode || - formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS + formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS ] || formData.financingCode || - formData.vsmFinancing - : "-"} - + formData.qsmFinancing + : currentApplication.variant === "VSM" && + (formData.financingCode || formData.vsmFinancing) + ? VSM_FINANCING_LABELS[ + (formData.financingCode || + formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS + ] || + formData.financingCode || + formData.vsmFinancing + : "-"} + + {/* Display flags */} {formData.vsmFlags && currentApplication.variant === "VSM" && ( - + Zusätzliche Angaben: - - • Die Maßnahme erfüllt Aufgaben der verfassten - Studierendenschaft:{" "} - {formData.vsmFlags.aufgaben ? "Ja" : "Nein"} - - - • Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "} - {formData.vsmFlags.individuell ? "Ja" : "Nein"} - + + {formData.vsmFlags.aufgaben ? ( + + ) : ( + + )} + + Die Maßnahme erfüllt Aufgaben der verfassten + Studierendenschaft + + + + {formData.vsmFlags.individuell ? ( + + ) : ( + + )} + + Es werden keine Einzelpersonen von der Maßnahme gefördert + + )} {formData.qsmFlags && currentApplication.variant === "QSM" && ( - + Zusätzliche Angaben: - - • Es handelt sich um Stellenfinanzierungen:{" "} - {formData.qsmFlags.stellenfinanzierungen ? "Ja" : "Nein"} - - - • Die Studierenden werden an der Planung und Durchführung - der Maßnahme beteiligt:{" "} - {formData.qsmFlags.studierende ? "Ja" : "Nein"} - - - • Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "} - {formData.qsmFlags.individuell ? "Ja" : "Nein"} - - {formData.qsmFlags.exkursionGenehmigt !== undefined && ( - - • Die beantragte Exkursion wurde von den zuständigen - Stellen genehmigt:{" "} - {formData.qsmFlags.exkursionGenehmigt ? "Ja" : "Nein"} + + {formData.qsmFlags.stellenfinanzierungen ? ( + + ) : ( + + )} + + Es handelt sich um Stellenfinanzierungen + + + {formData.qsmFlags.studierende ? ( + + ) : ( + + )} + + Die Studierenden werden an der Planung und Durchführung + der Maßnahme beteiligt + + + + {formData.qsmFlags.individuell ? ( + + ) : ( + + )} + + Es werden keine Einzelpersonen von der Maßnahme gefördert + + + {formData.qsmFlags.exkursionGenehmigt !== undefined && ( + + {formData.qsmFlags.exkursionGenehmigt ? ( + + ) : ( + + )} + + Die beantragte Exkursion wurde von den zuständigen + Stellen genehmigt + + )} {formData.qsmFlags.exkursionBezuschusst !== undefined && ( - - • Die Exkursion wird bereits aus anderen Mitteln - bezuschusst:{" "} - {formData.qsmFlags.exkursionBezuschusst ? "Ja" : "Nein"} - + + {formData.qsmFlags.exkursionBezuschusst ? ( + + ) : ( + + )} + + Die Exkursion wird bereits aus anderen Mitteln + bezuschusst + + )} )} - - {/* Attachments */} - - - Anhänge: - - - • Vergleichsangebote liegen bei:{" "} - {formData.comparativeOffers ? "Ja" : "Nein"} - - {currentApplication.variant === "QSM" && ( - - • Die Fakultät ist über den Antrag informiert:{" "} - {formData.fakultaet ? "Ja" : "Nein"} - - )} - )} @@ -622,6 +672,14 @@ const ViewApplicationPage: React.FC = () => { + {/* Attachments */} + + {/* Delete Confirmation Dialog */} = { costs: [], requestedAmountEur: 0, variant: "VSM", - comparativeOffers: false, }; const initialState: ApplicationState = { @@ -1158,10 +1157,6 @@ function createFormJsonFromData(formData: Partial): string { // Totals requestedAmountEur: "pa-requested-amount-euro-sum", - - // Attachments - comparativeOffers: "pa-anh-vergleichsangebote", - fakultaet: "pa-anh-fakultaet", }; // Add basic fields @@ -1229,16 +1224,6 @@ function createFormJsonFromData(formData: Partial): string { } } - // Add attachments - if (formData.comparativeOffers !== undefined) { - formJson["pa-anh-vergleichsangebote"] = { - "/V": formData.comparativeOffers, - }; - } - if (formData.fakultaet !== undefined) { - formJson["pa-anh-fakultaet"] = { "/V": formData.fakultaet }; - } - const jsonString = JSON.stringify(formJson); return btoa(unescape(encodeURIComponent(jsonString))); // Base64 encode with UTF-8 support } @@ -1303,7 +1288,5 @@ function convertPayloadToFormData(payload: RootPayload): Partial { individuell: pa.project.financing.vsm.flags.individuell || false, } : undefined, - comparativeOffers: pa.attachments.comparativeOffers || false, - fakultaet: pa.attachments.fakultaet || false, }; } diff --git a/src/migrations/add_attachments_tables.sql b/src/migrations/add_attachments_tables.sql new file mode 100644 index 00000000..2ee60829 --- /dev/null +++ b/src/migrations/add_attachments_tables.sql @@ -0,0 +1,32 @@ +-- Migration: Add attachment tables +-- Description: Add tables for storing file attachments for applications +-- Date: 2024 + +-- Create attachments table to store file data +CREATE TABLE IF NOT EXISTS `attachments` ( + `id` INT NOT NULL AUTO_INCREMENT, + `filename` VARCHAR(255) NOT NULL, + `content_type` VARCHAR(100) NOT NULL, + `size` INT NOT NULL, + `data` LONGTEXT NOT NULL, -- Base64 encoded file data + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Create junction table to link applications and attachments +CREATE TABLE IF NOT EXISTS `application_attachments` ( + `id` INT NOT NULL AUTO_INCREMENT, + `application_id` INT NOT NULL, + `attachment_id` INT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_app_attachment` (`application_id`, `attachment_id`), + INDEX `idx_application_id` (`application_id`), + INDEX `idx_attachment_id` (`attachment_id`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Note: Foreign key constraints are not added because the Application table +-- doesn't have a direct foreign key relationship setup in the current schema. +-- The application_id references the id column in the applications table. diff --git a/src/migrations/alter_attachments_data_column.sql b/src/migrations/alter_attachments_data_column.sql new file mode 100644 index 00000000..9312221b --- /dev/null +++ b/src/migrations/alter_attachments_data_column.sql @@ -0,0 +1,10 @@ +-- Migration: Alter attachments table data column to LONGTEXT +-- Description: Change data column from TEXT to LONGTEXT to support larger files +-- Date: 2024 + +-- Check if the attachments table exists and alter the data column +ALTER TABLE `attachments` +MODIFY COLUMN `data` LONGTEXT NOT NULL COMMENT 'Base64 encoded file data'; + +-- This migration fixes the issue where TEXT type (max 65,535 bytes) was too small +-- for Base64 encoded files. LONGTEXT supports up to 4GB of data. diff --git a/src/pdf_to_struct.py b/src/pdf_to_struct.py index c8b3a1a4..e8400972 100755 --- a/src/pdf_to_struct.py +++ b/src/pdf_to_struct.py @@ -372,7 +372,7 @@ def payload_to_model(payload: Dict[str, Any]) -> RootPayload: # Build Applicant applicant_dict = _get(payload, "pa.applicant", {}) or {} applicant = Applicant( - type=applicant_dict.get("type"), + type=applicant_dict.get("type") or "person", institution=Institution( name=_get(applicant_dict, "institution.name"), type=_get(applicant_dict, "institution.type"), diff --git a/src/service_api.py b/src/service_api.py index dd57e965..d0745b57 100644 --- a/src/service_api.py +++ b/src/service_api.py @@ -40,6 +40,7 @@ from sqlalchemy import ( create_engine, Column, Integer, String, Text, DateTime, JSON as SAJSON, select, func, UniqueConstraint ) +from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.orm import declarative_base, sessionmaker, Session from sqlalchemy.exc import IntegrityError from sqlalchemy import text as sql_text @@ -108,6 +109,32 @@ class Application(Base): ) +class Attachment(Base): + __tablename__ = "attachments" + id = Column(Integer, primary_key=True, autoincrement=True) + + filename = Column(String(255), nullable=False) + content_type = Column(String(100), nullable=False) + size = Column(Integer, nullable=False) + data = Column(LONGTEXT, nullable=False) # Base64 encoded blob + + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + + +class ApplicationAttachment(Base): + __tablename__ = "application_attachments" + id = Column(Integer, primary_key=True, autoincrement=True) + + application_id = Column(Integer, nullable=False, index=True) + attachment_id = Column(Integer, nullable=False, index=True) + + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + + __table_args__ = ( + UniqueConstraint("application_id", "attachment_id", name="uq_app_attachment"), + ) + + def init_db(): Base.metadata.create_all(bind=engine) @@ -190,6 +217,20 @@ class SearchQuery(BaseModel): limit: int = 50 offset: int = 0 + +class AttachmentInfo(BaseModel): + id: int + filename: str + content_type: str + size: int + created_at: datetime + + +class AttachmentUploadResponse(BaseModel): + attachment_id: int + filename: str + size: int + # ------------------------------------------------------------- # Auth-Helpers # ------------------------------------------------------------- @@ -747,3 +788,191 @@ def search_applications( "created_at": r[3].isoformat(), "updated_at": r[4].isoformat()} for r in rows ] + + +# ------------------------------------------------------------- +# Attachment Endpoints +# ------------------------------------------------------------- + +@app.post("/applications/{pa_id}/attachments", response_model=AttachmentUploadResponse) +async def upload_attachment( + pa_id: str, + file: UploadFile = File(...), + x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"), + key: Optional[str] = Query(None), + x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"), + x_forwarded_for: Optional[str] = Header(None), + db: Session = Depends(get_db), +): + """Upload an attachment for an application""" + rate_limit_ip(x_forwarded_for or "") + auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) + if not auth: + raise HTTPException(status_code=401, detail="Unauthorized") + + # Check if application exists + app = db.query(Application).filter(Application.pa_id == pa_id).first() + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Check attachment count limit (30 attachments max) + attachment_count = db.query(ApplicationAttachment).filter( + ApplicationAttachment.application_id == app.id + ).count() + if attachment_count >= 30: + raise HTTPException(status_code=400, detail="Maximum number of attachments (30) reached") + + # Check total size limit (100MB) + existing_attachments = db.query(Attachment).join( + ApplicationAttachment, Attachment.id == ApplicationAttachment.attachment_id + ).filter(ApplicationAttachment.application_id == app.id).all() + + total_size = sum(att.size for att in existing_attachments) + file_content = await file.read() + file_size = len(file_content) + + if total_size + file_size > 100 * 1024 * 1024: # 100MB + raise HTTPException(status_code=400, detail="Total attachment size would exceed 100MB limit") + + # Create attachment + attachment = Attachment( + filename=file.filename, + content_type=file.content_type or "application/octet-stream", + size=file_size, + data=base64.b64encode(file_content).decode('utf-8') + ) + db.add(attachment) + db.flush() + + # Link to application + app_attachment = ApplicationAttachment( + application_id=app.id, + attachment_id=attachment.id + ) + db.add(app_attachment) + db.commit() + + return AttachmentUploadResponse( + attachment_id=attachment.id, + filename=attachment.filename, + size=attachment.size + ) + + +@app.get("/applications/{pa_id}/attachments", response_model=List[AttachmentInfo]) +def list_attachments( + pa_id: str, + x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"), + key: Optional[str] = Query(None), + x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"), + x_forwarded_for: Optional[str] = Header(None), + db: Session = Depends(get_db), +): + """List all attachments for an application""" + rate_limit_ip(x_forwarded_for or "") + auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) + if not auth: + raise HTTPException(status_code=401, detail="Unauthorized") + + # Get application + app = db.query(Application).filter(Application.pa_id == pa_id).first() + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Get attachments + attachments = db.query(Attachment).join( + ApplicationAttachment, Attachment.id == ApplicationAttachment.attachment_id + ).filter(ApplicationAttachment.application_id == app.id).all() + + return [ + AttachmentInfo( + id=att.id, + filename=att.filename, + content_type=att.content_type, + size=att.size, + created_at=att.created_at + ) + for att in attachments + ] + + +@app.get("/applications/{pa_id}/attachments/{attachment_id}") +def download_attachment( + pa_id: str, + attachment_id: int, + x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"), + key: Optional[str] = Query(None), + x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"), + x_forwarded_for: Optional[str] = Header(None), + db: Session = Depends(get_db), +): + """Download a specific attachment""" + rate_limit_ip(x_forwarded_for or "") + auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) + if not auth: + raise HTTPException(status_code=401, detail="Unauthorized") + + # Get application + app = db.query(Application).filter(Application.pa_id == pa_id).first() + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Check if attachment belongs to this application + app_attachment = db.query(ApplicationAttachment).filter( + ApplicationAttachment.application_id == app.id, + ApplicationAttachment.attachment_id == attachment_id + ).first() + if not app_attachment: + raise HTTPException(status_code=404, detail="Attachment not found for this application") + + # Get attachment + attachment = db.query(Attachment).filter(Attachment.id == attachment_id).first() + if not attachment: + raise HTTPException(status_code=404, detail="Attachment not found") + + # Decode and return file + file_data = base64.b64decode(attachment.data) + return StreamingResponse( + io.BytesIO(file_data), + media_type=attachment.content_type, + headers={"Content-Disposition": f"attachment; filename={attachment.filename}"} + ) + + +@app.delete("/applications/{pa_id}/attachments/{attachment_id}") +def delete_attachment( + pa_id: str, + attachment_id: int, + x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"), + key: Optional[str] = Query(None), + x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"), + x_forwarded_for: Optional[str] = Header(None), + db: Session = Depends(get_db), +): + """Delete a specific attachment""" + rate_limit_ip(x_forwarded_for or "") + auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) + if not auth: + raise HTTPException(status_code=401, detail="Unauthorized") + + # Get application + app = db.query(Application).filter(Application.pa_id == pa_id).first() + if not app: + raise HTTPException(status_code=404, detail="Application not found") + + # Check if attachment belongs to this application + app_attachment = db.query(ApplicationAttachment).filter( + ApplicationAttachment.application_id == app.id, + ApplicationAttachment.attachment_id == attachment_id + ).first() + if not app_attachment: + raise HTTPException(status_code=404, detail="Attachment not found for this application") + + # Delete link and attachment + db.delete(app_attachment) + attachment = db.query(Attachment).filter(Attachment.id == attachment_id).first() + if attachment: + db.delete(attachment) + + db.commit() + return {"detail": "Attachment deleted successfully"}