Skip to content

Commit abc7366

Browse files
committed
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.
1 parent 439373b commit abc7366

16 files changed

Lines changed: 162 additions & 1 deletion

File tree

‎apps/backend/src/api/routes/ingestion/integrity_links.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,13 @@ def _final_exists(lnk: IntegrityLink) -> bool:
271271
display_names = ConsoleService(get_settings().CONSOLE_INTERNAL_URL).fetch_users_by_usernames(
272272
usernames
273273
)
274+
organizations = list({item.integrity_organization for item in items})
275+
organization_names = ConsoleService(
276+
get_settings().CONSOLE_INTERNAL_URL
277+
).fetch_organization_long_names(organizations)
274278
for item in items:
275279
item.owner_display_name = display_names.get(item.integrity_owner)
280+
item.organization_display_name = organization_names.get(item.integrity_organization)
276281

277282
return IntegrityLinkListResponse(
278283
items=items,

‎apps/backend/src/models/data_import.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ class IntegrityLinkListItem(BaseModel):
184184
public_access: PublicAccess = PublicAccess.UNCONFIGURED
185185
has_final_table: bool = False
186186
owner_display_name: str | None = None
187+
organization_display_name: str | None = None
187188

188189
@field_validator("id", mode="before")
189190
@classmethod

‎apps/backend/src/services/console_service.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,29 @@ def get_all_organizations(self) -> list[dict[str, Any]]:
149149
except ValueError as exc:
150150
logger.error("Console returned invalid JSON from %s", url)
151151
raise ValueError(f"Console returned invalid JSON from {url}") from exc
152+
153+
def fetch_organization_long_names(self, short_names: list[str]) -> dict[str, str]:
154+
"""Resolve organization short names to long names via the console.
155+
156+
Args:
157+
short_names: List of organization short names to resolve.
158+
159+
Returns:
160+
Dict mapping short name -> long name. Short names with no match are omitted.
161+
Returns empty dict on any error (graceful degradation).
162+
"""
163+
if not short_names:
164+
return {}
165+
try:
166+
organizations = self.get_all_organizations()
167+
except Exception as e:
168+
logger.warning(
169+
f"Failed to fetch organization names from console API: {e}", exc_info=True
170+
)
171+
return {}
172+
173+
return {
174+
org["shortName"]: org["name"]
175+
for org in organizations
176+
if org.get("shortName") in short_names and org.get("name")
177+
}

‎apps/backend/tests/api/routes/test_integrity_links.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def mock_console_service():
3030
"""Patch ConsoleService so tests don't hit the network; returns empty display names by default."""
3131
with patch(_CONSOLE_PATCH) as mock_cls:
3232
mock_cls.return_value.fetch_users_by_usernames.return_value = {}
33+
mock_cls.return_value.fetch_organization_long_names.return_value = {}
3334
yield mock_cls
3435

3536

@@ -771,6 +772,42 @@ def test_owner_display_name_none_when_console_fails(
771772

772773
assert response.items[0].owner_display_name is None
773774

775+
@patch("src.api.routes.ingestion.integrity_links.logger")
776+
def test_organization_display_name_populated_from_console(
777+
self,
778+
mock_logger: MagicMock,
779+
mock_session: MagicMock,
780+
mock_data_session: MagicMock,
781+
mock_console_service: MagicMock,
782+
) -> None:
783+
"""organization_display_name is the console long name of the owning organization."""
784+
link = IntegrityLink(
785+
id=uuid4(),
786+
integrity_title="Org Link",
787+
integrity_owner="jdoe",
788+
integrity_organization="testorg",
789+
source_import_type=ImportType.URL,
790+
staging_table_name="staging_org",
791+
created_at=datetime.now(timezone.utc),
792+
schedule_enabled=False,
793+
)
794+
self._setup_session(mock_session, [link])
795+
self._setup_data_session(mock_data_session, ["staging_org"], [])
796+
mock_console_service.return_value.fetch_organization_long_names.return_value = {
797+
"testorg": "Test Organization"
798+
}
799+
800+
geo_ctx = self._create_geo_ctx("jdoe", {"IMPORT"})
801+
response = list_integrity_links(
802+
session=mock_session,
803+
data_session=mock_data_session,
804+
geo_ctx=geo_ctx,
805+
group_ids=[],
806+
offset=0,
807+
)
808+
809+
assert response.items[0].organization_display_name == "Test Organization"
810+
774811
@patch("src.api.routes.ingestion.integrity_links.logger")
775812
def test_next_offset_present_no_more(
776813
self, mock_logger: MagicMock, mock_session: MagicMock, mock_data_session: MagicMock

‎apps/backend/tests/services/test_console_service.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,35 @@ def test_http_error_returns_empty_dict(self, mock_post: MagicMock) -> None:
264264
result = ConsoleService("http://console.example.com").fetch_users_by_usernames(["jdoe"])
265265

266266
assert result == {}
267+
268+
269+
class TestFetchOrganizationLongNames:
270+
@patch("src.services.console_service.httpx.get")
271+
def test_success_returns_long_name_map(self, mock_get: MagicMock) -> None:
272+
"""Only the requested short names are returned, mapped to their long names."""
273+
mock_response = MagicMock()
274+
mock_response.json.return_value = [
275+
{"shortName": "org1", "name": "Organization One"},
276+
{"shortName": "org2", "name": "Organization Two"},
277+
]
278+
mock_get.return_value = mock_response
279+
280+
result = ConsoleService("http://console.example.com").fetch_organization_long_names(
281+
["org1", "unknown"]
282+
)
283+
284+
assert result == {"org1": "Organization One"}
285+
mock_get.assert_called_once_with(
286+
"http://console.example.com/internal/organizations", timeout=5.0
287+
)
288+
289+
@patch("src.services.console_service.httpx.get")
290+
def test_network_error_returns_empty_dict(self, mock_get: MagicMock) -> None:
291+
"""Network failures degrade gracefully — empty dict, no exception propagated."""
292+
mock_get.side_effect = httpx.ConnectError("Connection refused")
293+
294+
result = ConsoleService("http://console.example.com").fetch_organization_long_names(
295+
["org1"]
296+
)
297+
298+
assert result == {}

‎apps/frontend/src/app/core/api/models/integrity-link-list-item.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface IntegrityLinkListItem {
2626
integrity_title: string | null
2727
last_retrieval_timestamp: string | null
2828
metadata_id: string | null
29+
organization_display_name?: string | null
2930
owner_display_name?: string | null
3031
preset_id?: RecurrencePreset | null
3132
public_access?: PublicAccess

‎apps/frontend/src/app/features/integrity-link-list/integrity-link-list.component.html‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ <h1 class="text-2xl font-bold text-gray-2">
8181
<div class="flex-1 pl-2 pr-4 text-sm font-semibold text-gray-2">
8282
{{ 'integrityLinks.title' | translate }}
8383
</div>
84+
<div class="w-48 pr-4 text-sm font-semibold text-gray-2">
85+
{{ 'integrityLinks.organization' | translate }}
86+
</div>
8487
<div class="w-48 pr-4 text-sm font-semibold text-gray-2">
8588
{{ 'integrityLinks.owner' | translate }}
8689
</div>
@@ -126,7 +129,18 @@ <h1 class="text-2xl font-bold text-gray-2">
126129
</span>
127130
}
128131
</div>
129-
<div class="w-48 pr-4 text-sm text-gray-2">
132+
<div
133+
class="w-48 truncate pr-4 text-sm text-gray-2"
134+
[title]="
135+
link.organization_display_name ?? link.integrity_organization
136+
"
137+
>
138+
{{ link.organization_display_name ?? link.integrity_organization }}
139+
</div>
140+
<div
141+
class="w-48 truncate pr-4 text-sm text-gray-2"
142+
[title]="link.owner_display_name ?? link.integrity_owner"
143+
>
130144
{{ link.owner_display_name ?? link.integrity_owner }}
131145
</div>
132146
<div class="w-32 pr-4 text-sm text-gray-2">

‎apps/frontend/src/app/features/integrity-link-list/integrity-link-list.component.spec.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,43 @@ describe('IntegrityLinkListComponent', () => {
203203
})
204204
})
205205

206+
describe('Organization column', () => {
207+
it('should display the organization long name with a title tooltip, falling back to the short name', async () => {
208+
const fixture = TestBed.createComponent(IntegrityLinkListComponent)
209+
210+
const req = httpMock.expectOne(
211+
'http://localhost:8000/ingestion/integrity-links/?offset=0'
212+
)
213+
req.flush({
214+
items: [
215+
{
216+
...createMockItem('1'),
217+
organization_display_name: 'Organisation Longue'
218+
},
219+
createMockItem('2')
220+
],
221+
has_more: false,
222+
offset: 0
223+
})
224+
225+
await new Promise((resolve) => setTimeout(resolve, 10))
226+
fixture.detectChanges()
227+
228+
const cells = Array.from<HTMLElement>(
229+
fixture.nativeElement.querySelectorAll('.group > div:nth-child(2)')
230+
)
231+
232+
expect(cells.map((el) => el.textContent?.trim())).toEqual([
233+
'Organisation Longue',
234+
'org'
235+
])
236+
expect(cells.map((el) => el.title)).toEqual([
237+
'Organisation Longue',
238+
'org'
239+
])
240+
})
241+
})
242+
206243
describe('Load More Pagination', () => {
207244
it('should append items when loadMore() is called', async () => {
208245
const fixture = TestBed.createComponent(IntegrityLinkListComponent)

‎apps/frontend/translations/de.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@
294294
"integrityLinks.loadMore": "",
295295
"integrityLinks.noFinalTable": "Aufnahme zu konfigurieren",
296296
"integrityLinks.noResults": "",
297+
"integrityLinks.organization": "",
297298
"integrityLinks.owner": "",
298299
"integrityLinks.readOnly": "",
299300
"integrityLinks.recurrence": "",

‎apps/frontend/translations/en.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@
306306
"integrityLinks.loadMore": "Load more",
307307
"integrityLinks.noFinalTable": "Ingestion to configure",
308308
"integrityLinks.noResults": "No results found",
309+
"integrityLinks.organization": "Organization",
309310
"integrityLinks.owner": "Author",
310311
"integrityLinks.readOnly": "Read only",
311312
"integrityLinks.recurrence": "Recurrence",

0 commit comments

Comments
 (0)