manage-your-printer/utils/advanced_tables.py
2025-06-04 10:03:22 +02:00

968 lines
31 KiB
Python

"""
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;
}
}
"""
def create_table_config(table_id: str, columns: List[ColumnConfig], **kwargs) -> TableConfig:
"""
Erstellt eine neue Tabellen-Konfiguration.
Args:
table_id: Eindeutige ID für die Tabelle
columns: Liste der Spalten-Konfigurationen
**kwargs: Zusätzliche Konfigurationsoptionen
Returns:
TableConfig: Konfiguration für die erweiterte Tabelle
"""
return TableConfig(
table_id=table_id,
columns=columns,
default_sort=kwargs.get('default_sort', []),
default_filters=kwargs.get('default_filters', []),
pagination=kwargs.get('pagination', PaginationConfig()),
searchable=kwargs.get('searchable', True),
exportable=kwargs.get('exportable', True),
selectable=kwargs.get('selectable', False),
row_actions=kwargs.get('row_actions', [])
)
def get_advanced_tables_js() -> str:
"""Alias für die bestehende Funktion"""
return get_advanced_table_javascript()
def get_advanced_tables_css() -> str:
"""Alias für die bestehende Funktion"""
return get_advanced_table_css()