"Refactor database connections and add analytics/permissions utils"

This commit is contained in:
Till Tomczak 2025-05-29 15:41:48 +02:00
parent c8de6b6ca2
commit b916cdaca3
7 changed files with 1404 additions and 27 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1 +1,752 @@
/**
* MYP Platform Advanced UI Components
* Erweiterte Komponenten: Progress-Bars, File-Upload, Datepicker
* Version: 2.0.0
*/
(function() {
'use strict';
// Namespace erweitern
window.MYP = window.MYP || {};
window.MYP.Advanced = window.MYP.Advanced || {};
/**
* Progress Bar Component
*/
class ProgressBar {
constructor(container, options = {}) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
this.options = {
value: 0,
max: 100,
showLabel: true,
showPercentage: true,
animated: true,
color: 'blue',
size: 'md',
striped: false,
...options
};
this.currentValue = this.options.value;
this.init();
}
init() {
if (!this.container) {
console.error('ProgressBar: Container nicht gefunden');
return;
}
this.render();
}
render() {
const percentage = Math.round((this.currentValue / this.options.max) * 100);
const sizeClass = this.getSizeClass();
const colorClass = this.getColorClass();
this.container.innerHTML = `
<div class="progress-bar-container ${sizeClass}">
${this.options.showLabel ? `
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
${this.options.label || 'Fortschritt'}
</span>
${this.options.showPercentage ? `
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
${percentage}%
</span>
` : ''}
</div>
` : ''}
<div class="progress-bar-track ${sizeClass}">
<div class="progress-bar-fill ${colorClass} ${this.options.animated ? 'animated' : ''} ${this.options.striped ? 'striped' : ''}"
style="width: ${percentage}%"
role="progressbar"
aria-valuenow="${this.currentValue}"
aria-valuemin="0"
aria-valuemax="${this.options.max}">
</div>
</div>
</div>
`;
}
getSizeClass() {
const sizes = {
'sm': 'h-2',
'md': 'h-3',
'lg': 'h-4',
'xl': 'h-6'
};
return sizes[this.options.size] || sizes.md;
}
getColorClass() {
const colors = {
'blue': 'bg-blue-500',
'green': 'bg-green-500',
'red': 'bg-red-500',
'yellow': 'bg-yellow-500',
'purple': 'bg-purple-500',
'indigo': 'bg-indigo-500'
};
return colors[this.options.color] || colors.blue;
}
setValue(value, animate = true) {
const oldValue = this.currentValue;
this.currentValue = Math.max(0, Math.min(this.options.max, value));
if (animate) {
this.animateToValue(oldValue, this.currentValue);
} else {
this.render();
}
}
animateToValue(from, to) {
const duration = 500; // ms
const steps = 60;
const stepValue = (to - from) / steps;
let currentStep = 0;
const animate = () => {
if (currentStep < steps) {
this.currentValue = from + (stepValue * currentStep);
this.render();
currentStep++;
requestAnimationFrame(animate);
} else {
this.currentValue = to;
this.render();
}
};
animate();
}
increment(amount = 1) {
this.setValue(this.currentValue + amount);
}
decrement(amount = 1) {
this.setValue(this.currentValue - amount);
}
reset() {
this.setValue(0);
}
complete() {
this.setValue(this.options.max);
}
}
/**
* Advanced File Upload Component
*/
class FileUpload {
constructor(container, options = {}) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
this.options = {
multiple: false,
accept: '*/*',
maxSize: 50 * 1024 * 1024, // 50MB
maxFiles: 10,
dragDrop: true,
showProgress: true,
showPreview: true,
uploadUrl: '/api/upload',
chunkSize: 1024 * 1024, // 1MB chunks
...options
};
this.files = [];
this.uploads = new Map();
this.init();
}
init() {
if (!this.container) {
console.error('FileUpload: Container nicht gefunden');
return;
}
this.render();
this.setupEventListeners();
}
render() {
this.container.innerHTML = `
<div class="file-upload-area" id="fileUploadArea">
<div class="file-upload-dropzone ${this.options.dragDrop ? 'drag-enabled' : ''}" id="dropzone">
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-slate-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="mt-4">
<label for="fileInput" class="cursor-pointer">
<span class="mt-2 block text-sm font-medium text-slate-900 dark:text-white">
${this.options.dragDrop ? 'Dateien hierher ziehen oder' : ''}
<span class="text-blue-600 dark:text-blue-400 hover:text-blue-500">durchsuchen</span>
</span>
</label>
<input type="file"
id="fileInput"
name="files"
${this.options.multiple ? 'multiple' : ''}
accept="${this.options.accept}"
class="sr-only">
</div>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
${this.getFileTypeText()} Max. ${this.formatFileSize(this.options.maxSize)}
</p>
</div>
</div>
<div class="file-list mt-4" id="fileList"></div>
</div>
`;
}
setupEventListeners() {
const fileInput = this.container.querySelector('#fileInput');
const dropzone = this.container.querySelector('#dropzone');
// File Input Change
fileInput.addEventListener('change', (e) => {
this.handleFiles(Array.from(e.target.files));
});
if (this.options.dragDrop) {
// Drag and Drop Events
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('drag-over');
});
dropzone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropzone.classList.remove('drag-over');
});
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('drag-over');
this.handleFiles(Array.from(e.dataTransfer.files));
});
}
}
handleFiles(fileList) {
for (const file of fileList) {
if (this.validateFile(file)) {
this.addFile(file);
}
}
this.renderFileList();
}
validateFile(file) {
// Dateigröße prüfen
if (file.size > this.options.maxSize) {
this.showError(`Datei "${file.name}" ist zu groß. Maximum: ${this.formatFileSize(this.options.maxSize)}`);
return false;
}
// Anzahl Dateien prüfen
if (!this.options.multiple && this.files.length > 0) {
this.files = []; // Ersetze einzelne Datei
} else if (this.files.length >= this.options.maxFiles) {
this.showError(`Maximal ${this.options.maxFiles} Dateien erlaubt`);
return false;
}
return true;
}
addFile(file) {
const fileData = {
id: this.generateId(),
file: file,
name: file.name,
size: file.size,
type: file.type,
status: 'pending',
progress: 0,
error: null
};
this.files.push(fileData);
// Preview generieren
if (this.options.showPreview && file.type.startsWith('image/')) {
this.generatePreview(fileData);
}
}
generatePreview(fileData) {
const reader = new FileReader();
reader.onload = (e) => {
fileData.preview = e.target.result;
this.renderFileList();
};
reader.readAsDataURL(fileData.file);
}
renderFileList() {
const fileListContainer = this.container.querySelector('#fileList');
if (this.files.length === 0) {
fileListContainer.innerHTML = '';
return;
}
fileListContainer.innerHTML = this.files.map(fileData => `
<div class="file-item" data-file-id="${fileData.id}">
<div class="flex items-center space-x-4 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
${fileData.preview ? `
<img src="${fileData.preview}" class="w-12 h-12 object-cover rounded" alt="Preview">
` : `
<div class="w-12 h-12 bg-slate-200 dark:bg-slate-700 rounded flex items-center justify-center">
<svg class="w-6 h-6 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
</svg>
</div>
`}
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-slate-900 dark:text-white truncate">
${fileData.name}
</p>
<button class="remove-file text-slate-400 hover:text-red-500" data-file-id="${fileData.id}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">
${this.formatFileSize(fileData.size)} ${this.getStatusText(fileData.status)}
</p>
${this.options.showProgress && fileData.status === 'uploading' ? `
<div class="mt-2">
<div class="w-full bg-slate-200 dark:bg-slate-600 rounded-full h-2">
<div class="bg-blue-500 h-2 rounded-full transition-all duration-300"
style="width: ${fileData.progress}%"></div>
</div>
</div>
` : ''}
${fileData.error ? `
<p class="text-xs text-red-500 mt-1">${fileData.error}</p>
` : ''}
</div>
</div>
</div>
`).join('');
// Event Listeners für Remove-Buttons
fileListContainer.querySelectorAll('.remove-file').forEach(button => {
button.addEventListener('click', (e) => {
const fileId = e.target.closest('.remove-file').dataset.fileId;
this.removeFile(fileId);
});
});
}
removeFile(fileId) {
this.files = this.files.filter(f => f.id !== fileId);
this.renderFileList();
}
async uploadFiles() {
const pendingFiles = this.files.filter(f => f.status === 'pending');
for (const fileData of pendingFiles) {
await this.uploadFile(fileData);
}
}
async uploadFile(fileData) {
fileData.status = 'uploading';
fileData.progress = 0;
this.renderFileList();
try {
const formData = new FormData();
formData.append('file', fileData.file);
const response = await fetch(this.options.uploadUrl, {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
});
if (response.ok) {
fileData.status = 'completed';
fileData.progress = 100;
const result = await response.json();
fileData.url = result.url;
} else {
throw new Error(`Upload fehlgeschlagen: ${response.status}`);
}
} catch (error) {
fileData.status = 'error';
fileData.error = error.message;
}
this.renderFileList();
}
getFileTypeText() {
if (this.options.accept === '*/*') return 'Alle Dateitypen';
if (this.options.accept.includes('image/')) return 'Bilder';
if (this.options.accept.includes('.pdf')) return 'PDF-Dateien';
return 'Spezifische Dateitypen';
}
getStatusText(status) {
const statusTexts = {
'pending': 'Wartend',
'uploading': 'Wird hochgeladen...',
'completed': 'Abgeschlossen',
'error': 'Fehler'
};
return statusTexts[status] || status;
}
formatFileSize(bytes) {
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];
}
generateId() {
return 'file_' + Math.random().toString(36).substr(2, 9);
}
showError(message) {
if (window.showToast) {
window.showToast(message, 'error');
} else {
alert(message);
}
}
getFiles() {
return this.files;
}
getCompletedFiles() {
return this.files.filter(f => f.status === 'completed');
}
clear() {
this.files = [];
this.renderFileList();
}
}
/**
* Simple Datepicker Component
*/
class DatePicker {
constructor(input, options = {}) {
this.input = typeof input === 'string' ? document.querySelector(input) : input;
this.options = {
format: 'dd.mm.yyyy',
minDate: null,
maxDate: null,
disabledDates: [],
language: 'de',
closeOnSelect: true,
showWeekNumbers: false,
...options
};
this.isOpen = false;
this.currentDate = new Date();
this.selectedDate = null;
this.init();
}
init() {
if (!this.input) {
console.error('DatePicker: Input-Element nicht gefunden');
return;
}
this.setupInput();
this.createCalendar();
this.setupEventListeners();
}
setupInput() {
this.input.setAttribute('readonly', 'true');
this.input.classList.add('datepicker-input');
// Container für Input und Calendar
this.container = document.createElement('div');
this.container.className = 'datepicker-container relative';
this.input.parentNode.insertBefore(this.container, this.input);
this.container.appendChild(this.input);
}
createCalendar() {
this.calendar = document.createElement('div');
this.calendar.className = 'datepicker-calendar absolute top-full left-0 mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded-lg shadow-lg z-50 hidden';
this.calendar.innerHTML = this.renderCalendar();
this.container.appendChild(this.calendar);
}
renderCalendar() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const monthName = this.getMonthName(month);
return `
<div class="datepicker-header p-4 border-b border-slate-200 dark:border-slate-600">
<div class="flex items-center justify-between">
<button type="button" class="prev-month p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</button>
<div class="text-sm font-medium text-slate-900 dark:text-white">
${monthName} ${year}
</div>
<button type="button" class="next-month p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>
</button>
</div>
</div>
<div class="datepicker-body p-4">
<div class="grid grid-cols-7 gap-1 mb-2">
${this.getWeekdayHeaders()}
</div>
<div class="grid grid-cols-7 gap-1">
${this.getDaysOfMonth(year, month)}
</div>
</div>
`;
}
getWeekdayHeaders() {
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
return weekdays.map(day =>
`<div class="text-xs font-medium text-slate-500 dark:text-slate-400 text-center p-1">${day}</div>`
).join('');
}
getDaysOfMonth(year, month) {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - ((firstDay.getDay() + 6) % 7));
const days = [];
const current = new Date(startDate);
while (current <= lastDay || current.getMonth() === month) {
const isCurrentMonth = current.getMonth() === month;
const isToday = this.isToday(current);
const isSelected = this.isSelectedDate(current);
const isDisabled = this.isDisabledDate(current);
const classes = [
'w-8 h-8 text-sm rounded cursor-pointer flex items-center justify-center transition-colors',
isCurrentMonth ? 'text-slate-900 dark:text-white' : 'text-slate-400 dark:text-slate-600',
isToday ? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100' : '',
isSelected ? 'bg-blue-500 text-white' : '',
!isDisabled && isCurrentMonth ? 'hover:bg-slate-100 dark:hover:bg-slate-700' : '',
isDisabled ? 'cursor-not-allowed opacity-50' : ''
].filter(Boolean);
days.push(`
<div class="${classes.join(' ')}"
data-date="${this.formatDateForData(current)}"
${isDisabled ? '' : 'data-selectable="true"'}>
${current.getDate()}
</div>
`);
current.setDate(current.getDate() + 1);
if (days.length >= 42) break; // Max 6 Wochen
}
return days.join('');
}
setupEventListeners() {
// Input click
this.input.addEventListener('click', () => {
this.toggle();
});
// Calendar clicks
this.calendar.addEventListener('click', (e) => {
if (e.target.classList.contains('prev-month')) {
this.previousMonth();
} else if (e.target.classList.contains('next-month')) {
this.nextMonth();
} else if (e.target.dataset.selectable) {
this.selectDate(new Date(e.target.dataset.date));
}
});
// Click outside
document.addEventListener('click', (e) => {
if (!this.container.contains(e.target)) {
this.close();
}
});
}
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
open() {
this.calendar.classList.remove('hidden');
this.isOpen = true;
this.updateCalendar();
}
close() {
this.calendar.classList.add('hidden');
this.isOpen = false;
}
selectDate(date) {
this.selectedDate = new Date(date);
this.input.value = this.formatDate(date);
// Custom Event
this.input.dispatchEvent(new CustomEvent('dateselected', {
detail: { date: new Date(date) }
}));
if (this.options.closeOnSelect) {
this.close();
}
}
previousMonth() {
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
this.updateCalendar();
}
nextMonth() {
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
this.updateCalendar();
}
updateCalendar() {
this.calendar.innerHTML = this.renderCalendar();
}
isToday(date) {
const today = new Date();
return date.toDateString() === today.toDateString();
}
isSelectedDate(date) {
return this.selectedDate && date.toDateString() === this.selectedDate.toDateString();
}
isDisabledDate(date) {
if (this.options.minDate && date < this.options.minDate) return true;
if (this.options.maxDate && date > this.options.maxDate) return true;
return this.options.disabledDates.some(disabled =>
date.toDateString() === disabled.toDateString()
);
}
formatDate(date) {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
return this.options.format
.replace('dd', day)
.replace('mm', month)
.replace('yyyy', year);
}
formatDateForData(date) {
return date.toISOString().split('T')[0];
}
getMonthName(monthIndex) {
const months = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
return months[monthIndex];
}
setValue(date) {
if (date) {
this.selectDate(new Date(date));
}
}
getValue() {
return this.selectedDate;
}
clear() {
this.selectedDate = null;
this.input.value = '';
}
}
// Globale API
window.MYP.Advanced = {
ProgressBar,
FileUpload,
DatePicker,
// Convenience Functions
createProgressBar: (container, options) => new ProgressBar(container, options),
createFileUpload: (container, options) => new FileUpload(container, options),
createDatePicker: (input, options) => new DatePicker(input, options)
};
// Auto-Initialize
document.addEventListener('DOMContentLoaded', function() {
// Auto-initialize datepickers
document.querySelectorAll('[data-datepicker]').forEach(input => {
const options = JSON.parse(input.dataset.datepicker || '{}');
new DatePicker(input, options);
});
// Auto-initialize file uploads
document.querySelectorAll('[data-file-upload]').forEach(container => {
const options = JSON.parse(container.dataset.fileUpload || '{}');
new FileUpload(container, options);
});
console.log('🚀 MYP Advanced Components geladen');
});
})();

