diff --git a/amt/api/deps.py b/amt/api/deps.py index 3a1890ca..29759a07 100644 --- a/amt/api/deps.py +++ b/amt/api/deps.py @@ -32,7 +32,7 @@ time_ago, ) from amt.schema.shared import IterMixin -from amt.schema.webform import WebFormFieldImplementationType, WebFormFieldType +from amt.schema.webform import WebFormFieldType T = TypeVar("T", bound=Enum | LocalizableEnum) @@ -55,7 +55,6 @@ def custom_context_processor( "user": get_user(request), "permissions": permissions, "WebFormFieldType": WebFormFieldType, - "WebFormFieldImplementationType": WebFormFieldImplementationType, } diff --git a/amt/api/editable.py b/amt/api/editable.py index 98d2b524..5437f309 100644 --- a/amt/api/editable.py +++ b/amt/api/editable.py @@ -219,7 +219,7 @@ async def get_enriched_resolved_editable( ) -> ResolvedEditable: """ Using the given full_resource_path, resolves the resource and current value. - For example, using /algorithm/1/systemcard/info, the value of the info field end the resource, + For example, using /algorithm/1/systemcard/info, the value of the info field and the resource, being an algorithm object, are available. The first is used in 'get' situations, the resource_object can be used to store a new value. diff --git a/amt/api/http_browser_caching.py b/amt/api/http_browser_caching.py index dcdf78c2..ec35ffdc 100644 --- a/amt/api/http_browser_caching.py +++ b/amt/api/http_browser_caching.py @@ -1,4 +1,5 @@ import os +import sys import urllib from functools import lru_cache from os import PathLike @@ -61,7 +62,7 @@ class URLComponents(NamedTuple): fragment: str -@lru_cache(maxsize=1000) +@lru_cache(maxsize=0 if "pytest" in sys.modules else 1000) def url_for_cache(name: str, /, **path_params: str) -> str: if name != "static": raise AMTOnlyStatic() diff --git a/amt/api/routes/algorithm.py b/amt/api/routes/algorithm.py index d303f3fe..020e5fef 100644 --- a/amt/api/routes/algorithm.py +++ b/amt/api/routes/algorithm.py @@ -26,7 +26,7 @@ resolve_base_navigation_items, resolve_navigation_items, ) -from amt.api.routes.shared import UpdateFieldModel, get_filters_and_sort_by +from amt.api.routes.shared import UpdateFieldModel, get_filters_and_sort_by, replace_none_with_empty_string_inplace from amt.core.authorization import get_user from amt.core.exceptions import AMTError, AMTNotFound, AMTRepositoryError from amt.core.internationalization import get_current_translation @@ -377,8 +377,11 @@ async def get_system_card( request, ) + system_card = algorithm.system_card + replace_none_with_empty_string_inplace(system_card) + context = { - "system_card": algorithm.system_card, + "system_card": system_card, "instrument_state": instrument_state, "requirements_state": requirements_state, "last_edited": algorithm.last_edited, @@ -469,6 +472,9 @@ async def get_system_card_requirements( extended_linked_measures: list[ExtendedMeasureTask] = [] for measure in linked_measures: measure_task = find_measure_task(algorithm.system_card, measure.urn) + # TODO: it is strange if measures would be missing as they should be added by the requirements? + if measure_task is None: + measure_tasks.append(measure) if measure_task not in measure_tasks: measure_tasks.append(measure_task) if measure_task: @@ -509,7 +515,7 @@ async def get_measure_task_functions( ) -> dict[str, list[Any]]: measure_task_functions: dict[str, list[Any]] = defaultdict(list) for measure_task in measure_tasks: - if measure_task.accountable_persons: # pyright: ignore [reportOptionalMemberAccess] + if hasattr(measure_task, "accountable_persons") and len(measure_task.accountable_persons) > 0: members_accountable = await users_repository.find_all( search=measure_task.accountable_persons[0].name, # pyright: ignore [reportOptionalMemberAccess] sort=sort_by, @@ -518,7 +524,7 @@ async def get_measure_task_functions( if members_accountable: measure_task_functions[measure_task.urn].append(members_accountable[0]) # pyright: ignore [reportOptionalMemberAccess] - if measure_task.reviewer_persons: # pyright: ignore [reportOptionalMemberAccess] + if hasattr(measure_task, "reviewer_persons") and len(measure_task.reviewer_persons) > 0: members_reviewer = await users_repository.find_all( search=measure_task.reviewer_persons[0].name, # pyright: ignore [reportOptionalMemberAccess] sort=sort_by, @@ -527,7 +533,7 @@ async def get_measure_task_functions( if members_reviewer: measure_task_functions[measure_task.urn].append(members_reviewer[0]) # pyright: ignore [reportOptionalMemberAccess] - if measure_task.responsible_persons: # pyright: ignore [reportOptionalMemberAccess] + if hasattr(measure_task, "responsible_persons") and len(measure_task.responsible_persons) > 0: members_responsible = await users_repository.find_all( search=measure_task.responsible_persons[0].name, # pyright: ignore [reportOptionalMemberAccess] sort=sort_by, @@ -821,6 +827,8 @@ async def get_assessment_card( logger.warning("assessment card not found") raise AMTNotFound() + editables = get_resolved_editables(context_variables={"algorithm_id": algorithm_id}) + context = { "instrument_state": instrument_state, "requirements_state": requirements_state, @@ -828,6 +836,8 @@ async def get_assessment_card( "last_edited": algorithm.last_edited, "sub_menu_items": sub_menu_items, "breadcrumbs": breadcrumbs, + "algorithm_id": algorithm.id, + "editables": editables, } return templates.TemplateResponse(request, "pages/assessment_card.html.j2", context) @@ -869,7 +879,10 @@ async def get_model_card( logger.warning("model card not found") raise AMTNotFound() + editables = get_resolved_editables(context_variables={"algorithm_id": algorithm_id}) + context = { + "base_href": f"/algorithm/{ algorithm_id }", "instrument_state": instrument_state, "requirements_state": requirements_state, "model_card": model_card_data, @@ -878,6 +891,7 @@ async def get_model_card( "algorithm": algorithm, "algorithm_id": algorithm.id, "tab_items": tab_items, + "editables": editables, } return templates.TemplateResponse(request, "pages/model_card.html.j2", context) diff --git a/amt/api/routes/shared.py b/amt/api/routes/shared.py index 15b99539..2b600c06 100644 --- a/amt/api/routes/shared.py +++ b/amt/api/routes/shared.py @@ -9,6 +9,7 @@ from amt.api.organization_filter_options import OrganizationFilterOptions, get_localized_organization_filter from amt.api.risk_group import RiskGroup, get_localized_risk_group from amt.schema.localized_value_item import LocalizedValueItem +from amt.schema.shared import IterMixin def get_filters_and_sort_by( @@ -94,3 +95,34 @@ def nested_enum_value(obj: Any, attr_path: str, language: str) -> Any: # noqa: class UpdateFieldModel(BaseModel): value: str + + +def replace_none_with_empty_string_inplace(obj: dict | list | IterMixin) -> None: # noqa: C901 + """ + Recursively replaces all None values within a list, dict, + or an IterMixin (class) object with an empty string. + This function modifies the object in-place. + + Args: + obj: The input object, which can be a list, dict, or an IterMixin (class) object. + """ + if isinstance(obj, list): + for i, item in enumerate(obj): + if item is None and isinstance(item, str): + obj[i] = "" + elif isinstance(item, list | dict | IterMixin): + replace_none_with_empty_string_inplace(item) + + elif isinstance(obj, dict): + for key, value in obj.items(): + if value is None and isinstance(value, str): + obj[key] = "" + elif isinstance(value, (list, dict, IterMixin)): # noqa: UP038 + replace_none_with_empty_string_inplace(value) + + elif isinstance(obj, IterMixin): + for item in obj: + if isinstance(item, tuple) and item[1] is None: + setattr(obj, item[0], "") + if isinstance(item, list | dict | IterMixin): + replace_none_with_empty_string_inplace(item) diff --git a/amt/clients/clients.py b/amt/clients/clients.py index 5522d57f..338a7b04 100644 --- a/amt/clients/clients.py +++ b/amt/clients/clients.py @@ -1,4 +1,5 @@ import logging +import sys from enum import StrEnum from typing import Any @@ -54,7 +55,7 @@ async def get_task_by_urn(self, task_type: TaskType, urn: str, version: str = "l return response_data -@alru_cache +@alru_cache(maxsize=0 if "pytest" in sys.modules else 1000) async def get_task_by_urn(task_type: TaskType, urn: str, version: str = "latest") -> dict[str, Any]: client = TaskRegistryAPIClient() return await client.get_task_by_urn(task_type, urn, version) diff --git a/amt/core/internationalization.py b/amt/core/internationalization.py index 6d083ae2..371c5fc6 100644 --- a/amt/core/internationalization.py +++ b/amt/core/internationalization.py @@ -1,4 +1,5 @@ import logging +import sys from datetime import UTC, datetime, timedelta from functools import lru_cache @@ -13,7 +14,7 @@ supported_translations: tuple[str, ...] = ("en", "nl") -@lru_cache(maxsize=len(supported_translations)) +@lru_cache(maxsize=0 if "pytest" in sys.modules else len(supported_translations)) def get_dynamic_field_translations(lang: str) -> dict[str, str]: lang = get_supported_translation(lang) with open(f"amt/languages/{lang}.yaml") as stream: @@ -27,7 +28,7 @@ def get_supported_translation(lang: str) -> str: return lang -@lru_cache(maxsize=len(supported_translations)) +@lru_cache(maxsize=0 if "pytest" in sys.modules else len(supported_translations)) def get_translation(lang: str) -> NullTranslations: lang = get_supported_translation(lang) return Translations.load("amt/locale", locales=lang) diff --git a/amt/middleware/authorization.py b/amt/middleware/authorization.py index 6b666396..a0380c47 100644 --- a/amt/middleware/authorization.py +++ b/amt/middleware/authorization.py @@ -19,6 +19,12 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - if request.url.path.startswith("/static/"): return await call_next(request) + auth_disable = False if not os.environ.get("DISABLE_AUTH") else os.environ.get("DISABLE_AUTH").lower() == "true" + if auth_disable: + auto_login_uuid: id = os.environ.get("AUTO_LOGIN_UUID", None) + if auto_login_uuid: + request.session["user"] = {"sub": auto_login_uuid} + authorization_service = AuthorizationService() user = get_user(request) @@ -30,8 +36,6 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - response = await call_next(request) - auth_disable: bool = bool(os.environ.get("DISABLE_AUTH", False)) - if auth_disable: return response diff --git a/amt/schema/system_card.py b/amt/schema/system_card.py index d8d1e656..5b7cb2b9 100644 --- a/amt/schema/system_card.py +++ b/amt/schema/system_card.py @@ -113,19 +113,21 @@ class Owner(BaseModel): class SystemCard(BaseModel): - version: str = Field(..., description="The version of the schema used") + version: str | None = Field(description="The version of the schema used", default="0.0.0") provenance: Provenance | None = None name: str | None = Field(None, description="Name used to describe the system") - instruments: list[InstrumentBase] = Field(default=[]) + instruments: list[InstrumentBase] = Field(default_factory=list) upl: str | None = Field( None, description="If this algorithm is part of a product offered by the Dutch Government," "it should contain a URI from the Uniform Product List", ) - owners: list[Owner] | None = None + owners: list[Owner] = Field(default_factory=list) description: str | None = Field(None, description="A short description of the system") ai_act_profile: AiActProfile | None = None - labels: list[Label] | None = Field(None, description="Labels to store meta information about the system") + labels: list[Label] | None = Field( + default_factory=list, description="Labels to store meta information about the system" + ) status: str | None = Field(None, description="Status of the system") begin_date: date | None = Field( None, diff --git a/amt/services/algorithms.py b/amt/services/algorithms.py index a8afb769..32330cea 100644 --- a/amt/services/algorithms.py +++ b/amt/services/algorithms.py @@ -1,5 +1,6 @@ import json import logging +import sys from datetime import datetime from functools import lru_cache from os import listdir @@ -129,7 +130,7 @@ async def update(self, algorithm: Algorithm) -> Algorithm: return algorithm -@lru_cache +@lru_cache(maxsize=0 if "pytest" in sys.modules else 256) def get_template_files() -> dict[str, dict[str, str]]: return { str(i): {"display_value": k.split(".")[0].replace("_", " "), "value": k} diff --git a/amt/site/static/scss/layout.scss b/amt/site/static/scss/layout.scss index c592ec6e..4052c062 100644 --- a/amt/site/static/scss/layout.scss +++ b/amt/site/static/scss/layout.scss @@ -340,9 +340,15 @@ main { } /* we override the default ROOS style because we want to display as column, not rows */ -.amt-theme .rvo-accordion__item > .rvo-accordion__item-summary { - align-items: initial; - flex-direction: column; +.amt-theme { + & .rvo-accordion__item > .rvo-accordion__item-summary { + align-items: initial; + flex-direction: column; + } + + & .rvo-accordion__item-title { + align-items: baseline; + } } .amt-avatar-list { @@ -469,8 +475,14 @@ main { } /** TODO: this is a fix for width: 100% on a margin-left element which should be fixed by ROOS */ -.amt-theme .rvo-header__logo-wrapper { - width: auto; +.amt-theme { + & .rvo-header__logo-wrapper { + width: auto; + } + + & main { + margin-bottom: var(--rvo-size-2xl); + } } /* stylelint-enable */ diff --git a/amt/site/templates/algorithms/details_requirements.html.j2 b/amt/site/templates/algorithms/details_requirements.html.j2 index f2fe620b..bb1ed7c5 100644 --- a/amt/site/templates/algorithms/details_requirements.html.j2 +++ b/amt/site/templates/algorithms/details_requirements.html.j2 @@ -15,7 +15,7 @@ {{ requirement.name }}