add section Privacy Policy DSGVO
This commit is contained in:
+18
@@ -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."),
|
||||
})
|
||||
|
||||
+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")
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<button type="button" class="button button-secondary" id="bulk-edit-button" data-base-label="✏ {{ t('assets.bulk_edit', 'Daten ändern')|e }}" disabled>✏ {{ t('assets.bulk_edit', 'Daten ändern') }}</button>
|
||||
<button type="button" class="button button-danger" id="bulk-delete-button" data-base-label="🗑 {{ t('assets.delete_selected', 'Markierte löschen')|e }}" disabled>🗑 {{ t('assets.delete_selected', 'Markierte löschen') }}</button>
|
||||
<button type="button" class="button button-secondary" id="merge-duplicates-button" disabled>⧉ {{ t('duplicates.merge', 'Duplikate zusammenführen') }}</button>
|
||||
<button type="button" class="button button-secondary" id="software-compare-button" disabled>⇄ {{ t('software.compare.button', 'Software vergleichen') }}</button>
|
||||
<div class="bulk-job-controls" aria-label="{{ t('jobs.bulk.controls_label')|e }}">
|
||||
<label class="visually-hidden" for="bulk-job-type">{{ t('jobs.type') }}</label>
|
||||
<select id="bulk-job-type" class="bulk-job-type" disabled>
|
||||
@@ -65,6 +66,7 @@
|
||||
<form id="bulk-edit-form" class="hidden-utility-form" method="post" action="/assets/bulk-edit">{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}<span id="bulk-edit-ids"></span></form>
|
||||
<form id="bulk-delete-form" class="hidden-utility-form" method="post" action="/assets/bulk-delete" onsubmit="return confirm({{ t('assets.bulk_delete_confirm')|tojson }});">{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}<span id="bulk-delete-ids"></span></form>
|
||||
<form id="merge-duplicates-form" class="hidden-utility-form" method="post" action="/assets/merge"><span id="merge-duplicate-ids"></span></form>
|
||||
<form id="software-compare-form" class="hidden-utility-form" method="post" action="/assets/software-compare"><span id="software-compare-ids"></span></form>
|
||||
<form id="bulk-job-form" class="hidden-utility-form" method="post" action="/jobs/bulk-create">
|
||||
<input type="hidden" name="job_type" id="bulk-job-type-value">
|
||||
<input type="hidden" name="definition_id" id="bulk-job-definition-value">
|
||||
@@ -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;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{% if session_user %}
|
||||
<form method="post" action="/profile/theme" class="header-theme-form" id="header-theme-form">
|
||||
<input type="hidden" name="theme_mode" value="{{ 'light' if theme_mode == 'dark' else 'dark' }}">
|
||||
<input type="hidden" name="next_url" value="{{ request.url.path }}">
|
||||
<input type="hidden" name="next_url" value="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}">
|
||||
<button type="submit" class="header-theme-toggle" title="{{ t('appearance.switch_to_light') if theme_mode == 'dark' else t('appearance.switch_to_dark') }}" aria-label="{{ t('appearance.switch_to_light') if theme_mode == 'dark' else t('appearance.switch_to_dark') }}">
|
||||
<span class="theme-icon-light" aria-hidden="true">☀</span>
|
||||
<span class="theme-icon-dark" aria-hidden="true">☾</span>
|
||||
@@ -30,7 +30,7 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="header-help-button" id="about-dialog-button" title="{{ t('about.open') }}" aria-label="{{ t('about.open') }}">?</button>
|
||||
<details class="language-menu"><summary>🌐 {{ current_language|upper }} ▾</summary><div class="user-menu-panel">{% for language in available_languages() %}<form method="post" action="/profile/language"><input type="hidden" name="language_code" value="{{ language.code }}"><input type="hidden" name="next_url" value="{{ request.url.path }}"><button type="submit" class="menu-button{% if language.code == current_language %} active{% endif %}">{{ language.native_name }}</button></form>{% endfor %}</div></details>
|
||||
<details class="language-menu"><summary>🌐 {{ current_language|upper }} ▾</summary><div class="user-menu-panel">{% for language in available_languages() %}<form method="post" action="/profile/language"><input type="hidden" name="language_code" value="{{ language.code }}"><input type="hidden" name="next_url" value="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}"><button type="submit" class="menu-button{% if language.code == current_language %} active{% endif %}">{{ language.native_name }}</button></form>{% endfor %}</div></details>
|
||||
<details class="user-menu"><summary>👤 {{ session_user.display_name if session_user else t('app.guest') }} ▾</summary><div class="user-menu-panel">{% if session_user %}<a href="/profile">{{ t('app.profile') }}</a><a href="/logout">{{ t('app.logout') }}</a>{% else %}{% if auth.get('mode', 'none') == 'none' %}<span class="muted">Authentication disabled</span>{% endif %}<a href="/login">{{ t('app.login') }}</a>{% endif %}</div></details></div>
|
||||
</header>
|
||||
<dialog id="about-dialog" class="about-dialog">
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h1>{{ t('software.compare.title', 'Softwareinventuren vergleichen') }}</h1>
|
||||
<p class="muted">{{ t('software.compare.subtitle', 'Vergleich von {asset_a} und {asset_b}', asset_a=asset_a.name, asset_b=asset_b.name) }}</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<a class="button button-secondary" href="/assets/software-compare/export.xlsx?asset_a={{ asset_a.id }}&asset_b={{ asset_b.id }}&mode={{ mode }}">⇩ {{ t('software.compare.export', 'Als Excel herunterladen') }}</a>
|
||||
<a class="button button-secondary" href="/assets">{{ t('common.back', 'Zurück') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<form method="get" action="/assets/software-compare" class="form-grid compact-form">
|
||||
<input type="hidden" name="asset_a" value="{{ asset_a.id }}">
|
||||
<input type="hidden" name="asset_b" value="{{ asset_b.id }}">
|
||||
<label>{{ t('software.compare.mode', 'Vergleichsmodus') }}
|
||||
<select name="mode" onchange="this.form.submit()">
|
||||
<option value="exact" {% if mode == 'exact' %}selected{% endif %}>{{ t('software.compare.mode.exact', 'Exakter Softwarename') }}</option>
|
||||
<option value="ignore_version" {% if mode == 'ignore_version' %}selected{% endif %}>{{ t('software.compare.mode.ignore_version', 'Versionen ignorieren') }}</option>
|
||||
<option value="ignore_version_year" {% if mode == 'ignore_version_year' %}selected{% endif %}>{{ t('software.compare.mode.ignore_version_year', 'Versionen und Jahreszahlen ignorieren') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
<p class="muted">{{ t('software.compare.mode_help', 'Originalnamen und Versionen bleiben sichtbar. Die Normalisierung wird nur für die Zuordnung verwendet.') }}</p>
|
||||
<div class="summary-cards compact-summary">
|
||||
<span class="badge">{{ t('software.compare.only_a', 'Nur auf {asset}', asset=asset_a.name) }}: {{ counts.only_a }}</span>
|
||||
<span class="badge">{{ t('software.compare.only_b', 'Nur auf {asset}', asset=asset_b.name) }}: {{ counts.only_b }}</span>
|
||||
<span class="badge">{{ t('software.compare.both', 'Auf beiden vorhanden') }}: {{ counts.both }}</span>
|
||||
</div>
|
||||
<div class="table-scroll management-table-scroll">
|
||||
<table class="data-table" id="software-comparison-table" data-storage-key="software-comparison-table">
|
||||
<thead><tr>
|
||||
<th>{{ t('software.compare.comparison_name', 'Vergleichsname') }}</th>
|
||||
<th>{{ t('software.platform', 'Plattform') }}</th>
|
||||
<th>{{ t('software.compare.status', 'Vergleichsstatus') }}</th>
|
||||
<th>{{ asset_a.name }} · {{ t('common.name', 'Name') }}</th>
|
||||
<th>{{ asset_a.name }} · {{ t('about.version', 'Version') }}</th>
|
||||
<th>{{ asset_a.name }} · {{ t('software.publisher', 'Hersteller') }}</th>
|
||||
<th>{{ asset_b.name }} · {{ t('common.name', 'Name') }}</th>
|
||||
<th>{{ asset_b.name }} · {{ t('about.version', 'Version') }}</th>
|
||||
<th>{{ asset_b.name }} · {{ t('software.publisher', 'Hersteller') }}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td>{{ row.comparison_name }}</td>
|
||||
<td>{{ t('software.platform.' ~ row.platform, row.platform) }}</td>
|
||||
<td><span class="job-status job-status-{% if row.status == 'both' %}success{% else %}pending{% endif %}">{% 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 %}</span></td>
|
||||
<td>{{ row.a_names or '—' }}</td><td>{{ row.a_versions or '—' }}</td><td>{{ row.a_publishers or '—' }}</td>
|
||||
<td>{{ row.b_names or '—' }}</td><td>{{ row.b_versions or '—' }}</td><td>{{ row.b_publishers or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="9">{{ t('software.compare.empty', 'Für diese Assets sind keine Softwareinventuren vorhanden.') }}</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = "0.5.5.32"
|
||||
APP_VERSION = "0.5.5.35"
|
||||
__version__ = APP_VERSION
|
||||
|
||||
Reference in New Issue
Block a user