""" 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 = `
`; 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 ? `${action.icon}` : ''; return ` ${icon}${action.label} `; }).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 = `
Zeige ${pagination.start_item}-${pagination.end_item} von ${pagination.total_items} Einträgen
${this.renderPaginationButtons()}
`; // 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(` `); // 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(` `); } // Next button buttons.push(` `); 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; } } """