From c9e64382224a853b8c7ec724691d6f532825348b Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 5 Aug 2026 18:17:22 +0000 Subject: [PATCH] add section Privacy Policy DSGVO --- README.md | 2 +- VERSION | 2 +- app/i18n.py | 18 +++ app/main.py | 147 +++++++++++++++++++++++- app/templates/assets.html | 8 ++ app/templates/base.html | 4 +- app/templates/software_compare.html | 58 ++++++++++ app/version.py | 2 +- docs/version-history/README.md | 2 + docs/version-history/UPDATE-0.5.5.33.md | 6 + docs/version-history/UPDATE-0.5.5.34.md | 8 ++ docs/version-history/UPDATE-0.5.5.35.md | 8 ++ 12 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 app/templates/software_compare.html create mode 100644 docs/version-history/UPDATE-0.5.5.33.md create mode 100644 docs/version-history/UPDATE-0.5.5.34.md create mode 100644 docs/version-history/UPDATE-0.5.5.35.md diff --git a/README.md b/README.md index 66ca244..0fa942e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AssetManager 0.5.5.32 +# AssetManager 0.5.5.35 AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. The project is released under the **Apache License 2.0** and may be used, modified, and redistributed for private and commercial purposes. diff --git a/VERSION b/VERSION index 599ba9c..a609c26 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5.32 +0.5.5.35 diff --git a/app/i18n.py b/app/i18n.py index c566bc0..57937e3 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -1198,3 +1198,21 @@ BASE_TRANSLATIONS.update({ "settings.ldap_debug_logging_help": ("When disabled, the LDAP log records only the searched username, whether it was found, and whether authentication succeeded. Enable this only temporarily for troubleshooting.", "Wenn deaktiviert, protokolliert LDAP nur den gesuchten Benutzernamen, ob er gefunden wurde und ob die Anmeldung erfolgreich war. Nur vorübergehend zur Fehlersuche aktivieren."), }) + +BASE_TRANSLATIONS.update({ + "software.compare.button": ("Compare software", "Software vergleichen"), + "software.compare.title": ("Compare software inventories", "Softwareinventuren vergleichen"), + "software.compare.subtitle": ("Comparison of {asset_a} and {asset_b}", "Vergleich von {asset_a} und {asset_b}"), + "software.compare.export": ("Download as Excel", "Als Excel herunterladen"), + "software.compare.mode": ("Comparison mode", "Vergleichsmodus"), + "software.compare.mode.exact": ("Exact software name", "Exakter Softwarename"), + "software.compare.mode.ignore_version": ("Ignore versions", "Versionen ignorieren"), + "software.compare.mode.ignore_version_year": ("Ignore versions and years", "Versionen und Jahreszahlen ignorieren"), + "software.compare.mode_help": ("Original names and versions remain visible. Normalization is used only for matching.", "Originalnamen und Versionen bleiben sichtbar. Die Normalisierung wird nur für die Zuordnung verwendet."), + "software.compare.only_a": ("Only on {asset}", "Nur auf {asset}"), + "software.compare.only_b": ("Only on {asset}", "Nur auf {asset}"), + "software.compare.both": ("Present on both", "Auf beiden vorhanden"), + "software.compare.comparison_name": ("Comparison name", "Vergleichsname"), + "software.compare.status": ("Comparison status", "Vergleichsstatus"), + "software.compare.empty": ("No software inventory is available for these assets.", "Für diese Assets sind keine Softwareinventuren vorhanden."), +}) diff --git a/app/main.py b/app/main.py index 45ea843..7bc77a5 100644 --- a/app/main.py +++ b/app/main.py @@ -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"(? 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") diff --git a/app/templates/assets.html b/app/templates/assets.html index ae7935f..c25e443 100644 --- a/app/templates/assets.html +++ b/app/templates/assets.html @@ -15,6 +15,7 @@ +
{% endif %}
{% if selected %}{% endif %}
+
@@ -345,6 +347,7 @@ const editButton = document.getElementById('bulk-edit-button'); const deleteButton = document.getElementById('bulk-delete-button'); const mergeButton = document.getElementById('merge-duplicates-button'); + const softwareCompareButton = document.getElementById('software-compare-button'); const jobTypeSelect = document.getElementById('bulk-job-type'); const jobStartButton = document.getElementById('bulk-job-start-button'); const isVisible = box => { @@ -381,6 +384,10 @@ mergeButton.disabled = count < 2; mergeButton.textContent = count >= 2 ? `⧉ {{ t('duplicates.merge') }} (${count})` : '⧉ {{ t('duplicates.merge') }}'; } + if (softwareCompareButton) { + softwareCompareButton.disabled = count !== 2; + softwareCompareButton.textContent = count === 2 ? `⇄ {{ t('software.compare.button', 'Software vergleichen') }} (2)` : '⇄ {{ t('software.compare.button', 'Software vergleichen') }}'; + } if (jobTypeSelect) { jobTypeSelect.disabled = count === 0; filterJobDefinitions(); } if (jobStartButton) { jobStartButton.disabled = count === 0 || !jobTypeSelect?.value; @@ -404,6 +411,7 @@ editButton?.addEventListener('click', () => submit('bulk-edit-form', 'bulk-edit-ids')); deleteButton?.addEventListener('click', () => submit('bulk-delete-form', 'bulk-delete-ids')); mergeButton?.addEventListener('click', () => submit('merge-duplicates-form', 'merge-duplicate-ids')); + softwareCompareButton?.addEventListener('click', () => submit('software-compare-form', 'software-compare-ids')); jobTypeSelect?.addEventListener('change', refresh); jobStartButton?.addEventListener('click', () => { const count = selected().length; diff --git a/app/templates/base.html b/app/templates/base.html index bdc91c6..a04ac46 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -22,7 +22,7 @@ {% if session_user %} - +
-
🌐 {{ current_language|upper }} ▾
{% for language in available_languages() %}
{% endfor %}
+
🌐 {{ current_language|upper }} ▾
{% for language in available_languages() %}
{% endfor %}
👤 {{ session_user.display_name if session_user else t('app.guest') }} ▾
{% if session_user %}{{ t('app.profile') }}{{ t('app.logout') }}{% else %}{% if auth.get('mode', 'none') == 'none' %}Authentication disabled{% endif %}{{ t('app.login') }}{% endif %}
diff --git a/app/templates/software_compare.html b/app/templates/software_compare.html new file mode 100644 index 0000000..6987e9d --- /dev/null +++ b/app/templates/software_compare.html @@ -0,0 +1,58 @@ +{% extends 'base.html' %} +{% block content %} +
+
+

{{ t('software.compare.title', 'Softwareinventuren vergleichen') }}

+

{{ t('software.compare.subtitle', 'Vergleich von {asset_a} und {asset_b}', asset_a=asset_a.name, asset_b=asset_b.name) }}

+
+ +
+
+
+ + + +
+

{{ t('software.compare.mode_help', 'Originalnamen und Versionen bleiben sichtbar. Die Normalisierung wird nur für die Zuordnung verwendet.') }}

+
+ {{ t('software.compare.only_a', 'Nur auf {asset}', asset=asset_a.name) }}: {{ counts.only_a }} + {{ t('software.compare.only_b', 'Nur auf {asset}', asset=asset_b.name) }}: {{ counts.only_b }} + {{ t('software.compare.both', 'Auf beiden vorhanden') }}: {{ counts.both }} +
+
+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + {% else %}{% endfor %} + +
{{ t('software.compare.comparison_name', 'Vergleichsname') }}{{ t('software.platform', 'Plattform') }}{{ t('software.compare.status', 'Vergleichsstatus') }}{{ asset_a.name }} · {{ t('common.name', 'Name') }}{{ asset_a.name }} · {{ t('about.version', 'Version') }}{{ asset_a.name }} · {{ t('software.publisher', 'Hersteller') }}{{ asset_b.name }} · {{ t('common.name', 'Name') }}{{ asset_b.name }} · {{ t('about.version', 'Version') }}{{ asset_b.name }} · {{ t('software.publisher', 'Hersteller') }}
{{ row.comparison_name }}{{ t('software.platform.' ~ row.platform, row.platform) }}{% if row.status == 'both' %}{{ t('software.compare.both', 'Auf beiden vorhanden') }}{% elif row.status == 'only_a' %}{{ t('software.compare.only_a', 'Nur auf {asset}', asset=asset_a.name) }}{% else %}{{ t('software.compare.only_b', 'Nur auf {asset}', asset=asset_b.name) }}{% endif %}{{ row.a_names or '—' }}{{ row.a_versions or '—' }}{{ row.a_publishers or '—' }}{{ row.b_names or '—' }}{{ row.b_versions or '—' }}{{ row.b_publishers or '—' }}
{{ t('software.compare.empty', 'Für diese Assets sind keine Softwareinventuren vorhanden.') }}
+
+
+{% endblock %} diff --git a/app/version.py b/app/version.py index 2adc87f..4173f18 100644 --- a/app/version.py +++ b/app/version.py @@ -1,2 +1,2 @@ -APP_VERSION = "0.5.5.32" +APP_VERSION = "0.5.5.35" __version__ = APP_VERSION diff --git a/docs/version-history/README.md b/docs/version-history/README.md index 5981bc6..75ced7e 100644 --- a/docs/version-history/README.md +++ b/docs/version-history/README.md @@ -1,3 +1,5 @@ +- [0.5.5.35](UPDATE-0.5.5.35.md) +- [0.5.5.34](UPDATE-0.5.5.34.md) # Version history Release notes are stored outside the project root to keep the repository overview compact. diff --git a/docs/version-history/UPDATE-0.5.5.33.md b/docs/version-history/UPDATE-0.5.5.33.md new file mode 100644 index 0000000..5da1204 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.33.md @@ -0,0 +1,6 @@ +# Version 0.5.5.33 + +- Added a two-asset software inventory comparison. +- Added exact, version-insensitive, and version/year-insensitive comparison modes. +- Added standard table filtering and sorting to the comparison result. +- Added an Excel export for comparison results. diff --git a/docs/version-history/UPDATE-0.5.5.34.md b/docs/version-history/UPDATE-0.5.5.34.md new file mode 100644 index 0000000..295dd12 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.34.md @@ -0,0 +1,8 @@ +# AssetManager 0.5.5.34 + +## Fixes + +- Preserves the complete current query string when changing the interface language. +- Fixes language switching on the software comparison page where `asset_a` and `asset_b` were previously lost. +- Safely appends the language-change notification without corrupting existing query parameters. +- Applies the same query-preservation behavior to the header theme switch. diff --git a/docs/version-history/UPDATE-0.5.5.35.md b/docs/version-history/UPDATE-0.5.5.35.md new file mode 100644 index 0000000..258f22f --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.35.md @@ -0,0 +1,8 @@ +# AssetManager 0.5.5.35 + +## Changes + +- Improved software comparison normalization for product names where a year is followed by language or architecture information. +- Architecture markers such as `64-Bit`, `32-Bit`, `x64`, `x86`, `amd64`, and `arm64` are ignored in version-insensitive comparison modes even when they are not enclosed in parentheses. +- The software comparison table is now sorted by the normalized comparison name instead of comparison status. +- Stored inventory data and displayed original product names remain unchanged.