View File

@ -263,68 +263,66 @@
<div class="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900 dark:border-t-slate-700"></div> <div class="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-slate-900 dark:border-t-slate-700"></div>
</div> </div>
</div> </div>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<!-- Benachrichtigungen --> <!-- Benachrichtigungen - kompakteres Design -->
<div class="relative"> <div class="relative">
<button <button
id="notificationToggle" id="notificationToggle"
class="relative p-2 rounded-full text-slate-700 dark:text-slate-300 hover:bg-slate-100/80 dark:hover:bg-slate-800/50 transition-all duration-200" class="relative p-1.5 rounded-full text-slate-700 dark:text-slate-300 hover:bg-slate-100/80 dark:hover:bg-slate-800/50 transition-all duration-200"
aria-label="Benachrichtigungen anzeigen" aria-label="Benachrichtigungen anzeigen"
title="Benachrichtigungen" title="Benachrichtigungen"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg> </svg>
<!-- Badge für ungelesene Benachrichtigungen --> <!-- Badge für ungelesene Benachrichtigungen -->
<span id="notificationBadge" class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium hidden"> <span id="notificationBadge" class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center font-medium hidden">
0 0
</span> </span>
</button> </button>
<!-- Benachrichtigungs-Dropdown --> <!-- Benachrichtigungs-Dropdown -->
<div id="notificationDropdown" class="absolute right-0 mt-2 w-80 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-slate-200 dark:border-slate-600 z-50 hidden"> <div id="notificationDropdown" class="absolute right-0 mt-2 w-72 sm:w-80 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-slate-200 dark:border-slate-600 z-50 hidden">
<div class="p-4 border-b border-slate-200 dark:border-slate-600"> <div class="p-3 border-b border-slate-200 dark:border-slate-600">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Benachrichtigungen</h3> <h3 class="text-base font-semibold text-slate-900 dark:text-white">Benachrichtigungen</h3>
</div> </div>
<div id="notificationList" class="max-h-96 overflow-y-auto"> <div id="notificationList" class="max-h-80 overflow-y-auto">
<div class="p-4 text-center text-slate-500 dark:text-slate-400"> <div class="p-3 text-center text-slate-500 dark:text-slate-400 text-sm">
Keine neuen Benachrichtigungen Keine neuen Benachrichtigungen
</div> </div>
</div> </div>
<div class="p-3 border-t border-slate-200 dark:border-slate-600"> <div class="p-2 border-t border-slate-200 dark:border-slate-600">
<button id="markAllRead" class="w-full text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"> <button id="markAllRead" class="w-full text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors">
Alle als gelesen markieren Alle als gelesen markieren
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- User Profile Dropdown - neues Design --> <!-- User Profile Dropdown - kompakteres Design -->
<div class="relative" id="user-menu-container"> <div class="relative" id="user-menu-container">
<button <button
id="user-menu-button" id="user-menu-button"
class="user-menu-button-new" class="flex items-center space-x-1 rounded-full p-1 text-slate-700 dark:text-slate-300 hover:bg-slate-100/80 dark:hover:bg-slate-800/50 transition-all duration-200"
aria-expanded="false" aria-expanded="false"
aria-haspopup="true" aria-haspopup="true"
aria-label="Benutzermenu öffnen" aria-label="Benutzermenu öffnen"
> >
<!-- Profile Avatar --> <!-- Profile Avatar -->
<div class="user-avatar-new"> <div class="w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-medium">
{{ current_user.email[0].upper() if current_user.email else 'U' }} {{ current_user.email[0].upper() if current_user.email else 'U' }}
</div> </div>
<!-- User Info (hidden on mobile) --> <!-- User Info (nur auf größeren Geräten) -->
<div class="hidden md:block text-left ml-2"> <div class="hidden sm:block text-left ml-1">
<div class="text-sm font-semibold text-slate-900 dark:text-white transition-colors duration-300">{{ current_user.email.split('@')[0] if current_user.email else 'Benutzer' }}</div> <div class="text-xs font-medium text-slate-900 dark:text-white transition-colors duration-300">{{ current_user.email.split('@')[0] if current_user.email else 'Benutzer' }}</div>
<div class="text-xs text-slate-600 dark:text-slate-400 transition-colors duration-300">Mercedes-Benz Mitarbeiter</div>
</div> </div>
</button> </button>
</div> </div>
{% else %} {% else %}
<!-- Login Button - neues Design --> <!-- Login Button - kompakteres Design -->
<a href="{{ url_for('login') }}" <a href="{{ url_for('login') }}"
class="login-button-new"> class="flex items-center space-x-1 py-1 px-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-xs transition-colors duration-200">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg> </svg>
<span class="hidden sm:inline">Anmelden</span> <span class="hidden sm:inline">Anmelden</span>

