diff --git a/application/blueprints/datamanager/controllers/flagged_resources.py b/application/blueprints/datamanager/controllers/flagged_resources.py index 5671c2d..da3ec12 100644 --- a/application/blueprints/datamanager/controllers/flagged_resources.py +++ b/application/blueprints/datamanager/controllers/flagged_resources.py @@ -348,7 +348,12 @@ def _get_resource_organisation(resource, dataset_id): def _submit_assign_entities_request( - dataset_input, resource, organisation=None, return_endpoint=None + dataset_input, + resource, + organisation=None, + return_endpoint=None, + excluded_references=None, + selected_redirects=None, ): dataset_id, collection_id = _resolve_dataset_and_collection(dataset_input) organisation = organisation or _get_resource_organisation(resource, dataset_id) @@ -365,6 +370,10 @@ def _submit_assign_entities_request( params["organisation"] = organisation if return_endpoint: params["return_endpoint"] = return_endpoint + if excluded_references is not None: + params["excluded_references"] = excluded_references + if selected_redirects is not None: + params["selected_redirects"] = selected_redirects preview_id = submit_request(params) record_branch_baseline(preview_id, params["github_branch"]) return preview_id diff --git a/application/blueprints/datamanager/controllers/preview.py b/application/blueprints/datamanager/controllers/preview.py index 927b7d2..7d722bc 100644 --- a/application/blueprints/datamanager/controllers/preview.py +++ b/application/blueprints/datamanager/controllers/preview.py @@ -1,6 +1,5 @@ import json import logging -from datetime import date from flask import ( render_template, @@ -102,8 +101,17 @@ def _load_json_list(value: str | None) -> list: return loaded if isinstance(loaded, list) else [] -def build_old_entity_redirect_table(entity_redirects: list[dict]) -> dict | None: - if not entity_redirects: +def _count_excluded_references(params: dict) -> int: + references = params.get("excluded_references") or [] + if not isinstance(references, list): + return 0 + return len( + {str(reference).strip() for reference in references if str(reference).strip()} + ) + + +def build_old_entity_redirect_table(old_entity_rows: list[dict]) -> dict | None: + if not old_entity_rows: return None columns = [ @@ -115,22 +123,30 @@ def build_old_entity_redirect_table(entity_redirects: list[dict]) -> dict | None "entry-date", "start-date", ] - entry_date = date.today().isoformat() rows = [] - for redirect in entity_redirects: + for old_entity in old_entity_rows: + if not isinstance(old_entity, dict): + continue row = { - "old-entity": str(redirect.get("old_entity", "")), - "status": "301", - "entity": str(redirect.get("entity", "")), - "notes": str( - redirect.get("notes") - or "Redirect duplicate entity selected in Assign Entities" + "old-entity": str( + old_entity.get("old-entity", "") or old_entity.get("old_entity", "") + ), + "status": str(old_entity.get("status", "") or ""), + "entity": str(old_entity.get("entity", "") or ""), + "notes": str(old_entity.get("notes", "") or ""), + "end-date": str( + old_entity.get("end-date", "") or old_entity.get("end_date", "") + ), + "entry-date": str( + old_entity.get("entry-date", "") or old_entity.get("entry_date", "") + ), + "start-date": str( + old_entity.get("start-date", "") or old_entity.get("start_date", "") ), - "end-date": "", - "entry-date": entry_date, - "start-date": "", } rows.append({"columns": {c: {"value": row[c]} for c in columns}}) + if not rows: + return None return { "columns": columns, @@ -260,10 +276,13 @@ def handle_entities_preview(request_id, req): endpoints_to_retire = ( _load_json_list(request_meta.endpoints_to_retire) if request_meta else [] ) - entity_redirects = ( - _load_json_list(request_meta.entity_redirects) if request_meta else [] + old_entity_rows = pipeline_summary.get("old-entity") or [] + old_entity_redirect_table_params = build_old_entity_redirect_table(old_entity_rows) + old_entity_redirect_count = ( + len(old_entity_redirect_table_params["rows"]) + if old_entity_redirect_table_params + else 0 ) - old_entity_redirect_table_params = build_old_entity_redirect_table(entity_redirects) existing_endpoints = ( source_summary_data.get("existing_endpoint_for_organisation_dataset") or [] ) @@ -306,9 +325,10 @@ def handle_entities_preview(request_id, req): source_flow=source_flow, return_url=return_url, retire_summary=retire_summary, - entity_redirects=entity_redirects, old_entity_redirect_table_params=old_entity_redirect_table_params, - new_count=int(pipeline_summary.get("new-in-resource") or 0), + old_entity_redirect_count=old_entity_redirect_count, + new_count=len(new_entities), + excluded_count=_count_excluded_references(params), existing_count=int(pipeline_summary.get("existing-in-resource") or 0), endpoint_already_exists=endpoint_already_exists, endpoint_url=endpoint_url, @@ -337,9 +357,6 @@ def handle_add_data_confirm( endpoints_to_retire = ( _load_json_list(request_meta.endpoints_to_retire) if request_meta else [] ) - entity_redirects = ( - _load_json_list(request_meta.entity_redirects) if request_meta else [] - ) # Stale-assessment guard: if the config branch has advanced for this collection # since the assessment was taken, the assigned entity numbers may now collide. @@ -386,7 +403,6 @@ def handle_add_data_confirm( triggered_by=f"{session.get('user', {}).get('login', 'unknown')}", github_branch=github_branch, endpoints_to_retire=endpoints_to_retire, - entity_redirects=entity_redirects, ) except GitHubWorkflowError as e: logger.exception(f"GitHub async workflow error: {e}") diff --git a/application/blueprints/datamanager/controllers/transform.py b/application/blueprints/datamanager/controllers/transform.py index b6bec4a..f1322e4 100644 --- a/application/blueprints/datamanager/controllers/transform.py +++ b/application/blueprints/datamanager/controllers/transform.py @@ -220,6 +220,54 @@ def _build_entities_data(resp_details: list, platform_entities: list) -> dict: return {"columns": columns, "rows": rows} +def _normalise_excluded_references(excluded_references) -> set: + """Return explicitly excluded references from async request params. + + A missing or empty param means no references were excluded from assignment. + """ + if not excluded_references: + return set() + if not isinstance(excluded_references, list): + return set() + return { + str(reference).strip() + for reference in excluded_references + if str(reference).strip() + } + + +def _entity_selection_form_value(reference: str) -> str: + return reference + + +def _add_assign_entities_selection_metadata( + entities_data: dict, + excluded_references, +) -> dict: + """Add checkbox metadata to Assign Entities rows. + + Only rows categorised as new can be assigned entity numbers. Async request + params contain references excluded from assignment, so every selectable row + starts checked unless its reference is explicitly excluded. + """ + excluded_reference_set = _normalise_excluded_references(excluded_references) + + for row in entities_data.get("rows", []): + fields = row.get("fields") or {} + reference = str(fields.get("reference", "")).strip() + can_select = row.get("category") == "new" and bool(reference) + selected = can_select and reference not in excluded_reference_set + row["entity_selection"] = { + "can_select": can_select, + "selected": selected, + "form_value": ( + _entity_selection_form_value(reference) if reference else "" + ), + } + + return entities_data + + def _entity_row_matches_search(row: dict, search_query: str) -> bool: if not search_query: return True @@ -235,26 +283,44 @@ def _entity_row_matches_filter(row: dict, category_filter: str) -> bool: return row.get("category") == category_filter -def _name_similarity_percent(value) -> float: - if value in (None, ""): - return 0 - try: - similarity = float(str(value).strip().rstrip("%")) - except (TypeError, ValueError): - return 0 - return similarity * 100 if 0 < similarity <= 1 else similarity +def _dedup_candidate_redirect_key(candidate: dict) -> tuple[str, str]: + return ( + str(candidate.get("old_entity", "") or "").strip(), + str(candidate.get("entity", "") or "").strip(), + ) -def _dedup_candidate_auto_select(candidate: dict) -> bool: - if candidate.get("old_entity_redirects"): - return False +def _old_entity_redirect_key(row: dict) -> tuple[str, str]: + return ( + str(row.get("old-entity", "") or row.get("old_entity", "") or "").strip(), + str(row.get("entity", "") or "").strip(), + ) - match_type = str(candidate.get("match_type", "")).replace(" ", "_").lower() - if match_type == "complete_match": - return True - if match_type != "single_match": - return False - return _name_similarity_percent(candidate.get("name_similarity")) > 85 + +def _dedup_candidate_selected_entity_reference(candidate: dict) -> str: + return str( + candidate.get("new_reference", "") or candidate.get("reference", "") or "" + ).strip() + + +def _dedup_candidate_selected_redirect_key(candidate: dict) -> tuple[str, str]: + """Return the key used to compare a Dedup candidate with selected_redirects.""" + return ( + _dedup_candidate_selected_entity_reference(candidate), + str(candidate.get("old_entity", "") or "").strip(), + ) + + +def _selected_redirect_key(redirect: dict) -> tuple[str, str]: + """Return the async selected_redirects key for a submitted redirect param.""" + return ( + str(redirect.get("reference", "") or "").strip(), + str( + redirect.get("old_entity_number", "") + or redirect.get("old_entity", "") + or "" + ).strip(), + ) def _dedup_candidate_form_value(candidate: dict) -> str: @@ -276,11 +342,46 @@ def _dedup_candidate_form_value(candidate: dict) -> str: ) -def _prepare_duplicate_candidates(candidates: list[dict]) -> list[dict]: +def _prepare_duplicate_candidates( + candidates: list[dict], + old_entity_rows: list[dict] | None = None, + organisation: str = "", + excluded_references=None, + selected_redirects=None, +) -> list[dict]: + """Prepare Dedup candidates for rendering and selection. + + Async's ``pipeline-summary.old-entity`` is the source of initial redirect + preselection. Rows present in old-entity but absent from request + ``selected_redirects`` are inferred to be async auto-selected and are locked + in the UI so users cannot untick redirects generated by async policy. + """ + preselected_redirects = { + key + for key in ( + _old_entity_redirect_key(row) + for row in (old_entity_rows or []) + if isinstance(row, dict) + ) + if all(key) + } + selected_redirect_keys = { + _selected_redirect_key(redirect) + for redirect in (selected_redirects or []) + if isinstance(redirect, dict) and all(_selected_redirect_key(redirect)) + } + excluded_reference_set = _normalise_excluded_references(excluded_references) return [ { **candidate, - "auto_select": _dedup_candidate_auto_select(candidate), + "auto_select": _dedup_candidate_redirect_key(candidate) + in preselected_redirects, + "redirect_locked": _dedup_candidate_redirect_key(candidate) + in preselected_redirects + and _dedup_candidate_selected_redirect_key(candidate) + not in selected_redirect_keys, + "redirect_can_select": _dedup_candidate_selected_entity_reference(candidate) + not in excluded_reference_set, "form_value": _dedup_candidate_form_value(candidate), } for candidate in candidates @@ -337,9 +438,12 @@ def _paginate_entity_data( entity_page: int, entity_search: str, entity_filter: str = "", + include_selection: bool = False, + excluded_references=None, ) -> tuple: entity_start_offset = (entity_page - 1) * _ROWS_PER_PAGE entities_data_full = _build_entities_data(all_resp_details, platform_entities) + # Counts cover every entity, independent of the current search/filter, so the # summary boxes always show the full picture. category_counts = _count_categories(entities_data_full["rows"]) @@ -366,6 +470,11 @@ def _paginate_entity_data( "columns": entities_data_full["columns"], "rows": entity_page_rows, } + if include_selection: + entities_data = _add_assign_entities_selection_metadata( + entities_data, + excluded_references, + ) return ( entities_data, has_next_entity_page, @@ -618,6 +727,7 @@ def handle_check_transform( """ params = req.get("params") or {} organisation_code = params.get("organisationName") or params.get("organisation", "") + excluded_references = params.get("excluded_references") dataset_id = params.get("dataset", "") is_assign_entities = transform_endpoint == "assign_entities.flagged_resource_detail" resource_hash = params.get("resource", "") @@ -664,7 +774,11 @@ def handle_check_transform( pipeline_summary = response_data.get("pipeline-summary") or {} show_dedup_tab = is_assign_entities and dataset_id == "conservation-area" duplicate_candidates = _prepare_duplicate_candidates( - pipeline_summary.get("duplicate-candidates") or [] if show_dedup_tab else [] + pipeline_summary.get("duplicate-candidates") or [] if show_dedup_tab else [], + pipeline_summary.get("old-entity") or [], + organisation=organisation_code, + excluded_references=excluded_references, + selected_redirects=params.get("selected_redirects"), ) # Calculate pagination for transformed facts and issue logs, and for entities. @@ -693,6 +807,8 @@ def handle_check_transform( entity_page, entity_search, entity_filter, + include_selection=is_assign_entities, + excluded_references=excluded_references, ) transformed_table = _build_transform_table(resp_details) issue_log_table = _build_issue_log_table(resp_details) @@ -746,6 +862,7 @@ def handle_check_transform( endpoint_url=endpoint_url, documentation_url=documentation_url, resource_hash=resource_hash, + is_assign_entities=is_assign_entities, show_dedup_tab=show_dedup_tab, duplicate_candidates=duplicate_candidates, planning_entity_base_url=( diff --git a/application/blueprints/datamanager/router.py b/application/blueprints/datamanager/router.py index 1578524..69b83fe 100644 --- a/application/blueprints/datamanager/router.py +++ b/application/blueprints/datamanager/router.py @@ -25,6 +25,7 @@ ) from .controllers.flagged_resources import ( REQUIRED_COLUMNS, + _submit_assign_entities_request, handle_flagged_resource_detail, handle_flagged_resource_submit, handle_flagged_resources_import, @@ -311,24 +312,105 @@ def flagged_resource_detail(request_id): return render_template("datamanager/error.html", message=e.message) +def _normalise_reference_values(values): + """Return submitted reference values as a de-duplicated, ordered list.""" + references = [] + seen = set() + for value in values or []: + reference = str(value or "").strip() + if not reference or reference in seen: + continue + references.append(reference) + seen.add(reference) + return references + + +def _merge_visible_excluded_references( + current_excluded_references, + visible_references, + selected_visible_references, +): + """Merge current exclusions with checkbox state from the visible page.""" + current_excluded = set(_normalise_reference_values(current_excluded_references)) + visible = set(_normalise_reference_values(visible_references)) + selected_visible = set(_normalise_reference_values(selected_visible_references)) + excluded = (current_excluded - visible) | (visible - selected_visible) + return sorted(excluded) + + +def _selected_redirects_for_async(redirects, excluded_references=None): + """Map validated Dedup rows to async selected_redirects params. + + Config-manager's Dedup form values use old/new entity field names, while + async expects ``reference`` and ``old_entity_number``. Redirects for + explicitly excluded references are dropped because async can only redirect + entities being assigned. + """ + excluded_references = set(_normalise_reference_values(excluded_references)) + selected_redirects = [] + seen = set() + for redirect_row in redirects: + reference = str( + redirect_row.get("new_reference") or redirect_row.get("reference") or "" + ).strip() + old_entity_number = str( + redirect_row.get("old_entity") + or redirect_row.get("old_entity_number") + or "" + ).strip() + if reference in excluded_references: + continue + key = (reference, old_entity_number) + if not all(key) or key in seen: + continue + selected_redirects.append( + { + "reference": reference, + "old_entity_number": old_entity_number, + } + ) + seen.add(key) + return selected_redirects + + def flagged_resource_detail_post(request_id): req = fetch_request(request_id) + params = req.get("params") or {} response_data = (req.get("response") or {}).get("data") or {} pipeline_summary = response_data.get("pipeline-summary") or {} + organisation = params.get("organisation") or params.get("organisationName") or None duplicate_candidates = pipeline_summary.get("duplicate-candidates") or [] redirects = parse_selected_redirects( request.form.getlist("entity_redirects"), duplicate_candidates ) - meta = db.session.get(RequestMeta, request_id) - if meta is None: - meta = RequestMeta( - request_id=request_id, - entity_redirects=json.dumps(redirects), + if request.form.get("entity_selection_changed") == "true": + excluded_references = _merge_visible_excluded_references( + params.get("excluded_references") or [], + request.form.getlist("visible_entity_references"), + request.form.getlist("selected_entity_references"), ) - db.session.add(meta) - else: - meta.entity_redirects = json.dumps(redirects) - db.session.commit() + try: + new_request_id = _submit_assign_entities_request( + params.get("dataset", ""), + params.get("resource", ""), + organisation=organisation, + return_endpoint=params.get("return_endpoint") + or "assign_entities.flagged_resources_start", + excluded_references=excluded_references, + selected_redirects=_selected_redirects_for_async( + redirects, excluded_references + ), + ) + except AsyncAPIError as e: + raise ControllerError( + f"Assign entities submission failed: {e.detail}" + ) from e + return redirect( + url_for( + "assign_entities.flagged_resource_detail", request_id=new_request_id + ) + ) + return redirect(url_for("datamanager.entities_preview", request_id=request_id)) diff --git a/application/blueprints/datamanager/services/github.py b/application/blueprints/datamanager/services/github.py index c98bc9f..fff8eb1 100644 --- a/application/blueprints/datamanager/services/github.py +++ b/application/blueprints/datamanager/services/github.py @@ -2,7 +2,6 @@ GitHub App service for authenticating and triggering workflows. """ -import json import time import logging import requests @@ -279,7 +278,6 @@ def trigger_add_data_async_workflow( triggered_by: str = "config-manager", github_branch: str = None, endpoints_to_retire: list = None, - entity_redirects: list = None, ) -> dict: """ Trigger the 'add-data-async-script' workflow in the digital-land/config repository. @@ -299,11 +297,6 @@ def trigger_add_data_async_workflow( "retire_endpoints": ( ",".join(endpoints_to_retire or []) if endpoints_to_retire else "" ), - "entity_redirects": ( - json.dumps(entity_redirects, separators=(",", ":")) - if entity_redirects - else "" - ), "environment": current_app.config.get("ENVIRONMENT"), }, } diff --git a/application/db/models.py b/application/db/models.py index 2186cc7..c443058 100644 --- a/application/db/models.py +++ b/application/db/models.py @@ -448,7 +448,6 @@ class RequestMeta(db.Model): endpoints_to_retire = db.Column( db.Text, nullable=True ) # JSON list of endpoint hashes - entity_redirects = db.Column(db.Text, nullable=True) # JSON list of old-entity rows branch_sha = db.Column( db.Text, nullable=True ) # config-manager-update HEAD SHA when the assessment was submitted diff --git a/application/templates/components/check-transform-base.html b/application/templates/components/check-transform-base.html index 6014bba..d683c59 100644 --- a/application/templates/components/check-transform-base.html +++ b/application/templates/components/check-transform-base.html @@ -131,6 +131,18 @@
|
+
+
+
+
+
+
+ |
+ {% endif %}
{% for col in entities_data.columns %}
{{ col }} | {% endfor %} @@ -143,6 +155,22 @@||
|---|---|---|---|
|
+
+
+
+ {% if selection.can_select %}
+
+ {% endif %}
+
+
+
+ |
+ {% endif %}
{% for col in entities_data.columns %}
{% set _changed = row.changed_fields and col in row.changed_fields %}
@@ -155,6 +183,17 @@ {% block heading %}{% |
+ {% if selectable_entity_count %} + {{ selected_entity_count }} of {{ selectable_entity_count }} {{ 'entity' if selectable_entity_count == 1 else 'entities' }} selected for assignment + {% else %} + No entities available for assignment + {% endif %} +
+ {% endif %} {% elif entity_search or entity_filter %}No entities match your search or filter.
{% else %} @@ -330,6 +369,135 @@