table optimization (filtering, resizing and settings saved in user settings

This commit is contained in:
2026-08-12 23:14:21 +02:00
parent e0175ab48a
commit 0fb18ae4f7
24 changed files with 991 additions and 189 deletions
+217 -9
View File
@@ -5412,17 +5412,145 @@ def _translated_filter_codes(
return matches
def _software_job_date_filter(column: Any, search_value: str):
"""Support ISO/database text and the common German date formats."""
def _parse_table_filter_number(value: Any) -> float | None:
"""Parse a simple locale-friendly table-filter number."""
text = str(value or "").strip().replace(" ", "").replace(",", ".")
if not re.fullmatch(r"[-+]?\d+(?:\.\d+)?", text):
return None
try:
return float(text)
except ValueError:
return None
def _table_filter_range_parts(value: Any) -> tuple[str, str] | None:
"""Return the two operands of a user-entered inclusive range."""
text = str(value or "").strip()
patterns = (
r"^(?:von|from)\s+(.+?)\s+(?:bis|to)\s+(.+)$",
r"^(?:zwischen|between)\s+(.+?)\s+(?:und|and)\s+(.+)$",
r"^(.+?)\s*\.\.\s*(.+)$",
r"^(.+?)\s+-\s+(.+)$",
)
for pattern in patterns:
match = re.fullmatch(pattern, text, flags=re.IGNORECASE)
if match:
return match.group(1).strip(), match.group(2).strip()
return None
def _software_job_numeric_filter(column: Any, search_value: str):
"""Build a numeric comparison/range filter, or return None for normal text."""
value = str(search_value or "").strip()
conditions = [_sql_text_contains(column, value)]
for date_format in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
if value.lower() == "@empty":
return column.is_(None)
if value.lower() == "@notempty":
return column.is_not(None)
match = re.fullmatch(r"(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)", value)
if match:
expected = _parse_table_filter_number(match.group(2))
if expected is None:
return None
return {
"<": column < expected,
"<=": column <= expected,
">": column > expected,
">=": column >= expected,
"=": column == expected,
}[match.group(1)]
range_values = _table_filter_range_parts(value)
if range_values:
first = _parse_table_filter_number(range_values[0])
second = _parse_table_filter_number(range_values[1])
if first is not None and second is not None:
minimum, maximum = sorted((first, second))
return and_(column >= minimum, column <= maximum)
return None
def _parse_table_filter_date(value: Any) -> tuple[datetime, bool] | None:
"""Parse supported local/ISO dates and tell whether no time was supplied."""
text = str(value or "").strip()
formats = (
("%d.%m.%Y %H:%M:%S", False),
("%d.%m.%Y %H:%M", False),
("%d.%m.%y %H:%M:%S", False),
("%d.%m.%y %H:%M", False),
("%Y-%m-%d %H:%M:%S", False),
("%Y-%m-%d %H:%M", False),
("%Y-%m-%dT%H:%M:%S", False),
("%Y-%m-%dT%H:%M", False),
("%d.%m.%Y", True),
("%d.%m.%y", True),
("%Y-%m-%d", True),
)
for date_format, date_only in formats:
try:
parsed = datetime.strptime(value, date_format)
return datetime.strptime(text, date_format), date_only
except ValueError:
continue
conditions.append(and_(column >= parsed, column < parsed + timedelta(days=1)))
break
return None
def _software_job_date_comparison_filter(column: Any, search_value: str):
"""Build a date comparison/range filter, or return None for normal text."""
value = str(search_value or "").strip()
if value.lower() == "@empty":
return column.is_(None)
if value.lower() == "@notempty":
return column.is_not(None)
match = re.fullmatch(r"(<=|>=|<|>|=)\s*(.+)", value)
if match:
parsed = _parse_table_filter_date(match.group(2))
if not parsed:
return None
expected, date_only = parsed
next_day = expected + timedelta(days=1)
operator = match.group(1)
if operator == "<":
return column < expected
if operator == "<=":
return column < next_day if date_only else column <= expected
if operator == ">":
return column >= next_day if date_only else column > expected
if operator == ">=":
return column >= expected
return and_(column >= expected, column < next_day) if date_only else column == expected
range_values = _table_filter_range_parts(value)
if range_values:
first = _parse_table_filter_date(range_values[0])
second = _parse_table_filter_date(range_values[1])
if first and second:
(first_value, first_date_only), (second_value, second_date_only) = first, second
if first_value <= second_value:
start = first_value
end_value, end_date_only = second_value, second_date_only
else:
start = second_value
end_value, end_date_only = first_value, first_date_only
end = end_value + timedelta(days=1) if end_date_only else end_value
return and_(column >= start, column < end) if end_date_only else and_(column >= start, column <= end)
return None
def _software_job_date_filter(column: Any, search_value: str):
"""Support comparisons/ranges plus the existing exact/text date search."""
value = str(search_value or "").strip()
advanced = _software_job_date_comparison_filter(column, value)
if advanced is not None:
return advanced
# Existing behavior intentionally remains unchanged for ordinary values.
conditions = [_sql_text_contains(column, value)]
parsed = _parse_table_filter_date(value)
if parsed:
date_value, date_only = parsed
if date_only:
conditions.append(and_(column >= date_value, column < date_value + timedelta(days=1)))
else:
conditions.append(column == date_value)
return or_(*conditions)
@@ -5457,7 +5585,10 @@ def _apply_software_job_filters(query: Any, request: Request, filters: dict[str,
if terms:
conditions = []
for value in terms:
if value.isdigit():
advanced = _software_job_numeric_filter(SoftwareJob.id, value)
if advanced is not None:
conditions.append(advanced)
elif value.isdigit():
conditions.append(SoftwareJob.id == int(value))
else:
conditions.append(_sql_text_contains(SoftwareJob.id, value))
@@ -5539,7 +5670,10 @@ def _apply_software_job_filters(query: Any, request: Request, filters: dict[str,
if terms:
conditions = []
for value in terms:
if re.fullmatch(r"[-+]?\d+", value):
advanced = _software_job_numeric_filter(SoftwareJob.priority, value)
if advanced is not None:
conditions.append(advanced)
elif re.fullmatch(r"[-+]?\d+", value):
conditions.append(SoftwareJob.priority == int(value))
else:
conditions.append(_sql_text_contains(SoftwareJob.priority, value))
@@ -7028,6 +7162,80 @@ def profile_language(request: Request, language_code: str = Form(...), next_url:
))
return RedirectResponse(redirect_target, status_code=303)
@app.get("/api/user-ui-state")
def api_user_ui_state(request: Request, db: Session = Depends(get_db)):
session_user = _session_user(request)
if not session_user:
return {"authenticated": False, "state": {}}
user = db.query(User).filter(User.id == session_user.get("id")).first()
if not user:
return {"authenticated": False, "state": {}}
state = user.ui_state if isinstance(getattr(user, "ui_state", None), dict) else {}
return {"authenticated": True, "state": state}
@app.post("/api/user-ui-state")
def api_user_ui_state_save(
request: Request,
payload: dict = Body(default={}),
db: Session = Depends(get_db),
):
session_user = _session_user(request)
if not session_user:
raise HTTPException(401, "Anmeldung erforderlich")
updates = payload.get("updates") or {}
removals = payload.get("remove") or []
if not isinstance(updates, dict) or not isinstance(removals, list):
raise HTTPException(400, "Ungültiger UI-Zustand")
if len(updates) > 500 or len(removals) > 500:
raise HTTPException(400, "Zu viele UI-Zustände in einer Anfrage")
try:
request_size = len(json.dumps(payload, ensure_ascii=False))
except (TypeError, ValueError):
raise HTTPException(400, "Ungültiger UI-Zustand")
if request_size > 524288:
raise HTTPException(413, "UI-Zustand ist zu groß")
normalized_updates: dict[str, Any] = {}
for key, value in updates.items():
key = str(key or "").strip()
if not key or len(key) > 512:
raise HTTPException(400, "Ungültiger UI-Zustandsschlüssel")
normalized_updates[key] = value
normalized_removals = []
for key in removals:
key = str(key or "").strip()
if key and len(key) <= 512:
normalized_removals.append(key)
user = (
db.query(User)
.filter(User.id == session_user.get("id"))
.with_for_update()
.first()
)
if not user:
raise HTTPException(401, "Benutzerkonto wurde nicht gefunden")
state = dict(user.ui_state or {}) if isinstance(user.ui_state, dict) else {}
for key in normalized_removals:
state.pop(key, None)
state.update(normalized_updates)
try:
total_size = len(json.dumps(state, ensure_ascii=False))
except (TypeError, ValueError):
raise HTTPException(400, "Ungültiger UI-Zustand")
if total_size > 1048576:
raise HTTPException(413, "Gespeicherter UI-Zustand ist zu groß")
user.ui_state = state
db.commit()
return {"ok": True}
@app.post("/profile/browser-state")
def profile_browser_state(
request: Request,