View File

@ -321,7 +321,7 @@ window.isAdmin = {% if current_user.is_admin %}true{% else %}false{% endif %};
// Live Time Update // Live Time Update
function updateLiveTime() { function updateLiveTime() {
const now = new Date(); const now = new Date();
const timeElement = document.getElementById('live-time'); const timeElement = document.getElementById('live-time');
if (timeElement) { if (timeElement) {
timeElement.textContent = now.toLocaleTimeString('de-DE'); timeElement.textContent = now.toLocaleTimeString('de-DE');
@ -360,8 +360,5 @@ function closeJobModal() {
function closeExtendModal() { function closeExtendModal() {
hideModal('extendJobModal'); hideModal('extendJobModal');
} }
// ... existing code ...
</script> </script>
// ... existing code ...
{% endblock %} {% endblock %}

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,630 @@
#!/usr/bin/env python3
"""
Erweiterte Berechtigungsverwaltung für MYP Platform
Granulare Rollen und Permissions für feingranulare Zugriffskontrolle
"""
from enum import Enum
from functools import wraps
from typing import List, Dict, Set, Optional
from flask import current_user, request, jsonify, abort
from flask_login import login_required
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, Table, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime, timedelta
from utils.logging_config import get_logger
logger = get_logger("permissions")
# ===== PERMISSION DEFINITIONS =====
class Permission(Enum):
"""Alle verfügbaren Berechtigungen im System"""
# Basis-Berechtigungen
LOGIN = "login"
VIEW_DASHBOARD = "view_dashboard"
# Drucker-Berechtigungen
VIEW_PRINTERS = "view_printers"
CREATE_PRINTER = "create_printer"
EDIT_PRINTER = "edit_printer"
DELETE_PRINTER = "delete_printer"
CONTROL_PRINTER = "control_printer" # Ein-/Ausschalten
VIEW_PRINTER_DETAILS = "view_printer_details"
# Job-Berechtigungen
VIEW_JOBS = "view_jobs"
CREATE_JOB = "create_job"
EDIT_OWN_JOB = "edit_own_job"
EDIT_ALL_JOBS = "edit_all_jobs"
DELETE_OWN_JOB = "delete_own_job"
DELETE_ALL_JOBS = "delete_all_jobs"
EXTEND_JOB = "extend_job"
CANCEL_JOB = "cancel_job"
VIEW_JOB_HISTORY = "view_job_history"
# Benutzer-Berechtigungen
VIEW_USERS = "view_users"
CREATE_USER = "create_user"
EDIT_USER = "edit_user"
DELETE_USER = "delete_user"
MANAGE_ROLES = "manage_roles"
VIEW_USER_DETAILS = "view_user_details"
# Admin-Berechtigungen
VIEW_ADMIN_PANEL = "view_admin_panel"
MANAGE_SYSTEM = "manage_system"
VIEW_LOGS = "view_logs"
EXPORT_DATA = "export_data"
BACKUP_DATABASE = "backup_database"
MANAGE_SETTINGS = "manage_settings"
# Gast-Berechtigungen
VIEW_GUEST_REQUESTS = "view_guest_requests"
CREATE_GUEST_REQUEST = "create_guest_request"
APPROVE_GUEST_REQUEST = "approve_guest_request"
DENY_GUEST_REQUEST = "deny_guest_request"
MANAGE_GUEST_REQUESTS = "manage_guest_requests"
# Statistik-Berechtigungen
VIEW_STATS = "view_stats"
VIEW_DETAILED_STATS = "view_detailed_stats"
EXPORT_STATS = "export_stats"
# Kalender-Berechtigungen
VIEW_CALENDAR = "view_calendar"
EDIT_CALENDAR = "edit_calendar"
MANAGE_SHIFTS = "manage_shifts"
# Wartung-Berechtigungen
SCHEDULE_MAINTENANCE = "schedule_maintenance"
VIEW_MAINTENANCE = "view_maintenance"
PERFORM_MAINTENANCE = "perform_maintenance"
class Role(Enum):
"""Vordefinierte Rollen mit Standard-Berechtigungen"""
GUEST = "guest"
USER = "user"
POWER_USER = "power_user"
TECHNICIAN = "technician"
SUPERVISOR = "supervisor"
ADMIN = "admin"
SUPER_ADMIN = "super_admin"
# ===== ROLE PERMISSIONS MAPPING =====
ROLE_PERMISSIONS = {
Role.GUEST: {
Permission.LOGIN,
Permission.VIEW_PRINTERS,
Permission.CREATE_GUEST_REQUEST,
Permission.VIEW_CALENDAR,
},
Role.USER: {
Permission.LOGIN,
Permission.VIEW_DASHBOARD,
Permission.VIEW_PRINTERS,
Permission.VIEW_JOBS,
Permission.CREATE_JOB,
Permission.EDIT_OWN_JOB,
Permission.DELETE_OWN_JOB,
Permission.EXTEND_JOB,
Permission.CANCEL_JOB,
Permission.VIEW_STATS,
Permission.VIEW_CALENDAR,
Permission.CREATE_GUEST_REQUEST,
},
}
# Power User erweitert User-Permissions
ROLE_PERMISSIONS[Role.POWER_USER] = ROLE_PERMISSIONS[Role.USER] | {
Permission.VIEW_PRINTER_DETAILS,
Permission.VIEW_JOB_HISTORY,
Permission.VIEW_DETAILED_STATS,
Permission.EXPORT_STATS,
Permission.VIEW_GUEST_REQUESTS,
}
# Technician erweitert Power User-Permissions
ROLE_PERMISSIONS[Role.TECHNICIAN] = ROLE_PERMISSIONS[Role.POWER_USER] | {
Permission.CONTROL_PRINTER,
Permission.EDIT_PRINTER,
Permission.SCHEDULE_MAINTENANCE,
Permission.VIEW_MAINTENANCE,
Permission.PERFORM_MAINTENANCE,
Permission.EDIT_CALENDAR,
}
# Supervisor erweitert Technician-Permissions
ROLE_PERMISSIONS[Role.SUPERVISOR] = ROLE_PERMISSIONS[Role.TECHNICIAN] | {
Permission.CREATE_PRINTER,
Permission.EDIT_ALL_JOBS,
Permission.DELETE_ALL_JOBS,
Permission.VIEW_USERS,
Permission.APPROVE_GUEST_REQUEST,
Permission.DENY_GUEST_REQUEST,
Permission.MANAGE_GUEST_REQUESTS,
Permission.MANAGE_SHIFTS,
Permission.VIEW_USER_DETAILS,
}
# Admin erweitert Supervisor-Permissions
ROLE_PERMISSIONS[Role.ADMIN] = ROLE_PERMISSIONS[Role.SUPERVISOR] | {
Permission.DELETE_PRINTER,
Permission.VIEW_ADMIN_PANEL,
Permission.CREATE_USER,
Permission.EDIT_USER,
Permission.DELETE_USER,
Permission.EXPORT_DATA,
Permission.VIEW_LOGS,
Permission.MANAGE_SETTINGS,
}
# Super Admin hat alle Berechtigungen
ROLE_PERMISSIONS[Role.SUPER_ADMIN] = {perm for perm in Permission}
# ===== DATABASE MODELS EXTENSIONS =====
# Many-to-Many Tabelle für User-Permissions
user_permissions = Table('user_permissions',
Column('user_id', Integer, ForeignKey('users.id'), primary_key=True),
Column('permission_id', Integer, ForeignKey('permissions.id'), primary_key=True)
)
# Many-to-Many Tabelle für User-Roles
user_roles = Table('user_roles',
Column('user_id', Integer, ForeignKey('users.id'), primary_key=True),
Column('role_id', Integer, ForeignKey('roles.id'), primary_key=True)
)
class PermissionModel:
"""Datenbank-Model für Berechtigungen"""
__tablename__ = 'permissions'
id = Column(Integer, primary_key=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(String(255))
category = Column(String(50)) # Gruppierung von Berechtigungen
created_at = Column(DateTime, default=datetime.now)
class RoleModel:
"""Datenbank-Model für Rollen"""
__tablename__ = 'roles'
id = Column(Integer, primary_key=True)
name = Column(String(50), unique=True, nullable=False)
display_name = Column(String(100))
description = Column(String(255))
is_system_role = Column(Boolean, default=False) # System-Rollen können nicht gelöscht werden
created_at = Column(DateTime, default=datetime.now)
# Relationships
permissions = relationship("PermissionModel", secondary="role_permissions", back_populates="roles")
class UserPermissionOverride:
"""Temporäre oder spezielle Berechtigungsüberschreibungen"""
__tablename__ = 'user_permission_overrides'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
permission = Column(String(100), nullable=False)
granted = Column(Boolean, nullable=False) # True = gewährt, False = verweigert
reason = Column(String(255))
granted_by = Column(Integer, ForeignKey('users.id'))
expires_at = Column(DateTime, nullable=True) # NULL = permanent
created_at = Column(DateTime, default=datetime.now)
# ===== PERMISSION CHECKER CLASS =====
class PermissionChecker:
"""Hauptklasse für Berechtigungsprüfungen"""
def __init__(self, user=None):
self.user = user or current_user
self._permission_cache = {}
self._cache_timeout = timedelta(minutes=5)
self._cache_timestamp = None
def has_permission(self, permission: Permission) -> bool:
"""
Prüft ob der Benutzer eine bestimmte Berechtigung hat
Args:
permission: Die zu prüfende Berechtigung
Returns:
bool: True wenn Berechtigung vorhanden
"""
if not self.user or not self.user.is_authenticated:
return False
# Cache prüfen
if self._is_cache_valid() and permission.value in self._permission_cache:
return self._permission_cache[permission.value]
# Berechtigungen neu berechnen
has_perm = self._calculate_permission(permission)
# Cache aktualisieren
self._update_cache(permission.value, has_perm)
return has_perm
def _calculate_permission(self, permission: Permission) -> bool:
"""Berechnet ob eine Berechtigung vorhanden ist"""
# Super Admin hat alle Rechte
if hasattr(self.user, 'is_super_admin') and self.user.is_super_admin:
return True
# Explizite Überschreibungen prüfen
override = self._check_permission_override(permission)
if override is not None:
return override
# Rollen-basierte Berechtigungen prüfen
user_roles = self._get_user_roles()
for role in user_roles:
if permission in ROLE_PERMISSIONS.get(role, set()):
return True
# Direkte Benutzer-Berechtigungen prüfen
if hasattr(self.user, 'permissions'):
user_permissions = [Permission(p.name) for p in self.user.permissions if hasattr(Permission, p.name.upper())]
if permission in user_permissions:
return True
return False
def _check_permission_override(self, permission: Permission) -> Optional[bool]:
"""Prüft ob es eine Berechtigungsüberschreibung gibt"""
if not hasattr(self.user, 'permission_overrides'):
return None
now = datetime.now()
for override in self.user.permission_overrides:
if (override.permission == permission.value and
(override.expires_at is None or override.expires_at > now)):
logger.info(f"Permission override angewendet: {permission.value} = {override.granted} für User {self.user.id}")
return override.granted
return None
def _get_user_roles(self) -> List[Role]:
"""Holt die Rollen des Benutzers"""
roles = []
# Legacy Admin-Check
if hasattr(self.user, 'is_admin') and self.user.is_admin:
roles.append(Role.ADMIN)
# Neue Rollen-System
if hasattr(self.user, 'roles'):
for role_model in self.user.roles:
try:
role = Role(role_model.name)
roles.append(role)
except ValueError:
logger.warning(f"Unbekannte Rolle: {role_model.name}")
# Standard-Rolle wenn keine andere definiert
if not roles:
roles.append(Role.USER)
return roles
def _is_cache_valid(self) -> bool:
"""Prüft ob der Permission-Cache noch gültig ist"""
if self._cache_timestamp is None:
return False
return datetime.now() - self._cache_timestamp < self._cache_timeout
def _update_cache(self, permission: str, has_permission: bool):
"""Aktualisiert den Permission-Cache"""
if self._cache_timestamp is None or not self._is_cache_valid():
self._permission_cache = {}
self._cache_timestamp = datetime.now()
self._permission_cache[permission] = has_permission
def get_all_permissions(self) -> Set[Permission]:
"""Gibt alle Berechtigungen des Benutzers zurück"""
permissions = set()
for permission in Permission:
if self.has_permission(permission):
permissions.add(permission)
return permissions
def can_access_resource(self, resource_type: str, resource_id: int = None, action: str = "view") -> bool:
"""
Prüft Zugriff auf spezifische Ressourcen
Args:
resource_type: Art der Ressource (job, printer, user, etc.)
resource_id: ID der Ressource (optional)
action: Aktion (view, edit, delete, etc.)
Returns:
bool: True wenn Zugriff erlaubt
"""
# Resource-spezifische Logik
if resource_type == "job":
return self._check_job_access(resource_id, action)
elif resource_type == "printer":
return self._check_printer_access(resource_id, action)
elif resource_type == "user":
return self._check_user_access(resource_id, action)
return False
def _check_job_access(self, job_id: int, action: str) -> bool:
"""Prüft Job-spezifische Zugriffsrechte"""
if action == "view":
if self.has_permission(Permission.VIEW_JOBS):
return True
elif action == "edit":
if self.has_permission(Permission.EDIT_ALL_JOBS):
return True
if self.has_permission(Permission.EDIT_OWN_JOB) and job_id:
# Prüfen ob eigener Job (vereinfacht)
return self._is_own_job(job_id)
elif action == "delete":
if self.has_permission(Permission.DELETE_ALL_JOBS):
return True
if self.has_permission(Permission.DELETE_OWN_JOB) and job_id:
return self._is_own_job(job_id)
return False
def _check_printer_access(self, printer_id: int, action: str) -> bool:
"""Prüft Drucker-spezifische Zugriffsrechte"""
if action == "view":
return self.has_permission(Permission.VIEW_PRINTERS)
elif action == "edit":
return self.has_permission(Permission.EDIT_PRINTER)
elif action == "delete":
return self.has_permission(Permission.DELETE_PRINTER)
elif action == "control":
return self.has_permission(Permission.CONTROL_PRINTER)
return False
def _check_user_access(self, user_id: int, action: str) -> bool:
"""Prüft Benutzer-spezifische Zugriffsrechte"""
if action == "view":
if self.has_permission(Permission.VIEW_USERS):
return True
# Eigenes Profil ansehen
if user_id == self.user.id:
return True
elif action == "edit":
if self.has_permission(Permission.EDIT_USER):
return True
# Eigenes Profil bearbeiten (begrenzt)
if user_id == self.user.id:
return True
elif action == "delete":
if self.has_permission(Permission.DELETE_USER) and user_id != self.user.id:
return True
return False
def _is_own_job(self, job_id: int) -> bool:
"""Hilfsfunktion um zu prüfen ob Job dem Benutzer gehört"""
# Vereinfachte Implementierung - sollte mit DB-Query implementiert werden
try:
from models import Job, get_db_session
db_session = get_db_session()
job = db_session.query(Job).filter(Job.id == job_id).first()
db_session.close()
return job and (job.user_id == self.user.id or job.owner_id == self.user.id)
except Exception as e:
logger.error(f"Fehler bei Job-Ownership-Check: {e}")
return False
# ===== DECORATORS =====
def require_permission(permission: Permission):
"""
Decorator der eine bestimmte Berechtigung erfordert
Args:
permission: Die erforderliche Berechtigung
"""
def decorator(f):
@wraps(f)
@login_required
def wrapper(*args, **kwargs):
checker = PermissionChecker()
if not checker.has_permission(permission):
logger.warning(f"Zugriff verweigert: User {current_user.id} hat keine Berechtigung {permission.value}")
if request.path.startswith('/api/'):
return jsonify({
'error': 'Insufficient permissions',
'message': f'Berechtigung "{permission.value}" erforderlich',
'required_permission': permission.value
}), 403
else:
abort(403)
return f(*args, **kwargs)
return wrapper
return decorator
def require_role(role: Role):
"""
Decorator der eine bestimmte Rolle erfordert
Args:
role: Die erforderliche Rolle
"""
def decorator(f):
@wraps(f)
@login_required
def wrapper(*args, **kwargs):
checker = PermissionChecker()
user_roles = checker._get_user_roles()
if role not in user_roles:
logger.warning(f"Zugriff verweigert: User {current_user.id} hat nicht die Rolle {role.value}")
if request.path.startswith('/api/'):
return jsonify({
'error': 'Insufficient role',
'message': f'Rolle "{role.value}" erforderlich',
'required_role': role.value
}), 403
else:
abort(403)
return f(*args, **kwargs)
return wrapper
return decorator
def require_resource_access(resource_type: str, action: str = "view"):
"""
Decorator für ressourcen-spezifische Berechtigungsprüfung
Args:
resource_type: Art der Ressource
action: Erforderliche Aktion
"""
def decorator(f):
@wraps(f)
@login_required
def wrapper(*args, **kwargs):
# Resource ID aus URL-Parametern extrahieren
resource_id = kwargs.get('id') or kwargs.get(f'{resource_type}_id')
checker = PermissionChecker()
if not checker.can_access_resource(resource_type, resource_id, action):
logger.warning(f"Ressourcen-Zugriff verweigert: User {current_user.id}, {resource_type}:{resource_id}, Action: {action}")
if request.path.startswith('/api/'):
return jsonify({
'error': 'Resource access denied',
'message': f'Zugriff auf {resource_type} nicht erlaubt',
'resource_type': resource_type,
'action': action
}), 403
else:
abort(403)
return f(*args, **kwargs)
return wrapper
return decorator
# ===== UTILITY FUNCTIONS =====
def check_permission(permission: Permission, user=None) -> bool:
"""
Standalone-Funktion zur Berechtigungsprüfung
Args:
permission: Die zu prüfende Berechtigung
user: Benutzer (optional, default: current_user)
Returns:
bool: True wenn Berechtigung vorhanden
"""
checker = PermissionChecker(user)
return checker.has_permission(permission)
def get_user_permissions(user=None) -> Set[Permission]:
"""
Gibt alle Berechtigungen eines Benutzers zurück
Args:
user: Benutzer (optional, default: current_user)
Returns:
Set[Permission]: Alle Berechtigungen des Benutzers
"""
checker = PermissionChecker(user)
return checker.get_all_permissions()
def grant_temporary_permission(user_id: int, permission: Permission, duration_hours: int = 24, reason: str = "", granted_by_id: int = None):
"""
Gewährt temporäre Berechtigung
Args:
user_id: ID des Benutzers
permission: Die zu gewährende Berechtigung
duration_hours: Dauer in Stunden
reason: Begründung
granted_by_id: ID des gewährenden Benutzers
"""
try:
from models import get_db_session
db_session = get_db_session()
override = UserPermissionOverride(
user_id=user_id,
permission=permission.value,
granted=True,
reason=reason,
granted_by=granted_by_id or (current_user.id if current_user.is_authenticated else None),
expires_at=datetime.now() + timedelta(hours=duration_hours)
)
db_session.add(override)
db_session.commit()
db_session.close()
logger.info(f"Temporäre Berechtigung gewährt: {permission.value} für User {user_id} ({duration_hours}h)")
except Exception as e:
logger.error(f"Fehler beim Gewähren temporärer Berechtigung: {e}")
# ===== TEMPLATE HELPERS =====
def init_permission_helpers(app):
"""
Registriert Template-Helper für Berechtigungen
Args:
app: Flask-App-Instanz
"""
@app.template_global()
def has_permission(permission_name: str) -> bool:
"""Template Helper für Berechtigungsprüfung"""
try:
permission = Permission(permission_name)
return check_permission(permission)
except ValueError:
return False
@app.template_global()
def has_role(role_name: str) -> bool:
"""Template Helper für Rollenprüfung"""
try:
role = Role(role_name)
checker = PermissionChecker()
return role in checker._get_user_roles()
except ValueError:
return False
@app.template_global()
def can_access(resource_type: str, resource_id: int = None, action: str = "view") -> bool:
"""Template Helper für Ressourcen-Zugriff"""
checker = PermissionChecker()
return checker.can_access_resource(resource_type, resource_id, action)
logger.info("🔐 Permission Template Helpers registriert")