multi value filtering added and font color fix for duplicates

This commit is contained in:
2026-08-11 23:28:35 +02:00
parent 5594431749
commit e0175ab48a
340 changed files with 739 additions and 160 deletions
Regular → Executable
+344 -116
View File
@@ -1840,21 +1840,41 @@ def save_upload(upload: UploadFile | None) -> str | None:
return f"/static/uploads/{filename}"
def _standard_image_url(filename: str) -> str:
return "/static/uploads/library/" + urllib.parse.quote(filename)
def _standard_image_url(relative_path: str | Path) -> str:
"""Build a public URL for a file inside the persistent image library."""
relative = Path(relative_path)
encoded = "/".join(urllib.parse.quote(part, safe="") for part in relative.parts)
return "/static/uploads/library/" + encoded
def list_standard_images() -> list[dict[str, str]]:
"""Return reusable images stored in the persistent standard image library."""
"""Read reusable images directly from the persistent filesystem library.
The directory is scanned on every form request so files copied to the Docker
host become available after a browser reload. Subdirectories are supported
as well; symlinks are ignored so the library cannot escape its configured
root directory.
"""
images: list[dict[str, str]] = []
try:
candidates = sorted(STANDARD_IMAGE_DIR.iterdir(), key=lambda item: item.name.casefold())
candidates = sorted(
STANDARD_IMAGE_DIR.rglob("*"),
key=lambda item: item.relative_to(STANDARD_IMAGE_DIR).as_posix().casefold(),
)
except OSError:
return images
for path in candidates:
if not path.is_file() or path.suffix.lower() not in STANDARD_IMAGE_EXTENSIONS:
try:
if path.is_symlink() or not path.is_file() or path.suffix.lower() not in STANDARD_IMAGE_EXTENSIONS:
continue
relative = path.relative_to(STANDARD_IMAGE_DIR)
except (OSError, ValueError):
continue
images.append({"name": path.name, "path": _standard_image_url(path.name)})
images.append({
"name": relative.as_posix(),
"path": _standard_image_url(relative),
})
return images
@@ -2313,7 +2333,11 @@ def _seed_default_charts(db: Session) -> None:
@app.get("/")
def dashboard(request: Request, db: Session = Depends(get_db)):
categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
categories = _sort_localized(
db.query(Category).options(joinedload(Category.visible_fields)).all(),
"name",
request,
)
category_asset_counts = dict(
_apply_asset_access(
db.query(Asset.category_id, func.count(Asset.id)),
@@ -2639,13 +2663,161 @@ def _filter_assets_by_job_states(
return filtered_assets, views
def _software_compare_field_options(db: Session, request: Request) -> list[dict[str, str]]:
"""Return scalar, filterable asset fields that can identify a comparison asset."""
definitions = (
db.query(FieldDefinition)
.filter(
FieldDefinition.is_active.is_(True),
FieldDefinition.filterable.is_(True),
)
.order_by(FieldDefinition.sort_order, func.lower(FieldDefinition.label), FieldDefinition.id)
.all()
)
language_code = _request_language(request)
own = translation_dictionary(db, language_code)
english = translation_dictionary(db, "en") if language_code != "en" else own
options: list[dict[str, str]] = []
for definition in definitions:
if definition.data_type in {"long_text", "json"}:
continue
if definition.field_name == "parent_asset_id":
continue
key = definition.translation_key or f"field.{definition.field_name}"
label = own.get(key) or english.get(key) or definition.label or definition.field_name
options.append({"field_name": definition.field_name, "label": label})
options.sort(
key=lambda item: (
0 if item["field_name"] == "name" else 1,
_localized_text_key(item["label"], language_code),
item["field_name"],
)
)
return options
def _software_compare_custom_value(row: AssetFieldValue, definition: FieldDefinition) -> Any:
if definition.data_type == "boolean":
return row.value_boolean
if definition.data_type == "datetime":
return row.value_datetime
if definition.data_type == "date":
return row.value_date
if definition.data_type in {"integer", "decimal"}:
return row.value_number
if definition.data_type == "json":
return row.value_json
return row.value_text
def _software_compare_display_value(request: Request, value: Any, definition: FieldDefinition) -> str:
if value is None:
return ""
if definition.data_type == "boolean":
return _translate_request(request, "common.yes", "Yes") if bool(value) else _translate_request(request, "common.no", "No")
if isinstance(value, datetime):
return value.isoformat(timespec="seconds")
if isinstance(value, date):
return value.isoformat()
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, sort_keys=True)
return str(value).strip()
@app.get("/api/assets/software-compare/options")
def assets_software_compare_options(
request: Request,
field_name: str = "name",
exclude_asset_id: int | None = None,
db: Session = Depends(get_db),
):
"""Return accessible second-asset choices for the requested selection field."""
_require_admin(request)
definition = (
db.query(FieldDefinition)
.filter(
FieldDefinition.field_name == field_name,
FieldDefinition.is_active.is_(True),
FieldDefinition.filterable.is_(True),
)
.first()
)
if not definition or definition.data_type in {"long_text", "json"} or definition.field_name == "parent_asset_id":
raise HTTPException(400, "Unsupported comparison selection field")
query = _apply_asset_access(db.query(Asset), request)
if exclude_asset_id is not None:
query = query.filter(Asset.id != exclude_asset_id)
assets = query.order_by(func.lower(Asset.name), Asset.id).all()
if not assets:
return {"field_name": field_name, "options": []}
values_by_asset: dict[int, Any] = {}
if definition.is_system and hasattr(Asset, definition.field_name):
for asset in assets:
values_by_asset[asset.id] = getattr(asset, definition.field_name, None)
else:
rows = (
db.query(AssetFieldValue)
.filter(
AssetFieldValue.field_definition_id == definition.id,
AssetFieldValue.asset_id.in_([asset.id for asset in assets]),
)
.all()
)
for row in rows:
values_by_asset[row.asset_id] = _software_compare_custom_value(row, definition)
candidates: list[dict[str, Any]] = []
counts: dict[str, int] = {}
for asset in assets:
display_value = _software_compare_display_value(
request,
values_by_asset.get(asset.id),
definition,
)
if not display_value:
continue
normalized = _normalize_filter_text(display_value)
counts[normalized] = counts.get(normalized, 0) + 1
candidates.append({
"asset_id": asset.id,
"asset_name": asset.name,
"value": display_value,
"normalized": normalized,
})
language_code = _request_language(request)
candidates.sort(
key=lambda item: (
_localized_text_key(item["value"], language_code),
_localized_text_key(item["asset_name"], language_code),
item["asset_id"],
)
)
options = []
for item in candidates:
label = item["value"]
if counts.get(item["normalized"], 0) > 1:
label = f"{label} — {item['asset_name']}"
options.append({
"asset_id": item["asset_id"],
"value": item["value"],
"label": label,
"asset_name": item["asset_name"],
})
return {"field_name": field_name, "options": options}
@app.get("/assets")
def assets_list(request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
categories = (
categories = _sort_localized(
db.query(Category)
.options(selectinload(Category.visible_fields).joinedload(CategoryField.definition))
.order_by(Category.name)
.all()
.all(),
"name",
request,
)
requested_exclusions = request.query_params.getlist("job_filter_exclude")
@@ -2715,6 +2887,7 @@ def assets_list(request: Request, category_id: int | None = None, db: Session =
"job_filter_statuses": ASSET_JOB_FILTER_STATUSES,
"job_state_by_asset": job_state_by_asset,
"asset_platforms": {asset.id: detect_platform(asset) for asset in assets},
"compare_field_options": _software_compare_field_options(db, request) if _is_admin(request) else [],
},
)
@@ -2949,7 +3122,11 @@ def chart_delete(chart_id: int, request: Request, db: Session = Depends(get_db))
@app.get("/assets/new")
def asset_new(request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
_require_admin(request)
categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
categories = _sort_localized(
db.query(Category).options(joinedload(Category.visible_fields)).all(),
"name",
request,
)
selected = next((c for c in categories if c.id == category_id), categories[0] if categories else None)
return templates.TemplateResponse("asset_form.html", {"request": request, "asset": None, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).order_by(Asset.name).all(), "custom_values": {}, "standard_images": list_standard_images()})
@@ -2960,7 +3137,11 @@ def asset_duplicate(asset_id: int, request: Request, db: Session = Depends(get_d
original = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first()
if not original:
raise HTTPException(404, "Asset nicht gefunden")
categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
categories = _sort_localized(
db.query(Category).options(joinedload(Category.visible_fields)).all(),
"name",
request,
)
class DuplicateValues:
pass
duplicate = DuplicateValues()
@@ -3431,7 +3612,7 @@ def asset_tree(
or bool(by_parent.get(asset.id))
]
categories = db.query(Category).order_by(Category.name).all()
categories = _sort_localized(db.query(Category).all(), "name", request)
actual_roots = [
asset for asset in all_assets
if asset.parent_asset_id is None or asset.parent_asset_id not in all_by_id
@@ -3821,7 +4002,11 @@ def asset_mesh_inventory_json(asset_id: int, request: Request, db: Session = Dep
def asset_edit(asset_id: int, request: Request, category_id: int | None = None, db: Session = Depends(get_db)):
_require_admin(request)
asset = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first()
categories = db.query(Category).options(joinedload(Category.visible_fields)).all()
categories = _sort_localized(
db.query(Category).options(joinedload(Category.visible_fields)).all(),
"name",
request,
)
if not asset:
raise HTTPException(404, "Asset nicht gefunden")
selected = next((category for category in categories if category.id == category_id), None) if category_id else asset.category
@@ -4429,11 +4614,12 @@ def status_option_delete(option_id: int, request: Request, db: Session = Depends
@app.get("/categories")
def categories_list(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
categories = (
categories = _sort_localized(
db.query(Category)
.options(selectinload(Category.visible_fields))
.order_by(Category.name)
.all()
.all(),
"name",
request,
)
asset_counts = dict(
db.query(Asset.category_id, func.count(Asset.id))
@@ -5115,7 +5301,7 @@ _SOFTWARE_JOB_FILTER_KEYS = (
def _software_job_filter_values(request: Request) -> dict[str, str]:
"""Read and normalize the server-side filters of the recent-job table."""
return {
key: str(request.query_params.get(f"job_filter_{key}") or "").strip()[:200]
key: str(request.query_params.get(f"job_filter_{key}") or "").strip()[:12000]
for key in _SOFTWARE_JOB_FILTER_KEYS
}
@@ -5240,115 +5426,157 @@ def _software_job_date_filter(column: Any, search_value: str):
return or_(*conditions)
def _multi_filter_terms(value: Any, *, max_terms: int = 200) -> list[str]:
"""Split a table filter into unique non-empty line-based OR terms."""
terms: list[str] = []
seen: set[str] = set()
for raw in str(value or "").replace("\r\n", "\n").replace("\r", "\n").split("\n"):
term = raw.strip()
if not term:
continue
key = term.casefold()
if key in seen:
continue
seen.add(key)
terms.append(term[:300])
if len(terms) >= max_terms:
break
return terms
def _apply_software_job_filters(query: Any, request: Request, filters: dict[str, str]):
"""Apply all recent-job filters before LIMIT is evaluated.
This keeps an old job searchable even when the visible result size is only
25 rows. The large script, result and log columns are still excluded from
the final SELECT by ``load_only`` below.
Multiple lines within one column are OR-linked. Different columns remain
AND-linked. This allows users to paste a column copied from Excel directly
into a table filter without changing the normal single-value behavior.
"""
parameters_text = cast(SoftwareJob.parameters, String)
if filters["job_id"]:
value = filters["job_id"]
if value.isdigit():
query = query.filter(SoftwareJob.id == int(value))
else:
query = query.filter(_sql_text_contains(SoftwareJob.id, value))
if filters["created_at"]:
query = query.filter(
_software_job_date_filter(SoftwareJob.created_at, filters["created_at"])
)
if filters["asset"]:
query = query.filter(_sql_text_contains(Asset.name, filters["asset"]))
if filters["job_type"]:
value = filters["job_type"]
conditions = [
_sql_text_contains(SoftwareJob.job_type, value),
_sql_text_contains(parameters_text, value),
]
job_type_codes = _translated_filter_codes(
request,
"jobs.type",
("software_inventory", "job_definition"),
value,
)
if job_type_codes:
conditions.append(SoftwareJob.job_type.in_(job_type_codes))
job_kind_codes = _translated_filter_codes(
request,
"jobdefs.kind",
("script", "registry"),
value,
)
for kind in job_kind_codes:
conditions.append(
and_(
SoftwareJob.job_type == "job_definition",
_sql_text_contains(parameters_text, kind),
)
)
terms = _multi_filter_terms(filters["job_id"])
if terms:
conditions = []
for value in terms:
if value.isdigit():
conditions.append(SoftwareJob.id == int(value))
else:
conditions.append(_sql_text_contains(SoftwareJob.id, value))
query = query.filter(or_(*conditions))
if filters["package"]:
value = filters["package"]
query = query.filter(
terms = _multi_filter_terms(filters["created_at"])
if terms:
query = query.filter(or_(*[
_software_job_date_filter(SoftwareJob.created_at, value)
for value in terms
]))
terms = _multi_filter_terms(filters["asset"])
if terms:
query = query.filter(or_(*[
_sql_text_contains(Asset.name, value)
for value in terms
]))
terms = _multi_filter_terms(filters["job_type"])
if terms:
term_conditions = []
for value in terms:
conditions = [
_sql_text_contains(SoftwareJob.job_type, value),
_sql_text_contains(parameters_text, value),
]
job_type_codes = _translated_filter_codes(
request,
"jobs.type",
("software_inventory", "job_definition"),
value,
)
if job_type_codes:
conditions.append(SoftwareJob.job_type.in_(job_type_codes))
job_kind_codes = _translated_filter_codes(
request,
"jobdefs.kind",
("script", "registry"),
value,
)
for kind in job_kind_codes:
conditions.append(
and_(
SoftwareJob.job_type == "job_definition",
_sql_text_contains(parameters_text, kind),
)
)
term_conditions.append(or_(*conditions))
query = query.filter(or_(*term_conditions))
terms = _multi_filter_terms(filters["package"])
if terms:
query = query.filter(or_(*[
or_(
_sql_text_contains(SoftwarePackage.name, value),
_sql_text_contains(parameters_text, value),
)
)
for value in terms
]))
if filters["action"]:
value = filters["action"]
conditions = [_sql_text_contains(SoftwareJob.action, value)]
action_codes = _translated_filter_codes(
request,
"jobs.action",
("run", "install", "uninstall", "update"),
value,
)
if action_codes:
conditions.append(SoftwareJob.action.in_(action_codes))
terms = _multi_filter_terms(filters["action"])
if terms:
term_conditions = []
for value in terms:
conditions = [_sql_text_contains(SoftwareJob.action, value)]
action_codes = _translated_filter_codes(
request,
"jobs.action",
("run", "install", "uninstall", "update"),
value,
)
if action_codes:
conditions.append(SoftwareJob.action.in_(action_codes))
term_conditions.append(or_(*conditions))
query = query.filter(or_(*term_conditions))
terms = _multi_filter_terms(filters["priority"])
if terms:
conditions = []
for value in terms:
if re.fullmatch(r"[-+]?\d+", value):
conditions.append(SoftwareJob.priority == int(value))
else:
conditions.append(_sql_text_contains(SoftwareJob.priority, value))
query = query.filter(or_(*conditions))
if filters["priority"]:
value = filters["priority"]
if re.fullmatch(r"[-+]?\d+", value):
query = query.filter(SoftwareJob.priority == int(value))
else:
query = query.filter(_sql_text_contains(SoftwareJob.priority, value))
terms = _multi_filter_terms(filters["platform"])
if terms:
query = query.filter(or_(*[
_sql_text_contains(SoftwareJob.platform, value)
for value in terms
]))
if filters["platform"]:
query = query.filter(
_sql_text_contains(SoftwareJob.platform, filters["platform"])
)
if filters["status"]:
value = filters["status"]
conditions = [_sql_text_contains(SoftwareJob.status, value)]
status_codes = _translated_filter_codes(
request,
"software.status",
(
"created",
"sending",
"sent",
"running",
"success",
"partial",
"failed",
"timeout",
"cancelled",
),
value,
)
if status_codes:
conditions.append(SoftwareJob.status.in_(status_codes))
query = query.filter(or_(*conditions))
terms = _multi_filter_terms(filters["status"])
if terms:
term_conditions = []
for value in terms:
conditions = [_sql_text_contains(SoftwareJob.status, value)]
status_codes = _translated_filter_codes(
request,
"software.status",
(
"created",
"sending",
"sent",
"running",
"success",
"partial",
"failed",
"timeout",
"cancelled",
),
value,
)
if status_codes:
conditions.append(SoftwareJob.status.in_(status_codes))
term_conditions.append(or_(*conditions))
query = query.filter(or_(*term_conditions))
return query
@@ -7269,7 +7497,7 @@ async def translations_import(request: Request, file: UploadFile = File(...), ig
@app.get("/settings/appearance")
def settings_appearance_page(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
categories = db.query(Category).order_by(Category.name.asc()).all()
categories = _sort_localized(db.query(Category).all(), "name", request)
return templates.TemplateResponse(
"settings.html",
{
@@ -7286,7 +7514,7 @@ def settings_appearance_page(request: Request, db: Session = Depends(get_db)):
@app.get("/settings/authentication")
def settings_authentication_page(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
categories = db.query(Category).order_by(Category.name.asc()).all()
categories = _sort_localized(db.query(Category).all(), "name", request)
return templates.TemplateResponse(
"settings.html",
{
@@ -7303,7 +7531,7 @@ def settings_authentication_page(request: Request, db: Session = Depends(get_db)
@app.get("/settings/system")
def settings_system_page(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
categories = db.query(Category).order_by(Category.name.asc()).all()
categories = _sort_localized(db.query(Category).all(), "name", request)
return templates.TemplateResponse(
"settings.html",
{
@@ -8050,7 +8278,7 @@ def settings_backup_restore(request: Request, backup_name: str, confirmation: st
@app.get("/settings")
def settings_page(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
categories = db.query(Category).order_by(Category.name.asc()).all()
categories = _sort_localized(db.query(Category).all(), "name", request)
return templates.TemplateResponse(
"settings.html",
{
@@ -8217,7 +8445,7 @@ def meshcentral_sync_page(request: Request, db: Session = Depends(get_db)):
missing = db.query(Asset).filter(Asset.mesh_sync_status == "missing").count()
definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all()
mappings = db.query(MeshFieldMapping).order_by(MeshFieldMapping.priority, MeshFieldMapping.id).all()
categories = db.query(Category).order_by(Category.name.asc()).all()
categories = _sort_localized(db.query(Category).all(), "name", request)
return templates.TemplateResponse("meshcentral_sync.html", {
"request": request, "runs": runs, "config": public_config(),
"linked": linked, "conflicts": conflicts, "missing": missing,