From 7499852a35624759a7076c0c5955e7a2299fc51b Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Wed, 24 Jun 2026 12:33:29 +0530 Subject: [PATCH 01/17] refactor(services): expose public symbols via __init__.py and update imports --- src/services/__init__.py | 8 ++++++-- src/services/data_service.py | 10 +++------- src/services/document_service.py | 11 +++++------ src/services/opengin_service.py | 17 +++++------------ src/services/organisation_service.py | 16 +++++----------- src/services/person_service.py | 15 ++++----------- src/services/search_service.py | 8 +++----- 7 files changed, 31 insertions(+), 54 deletions(-) diff --git a/src/services/__init__.py b/src/services/__init__.py index 7d2c6a5..12386fb 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -1,11 +1,15 @@ +from .data_service import DataService from .organisation_service import OrganisationService from .opengin_service import OpenGINService from .person_service import PersonService from .document_service import DocumentService +from .search_service import SearchService __all__ = [ + "DataService", + "DocumentService", "OrganisationService", "OpenGINService", "PersonService", - "DocumentService" -] \ No newline at end of file + "SearchService", +] diff --git a/src/services/data_service.py b/src/services/data_service.py index 16f84fd..ee8db8d 100644 --- a/src/services/data_service.py +++ b/src/services/data_service.py @@ -2,14 +2,10 @@ import asyncio from typing import Dict from src.enums import KindMajorEnum, KindMinorEnum, RelationNameEnum, RelationDirectionEnum -from src.exception.exceptions import InternalServerError, NotFoundError -from src.exception.exceptions import BadRequestError -from src.models.organisation_schemas import Relation -from src.utils.util_functions import Util -from src.models.organisation_schemas import Kind -from src.models.organisation_schemas import Entity +from src.exception import BadRequestError, InternalServerError, NotFoundError +from src.models import Entity, Kind, Relation from aiohttp import ClientSession -from src.utils import http_client +from src.utils import Util, http_client logger = logging.getLogger(__name__) diff --git a/src/services/document_service.py b/src/services/document_service.py index 96e18c3..3875d07 100644 --- a/src/services/document_service.py +++ b/src/services/document_service.py @@ -1,13 +1,12 @@ -from src.exception.exceptions import InternalServerError import asyncio -from src.utils.util_functions import Util from aiohttp import ClientSession -from src.utils import http_client -from src.models.organisation_schemas import Entity, Kind -from src.enums.kindEnum import KindMajorEnum, KindMinorEnum -from typing import Dict, List from datetime import datetime import logging +from typing import Dict, List +from src.enums import KindMajorEnum, KindMinorEnum +from src.exception import InternalServerError +from src.models import Entity, Kind +from src.utils import Util, http_client logger = logging.getLogger(__name__) diff --git a/src/services/opengin_service.py b/src/services/opengin_service.py index 14fccb5..cf18fc3 100644 --- a/src/services/opengin_service.py +++ b/src/services/opengin_service.py @@ -1,17 +1,10 @@ -from asyncio import timeout -from google.api_core.exceptions import GoogleAPICallError -from google.api_core.retry import if_transient_error -from src.models.organisation_schemas import AttributeFilterRecords -from src.models.organisation_schemas import Entity, Relation -from src.exception.exceptions import BadRequestError -from src.exception.exceptions import InternalServerError -from src.exception.exceptions import NotFoundError from google.api_core import retry_async -from google.api_core import exceptions from aiohttp import ClientSession -from src.utils.http_client import http_client -from src.core.config import settings import logging +from src.core import settings +from src.exception import BadRequestError, InternalServerError, NotFoundError +from src.models import AttributeFilterRecords, Entity, Relation +from src.utils import http_client logger = logging.getLogger(__name__) @@ -194,4 +187,4 @@ async def get_attributes( raise InternalServerError("An unexpected error occurred") from e - \ No newline at end of file + diff --git a/src/services/organisation_service.py b/src/services/organisation_service.py index 00bc260..9ac6e33 100644 --- a/src/services/organisation_service.py +++ b/src/services/organisation_service.py @@ -1,16 +1,11 @@ -from src.enums.relationEnum import RelationDirectionEnum -from src.enums.relationEnum import RelationNameEnum -from src.exception.exceptions import BadRequestError -from src.exception.exceptions import NotFoundError -from src.exception.exceptions import InternalServerError import asyncio -from src.utils.util_functions import Util from aiohttp import ClientSession -from src.utils import http_client -from src.models.organisation_schemas import Entity, Relation -from src.enums.idEnum import EntityIdEnum -from typing import Optional, Sequence import logging +from typing import Optional, Sequence +from src.enums import EntityIdEnum, RelationDirectionEnum, RelationNameEnum +from src.exception import BadRequestError, InternalServerError, NotFoundError +from src.models import Entity, Relation +from src.utils import Util, http_client logger = logging.getLogger(__name__) @@ -800,4 +795,3 @@ async def department_history_timeline(self, department_id: str): except Exception as e: logger.error(f"Error in enrich_department_timeline: {e}") raise InternalServerError("An unexpected error occurred") from e - diff --git a/src/services/person_service.py b/src/services/person_service.py index 1c8c548..727303e 100644 --- a/src/services/person_service.py +++ b/src/services/person_service.py @@ -1,19 +1,12 @@ -from src.enums.relationEnum import RelationDirectionEnum -from src.enums.relationEnum import RelationNameEnum -from src.exception.exceptions import BadRequestError -from src.exception.exceptions import NotFoundError -from src.exception.exceptions import InternalServerError import asyncio -from src.utils.util_functions import Util from aiohttp import ClientSession -from src.utils import http_client -from src.models.organisation_schemas import Entity, Relation, Kind -from src.enums.kindEnum import KindMajorEnum, KindMinorEnum -from src.enums.idEnum import EntityIdEnum -from src.models.person_schemas import PersonResponse from datetime import datetime import logging +from src.enums import EntityIdEnum, KindMajorEnum, KindMinorEnum, RelationDirectionEnum, RelationNameEnum +from src.exception import BadRequestError, InternalServerError, NotFoundError +from src.models import Entity, Kind, PersonResponse, Relation +from src.utils import Util, http_client logger = logging.getLogger(__name__) diff --git a/src/services/search_service.py b/src/services/search_service.py index ae1e940..ed2a39b 100644 --- a/src/services/search_service.py +++ b/src/services/search_service.py @@ -2,10 +2,9 @@ import logging from typing import List, Dict, Any, Optional from src.enums import KindMajorEnum, KindMinorEnum -from src.exception.exceptions import BadRequestError, InternalServerError -from src.models.organisation_schemas import Entity, Kind -from src.models.search_schemas import SearchResult, SearchResponse -from src.utils.util_functions import Util +from src.exception import BadRequestError, InternalServerError +from src.models import Entity, Kind, SearchResponse, SearchResult +from src.utils import Util logger = logging.getLogger(__name__) @@ -222,4 +221,3 @@ def _determine_entity_type(self, major: str, minor: str) -> str: return entity_type - From a28e2c2037d2bd2469af90843d89ad595434b167 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Wed, 24 Jun 2026 12:36:21 +0530 Subject: [PATCH 02/17] refactor(core): expose public symbols via __init__.py --- src/core/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/core/__init__.py diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..06bfa18 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,6 @@ +from .config import Settings, settings + +__all__ = [ + "Settings", + "settings", +] From 0d9884b99e43ae20775ffbfc05d10f2ff2863f40 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Wed, 24 Jun 2026 12:36:42 +0530 Subject: [PATCH 03/17] refactor(exception): expose public symbols via __init__.py --- src/exception/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/exception/__init__.py b/src/exception/__init__.py index e69de29..1aae83e 100644 --- a/src/exception/__init__.py +++ b/src/exception/__init__.py @@ -0,0 +1,15 @@ +from .exceptions import ( + BadRequestError, + GatewayTimeoutError, + InternalServerError, + NotFoundError, + ServiceUnavailableError, +) + +__all__ = [ + "BadRequestError", + "GatewayTimeoutError", + "InternalServerError", + "NotFoundError", + "ServiceUnavailableError", +] From 87f7643d04667c7806cb620a849210bf15dfe348 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Wed, 24 Jun 2026 12:37:27 +0530 Subject: [PATCH 04/17] refactor(middleware): expose public symbols via __init__.py and update throttling imports --- src/middleware/__init__.py | 5 +++++ src/middleware/throttling.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 src/middleware/__init__.py diff --git a/src/middleware/__init__.py b/src/middleware/__init__.py new file mode 100644 index 0000000..593e762 --- /dev/null +++ b/src/middleware/__init__.py @@ -0,0 +1,5 @@ +from .throttling import ThrottlingMiddleware + +__all__ = [ + "ThrottlingMiddleware", +] diff --git a/src/middleware/throttling.py b/src/middleware/throttling.py index ddc2426..0425c99 100644 --- a/src/middleware/throttling.py +++ b/src/middleware/throttling.py @@ -3,7 +3,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse -from src.core.config import settings +from src.core import settings logger = logging.getLogger(__name__) From 776e8e32db7a7aa7e3abb2eb4b6d7c569e9e3c91 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Wed, 24 Jun 2026 12:38:02 +0530 Subject: [PATCH 05/17] refactor(models): expose public symbols via __init__.py --- src/models/__init__.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/models/__init__.py b/src/models/__init__.py index 5aa05d3..351b3f0 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,6 +1,32 @@ +from .data_requestbody import DataCatalogRequest, DatasetYearsRequest +from .organisation_schemas import ( + AttributeFilterRecord, + AttributeFilterRecords, + Category, + Dataset, + Date, + Entity, + Kind, + Label, + Relation, +) from .person_schemas import PersonSource, PersonResponse +from .search_schemas import SearchResponse, SearchResult __all__ = [ + "AttributeFilterRecord", + "AttributeFilterRecords", + "Category", + "DataCatalogRequest", + "Dataset", + "DatasetYearsRequest", + "Date", + "Entity", + "Kind", + "Label", "PersonSource", - "PersonResponse" + "PersonResponse", + "Relation", + "SearchResponse", + "SearchResult", ] From 08357dbd912381774cf1f4f18401df3c581732cc Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Wed, 24 Jun 2026 12:39:00 +0530 Subject: [PATCH 06/17] refactor(utils): expose public symbols via __init__.py and update http_client imports --- src/utils/__init__.py | 8 ++++++++ src/utils/http_client.py | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 src/utils/__init__.py diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..23e50f6 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,8 @@ +from .http_client import HTTPClient, http_client +from .util_functions import Util + +__all__ = [ + "HTTPClient", + "http_client", + "Util", +] diff --git a/src/utils/http_client.py b/src/utils/http_client.py index 39c0544..d83f4c7 100644 --- a/src/utils/http_client.py +++ b/src/utils/http_client.py @@ -1,6 +1,6 @@ from aiohttp import ClientSession, ClientTimeout, TCPConnector from typing import Optional -from src.core.config import settings +from src.core import settings class HTTPClient: "Single HTTP client for the application" @@ -48,4 +48,4 @@ def session(self) -> ClientSession: return self._session # Create a global instance -http_client = HTTPClient() \ No newline at end of file +http_client = HTTPClient() From ae0fcd0201cabcf933b628beb70611228fcc757e Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Wed, 24 Jun 2026 12:40:40 +0530 Subject: [PATCH 07/17] refactor(routers): expose public symbols via __init__.py and update router imports --- src/__init__.py | 4 ++++ src/routers/data_router.py | 6 ++---- src/routers/organisation_router.py | 2 +- src/routers/search_router.py | 5 ++--- 4 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 src/__init__.py diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e52d843 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,4 @@ +"""Top-level package for the GI Service application.""" + +# Intentionally avoid re-exporting subpackages here to keep imports explicit. +__all__: list[str] = [] diff --git a/src/routers/data_router.py b/src/routers/data_router.py index fdb0526..d371a89 100644 --- a/src/routers/data_router.py +++ b/src/routers/data_router.py @@ -1,10 +1,8 @@ -from src.models.data_requestbody import DatasetYearsRequest -from src.models.data_requestbody import DataCatalogRequest from fastapi.param_functions import Depends, Query, Body, Path -from src.services.data_service import DataService -from src.services.opengin_service import OpenGINService from fastapi import APIRouter from pydantic import BaseModel, Field +from src.models import DataCatalogRequest, DatasetYearsRequest +from src.services import DataService, OpenGINService router = APIRouter(prefix="/v1/data", tags=["Data"]) diff --git a/src/routers/organisation_router.py b/src/routers/organisation_router.py index b0f38db..6bb7a2e 100644 --- a/src/routers/organisation_router.py +++ b/src/routers/organisation_router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, Query, Body, Path -from src.models.organisation_schemas import Date +from src.models import Date from src.services import OpenGINService, OrganisationService from typing import Sequence diff --git a/src/routers/search_router.py b/src/routers/search_router.py index b3b19fd..516905e 100644 --- a/src/routers/search_router.py +++ b/src/routers/search_router.py @@ -1,9 +1,8 @@ from fastapi import APIRouter, Depends, Query from typing import Optional, List from datetime import datetime -from src.services.search_service import SearchService -from src.services.opengin_service import OpenGINService -from src.models.search_schemas import SearchResponse +from src.models import SearchResponse +from src.services import OpenGINService, SearchService router = APIRouter(prefix="/v1/search", tags=["Search"]) From c470e1b4d7c29c7cda5dc93eb8180afc88a10fa6 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Wed, 24 Jun 2026 12:41:00 +0530 Subject: [PATCH 08/17] refactor(main): update imports to use package-level interface --- main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 5c6a47b..6a8cf29 100644 --- a/main.py +++ b/main.py @@ -6,10 +6,10 @@ person_router, document_router, ) -from src.core.config import settings +from src.core import settings from fastapi.middleware.cors import CORSMiddleware -from src.middleware.throttling import ThrottlingMiddleware -from src.utils.http_client import http_client +from src.middleware import ThrottlingMiddleware +from src.utils import http_client from contextlib import asynccontextmanager @asynccontextmanager @@ -46,4 +46,4 @@ async def lifespan(app: FastAPI): app.include_router(data_router) app.include_router(search_router) app.include_router(person_router) -app.include_router(document_router) \ No newline at end of file +app.include_router(document_router) From faaeeb5251dad7a6d3d5711754a2e8e173e30193 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Wed, 24 Jun 2026 12:50:17 +0530 Subject: [PATCH 09/17] test: update test imports to use package-level interface --- test/__init__.py | 6 ++++++ test/conftest.py | 10 ++-------- test/test_attribute_filter_schemas.py | 2 +- test/test_data_service.py | 4 ++-- test/test_document_service.py | 5 +---- test/test_opengin_service.py | 10 ++++------ test/test_organisation_service.py | 25 ++++++++++++------------- test/test_person_service.py | 6 ++---- test/test_retry_opengin_service.py | 8 +++----- test/test_search_service.py | 8 +++----- 10 files changed, 36 insertions(+), 48 deletions(-) diff --git a/test/__init__.py b/test/__init__.py index e69de29..d7d9c70 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -0,0 +1,6 @@ +"""Test package for GI Service. + +Fixtures and test helpers stay module-local to avoid creating a public API. +""" + +__all__: list[str] = [] diff --git a/test/conftest.py b/test/conftest.py index 9ae33f7..f207616 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,15 +1,9 @@ - -from src.services.data_service import DataService import pytest from aiohttp import ClientError from unittest.mock import patch, PropertyMock, MagicMock -from services.opengin_service import OpenGINService -from src.utils.http_client import HTTPClient -from services.organisation_service import OrganisationService from unittest.mock import AsyncMock -from src.utils.util_functions import Util -from src.services.person_service import PersonService -from src.services.document_service import DocumentService +from src.services import DataService, DocumentService, OpenGINService, OrganisationService, PersonService +from src.utils import HTTPClient, Util # MockResponse class to simulate aiohttp responses class MockResponse: diff --git a/test/test_attribute_filter_schemas.py b/test/test_attribute_filter_schemas.py index 0418212..69ee15b 100644 --- a/test/test_attribute_filter_schemas.py +++ b/test/test_attribute_filter_schemas.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from src.models.organisation_schemas import AttributeFilterRecord, AttributeFilterRecords +from src.models import AttributeFilterRecord, AttributeFilterRecords def test_attribute_filter_record_defaults_operator_to_eq(): diff --git a/test/test_data_service.py b/test/test_data_service.py index efb43c6..7890d5b 100644 --- a/test/test_data_service.py +++ b/test/test_data_service.py @@ -1,9 +1,9 @@ import pytest import asyncio -from src.exception.exceptions import InternalServerError, BadRequestError, NotFoundError from unittest.mock import patch -from src.models.organisation_schemas import Entity, Relation, Kind from src.enums import KindMajorEnum, KindMinorEnum, RelationNameEnum, RelationDirectionEnum +from src.exception import BadRequestError, InternalServerError, NotFoundError +from src.models import Entity, Kind, Relation # Tests for enrich_dataset @pytest.mark.asyncio diff --git a/test/test_document_service.py b/test/test_document_service.py index 39dcdff..71c7e86 100644 --- a/test/test_document_service.py +++ b/test/test_document_service.py @@ -1,8 +1,6 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from src.services.document_service import DocumentService -from src.exception.exceptions import InternalServerError, NotFoundError -from src.models.organisation_schemas import Entity +from src.exception import InternalServerError, NotFoundError @pytest.mark.asyncio async def test_get_gazette_data_points_success(document_service, mock_opengin_service): @@ -142,4 +140,3 @@ async def test_get_gazette_data_points_robustness(document_service, mock_opengin assert result["data"][1]["year"] == 2024 assert result["data"][1]["values"][4] == 1 # May - diff --git a/test/test_opengin_service.py b/test/test_opengin_service.py index b848e04..b844230 100644 --- a/test/test_opengin_service.py +++ b/test/test_opengin_service.py @@ -1,14 +1,13 @@ import pytest -from src.enums.relationEnum import RelationDirectionEnum -from src.enums.relationEnum import RelationNameEnum -from src.models.organisation_schemas import Kind -from src.exception.exceptions import NotFoundError, BadRequestError -from src.models.organisation_schemas import ( +from src.enums import RelationDirectionEnum, RelationNameEnum +from src.exception import BadRequestError, NotFoundError +from src.models import ( AttributeFilterRecord, AttributeFilterRecords, Entity, Relation, + Kind, ) from test.conftest import MockResponse @@ -374,4 +373,3 @@ async def test_get_attributes_bad_request(mock_service, mock_session): with pytest.raises(BadRequestError, match="Bad request"): await mock_service.get_attributes("category_123", "dataset_abc") - diff --git a/test/test_organisation_service.py b/test/test_organisation_service.py index 9e84959..d8e2974 100644 --- a/test/test_organisation_service.py +++ b/test/test_organisation_service.py @@ -1,10 +1,9 @@ import pytest -from src.enums.relationEnum import RelationNameEnum, RelationDirectionEnum -from src.exception.exceptions import InternalServerError, BadRequestError -from src.utils.util_functions import Util from unittest.mock import AsyncMock, patch, MagicMock -from src.models.organisation_schemas import Entity, Relation -from src.enums.idEnum import EntityIdEnum +from src.enums import EntityIdEnum, RelationDirectionEnum, RelationNameEnum +from src.exception import BadRequestError, InternalServerError +from src.models import Entity, Relation +from src.utils import Util @pytest.mark.asyncio @@ -20,7 +19,7 @@ async def test_enrich_person_data_as_president( ] with patch( - "services.organisation_service.Util.decode_protobuf_attribute_name", + "src.services.organisation_service.Util.decode_protobuf_attribute_name", return_value="John Doe", ): result = await organisation_service.enrich_person_data( @@ -58,7 +57,7 @@ async def test_enrich_person_data_as_not_president( ] with patch( - "services.organisation_service.Util.decode_protobuf_attribute_name", + "src.services.organisation_service.Util.decode_protobuf_attribute_name", return_value="John Doe", ): result = await organisation_service.enrich_person_data( @@ -104,7 +103,7 @@ async def test_enrich_department_item(organisation_service, mock_opengin_service ] with patch( - "services.organisation_service.Util.decode_protobuf_attribute_name", + "src.services.organisation_service.Util.decode_protobuf_attribute_name", return_value="Department_of_security", ): result = await organisation_service.enrich_department_item( @@ -141,7 +140,7 @@ async def test_enrich_department_item_with_no_data( mock_opengin_service.fetch_relation.return_value = [] with patch( - "services.organisation_service.Util.decode_protobuf_attribute_name", + "src.services.organisation_service.Util.decode_protobuf_attribute_name", return_value="Department_of_security", ): result = await organisation_service.enrich_department_item( @@ -178,7 +177,7 @@ async def test_enrich_department_item_not_new( mock_opengin_service.fetch_relation.return_value = [] with patch( - "services.organisation_service.Util.decode_protobuf_attribute_name", + "src.services.organisation_service.Util.decode_protobuf_attribute_name", return_value="Department_of_security", ): result = await organisation_service.enrich_department_item( @@ -217,7 +216,7 @@ async def test_departments_by_portfolio_id_success( # Patch enrich_department_item with AsyncMock returning the department dict with patch( - "services.organisation_service.OrganisationService.enrich_department_item", + "src.services.organisation_service.OrganisationService.enrich_department_item", new_callable=AsyncMock, ) as mock_enrich_department: mock_enrich_department.return_value = { @@ -322,7 +321,7 @@ async def test_prime_minister_success(organisation_service, mock_opengin_service # Patch enrich_department_item with AsyncMock returning the department dict with patch( - "services.organisation_service.OrganisationService.enrich_person_data", + "src.services.organisation_service.OrganisationService.enrich_person_data", new_callable=AsyncMock, ) as mock_enrich_person: mock_enrich_person.return_value = { @@ -375,7 +374,7 @@ async def test_prime_minister_without_person_data( # Patch enrich_department_item with AsyncMock returning the department dict with patch( - "services.organisation_service.OrganisationService.enrich_person_data", + "src.services.organisation_service.OrganisationService.enrich_person_data", new_callable=AsyncMock, ) as mock_enrich_person: mock_enrich_person.return_value = {} diff --git a/test/test_person_service.py b/test/test_person_service.py index 130c7dc..65610a6 100644 --- a/test/test_person_service.py +++ b/test/test_person_service.py @@ -1,10 +1,9 @@ import pytest from unittest.mock import AsyncMock, patch -from src.models.organisation_schemas import Entity, Relation -from src.models.person_schemas import PersonResponse -from src.exception.exceptions import BadRequestError, InternalServerError, NotFoundError from datetime import date +from src.exception import BadRequestError, InternalServerError, NotFoundError from src.enums import KindMinorEnum +from src.models import Entity, PersonResponse, Relation # --- Tests for is_president_during --- @@ -699,4 +698,3 @@ async def test_fetch_all_presidents_internal_error(person_service, mock_opengin_ with pytest.raises(InternalServerError): await person_service.fetch_all_presidents() - diff --git a/test/test_retry_opengin_service.py b/test/test_retry_opengin_service.py index 723668e..a64c148 100644 --- a/test/test_retry_opengin_service.py +++ b/test/test_retry_opengin_service.py @@ -1,9 +1,8 @@ import pytest -from src.enums.relationEnum import RelationNameEnum, RelationDirectionEnum -from src.models.organisation_schemas import Relation +from src.enums import RelationDirectionEnum, RelationNameEnum from test.conftest import MockResponse -from src.models.organisation_schemas import Entity -from src.exception.exceptions import BadRequestError +from src.exception import BadRequestError +from src.models import Entity, Relation from google.api_core.exceptions import InternalServerError from unittest.mock import AsyncMock, patch from google.api_core.exceptions import RetryError @@ -205,4 +204,3 @@ async def test_get_metadata_succeeds_after_retries(mock_service, mock_session): assert mock_session.get.call_count == 3 assert mock_sleep.call_count == 2 - diff --git a/test/test_search_service.py b/test/test_search_service.py index e72d0eb..d40900a 100644 --- a/test/test_search_service.py +++ b/test/test_search_service.py @@ -1,11 +1,9 @@ import pytest from unittest.mock import patch, AsyncMock -from src.exception.exceptions import BadRequestError -from src.models.organisation_schemas import Entity, Kind -from src.models.search_schemas import SearchResponse -from src.services.search_service import SearchService -from src.services.opengin_service import OpenGINService from src.enums import KindMajorEnum, KindMinorEnum +from src.exception import BadRequestError +from src.models import Entity, Kind, SearchResponse +from src.services import OpenGINService, SearchService # Fixtures From 23c766fd06672837854977bb9ee3811643e51330 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Wed, 24 Jun 2026 12:52:52 +0530 Subject: [PATCH 10/17] chore: update .env.example with new service configuration --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 88a61f6..326a0d5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -BASE_URL_QUERY=base-url-query +BASE_URL_QUERY=BASE_URL_QUERY # Comma-separated list of allowed origins for CORS (replace as needed) ALLOWED_ORIGINS=http://localhost:3000 From 591bf419c2beda5298221fec28af6a91b9d33da1 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka <124586352+rusiru-erandaka@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:13:05 +0530 Subject: [PATCH 11/17] Update package import alphabetically src/services/__init__.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/services/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/__init__.py b/src/services/__init__.py index 12386fb..34b57a6 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -1,8 +1,8 @@ from .data_service import DataService -from .organisation_service import OrganisationService +from .document_service import DocumentService from .opengin_service import OpenGINService +from .organisation_service import OrganisationService from .person_service import PersonService -from .document_service import DocumentService from .search_service import SearchService __all__ = [ From c734f1d9f72f3e3e52b0df57e2c59f3cd38f6725 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka <124586352+rusiru-erandaka@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:14:11 +0530 Subject: [PATCH 12/17] Update according to the alphabetical oder. src/services/__init__.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/services/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/__init__.py b/src/services/__init__.py index 34b57a6..953e35b 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -8,8 +8,8 @@ __all__ = [ "DataService", "DocumentService", - "OrganisationService", "OpenGINService", + "OrganisationService", "PersonService", "SearchService", ] From ece2c1e85a7a6b118c7cbc10bf2122934e91b1b1 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka <124586352+rusiru-erandaka@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:15:22 +0530 Subject: [PATCH 13/17] Update importers according to alphabetical oder. src/models/__init__.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/models/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/__init__.py b/src/models/__init__.py index 351b3f0..7af4c8c 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -10,7 +10,7 @@ Label, Relation, ) -from .person_schemas import PersonSource, PersonResponse +from .person_schemas import PersonResponse, PersonSource from .search_schemas import SearchResponse, SearchResult __all__ = [ From c86903364408fa29c9ad6e9cec31afe39a53c956 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 26 Jun 2026 09:03:13 +0530 Subject: [PATCH 14/17] Return department ids for links of cabinet flow response --- .../rest/organisation_api_contract.yaml | 9 +++++++ src/services/organisation_service.py | 24 ++++++++++++++----- test/test_organisation_service.py | 10 ++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/gi_service/contract/rest/organisation_api_contract.yaml b/gi_service/contract/rest/organisation_api_contract.yaml index c3d7990..c68a8ec 100644 --- a/gi_service/contract/rest/organisation_api_contract.yaml +++ b/gi_service/contract/rest/organisation_api_contract.yaml @@ -186,6 +186,7 @@ paths: - source - target - value + - departmentIds properties: source: type: integer @@ -196,6 +197,11 @@ paths: value: type: integer description: Number of departments shared between the source and target ministry nodes. + departmentIds: + type: array + items: + type: string + description: IDs of the departments that moved along this link between consecutive dates. dates: type: array items: @@ -226,6 +232,9 @@ paths: - source: 0 target: 1 value: 25 + departmentIds: + - "2289-43_dep_1" + - "2289-43_dep_2" dates: - date: "2024-09-23" status: "ok" diff --git a/src/services/organisation_service.py b/src/services/organisation_service.py index 9ac6e33..c404f75 100644 --- a/src/services/organisation_service.py +++ b/src/services/organisation_service.py @@ -484,7 +484,12 @@ async def fetch_cabinet_flow(self, president_id: str, dates: Sequence[str], max_ {"id": "", "time": "", "name": ""} ], "links": [ - {"source": , "target": , "value": } + { + "source": , + "target": , + "value": , + "departmentIds": ["", ...] + } ], "dates": [ {"date": "", "status": "", "departmentsCount": } @@ -510,7 +515,7 @@ async def fetch_cabinet_flow(self, president_id: str, dates: Sequence[str], max_ expected_slots = len(dates) # number of dates requested, used to initialise fixed-size timeline slots per department nodes: list[dict[str, str]] = [] # list of graph nodes, each representing a minister at a specific date e.g. {"id": "minister_001", "time": "2015-01-01"} node_indices: dict[tuple[str, int], int] = {} # maps (minister_id, date_index) -> index in `nodes`, avoids duplicate nodes for the same minister at the same date - links_counter: dict[tuple[int, int], int] = {} # maps (source_node_index, target_node_index) -> count of departments that moved between those two ministers across consecutive dates + links_departments: dict[tuple[int, int], list[str]] = {} # maps (source_node_index, target_node_index) -> department ids that moved between those two ministers across consecutive dates date_status: list[dict[str, object]] = [ {"date": d, "status": "pending"} for d in dates ] # tracks processing status per date ("pending" -> "ok" / "error" / "no_data") for the response metadata @@ -582,14 +587,21 @@ async def fetch_cabinet_flow(self, president_id: str, dates: Sequence[str], max_ previous_index = timeline[date_index - 1] if date_index > 0 else None timeline[date_index] = node_index - # Aggregate movements: each department that goes from previous_index -> node_index adds +1 to that link. + # Aggregate movements: each department that goes from previous_index -> node_index is added to that link. if previous_index is not None and node_index is not None: key = (previous_index, node_index) - links_counter[key] = links_counter.get(key, 0) + 1 # increment the number of departments moved from m1->m2 + if key not in links_departments: + links_departments[key] = [] + links_departments[key].append(department_id) links = [ - {"source": source, "target": target, "value": value} - for (source, target), value in links_counter.items() + { + "source": source, + "target": target, + "value": len(department_ids), + "departmentIds": department_ids, + } + for (source, target), department_ids in links_departments.items() ] unique_ids = list({node['id'] for node in nodes}) diff --git a/test/test_organisation_service.py b/test/test_organisation_service.py index d8e2974..b14f7b8 100644 --- a/test/test_organisation_service.py +++ b/test/test_organisation_service.py @@ -802,6 +802,15 @@ async def test_department_moves_between_ministers(organisation_service): total_flow = sum(link["value"] for link in result["links"]) assert total_flow == 4 + all_department_ids = { + department_id + for link in result["links"] + for department_id in link["departmentIds"] + } + assert all_department_ids == {"dep175", "dep176", "dep177", "dep182"} + for link in result["links"]: + assert link["value"] == len(link["departmentIds"]) + # nodes should exist assert len(result["nodes"]) > 0 @@ -966,6 +975,7 @@ async def test_multiple_departments_aggregation(organisation_service): # The value should be 2 because two departments moved along this path link = result["links"][0] assert link["value"] == 2 + assert set(link["departmentIds"]) == {"dep1", "dep2"} # Nodes should exist for both ministers node_ids = {node["id"] for node in result["nodes"]} From db52c27d3174d34588cf20dc7980d6d168791644 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 26 Jun 2026 09:31:12 +0530 Subject: [PATCH 15/17] Add endpoint to resolve entity IDs to display names --- .../rest/organisation_api_contract.yaml | 37 +++++++++++++++ src/routers/organisation_router.py | 11 +++++ src/services/organisation_service.py | 14 ++++++ test/test_organisation_service.py | 46 +++++++++++++++++++ 4 files changed, 108 insertions(+) diff --git a/gi_service/contract/rest/organisation_api_contract.yaml b/gi_service/contract/rest/organisation_api_contract.yaml index c68a8ec..2d38284 100644 --- a/gi_service/contract/rest/organisation_api_contract.yaml +++ b/gi_service/contract/rest/organisation_api_contract.yaml @@ -411,6 +411,43 @@ paths: "500": description: Server Error + /v1/organisation/entity-names: + post: + tags: + - Organization + summary: Resolve entity IDs to display names. + description: Returns a dictionary mapping each entity ID to its decoded display name. IDs that cannot be resolved are omitted from the response. + operationId: resolveEntityNames + requestBody: + required: true + description: List of entity IDs to resolve. + content: + application/json: + schema: + type: array + items: + type: string + example: + - "2289-43_min_1" + - "2289-43_dep_1" + responses: + "200": + description: Successfully returned + content: + application/json: + schema: + type: object + additionalProperties: + type: string + description: Map of entity ID to display name. + example: + "2289-43_min_1": "Minister of Defence" + "2289-43_dep_1": "Ministry of Health" + "400": + description: Bad request + "500": + description: Server Error + /v1/organisation/department-history/{department_id}: get: tags: diff --git a/src/routers/organisation_router.py b/src/routers/organisation_router.py index 6bb7a2e..358af34 100644 --- a/src/routers/organisation_router.py +++ b/src/routers/organisation_router.py @@ -44,6 +44,17 @@ async def cabinet_flow( service_response = await service.fetch_cabinet_flow(president_id=president_id, dates=dates) return service_response +@router.post("/entity-names", summary="Resolve entity IDs to display names.", description="Returns a dictionary mapping each entity ID to its decoded display name.") +async def entity_names( + entity_ids: list[str] = Body( + ..., + description="List of entity IDs to resolve.", + example=["2153-12_dep_168", "2153-12_dep_171"], + ), + service: OrganisationService = Depends(get_organisation_service), +): + return await service.resolve_entity_names(entity_ids) + @router.get('/department-history/{department_id}', summary="Get department history timeline.", description="Returns a timeline of a department including ministry relations and ministers.") async def department_history_timeline( department_id: str = Path(..., description="ID of the department"), diff --git a/src/services/organisation_service.py b/src/services/organisation_service.py index c404f75..59fd830 100644 --- a/src/services/organisation_service.py +++ b/src/services/organisation_service.py @@ -660,6 +660,20 @@ async def _fetch_and_map_entities(self, entity_ids: list[str]) -> dict[str, Enti entity_map[result[0].id] = result[0] return entity_map + async def resolve_entity_names(self, entity_ids: Sequence[str]) -> dict[str, str]: + """Resolve entity IDs to decoded display names.""" + if not entity_ids: + return {} + + unique_ids = list(dict.fromkeys(entity_ids)) + entity_map = await self._fetch_and_map_entities(unique_ids) + + return { + entity_id: Util.decode_protobuf_attribute_name(entity.name) + for entity_id, entity in entity_map.items() + if entity.name + } + # helper : fetch relations for multiple entities in parallel and map them by id async def _fetch_and_map_relations(self, entity_ids: list[str], relation_query: Relation) -> dict[str, list[Relation]]: """Fetch relations for multiple entities in parallel and map them.""" diff --git a/test/test_organisation_service.py b/test/test_organisation_service.py index b14f7b8..a6b93f9 100644 --- a/test/test_organisation_service.py +++ b/test/test_organisation_service.py @@ -701,6 +701,52 @@ async def test_fetch_and_map_entities_partial_failure( assert "e2" not in result +@pytest.mark.asyncio +async def test_resolve_entity_names_success(organisation_service, mock_opengin_service): + entity_ids = ["e1", "e2", "e1"] + mock_opengin_service.get_entities.side_effect = [ + [Entity(id="e1", name="encoded_name_1")], + [Entity(id="e2", name="encoded_name_2")], + ] + + with patch( + "services.organisation_service.Util.decode_protobuf_attribute_name", + side_effect=lambda name: f"decoded_{name}", + ): + result = await organisation_service.resolve_entity_names(entity_ids) + + assert result == { + "e1": "decoded_encoded_name_1", + "e2": "decoded_encoded_name_2", + } + assert mock_opengin_service.get_entities.call_count == 2 + + +@pytest.mark.asyncio +async def test_resolve_entity_names_partial_failure( + organisation_service, mock_opengin_service +): + entity_ids = ["e1", "e2"] + mock_opengin_service.get_entities.side_effect = [ + [Entity(id="e1", name="encoded_name_1")], + Exception("Failed to fetch"), + ] + + with patch( + "services.organisation_service.Util.decode_protobuf_attribute_name", + side_effect=lambda name: f"decoded_{name}", + ): + result = await organisation_service.resolve_entity_names(entity_ids) + + assert result == {"e1": "decoded_encoded_name_1"} + + +@pytest.mark.asyncio +async def test_resolve_entity_names_empty_list(organisation_service): + result = await organisation_service.resolve_entity_names([]) + assert result == {} + + @pytest.mark.asyncio async def test_fetch_and_map_relations_success( organisation_service, mock_opengin_service From f8ad791195e5e59808a873f9dfed8683b5ef0d89 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 26 Jun 2026 09:36:28 +0530 Subject: [PATCH 16/17] Refactor organisation_service to simplify department link aggregation using setdefault --- src/services/organisation_service.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/services/organisation_service.py b/src/services/organisation_service.py index 59fd830..1c5091a 100644 --- a/src/services/organisation_service.py +++ b/src/services/organisation_service.py @@ -590,9 +590,7 @@ async def fetch_cabinet_flow(self, president_id: str, dates: Sequence[str], max_ # Aggregate movements: each department that goes from previous_index -> node_index is added to that link. if previous_index is not None and node_index is not None: key = (previous_index, node_index) - if key not in links_departments: - links_departments[key] = [] - links_departments[key].append(department_id) + links_departments.setdefault(key, []).append(department_id) links = [ { From 9a38e5f95dbb6a3bd01fb50d5ba76bcab5b54429 Mon Sep 17 00:00:00 2001 From: Rusiru Erandaka Date: Fri, 26 Jun 2026 20:29:33 +0530 Subject: [PATCH 17/17] added relative imports instead of absolute imports in enums --- src/enums/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/enums/__init__.py b/src/enums/__init__.py index ae85baa..6c3e9ba 100644 --- a/src/enums/__init__.py +++ b/src/enums/__init__.py @@ -1,6 +1,6 @@ -from src.enums.kindEnum import KindMajorEnum, KindMinorEnum -from src.enums.relationEnum import RelationNameEnum, RelationDirectionEnum -from src.enums.idEnum import EntityIdEnum +from .kindEnum import KindMajorEnum, KindMinorEnum +from .relationEnum import RelationNameEnum, RelationDirectionEnum +from .idEnum import EntityIdEnum __all__ = [ "KindMajorEnum",