From 1f51144a7dad462396e64ccb5a5988bc4d46a4eb Mon Sep 17 00:00:00 2001 From: Frederic Junod Date: Wed, 21 Oct 2026 07:49:01 +0200 Subject: [PATCH 1/2] feat: add organization column to the dashboard Expose the organization long name as organization_display_name on the integrity link list. It is resolved from the console with a new ConsoleService.fetch_organization_long_names, which mirrors fetch_users_by_usernames: no call on empty input, and an empty result on console errors so the UI falls back to the short name. Show it in a new dashboard column before the owner. Organization and owner cells now have a fixed width, an ellipsis on overflow and the full name as a tooltip. --- .../api/routes/ingestion/integrity_links.py | 5 +++ apps/backend/src/models/data_import.py | 1 + apps/backend/src/services/console_service.py | 26 +++++++++++++ .../tests/api/routes/test_integrity_links.py | 37 +++++++++++++++++++ .../api/models/integrity-link-list-item.ts | 1 + .../integrity-link-list.component.html | 16 +++++++- .../integrity-link-list.component.spec.ts | 37 +++++++++++++++++++ apps/frontend/translations/de.json | 1 + apps/frontend/translations/en.json | 1 + apps/frontend/translations/es.json | 1 + apps/frontend/translations/fr.json | 1 + apps/frontend/translations/it.json | 1 + apps/frontend/translations/nl.json | 1 + apps/frontend/translations/pt.json | 1 + apps/frontend/translations/sk.json | 1 + 15 files changed, 130 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/api/routes/ingestion/integrity_links.py b/apps/backend/src/api/routes/ingestion/integrity_links.py index e0f27c8d..22ce9248 100644 --- a/apps/backend/src/api/routes/ingestion/integrity_links.py +++ b/apps/backend/src/api/routes/ingestion/integrity_links.py @@ -271,8 +271,13 @@ def _final_exists(lnk: IntegrityLink) -> bool: display_names = ConsoleService(get_settings().CONSOLE_INTERNAL_URL).fetch_users_by_usernames( usernames ) + organizations = list({item.integrity_organization for item in items}) + organization_names = ConsoleService( + get_settings().CONSOLE_INTERNAL_URL + ).fetch_organization_long_names(organizations) for item in items: item.owner_display_name = display_names.get(item.integrity_owner) + item.organization_display_name = organization_names.get(item.integrity_organization) return IntegrityLinkListResponse( items=items, diff --git a/apps/backend/src/models/data_import.py b/apps/backend/src/models/data_import.py index c7bbc082..9784ab18 100644 --- a/apps/backend/src/models/data_import.py +++ b/apps/backend/src/models/data_import.py @@ -184,6 +184,7 @@ class IntegrityLinkListItem(BaseModel): public_access: PublicAccess = PublicAccess.UNCONFIGURED has_final_table: bool = False owner_display_name: str | None = None + organization_display_name: str | None = None @field_validator("id", mode="before") @classmethod diff --git a/apps/backend/src/services/console_service.py b/apps/backend/src/services/console_service.py index c2b1b081..324e9c20 100644 --- a/apps/backend/src/services/console_service.py +++ b/apps/backend/src/services/console_service.py @@ -182,3 +182,29 @@ def get_all_organizations(self) -> list[dict[str, Any]]: except ValueError as exc: logger.error("Console returned invalid JSON from %s", url) raise ValueError(f"Console returned invalid JSON from {url}") from exc + + def fetch_organization_long_names(self, short_names: list[str]) -> dict[str, str]: + """Resolve organization short names to long names via the console. + + Args: + short_names: List of organization short names to resolve. + + Returns: + Dict mapping short name -> long name. Short names with no match are omitted. + Returns empty dict on any error (graceful degradation). + """ + if not short_names: + return {} + try: + organizations = self.get_all_organizations() + except Exception as e: + logger.warning( + f"Failed to fetch organization names from console API: {e}", exc_info=True + ) + return {} + + return { + org["shortName"]: org["name"] + for org in organizations + if org.get("shortName") in short_names and org.get("name") + } diff --git a/apps/backend/tests/api/routes/test_integrity_links.py b/apps/backend/tests/api/routes/test_integrity_links.py index 489aac84..e9487816 100644 --- a/apps/backend/tests/api/routes/test_integrity_links.py +++ b/apps/backend/tests/api/routes/test_integrity_links.py @@ -31,6 +31,7 @@ def mock_console_service(): """Patch ConsoleService so tests don't hit the network; returns empty display names by default.""" with patch(_CONSOLE_PATCH) as mock_cls: mock_cls.return_value.fetch_users_by_usernames.return_value = {} + mock_cls.return_value.fetch_organization_long_names.return_value = {} yield mock_cls @@ -772,6 +773,42 @@ def test_owner_display_name_none_when_console_fails( assert response.items[0].owner_display_name is None + @patch("src.api.routes.ingestion.integrity_links.logger") + def test_organization_display_name_populated_from_console( + self, + mock_logger: MagicMock, + mock_session: MagicMock, + mock_data_session: MagicMock, + mock_console_service: MagicMock, + ) -> None: + """organization_display_name is the console long name of the owning organization.""" + link = IntegrityLink( + id=uuid4(), + integrity_title="Org Link", + integrity_owner="jdoe", + integrity_organization="testorg", + source_import_type=ImportType.URL, + staging_table_name="staging_org", + created_at=datetime.now(timezone.utc), + schedule_enabled=False, + ) + self._setup_session(mock_session, [link]) + self._setup_data_session(mock_data_session, ["staging_org"], []) + mock_console_service.return_value.fetch_organization_long_names.return_value = { + "testorg": "Test Organization" + } + + geo_ctx = self._create_geo_ctx("jdoe", {"IMPORT"}) + response = list_integrity_links( + session=mock_session, + data_session=mock_data_session, + geo_ctx=geo_ctx, + group_ids=[], + offset=0, + ) + + assert response.items[0].organization_display_name == "Test Organization" + @patch("src.api.routes.ingestion.integrity_links.logger") def test_next_offset_present_no_more( self, mock_logger: MagicMock, mock_session: MagicMock, mock_data_session: MagicMock diff --git a/apps/frontend/src/app/core/api/models/integrity-link-list-item.ts b/apps/frontend/src/app/core/api/models/integrity-link-list-item.ts index 264b7a4e..72fadc8d 100644 --- a/apps/frontend/src/app/core/api/models/integrity-link-list-item.ts +++ b/apps/frontend/src/app/core/api/models/integrity-link-list-item.ts @@ -26,6 +26,7 @@ export interface IntegrityLinkListItem { integrity_title: string | null last_retrieval_timestamp: string | null metadata_id: string | null + organization_display_name?: string | null owner_display_name?: string | null preset_id?: RecurrencePreset | null public_access?: PublicAccess diff --git a/apps/frontend/src/app/features/integrity-link-list/integrity-link-list.component.html b/apps/frontend/src/app/features/integrity-link-list/integrity-link-list.component.html index ad9ddc13..182c7871 100644 --- a/apps/frontend/src/app/features/integrity-link-list/integrity-link-list.component.html +++ b/apps/frontend/src/app/features/integrity-link-list/integrity-link-list.component.html @@ -81,6 +81,9 @@

{{ 'integrityLinks.title' | translate }}
+
+ {{ 'integrityLinks.organization' | translate }} +
{{ 'integrityLinks.owner' | translate }}
@@ -126,7 +129,18 @@

} -
+
+ {{ link.organization_display_name ?? link.integrity_organization }} +
+
{{ link.owner_display_name ?? link.integrity_owner }}
diff --git a/apps/frontend/src/app/features/integrity-link-list/integrity-link-list.component.spec.ts b/apps/frontend/src/app/features/integrity-link-list/integrity-link-list.component.spec.ts index 52eddb68..5eef468e 100644 --- a/apps/frontend/src/app/features/integrity-link-list/integrity-link-list.component.spec.ts +++ b/apps/frontend/src/app/features/integrity-link-list/integrity-link-list.component.spec.ts @@ -203,6 +203,43 @@ describe('IntegrityLinkListComponent', () => { }) }) + describe('Organization column', () => { + it('should display the organization long name with a title tooltip, falling back to the short name', async () => { + const fixture = TestBed.createComponent(IntegrityLinkListComponent) + + const req = httpMock.expectOne( + 'http://localhost:8000/ingestion/integrity-links/?offset=0' + ) + req.flush({ + items: [ + { + ...createMockItem('1'), + organization_display_name: 'Organisation Longue' + }, + createMockItem('2') + ], + has_more: false, + offset: 0 + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + fixture.detectChanges() + + const cells = Array.from( + fixture.nativeElement.querySelectorAll('.group > div:nth-child(2)') + ) + + expect(cells.map((el) => el.textContent?.trim())).toEqual([ + 'Organisation Longue', + 'org' + ]) + expect(cells.map((el) => el.title)).toEqual([ + 'Organisation Longue', + 'org' + ]) + }) + }) + describe('Load More Pagination', () => { it('should append items when loadMore() is called', async () => { const fixture = TestBed.createComponent(IntegrityLinkListComponent) diff --git a/apps/frontend/translations/de.json b/apps/frontend/translations/de.json index 5e21a479..b5978a72 100644 --- a/apps/frontend/translations/de.json +++ b/apps/frontend/translations/de.json @@ -294,6 +294,7 @@ "integrityLinks.loadMore": "", "integrityLinks.noFinalTable": "Aufnahme zu konfigurieren", "integrityLinks.noResults": "", + "integrityLinks.organization": "", "integrityLinks.owner": "", "integrityLinks.readOnly": "", "integrityLinks.recurrence": "", diff --git a/apps/frontend/translations/en.json b/apps/frontend/translations/en.json index 2f80ede5..60c2ce45 100644 --- a/apps/frontend/translations/en.json +++ b/apps/frontend/translations/en.json @@ -306,6 +306,7 @@ "integrityLinks.loadMore": "Load more", "integrityLinks.noFinalTable": "Ingestion to configure", "integrityLinks.noResults": "No results found", + "integrityLinks.organization": "Organization", "integrityLinks.owner": "Author", "integrityLinks.readOnly": "Read only", "integrityLinks.recurrence": "Recurrence", diff --git a/apps/frontend/translations/es.json b/apps/frontend/translations/es.json index d5df8cda..252f030b 100644 --- a/apps/frontend/translations/es.json +++ b/apps/frontend/translations/es.json @@ -294,6 +294,7 @@ "integrityLinks.loadMore": "", "integrityLinks.noFinalTable": "Ingesta por configurar", "integrityLinks.noResults": "", + "integrityLinks.organization": "", "integrityLinks.owner": "", "integrityLinks.readOnly": "", "integrityLinks.recurrence": "", diff --git a/apps/frontend/translations/fr.json b/apps/frontend/translations/fr.json index 173f9cb3..f8a7daf4 100644 --- a/apps/frontend/translations/fr.json +++ b/apps/frontend/translations/fr.json @@ -307,6 +307,7 @@ "integrityLinks.loadMore": "Charger plus", "integrityLinks.noFinalTable": "Brouillon", "integrityLinks.noResults": "Aucun résultat trouvé", + "integrityLinks.organization": "Organisation", "integrityLinks.owner": "Propriétaire", "integrityLinks.readOnly": "Lecture seule", "integrityLinks.recurrence": "Récurrence", diff --git a/apps/frontend/translations/it.json b/apps/frontend/translations/it.json index dddd779e..8a388e78 100644 --- a/apps/frontend/translations/it.json +++ b/apps/frontend/translations/it.json @@ -294,6 +294,7 @@ "integrityLinks.loadMore": "", "integrityLinks.noFinalTable": "Ingestion da configurare", "integrityLinks.noResults": "", + "integrityLinks.organization": "", "integrityLinks.owner": "", "integrityLinks.readOnly": "", "integrityLinks.recurrence": "", diff --git a/apps/frontend/translations/nl.json b/apps/frontend/translations/nl.json index 69ef3083..cee6982a 100644 --- a/apps/frontend/translations/nl.json +++ b/apps/frontend/translations/nl.json @@ -294,6 +294,7 @@ "integrityLinks.loadMore": "", "integrityLinks.noFinalTable": "Ingestie te configureren", "integrityLinks.noResults": "", + "integrityLinks.organization": "", "integrityLinks.owner": "", "integrityLinks.readOnly": "", "integrityLinks.recurrence": "", diff --git a/apps/frontend/translations/pt.json b/apps/frontend/translations/pt.json index d1e4af70..aef2bf23 100644 --- a/apps/frontend/translations/pt.json +++ b/apps/frontend/translations/pt.json @@ -294,6 +294,7 @@ "integrityLinks.loadMore": "", "integrityLinks.noFinalTable": "Ingestão a configurar", "integrityLinks.noResults": "", + "integrityLinks.organization": "", "integrityLinks.owner": "", "integrityLinks.readOnly": "", "integrityLinks.recurrence": "", diff --git a/apps/frontend/translations/sk.json b/apps/frontend/translations/sk.json index 032a1fa2..88b49ce2 100644 --- a/apps/frontend/translations/sk.json +++ b/apps/frontend/translations/sk.json @@ -294,6 +294,7 @@ "integrityLinks.loadMore": "", "integrityLinks.noFinalTable": "Príjem na konfiguráciu", "integrityLinks.noResults": "", + "integrityLinks.organization": "", "integrityLinks.owner": "", "integrityLinks.readOnly": "", "integrityLinks.recurrence": "", From 1db0e04fff584115f0875ab73bc65224011ff9de Mon Sep 17 00:00:00 2001 From: fnecas Date: Thu, 22 Oct 2026 11:40:37 +0200 Subject: [PATCH 2/2] feat: adapt code to reuse more efficient endpoint for organizations as PR #189 has been merged --- .../api/routes/ingestion/integrity_links.py | 9 +++---- apps/backend/src/services/console_service.py | 26 ------------------- .../tests/api/routes/test_integrity_links.py | 4 +-- 3 files changed, 5 insertions(+), 34 deletions(-) diff --git a/apps/backend/src/api/routes/ingestion/integrity_links.py b/apps/backend/src/api/routes/ingestion/integrity_links.py index 22ce9248..d2baa38e 100644 --- a/apps/backend/src/api/routes/ingestion/integrity_links.py +++ b/apps/backend/src/api/routes/ingestion/integrity_links.py @@ -268,13 +268,10 @@ def _final_exists(lnk: IntegrityLink) -> bool: items.append(item) usernames = list({item.integrity_owner for item in items}) - display_names = ConsoleService(get_settings().CONSOLE_INTERNAL_URL).fetch_users_by_usernames( - usernames - ) organizations = list({item.integrity_organization for item in items}) - organization_names = ConsoleService( - get_settings().CONSOLE_INTERNAL_URL - ).fetch_organization_long_names(organizations) + console = ConsoleService(get_settings().CONSOLE_INTERNAL_URL) + display_names = console.fetch_users_by_usernames(usernames) + organization_names = console.fetch_organizations_by_short_names(organizations) for item in items: item.owner_display_name = display_names.get(item.integrity_owner) item.organization_display_name = organization_names.get(item.integrity_organization) diff --git a/apps/backend/src/services/console_service.py b/apps/backend/src/services/console_service.py index 324e9c20..c2b1b081 100644 --- a/apps/backend/src/services/console_service.py +++ b/apps/backend/src/services/console_service.py @@ -182,29 +182,3 @@ def get_all_organizations(self) -> list[dict[str, Any]]: except ValueError as exc: logger.error("Console returned invalid JSON from %s", url) raise ValueError(f"Console returned invalid JSON from {url}") from exc - - def fetch_organization_long_names(self, short_names: list[str]) -> dict[str, str]: - """Resolve organization short names to long names via the console. - - Args: - short_names: List of organization short names to resolve. - - Returns: - Dict mapping short name -> long name. Short names with no match are omitted. - Returns empty dict on any error (graceful degradation). - """ - if not short_names: - return {} - try: - organizations = self.get_all_organizations() - except Exception as e: - logger.warning( - f"Failed to fetch organization names from console API: {e}", exc_info=True - ) - return {} - - return { - org["shortName"]: org["name"] - for org in organizations - if org.get("shortName") in short_names and org.get("name") - } diff --git a/apps/backend/tests/api/routes/test_integrity_links.py b/apps/backend/tests/api/routes/test_integrity_links.py index e9487816..662da169 100644 --- a/apps/backend/tests/api/routes/test_integrity_links.py +++ b/apps/backend/tests/api/routes/test_integrity_links.py @@ -31,7 +31,7 @@ def mock_console_service(): """Patch ConsoleService so tests don't hit the network; returns empty display names by default.""" with patch(_CONSOLE_PATCH) as mock_cls: mock_cls.return_value.fetch_users_by_usernames.return_value = {} - mock_cls.return_value.fetch_organization_long_names.return_value = {} + mock_cls.return_value.fetch_organizations_by_short_names.return_value = {} yield mock_cls @@ -794,7 +794,7 @@ def test_organization_display_name_populated_from_console( ) self._setup_session(mock_session, [link]) self._setup_data_session(mock_data_session, ["staging_org"], []) - mock_console_service.return_value.fetch_organization_long_names.return_value = { + mock_console_service.return_value.fetch_organizations_by_short_names.return_value = { "testorg": "Test Organization" }