Attachments

This commit is contained in:
Frederik Beimgraben 2025-09-01 03:05:36 +02:00
parent 6772e3d8f6
commit dd1249fb35
11 changed files with 1144 additions and 213 deletions

138
ATTACHMENT_FEATURE.md Normal file
View File

@ -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
<AttachmentManager
paId={paId}
paKey={paKey}
readOnly={!isAdmin && currentApplication.status !== "new"}
isAdmin={isAdmin}
/>
```
### In AdminApplicationView
```tsx
<AttachmentManager
paId={paId}
readOnly={false}
isAdmin={true}
/>
```
## 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

View File

@ -99,8 +99,6 @@ const ApplicationForm: React.FC<ApplicationFormProps> = ({
exkursionGenehmigt: false,
exkursionBezuschusst: false,
},
comparativeOffers: initialData.comparativeOffers ?? false,
fakultaet: initialData.fakultaet ?? false,
};
}
return {
@ -151,10 +149,6 @@ const ApplicationForm: React.FC<ApplicationFormProps> = ({
exkursionGenehmigt: false,
exkursionBezuschusst: false,
},
// Attachments
comparativeOffers: false,
fakultaet: false,
};
});
@ -320,8 +314,7 @@ const ApplicationForm: React.FC<ApplicationFormProps> = ({
if (
!formData.qsmFlags?.stellenfinanzierungen ||
!formData.qsmFlags?.studierende ||
!formData.qsmFlags?.individuell ||
!formData.fakultaet
!formData.qsmFlags?.individuell
) {
setError("Bitte bestätigen Sie alle Pflichtangaben");
return;

View File

@ -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<AttachmentManagerProps> = ({
paId,
paKey,
readOnly = false,
isAdmin = false,
}) => {
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [attachmentToDelete, setAttachmentToDelete] = useState<number | null>(
null,
);
const [loading, setLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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 <Image />;
if (contentType === "application/pdf") return <PictureAsPdf />;
if (
contentType === "application/msword" ||
contentType ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
) {
return <Description />;
}
return <InsertDriveFile />;
};
// 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<HTMLInputElement>,
) => {
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 (
<Paper elevation={1} sx={{ p: 3, mt: 3 }}>
<Box
sx={{
mb: 2,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h6" gutterBottom>
Anhänge
</Typography>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Chip
size="small"
label={`${attachments.length} / 30`}
color={attachments.length >= 30 ? "error" : "default"}
/>
<Chip
size="small"
label={`${totalSizeMB} / 100 MB`}
color={totalSize >= 100 * 1024 * 1024 ? "error" : "default"}
/>
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{!readOnly && (
<Box sx={{ mb: 2 }}>
<input
ref={fileInputRef}
type="file"
style={{ display: "none" }}
onChange={handleFileUpload}
disabled={uploading || attachments.length >= 30}
/>
<Button
variant="outlined"
startIcon={<CloudUpload />}
onClick={() => fileInputRef.current?.click()}
disabled={uploading || attachments.length >= 30}
fullWidth
>
Datei hochladen
</Button>
</Box>
)}
{uploading && (
<Box sx={{ mb: 2 }}>
<LinearProgress variant="determinate" value={uploadProgress} />
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
Hochladen... {Math.round(uploadProgress)}%
</Typography>
</Box>
)}
{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress />
</Box>
) : attachments.length === 0 ? (
<Typography color="text.secondary" align="center" sx={{ py: 3 }}>
Keine Anhänge vorhanden
</Typography>
) : (
<List>
{attachments.map((attachment) => (
<ListItem
key={attachment.id}
sx={{
borderBottom: "1px solid",
borderColor: "divider",
"&:last-child": { borderBottom: "none" },
}}
>
<Box sx={{ mr: 2, color: "primary.main" }}>
{getFileIcon(attachment.content_type)}
</Box>
<ListItemText
primary={attachment.filename}
secondary={`${formatFileSize(attachment.size)}${new Date(
attachment.created_at,
).toLocaleDateString("de-DE")}`}
/>
<ListItemSecondaryAction>
<Tooltip title="Herunterladen">
<IconButton
edge="end"
onClick={() =>
handleDownload(attachment.id, attachment.filename)
}
>
<Download />
</IconButton>
</Tooltip>
{!readOnly && (
<Tooltip title="Löschen">
<IconButton
edge="end"
onClick={() => {
setAttachmentToDelete(attachment.id);
setDeleteDialogOpen(true);
}}
>
<Delete />
</IconButton>
</Tooltip>
)}
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{/* Delete confirmation dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle>Anhang löschen?</DialogTitle>
<DialogContent>
<Typography>
Möchten Sie diesen Anhang wirklich löschen? Diese Aktion kann nicht
rückgängig gemacht werden.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Abbrechen</Button>
<Button onClick={handleDelete} color="error" variant="contained">
Löschen
</Button>
</DialogActions>
</Dialog>
</Paper>
);
};
export default AttachmentManager;

View File

@ -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<LoadingSpinnerProps> = ({
overlay = false,
text = 'Wird geladen...',
text = "Wird geladen...",
size = 40,
color = 'primary',
color = "primary",
minHeight = 200,
backdrop = true
backdrop = true,
}) => {
const spinnerContent = (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
textAlign: 'center',
p: 3
textAlign: "center",
p: 3,
}}
>
<CircularProgress size={size} color={color} />
@ -62,7 +62,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
sx={{
fontWeight: 500,
maxWidth: 300,
lineHeight: 1.4
lineHeight: 1.4,
}}
>
{text}
@ -76,18 +76,23 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
<Backdrop
open={true}
sx={{
color: '#fff',
color: "#fff",
zIndex: (theme) => 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",
}}
>
<Paper
elevation={3}
sx={{
borderRadius: 2,
backgroundColor: 'background.paper',
color: 'text.primary',
backgroundColor: "background.paper",
color: "text.primary",
minWidth: 200,
maxWidth: 400,
}}
@ -101,11 +106,11 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: minHeight,
width: '100%'
width: "100%",
}}
>
{spinnerContent}

View File

@ -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,25 +440,43 @@ const AdminApplicationView: React.FC = () => {
<Typography variant="subtitle2" color="text.secondary">
Beantragte Summe
</Typography>
<Typography variant="h6" color="primary">
{formData.requestedAmountEur?.toLocaleString("de-DE", {
<Typography
variant="h4"
color="primary"
sx={{ fontWeight: "bold" }}
>
{calculateTotal().toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
}) || "0,00 €"}
})}
</Typography>
</Grid>
</Grid>
</Paper>
{/* Financing Information */}
{(formData.vsmFinancing || formData.qsmFinancing) && (
{(formData.vsmFinancing ||
formData.qsmFinancing ||
formData.financingCode) && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
{currentApplication.variant === "QSM"
? "Erfüllte Aufgabe der QSM"
: "Erfüllte Aufgabe der VS nach §65 des Landeshochschulgesetzes"}
</Typography>
<Typography variant="body1">
<Paper
variant="outlined"
sx={{
p: 2,
backgroundColor: (theme) =>
theme.palette.mode === "dark"
? theme.palette.grey[900]
: theme.palette.grey[50],
mb: 3,
}}
>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{currentApplication.variant === "QSM" &&
(formData.financingCode || formData.qsmFinancing)
? QSM_FINANCING_LABELS[
@ -468,88 +495,112 @@ const AdminApplicationView: React.FC = () => {
formData.vsmFinancing
: "-"}
</Typography>
</Paper>
{/* Display flags */}
{formData.vsmFlags && currentApplication.variant === "VSM" && (
<Box sx={{ mt: 2 }}>
<Box>
<Typography
variant="subtitle2"
color="text.secondary"
variant="subtitle1"
gutterBottom
sx={{ fontWeight: 500, mb: 2 }}
color="text.primary"
>
Zusätzliche Angaben:
</Typography>
<Typography variant="body2">
Die Maßnahme erfüllt Aufgaben der verfassten
Studierendenschaft:{" "}
{formData.vsmFlags.aufgaben ? "Ja" : "Nein"}
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.vsmFlags.aufgaben ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Die Maßnahme erfüllt Aufgaben der verfassten
Studierendenschaft
</Typography>
<Typography variant="body2">
Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
{formData.vsmFlags.individuell ? "Ja" : "Nein"}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.vsmFlags.individuell ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Es werden keine Einzelpersonen von der Maßnahme gefördert
</Typography>
</Box>
</Box>
)}
{formData.qsmFlags && currentApplication.variant === "QSM" && (
<Box sx={{ mt: 2 }}>
<Box>
<Typography
variant="subtitle2"
color="text.secondary"
variant="subtitle1"
gutterBottom
sx={{ fontWeight: 500, mb: 2 }}
color="text.primary"
>
Zusätzliche Angaben:
</Typography>
<Typography variant="body2">
Es handelt sich um Stellenfinanzierungen:{" "}
{formData.qsmFlags.stellenfinanzierungen ? "Ja" : "Nein"}
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.qsmFlags.stellenfinanzierungen ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Es handelt sich um Stellenfinanzierungen
</Typography>
<Typography variant="body2">
Die Studierenden werden an der Planung und Durchführung
der Maßnahme beteiligt:{" "}
{formData.qsmFlags.studierende ? "Ja" : "Nein"}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.qsmFlags.studierende ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Die Studierenden werden an der Planung und Durchführung
der Maßnahme beteiligt
</Typography>
<Typography variant="body2">
Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
{formData.qsmFlags.individuell ? "Ja" : "Nein"}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.qsmFlags.individuell ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Es werden keine Einzelpersonen von der Maßnahme gefördert
</Typography>
</Box>
{formData.qsmFlags.exkursionGenehmigt !== undefined && (
<Typography variant="body2">
Die beantragte Exkursion wurde von den zuständigen
Stellen genehmigt:{" "}
{formData.qsmFlags.exkursionGenehmigt ? "Ja" : "Nein"}
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.qsmFlags.exkursionGenehmigt ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Die beantragte Exkursion wurde von den zuständigen
Stellen genehmigt
</Typography>
</Box>
)}
{formData.qsmFlags.exkursionBezuschusst !== undefined && (
<Typography variant="body2">
Die Exkursion wird bereits aus anderen Mitteln
bezuschusst:{" "}
{formData.qsmFlags.exkursionBezuschusst ? "Ja" : "Nein"}
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.qsmFlags.exkursionBezuschusst ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Die Exkursion wird bereits aus anderen Mitteln
bezuschusst
</Typography>
</Box>
)}
</Box>
)}
{/* Attachments */}
<Box sx={{ mt: 2 }}>
<Typography
variant="subtitle2"
color="text.secondary"
gutterBottom
>
Anhänge:
</Typography>
<Typography variant="body2">
Vergleichsangebote liegen bei:{" "}
{formData.comparativeOffers ? "Ja" : "Nein"}
</Typography>
{currentApplication.variant === "QSM" && (
<Typography variant="body2">
Die Fakultät ist über den Antrag informiert:{" "}
{formData.fakultaet ? "Ja" : "Nein"}
</Typography>
)}
</Box>
</Paper>
)}
@ -591,10 +642,10 @@ const AdminApplicationView: React.FC = () => {
Gesamt:
</Typography>
<Typography variant="subtitle1" color="primary">
{formData.requestedAmountEur?.toLocaleString("de-DE", {
{calculateTotal().toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
}) || "0,00 €"}
})}
</Typography>
</Box>
</Paper>
@ -614,10 +665,10 @@ const AdminApplicationView: React.FC = () => {
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<TextField
select
fullWidth
size="small"
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
size="small"
sx={{ minWidth: "100px" }}
>
<MenuItem value="new">Neu</MenuItem>
<MenuItem value="in-review">In Prüfung</MenuItem>
@ -728,6 +779,9 @@ const AdminApplicationView: React.FC = () => {
</Grid>
</Grid>
{/* Attachments */}
<AttachmentManager paId={paId!} readOnly={false} isAdmin={true} />
{/* Delete Confirmation Dialog */}
<Dialog
open={showDeleteDialog}

View File

@ -18,7 +18,14 @@ import {
Tooltip,
Alert,
} from "@mui/material";
import { Download, Edit, Delete, Refresh } from "@mui/icons-material";
import {
Download,
Edit,
Delete,
Refresh,
CheckBox,
CheckBoxOutlineBlank,
} from "@mui/icons-material";
import { useParams, useNavigate } from "react-router-dom";
import dayjs from "dayjs";
@ -30,6 +37,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";
@ -373,7 +381,11 @@ const ViewApplicationPage: React.FC = () => {
<Typography variant="subtitle2" color="text.secondary">
Beantragte Summe
</Typography>
<Typography variant="h6" color="primary">
<Typography
variant="h4"
color="primary"
sx={{ fontWeight: "bold" }}
>
{calculateTotal().toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
@ -384,14 +396,28 @@ const ViewApplicationPage: React.FC = () => {
</Paper>
{/* Financing Information */}
{(formData.vsmFinancing || formData.qsmFinancing) && (
{(formData.vsmFinancing ||
formData.qsmFinancing ||
formData.financingCode) && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
{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"}
</Typography>
<Typography variant="body1">
<Paper
variant="outlined"
sx={{
p: 2,
backgroundColor: (theme) =>
theme.palette.mode === "dark"
? theme.palette.grey[900]
: theme.palette.grey[50],
mb: 3,
}}
>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{currentApplication.variant === "QSM" &&
(formData.financingCode || formData.qsmFinancing)
? QSM_FINANCING_LABELS[
@ -410,88 +436,112 @@ const ViewApplicationPage: React.FC = () => {
formData.vsmFinancing
: "-"}
</Typography>
</Paper>
{/* Display flags */}
{formData.vsmFlags && currentApplication.variant === "VSM" && (
<Box sx={{ mt: 2 }}>
<Box>
<Typography
variant="subtitle2"
color="text.secondary"
variant="subtitle1"
gutterBottom
sx={{ fontWeight: 500, mb: 2 }}
color="text.primary"
>
Zusätzliche Angaben:
</Typography>
<Typography variant="body2">
Die Maßnahme erfüllt Aufgaben der verfassten
Studierendenschaft:{" "}
{formData.vsmFlags.aufgaben ? "Ja" : "Nein"}
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.vsmFlags.aufgaben ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Die Maßnahme erfüllt Aufgaben der verfassten
Studierendenschaft
</Typography>
<Typography variant="body2">
Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
{formData.vsmFlags.individuell ? "Ja" : "Nein"}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.vsmFlags.individuell ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Es werden keine Einzelpersonen von der Maßnahme gefördert
</Typography>
</Box>
</Box>
)}
{formData.qsmFlags && currentApplication.variant === "QSM" && (
<Box sx={{ mt: 2 }}>
<Box>
<Typography
variant="subtitle2"
color="text.secondary"
variant="subtitle1"
gutterBottom
sx={{ fontWeight: 500, mb: 2 }}
color="text.primary"
>
Zusätzliche Angaben:
</Typography>
<Typography variant="body2">
Es handelt sich um Stellenfinanzierungen:{" "}
{formData.qsmFlags.stellenfinanzierungen ? "Ja" : "Nein"}
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.qsmFlags.stellenfinanzierungen ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Es handelt sich um Stellenfinanzierungen
</Typography>
<Typography variant="body2">
Die Studierenden werden an der Planung und Durchführung
der Maßnahme beteiligt:{" "}
{formData.qsmFlags.studierende ? "Ja" : "Nein"}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.qsmFlags.studierende ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Die Studierenden werden an der Planung und Durchführung
der Maßnahme beteiligt
</Typography>
<Typography variant="body2">
Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
{formData.qsmFlags.individuell ? "Ja" : "Nein"}
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.qsmFlags.individuell ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Es werden keine Einzelpersonen von der Maßnahme gefördert
</Typography>
</Box>
{formData.qsmFlags.exkursionGenehmigt !== undefined && (
<Typography variant="body2">
Die beantragte Exkursion wurde von den zuständigen
Stellen genehmigt:{" "}
{formData.qsmFlags.exkursionGenehmigt ? "Ja" : "Nein"}
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.qsmFlags.exkursionGenehmigt ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Die beantragte Exkursion wurde von den zuständigen
Stellen genehmigt
</Typography>
</Box>
)}
{formData.qsmFlags.exkursionBezuschusst !== undefined && (
<Typography variant="body2">
Die Exkursion wird bereits aus anderen Mitteln
bezuschusst:{" "}
{formData.qsmFlags.exkursionBezuschusst ? "Ja" : "Nein"}
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
{formData.qsmFlags.exkursionBezuschusst ? (
<CheckBox color="primary" sx={{ mr: 1 }} />
) : (
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
)}
<Typography variant="body1">
Die Exkursion wird bereits aus anderen Mitteln
bezuschusst
</Typography>
</Box>
)}
</Box>
)}
{/* Attachments */}
<Box sx={{ mt: 2 }}>
<Typography
variant="subtitle2"
color="text.secondary"
gutterBottom
>
Anhänge:
</Typography>
<Typography variant="body2">
Vergleichsangebote liegen bei:{" "}
{formData.comparativeOffers ? "Ja" : "Nein"}
</Typography>
{currentApplication.variant === "QSM" && (
<Typography variant="body2">
Die Fakultät ist über den Antrag informiert:{" "}
{formData.fakultaet ? "Ja" : "Nein"}
</Typography>
)}
</Box>
</Paper>
)}
@ -622,6 +672,14 @@ const ViewApplicationPage: React.FC = () => {
</Grid>
</Grid>
{/* Attachments */}
<AttachmentManager
paId={paId!}
paKey={paKey || undefined}
readOnly={!isAdmin && currentApplication.status !== "new"}
isAdmin={isAdmin}
/>
{/* Delete Confirmation Dialog */}
<Dialog
open={showDeleteDialog}

View File

@ -137,7 +137,6 @@ const initialFormData: Partial<FormData> = {
costs: [],
requestedAmountEur: 0,
variant: "VSM",
comparativeOffers: false,
};
const initialState: ApplicationState = {
@ -1158,10 +1157,6 @@ function createFormJsonFromData(formData: Partial<FormData>): 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<FormData>): 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<FormData> {
individuell: pa.project.financing.vsm.flags.individuell || false,
}
: undefined,
comparativeOffers: pa.attachments.comparativeOffers || false,
fakultaet: pa.attachments.fakultaet || false,
};
}

View File

@ -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.

View File

@ -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.

View File

@ -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"),

View File

@ -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"}