add section Privacy Policy DSGVO

This commit is contained in:
2026-08-05 18:17:22 +00:00
parent bd23858537
commit c9e6438222
12 changed files with 258 additions and 7 deletions
+145 -2
View File
@@ -2617,6 +2617,121 @@ def assets_list(request: Request, category_id: int | None = None, db: Session =
)
SOFTWARE_COMPARE_MODES = {"exact", "ignore_version", "ignore_version_year"}
def _software_compare_key(name: str, platform: str, mode: str) -> tuple[str, str]:
"""Build a conservative comparison key without changing stored inventory data."""
value = unicodedata.normalize("NFKC", str(name or "")).strip()
value = re.sub(r"\s+", " ", value)
if mode in {"ignore_version", "ignore_version_year"}:
# Remove explicit version/build markers and common architecture suffixes.
value = re.sub(r"(?i)\b(?:version|ver\.?|build|release)\s*v?\d+(?:[._-]\d+)*\b", " ", value)
value = re.sub(r"(?i)(?:\s|[-_(])v\d+(?:[._-]\d+)+(?:[)\s]*)$", " ", value)
value = re.sub(r"(?i)(?:\s|[-_(])\d+(?:[._-]\d+){1,}(?:[)\s]*)$", " ", value)
# Architecture markers may be parenthesized or appended after language/year,
# for example "2027 - English 64-Bit" versus "2016 - English".
value = re.sub(r"(?i)\(?\b(?:x64|x86|amd64|arm64|64[- ]?bit|32[- ]?bit)\b\)?", " ", value)
if mode == "ignore_version_year":
# Years are removed only as standalone tokens. Product identities such as
# Microsoft 365, Windows 11, .NET 8 and 7-Zip remain untouched.
value = re.sub(r"(?<!\d)(?:19|20)\d{2}(?!\d)", " ", value)
value = re.sub(r"[\s._-]+$", "", re.sub(r"\s+", " ", value)).strip()
return (value.casefold(), str(platform or "unknown").casefold())
def _software_compare_rows(db: Session, asset_a: Asset, asset_b: Asset, mode: str) -> list[dict[str, Any]]:
entries = (
db.query(InstalledSoftware)
.options(load_only(InstalledSoftware.asset_id, InstalledSoftware.name, InstalledSoftware.version, InstalledSoftware.publisher, InstalledSoftware.platform))
.filter(InstalledSoftware.asset_id.in_([asset_a.id, asset_b.id]))
.order_by(func.lower(InstalledSoftware.name), func.lower(InstalledSoftware.version), InstalledSoftware.id)
.all()
)
grouped: dict[tuple[str, str], dict[int, list[InstalledSoftware]]] = {}
for entry in entries:
key = _software_compare_key(entry.name, entry.platform, mode)
grouped.setdefault(key, {}).setdefault(entry.asset_id, []).append(entry)
result: list[dict[str, Any]] = []
for (normalized_name, platform), by_asset in grouped.items():
left = by_asset.get(asset_a.id, [])
right = by_asset.get(asset_b.id, [])
all_entries = left + right
display_name = min((entry.name for entry in all_entries), key=lambda value: (len(value), value.casefold()))
def values(items: list[InstalledSoftware], attr: str) -> str:
unique = sorted({str(getattr(item, attr) or "").strip() for item in items if str(getattr(item, attr) or "").strip()}, key=str.casefold)
return ", ".join(unique)
status = "both" if left and right else ("only_a" if left else "only_b")
result.append({
"comparison_name": display_name,
"platform": platform or "unknown",
"status": status,
"a_names": values(left, "name"),
"a_versions": values(left, "version"),
"a_publishers": values(left, "publisher"),
"b_names": values(right, "name"),
"b_versions": values(right, "version"),
"b_publishers": values(right, "publisher"),
})
# Keep the comparison table stable and easy to scan: sort primarily by the
# normalized comparison name instead of grouping rows by comparison status.
result.sort(key=lambda row: (_software_compare_key(row["comparison_name"], row["platform"], mode), row["comparison_name"].casefold()))
return result
def _software_compare_assets(request: Request, db: Session, asset_a_id: int, asset_b_id: int) -> tuple[Asset, Asset]:
if asset_a_id == asset_b_id:
raise HTTPException(400, "Select two different assets.")
rows = _apply_asset_access(db.query(Asset), request).filter(Asset.id.in_([asset_a_id, asset_b_id])).all()
assets = {row.id: row for row in rows}
if asset_a_id not in assets or asset_b_id not in assets:
raise HTTPException(404, "Asset not found or not accessible.")
return assets[asset_a_id], assets[asset_b_id]
@app.post("/assets/software-compare")
def assets_software_compare_start(request: Request, asset_ids: list[str] = Form([])):
_require_admin(request)
ids = []
for value in asset_ids:
try:
parsed = int(value)
except (TypeError, ValueError):
continue
if parsed not in ids:
ids.append(parsed)
if len(ids) != 2:
return RedirectResponse("/assets?toast_error=" + quote("Select exactly two assets for the software comparison."), status_code=303)
return RedirectResponse(f"/assets/software-compare?asset_a={ids[0]}&asset_b={ids[1]}&mode=ignore_version_year", status_code=303)
@app.get("/assets/software-compare")
def assets_software_compare(request: Request, asset_a: int, asset_b: int, mode: str = "ignore_version_year", db: Session = Depends(get_db)):
_require_admin(request)
mode = mode if mode in SOFTWARE_COMPARE_MODES else "ignore_version_year"
left, right = _software_compare_assets(request, db, asset_a, asset_b)
rows = _software_compare_rows(db, left, right, mode)
counts = {key: sum(1 for row in rows if row["status"] == key) for key in ("only_a", "only_b", "both")}
return templates.TemplateResponse("software_compare.html", {
"request": request, "asset_a": left, "asset_b": right, "mode": mode, "rows": rows, "counts": counts, "is_admin": True
})
@app.get("/assets/software-compare/export.xlsx")
def assets_software_compare_export(request: Request, asset_a: int, asset_b: int, mode: str = "ignore_version_year", db: Session = Depends(get_db)):
_require_admin(request)
mode = mode if mode in SOFTWARE_COMPARE_MODES else "ignore_version_year"
left, right = _software_compare_assets(request, db, asset_a, asset_b)
rows = _software_compare_rows(db, left, right, mode)
headers = [
"Comparison name", "Platform", "Status",
f"{left.name} - software", f"{left.name} - versions", f"{left.name} - publishers",
f"{right.name} - software", f"{right.name} - versions", f"{right.name} - publishers",
]
status_labels = {"both": "Present on both assets", "only_a": f"Only on {left.name}", "only_b": f"Only on {right.name}"}
export_rows = [[
row["comparison_name"], row["platform"], status_labels[row["status"]],
row["a_names"], row["a_versions"], row["a_publishers"],
row["b_names"], row["b_versions"], row["b_publishers"],
] for row in rows]
return _xlsx_response(f"software-comparison-{left.id}-{right.id}.xlsx", headers, export_rows)
@app.get("/charts")
def charts_page(request: Request, db: Session = Depends(get_db)):
_seed_default_charts(db)
@@ -6490,7 +6605,21 @@ def profile_language(request: Request, language_code: str = Form(...), next_url:
_login_user(request, user)
request.session["language_code"] = language_code
safe_next = next_url if next_url.startswith("/") and not next_url.startswith("//") else "/"
return RedirectResponse(f"{safe_next}?toast_success=Language changed", status_code=303)
parsed_next = urllib.parse.urlsplit(safe_next)
query_items = [
(key, value)
for key, value in urllib.parse.parse_qsl(parsed_next.query, keep_blank_values=True)
if key not in {"toast_success", "toast_warning", "toast_error"}
]
query_items.append(("toast_success", "Language changed"))
redirect_target = urllib.parse.urlunsplit((
"",
"",
parsed_next.path or "/",
urllib.parse.urlencode(query_items),
parsed_next.fragment,
))
return RedirectResponse(redirect_target, status_code=303)
@app.post("/profile/browser-state")
def profile_browser_state(
@@ -6555,7 +6684,21 @@ def profile_theme(
safe_next = next_url if next_url.startswith("/") and not next_url.startswith("//") else "/profile"
message = _translate_request(request, "appearance.theme_saved", "Display mode saved")
return RedirectResponse(f"{safe_next}?toast_success={quote(message)}", status_code=303)
parsed_next = urllib.parse.urlsplit(safe_next)
query_items = [
(key, value)
for key, value in urllib.parse.parse_qsl(parsed_next.query, keep_blank_values=True)
if key not in {"toast_success", "toast_warning", "toast_error"}
]
query_items.append(("toast_success", message))
redirect_target = urllib.parse.urlunsplit((
"",
"",
parsed_next.path or "/profile",
urllib.parse.urlencode(query_items),
parsed_next.fragment,
))
return RedirectResponse(redirect_target, status_code=303)
@app.post("/profile/password")