🎉 Refactor & Update Backend Code, Add Utils 🖥️📊
This commit is contained in:
936
backend/utils/advanced_tables.py
Normal file
936
backend/utils/advanced_tables.py
Normal file
@@ -0,0 +1,936 @@
|
||||
"""
|
||||
Erweitertes Tabellen-System für das MYP-System
|
||||
=============================================
|
||||
|
||||
Dieses Modul stellt erweiterte Tabellen-Funktionalität bereit:
|
||||
- Sortierung nach allen Spalten
|
||||
- Erweiterte Filter-Optionen
|
||||
- Pagination mit anpassbaren Seitengrößen
|
||||
- Spalten-Auswahl und -anpassung
|
||||
- Export-Funktionen
|
||||
- Responsive Design
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Tuple, Union, Callable
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
from flask import request, jsonify
|
||||
from sqlalchemy import func, text, or_, and_
|
||||
from sqlalchemy.orm import Query
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
from models import Job, User, Printer, GuestRequest, get_db_session
|
||||
|
||||
logger = get_logger("advanced_tables")
|
||||
|
||||
class SortDirection(Enum):
|
||||
ASC = "asc"
|
||||
DESC = "desc"
|
||||
|
||||
class FilterOperator(Enum):
|
||||
EQUALS = "eq"
|
||||
NOT_EQUALS = "ne"
|
||||
CONTAINS = "contains"
|
||||
NOT_CONTAINS = "not_contains"
|
||||
STARTS_WITH = "starts_with"
|
||||
ENDS_WITH = "ends_with"
|
||||
GREATER_THAN = "gt"
|
||||
LESS_THAN = "lt"
|
||||
GREATER_EQUAL = "gte"
|
||||
LESS_EQUAL = "lte"
|
||||
BETWEEN = "between"
|
||||
IN = "in"
|
||||
NOT_IN = "not_in"
|
||||
IS_NULL = "is_null"
|
||||
IS_NOT_NULL = "is_not_null"
|
||||
|
||||
@dataclass
|
||||
class SortConfig:
|
||||
"""Sortierung-Konfiguration"""
|
||||
column: str
|
||||
direction: SortDirection = SortDirection.ASC
|
||||
|
||||
@dataclass
|
||||
class FilterConfig:
|
||||
"""Filter-Konfiguration"""
|
||||
column: str
|
||||
operator: FilterOperator
|
||||
value: Any = None
|
||||
values: List[Any] = None
|
||||
|
||||
@dataclass
|
||||
class PaginationConfig:
|
||||
"""Pagination-Konfiguration"""
|
||||
page: int = 1
|
||||
page_size: int = 25
|
||||
max_page_size: int = 100
|
||||
|
||||
@dataclass
|
||||
class ColumnConfig:
|
||||
"""Spalten-Konfiguration"""
|
||||
key: str
|
||||
label: str
|
||||
sortable: bool = True
|
||||
filterable: bool = True
|
||||
searchable: bool = True
|
||||
visible: bool = True
|
||||
width: Optional[str] = None
|
||||
align: str = "left" # left, center, right
|
||||
format_type: str = "text" # text, number, date, datetime, boolean, currency
|
||||
format_options: Dict[str, Any] = None
|
||||
|
||||
@dataclass
|
||||
class TableConfig:
|
||||
"""Gesamt-Tabellen-Konfiguration"""
|
||||
table_id: str
|
||||
columns: List[ColumnConfig]
|
||||
default_sort: List[SortConfig] = None
|
||||
default_filters: List[FilterConfig] = None
|
||||
pagination: PaginationConfig = None
|
||||
searchable: bool = True
|
||||
exportable: bool = True
|
||||
selectable: bool = False
|
||||
row_actions: List[Dict[str, Any]] = None
|
||||
|
||||
class AdvancedTableQuery:
|
||||
"""Builder für erweiterte Tabellen-Abfragen"""
|
||||
|
||||
def __init__(self, base_query: Query, model_class):
|
||||
self.base_query = base_query
|
||||
self.model_class = model_class
|
||||
self.filters = []
|
||||
self.sorts = []
|
||||
self.search_term = None
|
||||
self.search_columns = []
|
||||
|
||||
def add_filter(self, filter_config: FilterConfig):
|
||||
"""Fügt einen Filter hinzu"""
|
||||
self.filters.append(filter_config)
|
||||
return self
|
||||
|
||||
def add_sort(self, sort_config: SortConfig):
|
||||
"""Fügt eine Sortierung hinzu"""
|
||||
self.sorts.append(sort_config)
|
||||
return self
|
||||
|
||||
def set_search(self, term: str, columns: List[str]):
|
||||
"""Setzt globale Suche"""
|
||||
self.search_term = term
|
||||
self.search_columns = columns
|
||||
return self
|
||||
|
||||
def build_query(self) -> Query:
|
||||
"""Erstellt die finale Query"""
|
||||
query = self.base_query
|
||||
|
||||
# Filter anwenden
|
||||
for filter_config in self.filters:
|
||||
query = self._apply_filter(query, filter_config)
|
||||
|
||||
# Globale Suche anwenden
|
||||
if self.search_term and self.search_columns:
|
||||
query = self._apply_search(query)
|
||||
|
||||
# Sortierung anwenden
|
||||
for sort_config in self.sorts:
|
||||
query = self._apply_sort(query, sort_config)
|
||||
|
||||
return query
|
||||
|
||||
def _apply_filter(self, query: Query, filter_config: FilterConfig) -> Query:
|
||||
"""Wendet einen Filter auf die Query an"""
|
||||
column = getattr(self.model_class, filter_config.column, None)
|
||||
if not column:
|
||||
logger.warning(f"Spalte {filter_config.column} nicht gefunden in {self.model_class}")
|
||||
return query
|
||||
|
||||
op = filter_config.operator
|
||||
value = filter_config.value
|
||||
values = filter_config.values
|
||||
|
||||
if op == FilterOperator.EQUALS:
|
||||
return query.filter(column == value)
|
||||
elif op == FilterOperator.NOT_EQUALS:
|
||||
return query.filter(column != value)
|
||||
elif op == FilterOperator.CONTAINS:
|
||||
return query.filter(column.ilike(f"%{value}%"))
|
||||
elif op == FilterOperator.NOT_CONTAINS:
|
||||
return query.filter(~column.ilike(f"%{value}%"))
|
||||
elif op == FilterOperator.STARTS_WITH:
|
||||
return query.filter(column.ilike(f"{value}%"))
|
||||
elif op == FilterOperator.ENDS_WITH:
|
||||
return query.filter(column.ilike(f"%{value}"))
|
||||
elif op == FilterOperator.GREATER_THAN:
|
||||
return query.filter(column > value)
|
||||
elif op == FilterOperator.LESS_THAN:
|
||||
return query.filter(column < value)
|
||||
elif op == FilterOperator.GREATER_EQUAL:
|
||||
return query.filter(column >= value)
|
||||
elif op == FilterOperator.LESS_EQUAL:
|
||||
return query.filter(column <= value)
|
||||
elif op == FilterOperator.BETWEEN and values and len(values) >= 2:
|
||||
return query.filter(column.between(values[0], values[1]))
|
||||
elif op == FilterOperator.IN and values:
|
||||
return query.filter(column.in_(values))
|
||||
elif op == FilterOperator.NOT_IN and values:
|
||||
return query.filter(~column.in_(values))
|
||||
elif op == FilterOperator.IS_NULL:
|
||||
return query.filter(column.is_(None))
|
||||
elif op == FilterOperator.IS_NOT_NULL:
|
||||
return query.filter(column.isnot(None))
|
||||
|
||||
return query
|
||||
|
||||
def _apply_search(self, query: Query) -> Query:
|
||||
"""Wendet globale Suche an"""
|
||||
if not self.search_term or not self.search_columns:
|
||||
return query
|
||||
|
||||
search_conditions = []
|
||||
for column_name in self.search_columns:
|
||||
column = getattr(self.model_class, column_name, None)
|
||||
if column:
|
||||
# Konvertiere zu String für Suche in numerischen Spalten
|
||||
search_conditions.append(
|
||||
func.cast(column, sqlalchemy.String).ilike(f"%{self.search_term}%")
|
||||
)
|
||||
|
||||
if search_conditions:
|
||||
return query.filter(or_(*search_conditions))
|
||||
|
||||
return query
|
||||
|
||||
def _apply_sort(self, query: Query, sort_config: SortConfig) -> Query:
|
||||
"""Wendet Sortierung an"""
|
||||
column = getattr(self.model_class, sort_config.column, None)
|
||||
if not column:
|
||||
logger.warning(f"Spalte {sort_config.column} für Sortierung nicht gefunden")
|
||||
return query
|
||||
|
||||
if sort_config.direction == SortDirection.DESC:
|
||||
return query.order_by(column.desc())
|
||||
else:
|
||||
return query.order_by(column.asc())
|
||||
|
||||
class TableDataProcessor:
|
||||
"""Verarbeitet Tabellendaten für die Ausgabe"""
|
||||
|
||||
def __init__(self, config: TableConfig):
|
||||
self.config = config
|
||||
|
||||
def process_data(self, data: List[Any]) -> List[Dict[str, Any]]:
|
||||
"""Verarbeitet rohe Daten für Tabellen-Ausgabe"""
|
||||
processed_rows = []
|
||||
|
||||
for item in data:
|
||||
row = {}
|
||||
for column in self.config.columns:
|
||||
if not column.visible:
|
||||
continue
|
||||
|
||||
# Wert extrahieren
|
||||
value = self._extract_value(item, column.key)
|
||||
|
||||
# Formatieren
|
||||
formatted_value = self._format_value(value, column)
|
||||
|
||||
row[column.key] = {
|
||||
'raw': value,
|
||||
'formatted': formatted_value,
|
||||
'sortable': column.sortable,
|
||||
'filterable': column.filterable
|
||||
}
|
||||
|
||||
# Row Actions hinzufügen
|
||||
if self.config.row_actions:
|
||||
row['_actions'] = self._get_row_actions(item)
|
||||
|
||||
# Row Metadata
|
||||
row['_id'] = getattr(item, 'id', None)
|
||||
row['_type'] = item.__class__.__name__.lower()
|
||||
|
||||
processed_rows.append(row)
|
||||
|
||||
return processed_rows
|
||||
|
||||
def _extract_value(self, item: Any, key: str) -> Any:
|
||||
"""Extrahiert Wert aus einem Objekt"""
|
||||
try:
|
||||
# Unterstützung für verschachtelte Attribute (z.B. "user.name")
|
||||
if '.' in key:
|
||||
obj = item
|
||||
for part in key.split('.'):
|
||||
obj = getattr(obj, part, None)
|
||||
if obj is None:
|
||||
break
|
||||
return obj
|
||||
else:
|
||||
return getattr(item, key, None)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
def _format_value(self, value: Any, column: ColumnConfig) -> str:
|
||||
"""Formatiert einen Wert basierend auf dem Spaltentyp"""
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
format_type = column.format_type
|
||||
options = column.format_options or {}
|
||||
|
||||
if format_type == "date" and isinstance(value, datetime):
|
||||
date_format = options.get('format', '%d.%m.%Y')
|
||||
return value.strftime(date_format)
|
||||
|
||||
elif format_type == "datetime" and isinstance(value, datetime):
|
||||
datetime_format = options.get('format', '%d.%m.%Y %H:%M')
|
||||
return value.strftime(datetime_format)
|
||||
|
||||
elif format_type == "number" and isinstance(value, (int, float)):
|
||||
decimals = options.get('decimals', 0)
|
||||
return f"{value:.{decimals}f}"
|
||||
|
||||
elif format_type == "currency" and isinstance(value, (int, float)):
|
||||
currency = options.get('currency', '€')
|
||||
decimals = options.get('decimals', 2)
|
||||
return f"{value:.{decimals}f} {currency}"
|
||||
|
||||
elif format_type == "boolean":
|
||||
true_text = options.get('true_text', 'Ja')
|
||||
false_text = options.get('false_text', 'Nein')
|
||||
return true_text if value else false_text
|
||||
|
||||
elif format_type == "truncate":
|
||||
max_length = options.get('max_length', 50)
|
||||
text = str(value)
|
||||
if len(text) > max_length:
|
||||
return text[:max_length-3] + "..."
|
||||
return text
|
||||
|
||||
return str(value)
|
||||
|
||||
def _get_row_actions(self, item: Any) -> List[Dict[str, Any]]:
|
||||
"""Generiert verfügbare Aktionen für eine Zeile"""
|
||||
actions = []
|
||||
|
||||
for action_config in self.config.row_actions:
|
||||
# Prüfe Bedingungen für Aktion
|
||||
if self._check_action_condition(item, action_config):
|
||||
actions.append({
|
||||
'type': action_config['type'],
|
||||
'label': action_config['label'],
|
||||
'icon': action_config.get('icon'),
|
||||
'url': self._build_action_url(item, action_config),
|
||||
'method': action_config.get('method', 'GET'),
|
||||
'confirm': action_config.get('confirm'),
|
||||
'class': action_config.get('class', '')
|
||||
})
|
||||
|
||||
return actions
|
||||
|
||||
def _check_action_condition(self, item: Any, action_config: Dict[str, Any]) -> bool:
|
||||
"""Prüft ob eine Aktion für ein Item verfügbar ist"""
|
||||
condition = action_config.get('condition')
|
||||
if not condition:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Einfache Bedingungsprüfung
|
||||
if isinstance(condition, dict):
|
||||
for key, expected_value in condition.items():
|
||||
actual_value = self._extract_value(item, key)
|
||||
if actual_value != expected_value:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _build_action_url(self, item: Any, action_config: Dict[str, Any]) -> str:
|
||||
"""Erstellt URL für eine Aktion"""
|
||||
url_template = action_config.get('url', '')
|
||||
|
||||
# Ersetze Platzhalter in URL
|
||||
try:
|
||||
return url_template.format(id=getattr(item, 'id', ''))
|
||||
except Exception:
|
||||
return url_template
|
||||
|
||||
def parse_table_request(request_data: Dict[str, Any]) -> Tuple[List[SortConfig], List[FilterConfig], PaginationConfig, str]:
|
||||
"""Parst Tabellen-Request-Parameter"""
|
||||
|
||||
# Sortierung parsen
|
||||
sorts = []
|
||||
sort_data = request_data.get('sort', [])
|
||||
if isinstance(sort_data, dict):
|
||||
sort_data = [sort_data]
|
||||
|
||||
for sort_item in sort_data:
|
||||
if isinstance(sort_item, dict):
|
||||
column = sort_item.get('column')
|
||||
direction = SortDirection(sort_item.get('direction', 'asc'))
|
||||
if column:
|
||||
sorts.append(SortConfig(column=column, direction=direction))
|
||||
|
||||
# Filter parsen
|
||||
filters = []
|
||||
filter_data = request_data.get('filters', [])
|
||||
if isinstance(filter_data, dict):
|
||||
filter_data = [filter_data]
|
||||
|
||||
for filter_item in filter_data:
|
||||
if isinstance(filter_item, dict):
|
||||
column = filter_item.get('column')
|
||||
operator = FilterOperator(filter_item.get('operator', 'eq'))
|
||||
value = filter_item.get('value')
|
||||
values = filter_item.get('values')
|
||||
|
||||
if column:
|
||||
filters.append(FilterConfig(
|
||||
column=column,
|
||||
operator=operator,
|
||||
value=value,
|
||||
values=values
|
||||
))
|
||||
|
||||
# Pagination parsen
|
||||
page = int(request_data.get('page', 1))
|
||||
page_size = min(int(request_data.get('page_size', 25)), 100)
|
||||
pagination = PaginationConfig(page=page, page_size=page_size)
|
||||
|
||||
# Suche parsen
|
||||
search = request_data.get('search', '')
|
||||
|
||||
return sorts, filters, pagination, search
|
||||
|
||||
def get_advanced_table_javascript() -> str:
|
||||
"""JavaScript für erweiterte Tabellen"""
|
||||
return """
|
||||
class AdvancedTable {
|
||||
constructor(tableId, config = {}) {
|
||||
this.tableId = tableId;
|
||||
this.config = {
|
||||
apiUrl: '/api/table-data',
|
||||
pageSize: 25,
|
||||
searchDelay: 500,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
...config
|
||||
};
|
||||
|
||||
this.currentSort = [];
|
||||
this.currentFilters = [];
|
||||
this.currentPage = 1;
|
||||
this.currentSearch = '';
|
||||
this.totalPages = 1;
|
||||
this.totalItems = 0;
|
||||
|
||||
this.searchTimeout = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupTable();
|
||||
this.setupEventListeners();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
setupTable() {
|
||||
const table = document.getElementById(this.tableId);
|
||||
if (!table) return;
|
||||
|
||||
table.classList.add('advanced-table');
|
||||
|
||||
// Add table wrapper
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'table-wrapper';
|
||||
table.parentNode.insertBefore(wrapper, table);
|
||||
wrapper.appendChild(table);
|
||||
|
||||
// Add controls
|
||||
this.createControls(wrapper);
|
||||
}
|
||||
|
||||
createControls(wrapper) {
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'table-controls';
|
||||
controls.innerHTML = `
|
||||
<div class="table-controls-left">
|
||||
<div class="search-box">
|
||||
<input type="text" id="${this.tableId}-search" placeholder="Suchen..." class="search-input">
|
||||
<span class="search-icon">🔍</span>
|
||||
</div>
|
||||
<div class="page-size-selector">
|
||||
<label>Einträge pro Seite:</label>
|
||||
<select id="${this.tableId}-page-size">
|
||||
<option value="10">10</option>
|
||||
<option value="25" selected>25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-controls-right">
|
||||
<button class="btn-filter" id="${this.tableId}-filter-btn">Filter</button>
|
||||
<button class="btn-export" id="${this.tableId}-export-btn">Export</button>
|
||||
<button class="btn-refresh" id="${this.tableId}-refresh-btn">↻</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wrapper.insertBefore(controls, wrapper.firstChild);
|
||||
|
||||
// Add pagination
|
||||
const pagination = document.createElement('div');
|
||||
pagination.className = 'table-pagination';
|
||||
pagination.id = `${this.tableId}-pagination`;
|
||||
wrapper.appendChild(pagination);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Search
|
||||
const searchInput = document.getElementById(`${this.tableId}-search`);
|
||||
searchInput?.addEventListener('input', (e) => {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.currentSearch = e.target.value;
|
||||
this.currentPage = 1;
|
||||
this.loadData();
|
||||
}, this.config.searchDelay);
|
||||
});
|
||||
|
||||
// Page size
|
||||
const pageSizeSelect = document.getElementById(`${this.tableId}-page-size`);
|
||||
pageSizeSelect?.addEventListener('change', (e) => {
|
||||
this.config.pageSize = parseInt(e.target.value);
|
||||
this.currentPage = 1;
|
||||
this.loadData();
|
||||
});
|
||||
|
||||
// Refresh
|
||||
const refreshBtn = document.getElementById(`${this.tableId}-refresh-btn`);
|
||||
refreshBtn?.addEventListener('click', () => {
|
||||
this.loadData();
|
||||
});
|
||||
|
||||
// Export
|
||||
const exportBtn = document.getElementById(`${this.tableId}-export-btn`);
|
||||
exportBtn?.addEventListener('click', () => {
|
||||
this.exportData();
|
||||
});
|
||||
|
||||
// Table header clicks (sorting)
|
||||
const table = document.getElementById(this.tableId);
|
||||
table?.addEventListener('click', (e) => {
|
||||
const th = e.target.closest('th[data-sortable="true"]');
|
||||
if (th) {
|
||||
const column = th.dataset.column;
|
||||
this.toggleSort(column);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleSort(column) {
|
||||
const existingSort = this.currentSort.find(s => s.column === column);
|
||||
|
||||
if (existingSort) {
|
||||
if (existingSort.direction === 'asc') {
|
||||
existingSort.direction = 'desc';
|
||||
} else {
|
||||
// Remove sort
|
||||
this.currentSort = this.currentSort.filter(s => s.column !== column);
|
||||
}
|
||||
} else {
|
||||
this.currentSort.push({ column, direction: 'asc' });
|
||||
}
|
||||
|
||||
this.updateSortHeaders();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
updateSortHeaders() {
|
||||
const table = document.getElementById(this.tableId);
|
||||
const headers = table?.querySelectorAll('th[data-column]');
|
||||
|
||||
headers?.forEach(th => {
|
||||
const column = th.dataset.column;
|
||||
const sort = this.currentSort.find(s => s.column === column);
|
||||
|
||||
th.classList.remove('sort-asc', 'sort-desc');
|
||||
|
||||
if (sort) {
|
||||
th.classList.add(`sort-${sort.direction}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const params = {
|
||||
page: this.currentPage,
|
||||
page_size: this.config.pageSize,
|
||||
search: this.currentSearch,
|
||||
sort: this.currentSort,
|
||||
filters: this.currentFilters
|
||||
};
|
||||
|
||||
const response = await fetch(this.config.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.renderTable(data.data);
|
||||
this.updatePagination(data.pagination);
|
||||
} else {
|
||||
console.error('Table data loading failed:', data.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Table data loading error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderTable(data) {
|
||||
const table = document.getElementById(this.tableId);
|
||||
const tbody = table?.querySelector('tbody');
|
||||
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.id = row._id;
|
||||
|
||||
// Render cells
|
||||
Object.keys(row).forEach(key => {
|
||||
if (key.startsWith('_')) return; // Skip metadata
|
||||
|
||||
const td = document.createElement('td');
|
||||
const cellData = row[key];
|
||||
|
||||
if (typeof cellData === 'object' && cellData.formatted !== undefined) {
|
||||
td.innerHTML = cellData.formatted;
|
||||
td.dataset.raw = cellData.raw;
|
||||
} else {
|
||||
td.textContent = cellData;
|
||||
}
|
||||
|
||||
tr.appendChild(td);
|
||||
});
|
||||
|
||||
// Add actions column if exists
|
||||
if (row._actions && row._actions.length > 0) {
|
||||
const actionsTd = document.createElement('td');
|
||||
actionsTd.className = 'actions-cell';
|
||||
actionsTd.innerHTML = this.renderActions(row._actions);
|
||||
tr.appendChild(actionsTd);
|
||||
}
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
renderActions(actions) {
|
||||
return actions.map(action => {
|
||||
const confirmAttr = action.confirm ? `onclick="return confirm('${action.confirm}')"` : '';
|
||||
const icon = action.icon ? `<span class="action-icon">${action.icon}</span>` : '';
|
||||
|
||||
return `<a href="${action.url}" class="action-btn ${action.class}" ${confirmAttr}>
|
||||
${icon}${action.label}
|
||||
</a>`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
updatePagination(pagination) {
|
||||
this.currentPage = pagination.page;
|
||||
this.totalPages = pagination.total_pages;
|
||||
this.totalItems = pagination.total_items;
|
||||
|
||||
const paginationEl = document.getElementById(`${this.tableId}-pagination`);
|
||||
if (!paginationEl) return;
|
||||
|
||||
paginationEl.innerHTML = `
|
||||
<div class="pagination-info">
|
||||
Zeige ${pagination.start_item}-${pagination.end_item} von ${pagination.total_items} Einträgen
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
${this.renderPaginationButtons()}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners für Pagination
|
||||
paginationEl.querySelectorAll('.page-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const page = parseInt(btn.dataset.page);
|
||||
if (page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
this.loadData();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderPaginationButtons() {
|
||||
const buttons = [];
|
||||
const maxButtons = 7;
|
||||
|
||||
// Previous button
|
||||
buttons.push(`
|
||||
<button class="page-btn ${this.currentPage === 1 ? 'disabled' : ''}"
|
||||
data-page="${this.currentPage - 1}" ${this.currentPage === 1 ? 'disabled' : ''}>
|
||||
←
|
||||
</button>
|
||||
`);
|
||||
|
||||
// Page number buttons
|
||||
let startPage = Math.max(1, this.currentPage - Math.floor(maxButtons / 2));
|
||||
let endPage = Math.min(this.totalPages, startPage + maxButtons - 1);
|
||||
|
||||
if (endPage - startPage + 1 < maxButtons) {
|
||||
startPage = Math.max(1, endPage - maxButtons + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
buttons.push(`
|
||||
<button class="page-btn ${i === this.currentPage ? 'active' : ''}"
|
||||
data-page="${i}">
|
||||
${i}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
|
||||
// Next button
|
||||
buttons.push(`
|
||||
<button class="page-btn ${this.currentPage === this.totalPages ? 'disabled' : ''}"
|
||||
data-page="${this.currentPage + 1}" ${this.currentPage === this.totalPages ? 'disabled' : ''}>
|
||||
→
|
||||
</button>
|
||||
`);
|
||||
|
||||
return buttons.join('');
|
||||
}
|
||||
|
||||
exportData() {
|
||||
const params = new URLSearchParams({
|
||||
search: this.currentSearch,
|
||||
sort: JSON.stringify(this.currentSort),
|
||||
filters: JSON.stringify(this.currentFilters),
|
||||
format: 'csv'
|
||||
});
|
||||
|
||||
window.open(`${this.config.apiUrl}/export?${params}`, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize tables with data-advanced-table attribute
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-advanced-table]').forEach(table => {
|
||||
const config = JSON.parse(table.dataset.advancedTable || '{}');
|
||||
new AdvancedTable(table.id, config);
|
||||
});
|
||||
});
|
||||
"""
|
||||
|
||||
def get_advanced_table_css() -> str:
|
||||
"""CSS für erweiterte Tabellen"""
|
||||
return """
|
||||
.table-wrapper {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.table-controls-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-right: 2rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.page-size-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table-controls-right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.advanced-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.advanced-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.advanced-table th[data-sortable="true"] {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.advanced-table th[data-sortable="true"]:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.advanced-table th.sort-asc::after {
|
||||
content: " ↑";
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.advanced-table th.sort-desc::after {
|
||||
content: " ↓";
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.advanced-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.advanced-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.action-btn.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(.disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.page-btn.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.table-controls {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.table-controls-left,
|
||||
.table-controls-right {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.advanced-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.advanced-table th,
|
||||
.advanced-table td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
"""
|
1231
backend/utils/drag_drop_system.py
Normal file
1231
backend/utils/drag_drop_system.py
Normal file
File diff suppressed because it is too large
Load Diff
1137
backend/utils/realtime_dashboard.py
Normal file
1137
backend/utils/realtime_dashboard.py
Normal file
File diff suppressed because it is too large
Load Diff
909
backend/utils/report_generator.py
Normal file
909
backend/utils/report_generator.py
Normal file
@@ -0,0 +1,909 @@
|
||||
"""
|
||||
Multi-Format-Report-Generator für das MYP-System
|
||||
===============================================
|
||||
|
||||
Dieses Modul stellt umfassende Report-Generierung in verschiedenen Formaten bereit:
|
||||
- PDF-Reports mit professionellem Layout
|
||||
- Excel-Reports mit Diagrammen und Formatierungen
|
||||
- CSV-Export für Datenanalyse
|
||||
- JSON-Export für API-Integration
|
||||
"""
|
||||
|
||||
import os
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Union, BinaryIO
|
||||
from dataclasses import dataclass, asdict
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# PDF-Generation
|
||||
try:
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch, cm
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image, PageBreak
|
||||
from reportlab.graphics.shapes import Drawing
|
||||
from reportlab.graphics.charts.lineplots import LinePlot
|
||||
from reportlab.graphics.charts.barcharts import VerticalBarChart
|
||||
from reportlab.graphics.charts.piecharts import Pie
|
||||
from reportlab.lib.validators import Auto
|
||||
PDF_AVAILABLE = True
|
||||
except ImportError:
|
||||
PDF_AVAILABLE = False
|
||||
|
||||
# Excel-Generation
|
||||
try:
|
||||
import xlsxwriter
|
||||
from xlsxwriter.workbook import Workbook
|
||||
from xlsxwriter.worksheet import Worksheet
|
||||
EXCEL_AVAILABLE = True
|
||||
except ImportError:
|
||||
EXCEL_AVAILABLE = False
|
||||
|
||||
import csv
|
||||
from flask import make_response, jsonify
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
from models import Job, User, Printer, Stats, GuestRequest, get_db_session
|
||||
|
||||
logger = get_logger("reports")
|
||||
|
||||
@dataclass
|
||||
class ReportConfig:
|
||||
"""Konfiguration für Report-Generierung"""
|
||||
title: str
|
||||
subtitle: str = ""
|
||||
author: str = "MYP System"
|
||||
date_range: tuple = None
|
||||
include_charts: bool = True
|
||||
include_summary: bool = True
|
||||
template: str = "standard"
|
||||
logo_path: str = None
|
||||
footer_text: str = "Generiert vom MYP-System"
|
||||
|
||||
@dataclass
|
||||
class ChartData:
|
||||
"""Daten für Diagramme"""
|
||||
chart_type: str # 'line', 'bar', 'pie'
|
||||
title: str
|
||||
data: List[Dict[str, Any]]
|
||||
labels: List[str] = None
|
||||
colors: List[str] = None
|
||||
|
||||
class BaseReportGenerator(ABC):
|
||||
"""Abstrakte Basis-Klasse für Report-Generatoren"""
|
||||
|
||||
def __init__(self, config: ReportConfig):
|
||||
self.config = config
|
||||
self.data = {}
|
||||
self.charts = []
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, output_stream: BinaryIO) -> bool:
|
||||
"""Generiert den Report in den angegebenen Stream"""
|
||||
pass
|
||||
|
||||
def add_data_section(self, name: str, data: List[Dict[str, Any]], headers: List[str] = None):
|
||||
"""Fügt eine Datensektion hinzu"""
|
||||
self.data[name] = {
|
||||
'data': data,
|
||||
'headers': headers or (list(data[0].keys()) if data else [])
|
||||
}
|
||||
|
||||
def add_chart(self, chart: ChartData):
|
||||
"""Fügt ein Diagramm hinzu"""
|
||||
self.charts.append(chart)
|
||||
|
||||
class PDFReportGenerator(BaseReportGenerator):
|
||||
"""PDF-Report-Generator mit professionellem Layout"""
|
||||
|
||||
def __init__(self, config: ReportConfig):
|
||||
super().__init__(config)
|
||||
if not PDF_AVAILABLE:
|
||||
raise ImportError("ReportLab ist nicht installiert. Verwenden Sie: pip install reportlab")
|
||||
|
||||
self.doc = None
|
||||
self.story = []
|
||||
self.styles = getSampleStyleSheet()
|
||||
self._setup_custom_styles()
|
||||
|
||||
def _setup_custom_styles(self):
|
||||
"""Richtet benutzerdefinierte Styles ein"""
|
||||
# Titel-Style
|
||||
self.styles.add(ParagraphStyle(
|
||||
name='CustomTitle',
|
||||
parent=self.styles['Heading1'],
|
||||
fontSize=24,
|
||||
spaceAfter=30,
|
||||
alignment=1, # Zentriert
|
||||
textColor=colors.HexColor('#1f2937')
|
||||
))
|
||||
|
||||
# Untertitel-Style
|
||||
self.styles.add(ParagraphStyle(
|
||||
name='CustomSubtitle',
|
||||
parent=self.styles['Heading2'],
|
||||
fontSize=16,
|
||||
spaceAfter=20,
|
||||
alignment=1,
|
||||
textColor=colors.HexColor('#6b7280')
|
||||
))
|
||||
|
||||
# Sektions-Header
|
||||
self.styles.add(ParagraphStyle(
|
||||
name='SectionHeader',
|
||||
parent=self.styles['Heading2'],
|
||||
fontSize=14,
|
||||
spaceBefore=20,
|
||||
spaceAfter=10,
|
||||
textColor=colors.HexColor('#374151'),
|
||||
borderWidth=1,
|
||||
borderColor=colors.HexColor('#d1d5db'),
|
||||
borderPadding=5
|
||||
))
|
||||
|
||||
def generate(self, output_stream: BinaryIO) -> bool:
|
||||
"""Generiert PDF-Report"""
|
||||
try:
|
||||
self.doc = SimpleDocTemplate(
|
||||
output_stream,
|
||||
pagesize=A4,
|
||||
rightMargin=2*cm,
|
||||
leftMargin=2*cm,
|
||||
topMargin=2*cm,
|
||||
bottomMargin=2*cm
|
||||
)
|
||||
|
||||
self._build_header()
|
||||
self._build_summary()
|
||||
self._build_data_sections()
|
||||
self._build_charts()
|
||||
self._build_footer()
|
||||
|
||||
self.doc.build(self.story)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei PDF-Generierung: {str(e)}")
|
||||
return False
|
||||
|
||||
def _build_header(self):
|
||||
"""Erstellt den Report-Header"""
|
||||
# Logo (falls vorhanden)
|
||||
if self.config.logo_path and os.path.exists(self.config.logo_path):
|
||||
try:
|
||||
logo = Image(self.config.logo_path, width=2*inch, height=1*inch)
|
||||
self.story.append(logo)
|
||||
self.story.append(Spacer(1, 0.2*inch))
|
||||
except Exception as e:
|
||||
logger.warning(f"Logo konnte nicht geladen werden: {str(e)}")
|
||||
|
||||
# Titel
|
||||
title = Paragraph(self.config.title, self.styles['CustomTitle'])
|
||||
self.story.append(title)
|
||||
|
||||
# Untertitel
|
||||
if self.config.subtitle:
|
||||
subtitle = Paragraph(self.config.subtitle, self.styles['CustomSubtitle'])
|
||||
self.story.append(subtitle)
|
||||
|
||||
# Generierungsdatum
|
||||
date_text = f"Generiert am: {datetime.now().strftime('%d.%m.%Y %H:%M')}"
|
||||
date_para = Paragraph(date_text, self.styles['Normal'])
|
||||
self.story.append(date_para)
|
||||
|
||||
# Autor
|
||||
author_text = f"Erstellt von: {self.config.author}"
|
||||
author_para = Paragraph(author_text, self.styles['Normal'])
|
||||
self.story.append(author_para)
|
||||
|
||||
self.story.append(Spacer(1, 0.3*inch))
|
||||
|
||||
def _build_summary(self):
|
||||
"""Erstellt die Zusammenfassung"""
|
||||
if not self.config.include_summary:
|
||||
return
|
||||
|
||||
header = Paragraph("Zusammenfassung", self.styles['SectionHeader'])
|
||||
self.story.append(header)
|
||||
|
||||
# Sammle Statistiken aus den Daten
|
||||
total_records = sum(len(section['data']) for section in self.data.values())
|
||||
|
||||
summary_data = [
|
||||
['Gesamtanzahl Datensätze', str(total_records)],
|
||||
['Berichtszeitraum', self._format_date_range()],
|
||||
['Anzahl Sektionen', str(len(self.data))],
|
||||
['Anzahl Diagramme', str(len(self.charts))]
|
||||
]
|
||||
|
||||
summary_table = Table(summary_data, colWidths=[4*inch, 2*inch])
|
||||
summary_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f3f4f6')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.black),
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 12),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.white),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.HexColor('#d1d5db'))
|
||||
]))
|
||||
|
||||
self.story.append(summary_table)
|
||||
self.story.append(Spacer(1, 0.2*inch))
|
||||
|
||||
def _build_data_sections(self):
|
||||
"""Erstellt die Datensektionen"""
|
||||
for section_name, section_data in self.data.items():
|
||||
# Sektions-Header
|
||||
header = Paragraph(section_name, self.styles['SectionHeader'])
|
||||
self.story.append(header)
|
||||
|
||||
# Daten-Tabelle
|
||||
table_data = [section_data['headers']]
|
||||
table_data.extend([
|
||||
[str(row.get(header, '')) for header in section_data['headers']]
|
||||
for row in section_data['data']
|
||||
])
|
||||
|
||||
# Spaltenbreiten berechnen
|
||||
col_count = len(section_data['headers'])
|
||||
col_width = (self.doc.width - 2*inch) / col_count
|
||||
col_widths = [col_width] * col_count
|
||||
|
||||
table = Table(table_data, colWidths=col_widths, repeatRows=1)
|
||||
table.setStyle(TableStyle([
|
||||
# Header-Styling
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3b82f6')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||
|
||||
# Daten-Styling
|
||||
('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
|
||||
('FONTSIZE', (0, 1), (-1, -1), 9),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f9fafb')]),
|
||||
|
||||
# Rahmen
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#d1d5db')),
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||||
]))
|
||||
|
||||
self.story.append(table)
|
||||
self.story.append(Spacer(1, 0.2*inch))
|
||||
|
||||
# Seitenumbruch bei vielen Daten
|
||||
if len(section_data['data']) > 20:
|
||||
self.story.append(PageBreak())
|
||||
|
||||
def _build_charts(self):
|
||||
"""Erstellt die Diagramme"""
|
||||
if not self.config.include_charts or not self.charts:
|
||||
return
|
||||
|
||||
header = Paragraph("Diagramme", self.styles['SectionHeader'])
|
||||
self.story.append(header)
|
||||
|
||||
for chart in self.charts:
|
||||
chart_title = Paragraph(chart.title, self.styles['Heading3'])
|
||||
self.story.append(chart_title)
|
||||
|
||||
# Diagramm basierend auf Typ erstellen
|
||||
drawing = self._create_chart_drawing(chart)
|
||||
if drawing:
|
||||
self.story.append(drawing)
|
||||
self.story.append(Spacer(1, 0.2*inch))
|
||||
|
||||
def _create_chart_drawing(self, chart: ChartData) -> Optional[Drawing]:
|
||||
"""Erstellt ein Diagramm-Drawing"""
|
||||
try:
|
||||
drawing = Drawing(400, 300)
|
||||
|
||||
if chart.chart_type == 'bar':
|
||||
bar_chart = VerticalBarChart()
|
||||
bar_chart.x = 50
|
||||
bar_chart.y = 50
|
||||
bar_chart.height = 200
|
||||
bar_chart.width = 300
|
||||
|
||||
# Daten vorbereiten
|
||||
values = [[item.get('value', 0) for item in chart.data]]
|
||||
categories = [item.get('label', f'Item {i}') for i, item in enumerate(chart.data)]
|
||||
|
||||
bar_chart.data = values
|
||||
bar_chart.categoryAxis.categoryNames = categories
|
||||
bar_chart.valueAxis.valueMin = 0
|
||||
|
||||
# Farben setzen
|
||||
if chart.colors:
|
||||
bar_chart.bars[0].fillColor = colors.HexColor(chart.colors[0] if chart.colors else '#3b82f6')
|
||||
|
||||
drawing.add(bar_chart)
|
||||
|
||||
elif chart.chart_type == 'pie':
|
||||
pie_chart = Pie()
|
||||
pie_chart.x = 150
|
||||
pie_chart.y = 100
|
||||
pie_chart.width = 100
|
||||
pie_chart.height = 100
|
||||
|
||||
# Daten vorbereiten
|
||||
pie_chart.data = [item.get('value', 0) for item in chart.data]
|
||||
pie_chart.labels = [item.get('label', f'Item {i}') for i, item in enumerate(chart.data)]
|
||||
|
||||
# Farben setzen
|
||||
if chart.colors:
|
||||
pie_chart.slices.fillColor = colors.HexColor(chart.colors[0] if chart.colors else '#3b82f6')
|
||||
|
||||
drawing.add(pie_chart)
|
||||
|
||||
return drawing
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Diagramm-Erstellung: {str(e)}")
|
||||
return None
|
||||
|
||||
def _build_footer(self):
|
||||
"""Erstellt den Report-Footer"""
|
||||
footer_text = self.config.footer_text
|
||||
footer = Paragraph(footer_text, self.styles['Normal'])
|
||||
self.story.append(Spacer(1, 0.3*inch))
|
||||
self.story.append(footer)
|
||||
|
||||
def _format_date_range(self) -> str:
|
||||
"""Formatiert den Datumsbereich"""
|
||||
if not self.config.date_range:
|
||||
return "Alle verfügbaren Daten"
|
||||
|
||||
start_date, end_date = self.config.date_range
|
||||
return f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
|
||||
|
||||
class ExcelReportGenerator(BaseReportGenerator):
|
||||
"""Excel-Report-Generator mit Diagrammen und Formatierungen"""
|
||||
|
||||
def __init__(self, config: ReportConfig):
|
||||
super().__init__(config)
|
||||
if not EXCEL_AVAILABLE:
|
||||
raise ImportError("XlsxWriter ist nicht installiert. Verwenden Sie: pip install xlsxwriter")
|
||||
|
||||
self.workbook = None
|
||||
self.formats = {}
|
||||
|
||||
def generate(self, output_stream: BinaryIO) -> bool:
|
||||
"""Generiert Excel-Report"""
|
||||
try:
|
||||
self.workbook = xlsxwriter.Workbook(output_stream, {'in_memory': True})
|
||||
self._setup_formats()
|
||||
|
||||
# Zusammenfassungs-Arbeitsblatt
|
||||
if self.config.include_summary:
|
||||
self._create_summary_worksheet()
|
||||
|
||||
# Daten-Arbeitsblätter
|
||||
for section_name, section_data in self.data.items():
|
||||
self._create_data_worksheet(section_name, section_data)
|
||||
|
||||
# Diagramm-Arbeitsblätter
|
||||
if self.config.include_charts and self.charts:
|
||||
self._create_charts_worksheet()
|
||||
|
||||
self.workbook.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Excel-Generierung: {str(e)}")
|
||||
return False
|
||||
|
||||
def _setup_formats(self):
|
||||
"""Richtet Excel-Formate ein"""
|
||||
self.formats = {
|
||||
'title': self.workbook.add_format({
|
||||
'font_size': 18,
|
||||
'bold': True,
|
||||
'align': 'center',
|
||||
'bg_color': '#1f2937',
|
||||
'font_color': 'white',
|
||||
'border': 1
|
||||
}),
|
||||
'header': self.workbook.add_format({
|
||||
'font_size': 12,
|
||||
'bold': True,
|
||||
'bg_color': '#3b82f6',
|
||||
'font_color': 'white',
|
||||
'align': 'center',
|
||||
'border': 1
|
||||
}),
|
||||
'data': self.workbook.add_format({
|
||||
'align': 'center',
|
||||
'border': 1
|
||||
}),
|
||||
'data_alt': self.workbook.add_format({
|
||||
'align': 'center',
|
||||
'bg_color': '#f9fafb',
|
||||
'border': 1
|
||||
}),
|
||||
'number': self.workbook.add_format({
|
||||
'num_format': '#,##0',
|
||||
'align': 'right',
|
||||
'border': 1
|
||||
}),
|
||||
'currency': self.workbook.add_format({
|
||||
'num_format': '#,##0.00 €',
|
||||
'align': 'right',
|
||||
'border': 1
|
||||
}),
|
||||
'percentage': self.workbook.add_format({
|
||||
'num_format': '0.00%',
|
||||
'align': 'right',
|
||||
'border': 1
|
||||
}),
|
||||
'date': self.workbook.add_format({
|
||||
'num_format': 'dd.mm.yyyy',
|
||||
'align': 'center',
|
||||
'border': 1
|
||||
})
|
||||
}
|
||||
|
||||
def _create_summary_worksheet(self):
|
||||
"""Erstellt das Zusammenfassungs-Arbeitsblatt"""
|
||||
worksheet = self.workbook.add_worksheet('Zusammenfassung')
|
||||
|
||||
# Titel
|
||||
worksheet.merge_range('A1:E1', self.config.title, self.formats['title'])
|
||||
|
||||
# Untertitel
|
||||
if self.config.subtitle:
|
||||
worksheet.merge_range('A2:E2', self.config.subtitle, self.formats['header'])
|
||||
|
||||
# Metadaten
|
||||
row = 4
|
||||
metadata = [
|
||||
['Generiert am:', datetime.now().strftime('%d.%m.%Y %H:%M')],
|
||||
['Erstellt von:', self.config.author],
|
||||
['Berichtszeitraum:', self._format_date_range()],
|
||||
['Anzahl Sektionen:', str(len(self.data))],
|
||||
['Anzahl Diagramme:', str(len(self.charts))]
|
||||
]
|
||||
|
||||
for label, value in metadata:
|
||||
worksheet.write(row, 0, label, self.formats['header'])
|
||||
worksheet.write(row, 1, value, self.formats['data'])
|
||||
row += 1
|
||||
|
||||
# Statistiken pro Sektion
|
||||
row += 2
|
||||
worksheet.write(row, 0, 'Sektions-Übersicht:', self.formats['header'])
|
||||
row += 1
|
||||
|
||||
for section_name, section_data in self.data.items():
|
||||
worksheet.write(row, 0, section_name, self.formats['data'])
|
||||
worksheet.write(row, 1, len(section_data['data']), self.formats['number'])
|
||||
row += 1
|
||||
|
||||
# Spaltenbreiten anpassen
|
||||
worksheet.set_column('A:A', 25)
|
||||
worksheet.set_column('B:B', 20)
|
||||
|
||||
def _create_data_worksheet(self, section_name: str, section_data: Dict[str, Any]):
|
||||
"""Erstellt ein Daten-Arbeitsblatt"""
|
||||
# Ungültige Zeichen für Arbeitsblatt-Namen ersetzen
|
||||
safe_name = ''.join(c for c in section_name if c.isalnum() or c in ' -_')[:31]
|
||||
worksheet = self.workbook.add_worksheet(safe_name)
|
||||
|
||||
# Header schreiben
|
||||
headers = section_data['headers']
|
||||
for col, header in enumerate(headers):
|
||||
worksheet.write(0, col, header, self.formats['header'])
|
||||
|
||||
# Daten schreiben
|
||||
for row_idx, row_data in enumerate(section_data['data'], start=1):
|
||||
for col_idx, header in enumerate(headers):
|
||||
value = row_data.get(header, '')
|
||||
|
||||
# Format basierend auf Datentyp wählen
|
||||
cell_format = self._get_cell_format(value, row_idx)
|
||||
worksheet.write(row_idx, col_idx, value, cell_format)
|
||||
|
||||
# Autofilter hinzufügen
|
||||
if section_data['data']:
|
||||
worksheet.autofilter(0, 0, len(section_data['data']), len(headers) - 1)
|
||||
|
||||
# Spaltenbreiten anpassen
|
||||
for col_idx, header in enumerate(headers):
|
||||
max_length = max(
|
||||
len(str(header)),
|
||||
max(len(str(row.get(header, ''))) for row in section_data['data']) if section_data['data'] else 0
|
||||
)
|
||||
worksheet.set_column(col_idx, col_idx, min(max_length + 2, 50))
|
||||
|
||||
def _create_charts_worksheet(self):
|
||||
"""Erstellt das Diagramm-Arbeitsblatt"""
|
||||
worksheet = self.workbook.add_worksheet('Diagramme')
|
||||
|
||||
row = 0
|
||||
for chart_idx, chart_data in enumerate(self.charts):
|
||||
# Diagramm-Titel
|
||||
worksheet.write(row, 0, chart_data.title, self.formats['header'])
|
||||
row += 2
|
||||
|
||||
# Daten für Diagramm vorbereiten
|
||||
data_worksheet_name = f'Chart_Data_{chart_idx}'
|
||||
data_worksheet = self.workbook.add_worksheet(data_worksheet_name)
|
||||
|
||||
# Daten ins Data-Arbeitsblatt schreiben
|
||||
labels = [item.get('label', f'Item {i}') for i, item in enumerate(chart_data.data)]
|
||||
values = [item.get('value', 0) for item in chart_data.data]
|
||||
|
||||
data_worksheet.write_column('A1', ['Label'] + labels)
|
||||
data_worksheet.write_column('B1', ['Value'] + values)
|
||||
|
||||
# Excel-Diagramm erstellen
|
||||
if chart_data.chart_type == 'bar':
|
||||
chart = self.workbook.add_chart({'type': 'column'})
|
||||
elif chart_data.chart_type == 'line':
|
||||
chart = self.workbook.add_chart({'type': 'line'})
|
||||
elif chart_data.chart_type == 'pie':
|
||||
chart = self.workbook.add_chart({'type': 'pie'})
|
||||
else:
|
||||
chart = self.workbook.add_chart({'type': 'column'})
|
||||
|
||||
# Datenreihe hinzufügen
|
||||
chart.add_series({
|
||||
'name': chart_data.title,
|
||||
'categories': [data_worksheet_name, 1, 0, len(labels), 0],
|
||||
'values': [data_worksheet_name, 1, 1, len(values), 1],
|
||||
})
|
||||
|
||||
chart.set_title({'name': chart_data.title})
|
||||
chart.set_x_axis({'name': 'Kategorien'})
|
||||
chart.set_y_axis({'name': 'Werte'})
|
||||
|
||||
# Diagramm ins Arbeitsblatt einfügen
|
||||
worksheet.insert_chart(row, 0, chart)
|
||||
row += 15 # Platz für nächstes Diagramm
|
||||
|
||||
def _get_cell_format(self, value: Any, row_idx: int):
|
||||
"""Bestimmt das Zellformat basierend auf dem Wert"""
|
||||
# Alternierende Zeilenfarben
|
||||
base_format = self.formats['data'] if row_idx % 2 == 1 else self.formats['data_alt']
|
||||
|
||||
# Spezielle Formate für Zahlen, Daten, etc.
|
||||
if isinstance(value, (int, float)):
|
||||
return self.formats['number']
|
||||
elif isinstance(value, datetime):
|
||||
return self.formats['date']
|
||||
elif isinstance(value, str) and value.endswith('%'):
|
||||
return self.formats['percentage']
|
||||
elif isinstance(value, str) and '€' in value:
|
||||
return self.formats['currency']
|
||||
|
||||
return base_format
|
||||
|
||||
def _format_date_range(self) -> str:
|
||||
"""Formatiert den Datumsbereich"""
|
||||
if not self.config.date_range:
|
||||
return "Alle verfügbaren Daten"
|
||||
|
||||
start_date, end_date = self.config.date_range
|
||||
return f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
|
||||
|
||||
class CSVReportGenerator(BaseReportGenerator):
|
||||
"""CSV-Report-Generator für Datenanalyse"""
|
||||
|
||||
def generate(self, output_stream: BinaryIO) -> bool:
|
||||
"""Generiert CSV-Report"""
|
||||
try:
|
||||
# Text-Stream für CSV-Writer
|
||||
text_stream = io.TextIOWrapper(output_stream, encoding='utf-8-sig', newline='')
|
||||
writer = csv.writer(text_stream, delimiter=';', quoting=csv.QUOTE_MINIMAL)
|
||||
|
||||
# Header mit Metadaten
|
||||
writer.writerow([f'# {self.config.title}'])
|
||||
writer.writerow([f'# Generiert am: {datetime.now().strftime("%d.%m.%Y %H:%M")}'])
|
||||
writer.writerow([f'# Erstellt von: {self.config.author}'])
|
||||
writer.writerow(['']) # Leerzeile
|
||||
|
||||
# Daten-Sektionen
|
||||
for section_name, section_data in self.data.items():
|
||||
writer.writerow([f'# Sektion: {section_name}'])
|
||||
|
||||
# Headers
|
||||
writer.writerow(section_data['headers'])
|
||||
|
||||
# Daten
|
||||
for row in section_data['data']:
|
||||
csv_row = [str(row.get(header, '')) for header in section_data['headers']]
|
||||
writer.writerow(csv_row)
|
||||
|
||||
writer.writerow(['']) # Leerzeile zwischen Sektionen
|
||||
|
||||
text_stream.flush()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei CSV-Generierung: {str(e)}")
|
||||
return False
|
||||
|
||||
class JSONReportGenerator(BaseReportGenerator):
|
||||
"""JSON-Report-Generator für API-Integration"""
|
||||
|
||||
def generate(self, output_stream: BinaryIO) -> bool:
|
||||
"""Generiert JSON-Report"""
|
||||
try:
|
||||
report_data = {
|
||||
'metadata': {
|
||||
'title': self.config.title,
|
||||
'subtitle': self.config.subtitle,
|
||||
'author': self.config.author,
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'date_range': {
|
||||
'start': self.config.date_range[0].isoformat() if self.config.date_range else None,
|
||||
'end': self.config.date_range[1].isoformat() if self.config.date_range else None
|
||||
} if self.config.date_range else None
|
||||
},
|
||||
'data': self.data,
|
||||
'charts': [asdict(chart) for chart in self.charts] if self.charts else []
|
||||
}
|
||||
|
||||
json_str = json.dumps(report_data, ensure_ascii=False, indent=2, default=str)
|
||||
output_stream.write(json_str.encode('utf-8'))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei JSON-Generierung: {str(e)}")
|
||||
return False
|
||||
|
||||
class ReportFactory:
|
||||
"""Factory für Report-Generatoren"""
|
||||
|
||||
GENERATORS = {
|
||||
'pdf': PDFReportGenerator,
|
||||
'excel': ExcelReportGenerator,
|
||||
'xlsx': ExcelReportGenerator,
|
||||
'csv': CSVReportGenerator,
|
||||
'json': JSONReportGenerator
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create_generator(cls, format_type: str, config: ReportConfig) -> BaseReportGenerator:
|
||||
"""Erstellt einen Report-Generator für das angegebene Format"""
|
||||
format_type = format_type.lower()
|
||||
|
||||
if format_type not in cls.GENERATORS:
|
||||
raise ValueError(f"Unbekanntes Report-Format: {format_type}")
|
||||
|
||||
generator_class = cls.GENERATORS[format_type]
|
||||
return generator_class(config)
|
||||
|
||||
@classmethod
|
||||
def get_available_formats(cls) -> List[str]:
|
||||
"""Gibt verfügbare Report-Formate zurück"""
|
||||
available = []
|
||||
|
||||
for format_type, generator_class in cls.GENERATORS.items():
|
||||
try:
|
||||
# Test ob Generator funktioniert
|
||||
if format_type in ['pdf'] and not PDF_AVAILABLE:
|
||||
continue
|
||||
elif format_type in ['excel', 'xlsx'] and not EXCEL_AVAILABLE:
|
||||
continue
|
||||
|
||||
available.append(format_type)
|
||||
except ImportError:
|
||||
continue
|
||||
|
||||
return available
|
||||
|
||||
# Vordefinierte Report-Templates
|
||||
class JobReportBuilder:
|
||||
"""Builder für Job-Reports"""
|
||||
|
||||
@staticmethod
|
||||
def build_jobs_report(
|
||||
start_date: datetime = None,
|
||||
end_date: datetime = None,
|
||||
user_id: int = None,
|
||||
printer_id: int = None,
|
||||
include_completed: bool = True,
|
||||
include_cancelled: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Erstellt Job-Report-Daten"""
|
||||
|
||||
with get_db_session() as db_session:
|
||||
query = db_session.query(Job)
|
||||
|
||||
# Filter anwenden
|
||||
if start_date:
|
||||
query = query.filter(Job.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(Job.created_at <= end_date)
|
||||
if user_id:
|
||||
query = query.filter(Job.user_id == user_id)
|
||||
if printer_id:
|
||||
query = query.filter(Job.printer_id == printer_id)
|
||||
|
||||
status_filters = []
|
||||
if include_completed:
|
||||
status_filters.append('finished')
|
||||
if include_cancelled:
|
||||
status_filters.append('cancelled')
|
||||
if not include_cancelled and not include_completed:
|
||||
status_filters = ['scheduled', 'running', 'paused']
|
||||
|
||||
if status_filters:
|
||||
query = query.filter(Job.status.in_(status_filters))
|
||||
|
||||
jobs = query.all()
|
||||
|
||||
# Daten vorbereiten
|
||||
job_data = []
|
||||
for job in jobs:
|
||||
job_data.append({
|
||||
'ID': job.id,
|
||||
'Name': job.name,
|
||||
'Benutzer': job.user.name if job.user else 'Unbekannt',
|
||||
'Drucker': job.printer.name if job.printer else 'Unbekannt',
|
||||
'Status': job.status,
|
||||
'Erstellt': job.created_at.strftime('%d.%m.%Y %H:%M') if job.created_at else '',
|
||||
'Gestartet': job.start_at.strftime('%d.%m.%Y %H:%M') if job.start_at else '',
|
||||
'Beendet': job.end_at.strftime('%d.%m.%Y %H:%M') if job.end_at else '',
|
||||
'Dauer (Min)': job.duration_minutes or 0,
|
||||
'Material (g)': job.material_used or 0,
|
||||
'Beschreibung': job.description or ''
|
||||
})
|
||||
|
||||
return {
|
||||
'data': job_data,
|
||||
'headers': ['ID', 'Name', 'Benutzer', 'Drucker', 'Status', 'Erstellt', 'Gestartet', 'Beendet', 'Dauer (Min)', 'Material (g)', 'Beschreibung']
|
||||
}
|
||||
|
||||
class UserReportBuilder:
|
||||
"""Builder für Benutzer-Reports"""
|
||||
|
||||
@staticmethod
|
||||
def build_users_report(include_inactive: bool = False) -> Dict[str, Any]:
|
||||
"""Erstellt Benutzer-Report-Daten"""
|
||||
|
||||
with get_db_session() as db_session:
|
||||
query = db_session.query(User)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(User.active == True)
|
||||
|
||||
users = query.all()
|
||||
|
||||
# Daten vorbereiten
|
||||
user_data = []
|
||||
for user in users:
|
||||
user_data.append({
|
||||
'ID': user.id,
|
||||
'Name': user.name,
|
||||
'E-Mail': user.email,
|
||||
'Benutzername': user.username,
|
||||
'Rolle': user.role,
|
||||
'Aktiv': 'Ja' if user.active else 'Nein',
|
||||
'Abteilung': user.department or '',
|
||||
'Position': user.position or '',
|
||||
'Erstellt': user.created_at.strftime('%d.%m.%Y') if user.created_at else '',
|
||||
'Letzter Login': user.last_login.strftime('%d.%m.%Y %H:%M') if user.last_login else 'Nie'
|
||||
})
|
||||
|
||||
return {
|
||||
'data': user_data,
|
||||
'headers': ['ID', 'Name', 'E-Mail', 'Benutzername', 'Rolle', 'Aktiv', 'Abteilung', 'Position', 'Erstellt', 'Letzter Login']
|
||||
}
|
||||
|
||||
class PrinterReportBuilder:
|
||||
"""Builder für Drucker-Reports"""
|
||||
|
||||
@staticmethod
|
||||
def build_printers_report(include_inactive: bool = False) -> Dict[str, Any]:
|
||||
"""Erstellt Drucker-Report-Daten"""
|
||||
|
||||
with get_db_session() as db_session:
|
||||
query = db_session.query(Printer)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(Printer.active == True)
|
||||
|
||||
printers = query.all()
|
||||
|
||||
# Daten vorbereiten
|
||||
printer_data = []
|
||||
for printer in printers:
|
||||
printer_data.append({
|
||||
'ID': printer.id,
|
||||
'Name': printer.name,
|
||||
'Modell': printer.model or '',
|
||||
'Standort': printer.location or '',
|
||||
'IP-Adresse': printer.ip_address or '',
|
||||
'MAC-Adresse': printer.mac_address,
|
||||
'Plug-IP': printer.plug_ip,
|
||||
'Status': printer.status,
|
||||
'Aktiv': 'Ja' if printer.active else 'Nein',
|
||||
'Erstellt': printer.created_at.strftime('%d.%m.%Y') if printer.created_at else '',
|
||||
'Letzte Prüfung': printer.last_checked.strftime('%d.%m.%Y %H:%M') if printer.last_checked else 'Nie'
|
||||
})
|
||||
|
||||
return {
|
||||
'data': printer_data,
|
||||
'headers': ['ID', 'Name', 'Modell', 'Standort', 'IP-Adresse', 'MAC-Adresse', 'Plug-IP', 'Status', 'Aktiv', 'Erstellt', 'Letzte Prüfung']
|
||||
}
|
||||
|
||||
def generate_comprehensive_report(
|
||||
format_type: str,
|
||||
start_date: datetime = None,
|
||||
end_date: datetime = None,
|
||||
include_jobs: bool = True,
|
||||
include_users: bool = True,
|
||||
include_printers: bool = True,
|
||||
user_id: int = None
|
||||
) -> bytes:
|
||||
"""Generiert einen umfassenden System-Report"""
|
||||
|
||||
# Konfiguration
|
||||
config = ReportConfig(
|
||||
title="MYP System Report",
|
||||
subtitle="Umfassende Systemübersicht",
|
||||
author="MYP System",
|
||||
date_range=(start_date, end_date) if start_date and end_date else None,
|
||||
include_charts=True,
|
||||
include_summary=True
|
||||
)
|
||||
|
||||
# Generator erstellen
|
||||
generator = ReportFactory.create_generator(format_type, config)
|
||||
|
||||
# Daten hinzufügen
|
||||
if include_jobs:
|
||||
job_data = JobReportBuilder.build_jobs_report(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_id=user_id
|
||||
)
|
||||
generator.add_data_section("Jobs", job_data['data'], job_data['headers'])
|
||||
|
||||
# Job-Status-Diagramm
|
||||
status_counts = {}
|
||||
for job in job_data['data']:
|
||||
status = job['Status']
|
||||
status_counts[status] = status_counts.get(status, 0) + 1
|
||||
|
||||
chart_data = ChartData(
|
||||
chart_type='pie',
|
||||
title='Job-Status-Verteilung',
|
||||
data=[{'label': status, 'value': count} for status, count in status_counts.items()]
|
||||
)
|
||||
generator.add_chart(chart_data)
|
||||
|
||||
if include_users:
|
||||
user_data = UserReportBuilder.build_users_report()
|
||||
generator.add_data_section("Benutzer", user_data['data'], user_data['headers'])
|
||||
|
||||
if include_printers:
|
||||
printer_data = PrinterReportBuilder.build_printers_report()
|
||||
generator.add_data_section("Drucker", printer_data['data'], printer_data['headers'])
|
||||
|
||||
# Report generieren
|
||||
output = io.BytesIO()
|
||||
success = generator.generate(output)
|
||||
|
||||
if success:
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
else:
|
||||
raise Exception("Report-Generierung fehlgeschlagen")
|
||||
|
||||
# Zusätzliche Abhängigkeiten zu requirements.txt hinzufügen
|
||||
ADDITIONAL_REQUIREMENTS = [
|
||||
"reportlab>=4.0.0",
|
||||
"xlsxwriter>=3.0.0"
|
||||
]
|
Reference in New Issue
Block a user