add section Privacy Policy DSGVO
This commit is contained in:
+145
-2
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user