diff --git a/amt/api/deps.py b/amt/api/deps.py index be07ee5d..49aa7b1a 100644 --- a/amt/api/deps.py +++ b/amt/api/deps.py @@ -1,19 +1,17 @@ import logging -from collections.abc import Sequence from enum import Enum -from os import PathLike from pyclbr import Class -from typing import Any, AnyStr, TypeVar +from typing import TypeVar -from fastapi import Request -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from jinja2 import Environment, StrictUndefined, Undefined -from starlette.background import BackgroundTask -from starlette.templating import _TemplateResponse # pyright: ignore [reportPrivateUsage] +from jinja2 import StrictUndefined, Undefined +from starlette.requests import Request -from amt.api.editable import is_editable_resource, is_parent_editable -from amt.api.editable_util import replace_digits_in_brackets, resolve_resource_list_path +from amt.api.editable_util import ( + is_editable_resource, + is_parent_editable, + replace_digits_in_brackets, + resolve_resource_list_path, +) from amt.api.http_browser_caching import url_for_cache from amt.api.localizable import LocalizableEnum from amt.api.navigation import NavigationItem, get_main_menu @@ -25,6 +23,7 @@ nested_enum_value, nested_value, ) +from amt.api.template_classes import LocaleJinja2Templates from amt.core.authorization import AuthorizationVerb, get_user from amt.core.config import VERSION, get_settings from amt.core.internationalization import ( @@ -33,8 +32,6 @@ get_current_translation, get_dynamic_field_translations, get_requested_language, - get_supported_translation, - get_translation, supported_translations, time_ago, ) @@ -78,51 +75,6 @@ def permission(permission: str, verb: AuthorizationVerb, permissions: dict[str, return authorized -# we use a custom override so we can add the translation per request, which is parsed in the Request object in kwargs -class LocaleJinja2Templates(Jinja2Templates): - def _create_env( - self, - directory: str | PathLike[AnyStr] | Sequence[str | PathLike[AnyStr]], - **env_options: Any, # noqa: ANN401 - ) -> Environment: - env: Environment = super()._create_env(directory, **env_options) # pyright: ignore [reportUnknownMemberType, reportUnknownVariableType, reportArgumentType] - env.add_extension("jinja2.ext.i18n") # pyright: ignore [reportUnknownMemberType] - return env # pyright: ignore [reportUnknownVariableType] - - def TemplateResponse( # pyright: ignore [reportIncompatibleMethodOverride] - self, - request: Request, - name: str, - context: dict[str, Any] | None = None, - status_code: int = 200, - headers: dict[str, str] | None = None, - media_type: str | None = None, - background: BackgroundTask | None = None, - ) -> _TemplateResponse: - content_language = get_supported_translation(get_requested_language(request)) - translations = get_translation(content_language) - if headers is None: - headers = {} - headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" - if "Content-Language" not in headers: - headers["Content-Language"] = ",".join(supported_translations) - self.env.install_gettext_translations(translations, newstyle=True) # pyright: ignore [reportUnknownMemberType] - - if context is None: - context = {} - - if hasattr(request.state, "csrftoken"): - context["csrftoken"] = request.state.csrftoken - else: - context["csrftoken"] = "" - - return super().TemplateResponse(request, name, context, status_code, headers, media_type, background) - - def Redirect(self, request: Request, url: str) -> HTMLResponse: - headers = {"HX-Redirect": url} - return self.TemplateResponse(request, "redirect.html.j2", headers=headers) - - def instance(obj: Class, type_string: str) -> bool: match type_string: case "str": diff --git a/amt/api/editable.py b/amt/api/editable.py index 396136d6..6b216836 100644 --- a/amt/api/editable.py +++ b/amt/api/editable.py @@ -4,18 +4,18 @@ from starlette.requests import Request -from amt.api.editable_classes import Editable, EditableType, EditModes, ResolvedEditable +from amt.api.editable_classes import Editable, EditableType, EditModes, FormState, ResolvedEditable from amt.api.editable_converters import EditableConverterForOrganizationInAlgorithm, StatusConverterForSystemcard from amt.api.editable_enforcers import EditableEnforcerForOrganizationInAlgorithm +from amt.api.editable_hooks import PreConfirmAIActHook, RedirectOrganizationHook, UpdateAIActHook from amt.api.editable_util import ( - extract_number_and_string, - replace_digits_in_brackets, replace_wildcard_with_digits_in_brackets, ) from amt.api.editable_validators import EditableValidatorMinMaxLength, EditableValidatorSlug from amt.api.editable_value_providers import AIActValuesProvider from amt.api.lifecycles import get_localized_lifecycles from amt.api.routes.shared import nested_value +from amt.api.update_utils import extract_number_and_string, set_path from amt.api.utils import SafeDict from amt.core.exceptions import AMTNotFound from amt.models import Algorithm, Organization @@ -269,11 +269,13 @@ class Editables: full_resource_path="organization/{organization_id}/slug", implementation_type=WebFormFieldImplementationType.TEXT, validator=EditableValidatorSlug(), + hooks={FormState.POST_SAVE: RedirectOrganizationHook()}, ) ALGORITHM_EDITABLE_AIACT = Editable( full_resource_path="algorithm/{algorithm_id}/system_card/ai_act_profile", implementation_type=WebFormFieldImplementationType.PARENT, + hooks={FormState.PRE_CONFIRM: PreConfirmAIActHook(), FormState.POST_SAVE: UpdateAIActHook()}, children=[ Editable( full_resource_path="algorithm/{algorithm_id}/system_card/ai_act_profile/role", @@ -481,6 +483,7 @@ def resolve_editable_path( converter=editable.converter, enforcer=editable.enforcer, validator=editable.validator, + hooks=editable.hooks, ) editables_resolved: list[ResolvedEditable] = [] @@ -494,6 +497,7 @@ def resolve_editable_path( return {editable.full_resource_path: editable for editable in editables_resolved} +# TODO: this probably should be a method of ResolvedEditable async def save_editable( # noqa: C901 editable: ResolvedEditable, editable_context: dict[str, Any], @@ -516,7 +520,6 @@ async def save_editable( # noqa: C901 new_value = editable_context.get("new_values", {}).get(editable.last_path_item()) # we validate on 'raw' form fields, so validation is done before the converter - # TODO: validate all fields (child and couples) before saving! if editable.validator and editable.relative_resource_path is not None: await editable.validator.validate(new_value, editable) # pyright: ignore[reportUnknownMemberType] @@ -566,45 +569,3 @@ async def save_editable( # noqa: C901 raise AMTNotFound() return editable - - -def set_path(obj: dict[str, Any] | object, path: str, value: typing.Any) -> None: # noqa: ANN401, C901 - if not path: - raise ValueError("Path cannot be empty") - - attrs = path.lstrip("/").split("/") - for attr in attrs[:-1]: - attr, index = extract_number_and_string(attr) - if isinstance(obj, dict): - obj = cast(dict[str, Any], obj) - if attr not in obj: - obj[attr] = {} - obj = obj[attr] - else: - if not hasattr(obj, attr): # pyright: ignore[reportUnknownArgumentType] - setattr(obj, attr, {}) # pyright: ignore[reportUnknownArgumentType] - obj = getattr(obj, attr) # pyright: ignore[reportUnknownArgumentType] - if obj and index is not None: - obj = cast(list[Any], obj)[index] # pyright: ignore[reportArgumentType, reportUnknownVariableType, reportUnknownArgumentType] - - if isinstance(obj, dict): - obj[attrs[-1]] = value - else: - attr, index = extract_number_and_string(attrs[-1]) - if index is not None: - cast(list[Any], getattr(obj, attr))[index] = value - else: - setattr(obj, attrs[-1], value) - - -def is_editable_resource(full_resource_path: str, editables: dict[str, ResolvedEditable]) -> bool: - return editables.get(replace_digits_in_brackets(full_resource_path), None) is not None - - -def is_parent_editable(editables: dict[str, ResolvedEditable], full_resource_path: str) -> bool: - full_resource_path = replace_digits_in_brackets(full_resource_path) - editable = editables.get(full_resource_path) - if editable is None: - return False - result = editable.implementation_type == WebFormFieldImplementationType.PARENT - return result diff --git a/amt/api/editable_classes.py b/amt/api/editable_classes.py index f76ebef7..eddc1c2f 100644 --- a/amt/api/editable_classes.py +++ b/amt/api/editable_classes.py @@ -1,6 +1,11 @@ +import logging import re -from enum import StrEnum -from typing import Any, Final +from abc import ABC, abstractmethod +from enum import Enum, StrEnum, auto +from typing import Any, Final, cast + +from fastapi import Request +from starlette.responses import HTMLResponse from amt.api.editable_converters import ( EditableConverter, @@ -8,12 +13,87 @@ from amt.api.editable_enforcers import EditableEnforcer from amt.api.editable_validators import EditableValidator from amt.api.editable_value_providers import EditableValuesProvider +from amt.api.template_classes import LocaleJinja2Templates from amt.models.base import Base from amt.schema.webform import WebFormFieldImplementationTypeFields, WebFormOption type EditableType = Editable +type FormStateType = FormState type ResolvedEditableType = ResolvedEditable +logger = logging.getLogger(__name__) + + +class FormState(Enum): + """ + The FormState is used to streamline the form flow for + the inline editor. States can have a hook attacked to it, which is + registered in the Editable object. + """ + + VALIDATE = auto() + PRE_CONFIRM = auto() + CONFIRM_SAVE = auto() + PRE_SAVE = auto() + SAVE = auto() + POST_SAVE = auto() + COMPLETED = auto() + + @classmethod + def pre_save_states(cls) -> frozenset[FormStateType]: + return frozenset({cls.PRE_CONFIRM, cls.CONFIRM_SAVE, cls.PRE_SAVE}) + + @classmethod + def post_save_states(cls) -> frozenset[FormStateType]: + return frozenset({cls.POST_SAVE, cls.COMPLETED}) + + def is_before_save(self) -> bool: + return self in self.pre_save_states() + + def is_validate(self) -> bool: + return self == self.VALIDATE + + def is_after_save(self) -> bool: + return self in self.post_save_states() + + def is_save(self) -> bool: + return self == self.SAVE + + @classmethod + def get_next_state(cls, state: FormStateType) -> FormStateType: + if state.value >= cls.COMPLETED.value: + return cls.COMPLETED + next_state = cls(state.value + 1) + logger.info(f"FormState is moving to next state: {next_state}") + return next_state + + @classmethod + def all_states_after(cls, state: FormStateType) -> list[FormStateType]: + return [s for s in cls if s.value > state.value] + + @classmethod + def from_string(cls, state_name: str) -> FormStateType: + try: + return cast(FormState, cls[state_name]) + except KeyError as e: + raise ValueError(f"Invalid state name: {state_name}") from e + + +class EditableHook(ABC): + """ + Hooks can be used to run a function at a specific moment in the FormState flow. + """ + + @abstractmethod + async def execute( + self, + request: Request, + templates: LocaleJinja2Templates, + editable: ResolvedEditableType, + editable_context: dict[str, str | dict[str, str]], + ) -> HTMLResponse | None: + pass + class Editable: """ @@ -42,6 +122,7 @@ def __init__( converter: EditableConverter | None = None, enforcer: EditableEnforcer | None = None, validator: EditableValidator | None = None, + hooks: dict[FormState, EditableHook] | None = None, # TODO: determine if relative resource path is really required for editable relative_resource_path: str | None = None, ) -> None: @@ -54,6 +135,7 @@ def __init__( self.enforcer = enforcer self.validator = validator self.relative_resource_path = relative_resource_path + self.hooks = hooks def add_bidirectional_couple(self, target: EditableType) -> None: """ @@ -74,6 +156,12 @@ def add_child(self, target: EditableType) -> None: """ self.children.append(target) + def register_hook(self, state: FormState, hook: EditableHook) -> None: + if self.hooks is not None: + self.hooks[state] = hook + else: + raise ValueError("Cannot register hook because hooks is None") + class ResolvedEditable: value: Any | None @@ -97,6 +185,7 @@ def __init__( value: str | None = None, resource_object: Base | None = None, relative_resource_path: str | None = None, + hooks: dict[FormState, EditableHook] | None = None, ) -> None: self.full_resource_path = full_resource_path self.implementation_type = implementation_type @@ -110,6 +199,7 @@ def __init__( self.value = value self.resource_object = resource_object self.relative_resource_path = relative_resource_path + self.hooks = hooks def last_path_item(self) -> str: return self.full_resource_path.split("/")[-1] @@ -122,6 +212,38 @@ def safe_html_path(self) -> str: return re.sub(r"[\[\]/*]", "_", self.relative_resource_path) # pyright: ignore[reportUnknownVariableType, reportCallIssue] raise ValueError("Can not convert path to save html path as it is None") + def has_hook(self, state: FormState) -> bool: + if self.hooks is None: + return False + return state in self.hooks + + def get_hook(self, state: FormState) -> EditableHook | None: + if self.hooks is None: + return None + return self.hooks.get(state) + + async def run_hook( + self, + state: FormState, + request: Request, + templates: LocaleJinja2Templates, + editable: ResolvedEditableType, + editable_context: dict[str, str | dict[str, str]], + ) -> HTMLResponse | None: + if self.hooks is not None and self.has_hook(state): + logger.info(f"Running hook for state {state} for editable {self.full_resource_path}") + return await self.hooks[state].execute(request, templates, editable, editable_context) + return None + + async def validate(self, editable_context: dict[str, Any]) -> None: + editables_to_validate = list(self.couples or set()) + (self.children or []) + [self] + for editable in editables_to_validate: + new_value = editable_context.get("new_values", {}).get(editable.last_path_item()) + if editable.validator and editable.relative_resource_path is not None: + await editable.validator.validate(new_value, editable) # pyright: ignore[reportUnknownMemberType] + if editable.enforcer: + await editable.enforcer.enforce(**editable_context) + class EditModes(StrEnum): EDIT = "EDIT" diff --git a/amt/api/editable_hooks.py b/amt/api/editable_hooks.py new file mode 100644 index 00000000..0a2f7f8c --- /dev/null +++ b/amt/api/editable_hooks.py @@ -0,0 +1,157 @@ +from typing import Any, cast + +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from amt.api.editable_classes import EditableHook, ResolvedEditable +from amt.api.routes.shared import nested_value +from amt.api.template_classes import LocaleJinja2Templates +from amt.api.update_utils import set_path +from amt.api.utils import compare_lists +from amt.enums.tasks import TaskType +from amt.repositories.tasks import TasksRepository +from amt.schema.ai_act_profile import AiActProfile +from amt.schema.measure import MeasureTask +from amt.schema.requirement import RequirementTask +from amt.services.algorithms import AlgorithmsService, sort_by_measure_name +from amt.services.instruments_and_requirements_state import get_first_lifecycle_idx +from amt.services.measures import measures_service +from amt.services.requirements import requirements_service +from amt.services.task_registry import get_requirements_and_measures + + +class RedirectOrganizationHook(EditableHook): + async def execute( + self, + request: Request, + templates: LocaleJinja2Templates, + editable: ResolvedEditable, + editable_context: dict[str, str | dict[str, str]], + ) -> HTMLResponse: + return templates.Redirect(request, f"/organizations/{editable.value}") + + +class UpdateAIActHook(EditableHook): + async def execute( + self, + request: Request, + templates: LocaleJinja2Templates, + editable: ResolvedEditable, + editable_context: dict[str, str | dict[str, str]], + ) -> None: + new_values: dict[str, str] = cast(dict[str, str], editable_context.get("new_values", {})) + + if "algorithms_service" not in editable_context: + raise TypeError("AlgorithmSService is missing but required") + if "tasks_service" not in editable_context: + raise TypeError("TasksService is missing but required") + if not editable.resource_object: + raise TypeError("ResourceObject is missing but required") + + current_requirements: list[RequirementTask] = nested_value( + editable.resource_object, "system_card/requirements", [] + ) + current_measures: list[MeasureTask] = nested_value(editable.resource_object, "system_card/measures", []) + ( + added_measures, + added_requirements, + removed_measures, + removed_requirements, + ) = await get_ai_act_differences(current_requirements, current_measures, new_values) + + # get urns of the removed items + removed_requirements_urns = [item.urn for item in removed_requirements] + # removed_measures_urns = [item.urn for item in removed_measures] # noqa ERA001 + + # delete the removed items from the current sets + updated_requirements = [task for task in current_requirements if task.urn not in removed_requirements_urns] + # TODO: we do not remove measures at this point + updated_measures = list(current_measures) # if task.urn not in removed_measures_urns] + + + # add new items to the current sets + updated_requirements.extend(added_requirements) + updated_measures.extend(added_measures) + + # update and save the systemcard + set_path(editable.resource_object, "system_card/requirements", updated_requirements) + set_path(editable.resource_object, "system_card/measures", updated_measures) + await cast(AlgorithmsService, editable_context.get("algorithms_service")).update(editable.resource_object) + + # remove tasks from deleted measures + await cast(TasksRepository, editable_context.get("tasks_service")).remove_tasks( + editable.resource_object.id, TaskType.MEASURE, removed_measures + ) + # add new tasks for added measures + last_task = await cast(TasksRepository, editable_context.get("tasks_service")).get_last_task( + editable.resource_object.id + ) + start_at_sort_order = last_task.sort_order + 10 if last_task else 0 + measures_sorted: list[MeasureTask] = sorted( # pyright: ignore[reportUnknownVariableType, reportCallIssue] + added_measures, + key=lambda measure: (get_first_lifecycle_idx(measure.lifecycle), sort_by_measure_name(measure)), # pyright: ignore[reportArgumentType] + ) + await cast(TasksRepository, editable_context.get("tasks_service")).add_tasks( + editable.resource_object.id, TaskType.MEASURE, measures_sorted, start_at_sort_order + ) + + +class PreConfirmAIActHook(EditableHook): + async def execute( + self, + request: Request, + templates: LocaleJinja2Templates, + editable: ResolvedEditable, + editable_context: dict[str, str | dict[str, str]], + ) -> HTMLResponse: + new_values: dict[str, str] = cast(dict[str, str], editable_context.get("new_values", {})) + headers = {"Hx-Trigger": "openModal", "HX-Reswap": "innerHTML", "HX-Retarget": "#dynamic-modal-content"} + context: dict[str, Any] = {"new_values": new_values} + + current_requirements = nested_value(editable.resource_object, "system_card/requirements", []) + current_measures = nested_value(editable.resource_object, "system_card/measures", []) + + ( + added_measures, + added_requirements, + removed_measures, + removed_requirements, + ) = await get_ai_act_differences(current_requirements, current_measures, new_values) + + added_requirements = await requirements_service.fetch_requirements([item.urn for item in added_requirements]) + removed_requirements = await requirements_service.fetch_requirements( + [getattr(item, "urn", "") for item in removed_requirements] + ) + added_measures = await measures_service.fetch_measures([item.urn for item in added_measures]) + removed_measures = await measures_service.fetch_measures([item.urn for item in removed_measures]) + + context.update( + { + "added_requirements": added_requirements, + "removed_requirements": removed_requirements, + "added_measures": added_measures, + "removed_measures": removed_measures, + } + ) + + return templates.TemplateResponse(request, "algorithms/ai_act_changes_modal.html.j2", context, headers=headers) + + +async def get_ai_act_differences( + current_requirements: list[RequirementTask], + current_measures: list[MeasureTask], + new_values: dict[str, str], +) -> tuple[list[MeasureTask], list[RequirementTask], list[MeasureTask], list[RequirementTask]]: + ai_act_profile = AiActProfile( + type=new_values.get("type"), + open_source=new_values.get("open_source"), + risk_group=new_values.get("risk_group"), + conformity_assessment_body=new_values.get("conformity_assessment_body"), + systemic_risk=new_values.get("systemic_risk"), + transparency_obligations=new_values.get("transparency_obligations"), + role=new_values.get("role"), + ) + new_requirements, new_measures = await get_requirements_and_measures(ai_act_profile) + added_requirements, removed_requirements = compare_lists(current_requirements, new_requirements, "urn", "urn") + added_measures, removed_measures = compare_lists(current_measures, new_measures, "urn", "urn") + return added_measures, added_requirements, removed_measures, removed_requirements diff --git a/amt/api/editable_route_utils.py b/amt/api/editable_route_utils.py new file mode 100644 index 00000000..ad2d134b --- /dev/null +++ b/amt/api/editable_route_utils.py @@ -0,0 +1,111 @@ +from typing import Any + +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from amt.api.deps import templates +from amt.api.editable import get_enriched_resolved_editable, get_resolved_editables, save_editable +from amt.api.editable_classes import EditModes, FormState, ResolvedEditable +from amt.core.authorization import get_user +from amt.core.exceptions import AMTError +from amt.services.algorithms import AlgorithmsService +from amt.services.organizations import OrganizationsService +from amt.services.tasks import TasksService + + +async def update_handler( # noqa: C901 + request: Request, + full_resource_path: str, + base_href: str, + current_state_str: str, + context_variables: dict[str, str | int], + algorithms_service: AlgorithmsService | None, + organizations_service: OrganizationsService | None, + tasks_service: TasksService | None, +) -> HTMLResponse: + user_id = get_user_id_or_error(request) + new_values = await request.json() + current_state = FormState.from_string(current_state_str) + + editable: ResolvedEditable = await get_enriched_resolved_editable( + context_variables=context_variables, + full_resource_path=full_resource_path, + algorithms_service=algorithms_service, + organizations_service=organizations_service, + edit_mode=EditModes.SAVE, + ) + + editable_context: dict[str, Any] = { + "user_id": user_id, + "new_values": new_values, + "organizations_service": organizations_service, + "algorithms_service": algorithms_service, + "tasks_service": tasks_service, + } + + if current_state.is_validate(): + # if validations fail, an exception is thrown and handled by the exception handler + await editable.validate(editable_context) + # if validation succeeds, we automatically continue to the next state + current_state = FormState.get_next_state(current_state) + + while current_state.is_before_save(): + if editable.has_hook(current_state): + result = await editable.run_hook(current_state, request, templates, editable, editable_context) + if result is not None: + return result + # if no hook exists or no hook returns a response, we automatically continue to the next state + current_state = FormState.get_next_state(current_state) + + if current_state.is_save(): + editable = await save_editable( + editable, + editable_context=editable_context, + algorithms_service=algorithms_service, + organizations_service=organizations_service, + do_save=True, + ) + current_state = FormState.get_next_state(current_state) + + if current_state.is_after_save(): + while current_state is not FormState.COMPLETED: + if editable.has_hook(current_state): + result = await editable.run_hook(current_state, request, templates, editable, editable_context) + if result is not None: + return result + # if no hook exists or no hook returns a response, we automatically continue to the next state + current_state = FormState.get_next_state(current_state) + + # If below is true, the default save was executed and there are no after save hooks or + # none of the hooks returned a 'not None' response. We then return the default response. + # We return this response here instead of at the default save action itself, because + # post save hooks must be executed before we can return the response + if current_state is FormState.COMPLETED: + # set the value back to view mode if needed + sub_editables = (editable.children or []) + (list(editable.couples) or []) + if sub_editables: + for sub_editable in sub_editables: + if sub_editable.converter: + sub_editable.value = await sub_editable.converter.view(sub_editable.value, **editable_context) + else: + if editable.converter: + editable.value = await editable.converter.view(editable.value, **editable_context) + + context = { + "relative_resource_path": editable.relative_resource_path if editable.relative_resource_path else "", + "base_href": base_href, + "resource_object": editable.resource_object, + "full_resource_path": full_resource_path, + "editable_object": editable, + "editables": get_resolved_editables(context_variables=context_variables), + } + + return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) + return templates.TemplateResponse(request, "parts/view_cell.html.j2", {}) + + +def get_user_id_or_error(request: Request) -> str: + user = get_user(request) + if user is None or user["sub"] is None: + raise AMTError + return user["sub"] diff --git a/amt/api/editable_util.py b/amt/api/editable_util.py index 46becaba..c7b200a9 100644 --- a/amt/api/editable_util.py +++ b/amt/api/editable_util.py @@ -1,5 +1,9 @@ import re +from amt.api.editable_classes import ResolvedEditable +from amt.api.update_utils import extract_number_and_string +from amt.schema.webform import WebFormFieldImplementationType + def replace_digits_in_brackets(string: str) -> str: return re.sub(r"\[(\d+)]", "[*]", string) @@ -23,20 +27,14 @@ def resolve_resource_list_path(full_resource_path_resolved: str, relative_resour return replace_wildcard_with_digits_in_brackets(relative_resource_path, index) -def extract_number_and_string(input_string: str) -> tuple[str, int | None]: - """ - Extracts the number within square brackets and the string before the brackets - from a given input string. +def is_editable_resource(full_resource_path: str, editables: dict[str, ResolvedEditable]) -> bool: + return editables.get(replace_digits_in_brackets(full_resource_path), None) is not None - Returns: - A tuple containing: - - The string before the brackets. - - The number within the brackets (as an integer) or None if no number is found within brackets. - """ - match = re.search(r"(.+)\[(\d+)]", input_string) - if match: - string_before = match.group(1) - number_in_brackets = int(match.group(2)) - return string_before, number_in_brackets - else: - return input_string, None + +def is_parent_editable(editables: dict[str, ResolvedEditable], full_resource_path: str) -> bool: + full_resource_path = replace_digits_in_brackets(full_resource_path) + editable = editables.get(full_resource_path) + if editable is None: + return False + result = editable.implementation_type == WebFormFieldImplementationType.PARENT + return result diff --git a/amt/api/routes/algorithm.py b/amt/api/routes/algorithm.py index fa04fa72..62b2485f 100644 --- a/amt/api/routes/algorithm.py +++ b/amt/api/routes/algorithm.py @@ -6,7 +6,7 @@ from typing import Annotated, Any, cast import yaml -from fastapi import APIRouter, Depends, File, Form, Query, Request, Response, UploadFile +from fastapi import APIRouter, Depends, File, Form, Header, Query, Request, Response, UploadFile from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from ulid import ULID @@ -15,9 +15,9 @@ from amt.api.editable import ( get_enriched_resolved_editable, get_resolved_editables, - save_editable, ) from amt.api.editable_classes import EditModes, ResolvedEditable +from amt.api.editable_route_utils import get_user_id_or_error, update_handler from amt.api.forms.measure import MeasureStatusOptions, get_measure_form from amt.api.lifecycles import Lifecycles, get_localized_lifecycles from amt.api.navigation import ( @@ -28,7 +28,7 @@ resolve_navigation_items, ) from amt.api.routes.shared import get_filters_and_sort_by, replace_none_with_empty_string_inplace -from amt.core.authorization import AuthorizationResource, AuthorizationVerb, get_user +from amt.core.authorization import AuthorizationResource, AuthorizationVerb from amt.core.exceptions import AMTError, AMTNotFound, AMTPermissionDenied, AMTRepositoryError from amt.core.internationalization import get_current_translation from amt.enums.tasks import Status, TaskType, life_cycle_mapper, measure_state_to_status, status_mapper @@ -91,13 +91,6 @@ async def get_algorithm_or_error( return algorithm -def get_user_id_or_error(request: Request) -> str: - user = get_user(request) - if user is None or user["sub"] is None: - raise AMTError - return user["sub"] - - def get_measure_task_or_error(system_card: SystemCard, measure_urn: str) -> MeasureTask: measure_task = find_measure_task(system_card, measure_urn) if not measure_task: @@ -203,7 +196,7 @@ def filters_match(display_task: DisplayTask) -> bool: breadcrumbs = resolve_base_navigation_items( [ Navigation.ALGORITHMS_ROOT, - BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details/system_card"), + BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details"), Navigation.ALGORITHM_TASKS, ], request, @@ -355,7 +348,7 @@ async def get_algorithm_details( breadcrumbs = resolve_base_navigation_items( [ Navigation.ALGORITHMS_ROOT, - BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details/system_card"), + BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details"), Navigation.ALGORITHM_INFO, ], request, @@ -451,49 +444,24 @@ async def get_algorithm_update( algorithm_id: int, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], + tasks_service: Annotated[TasksService, Depends(TasksService)], full_resource_path: str = Query(""), + current_state_str: str = Header("VALIDATE", alias="X-Current-State"), ) -> HTMLResponse: - user_id = get_user_id_or_error(request) - new_values = await request.json() - - editable: ResolvedEditable = await get_enriched_resolved_editable( - context_variables={"algorithm_id": algorithm_id}, - full_resource_path=full_resource_path, - algorithms_service=algorithms_service, - organizations_service=organizations_service, - edit_mode=EditModes.SAVE, - ) - - editable_context = { - "user_id": user_id, - "new_values": new_values, - "organizations_service": organizations_service, - } + context_variables: dict[str, str | int] = {"algorithm_id": algorithm_id} + base_href = f"/algorithm/{algorithm_id}" - editable = await save_editable( - editable, - editable_context=editable_context, - algorithms_service=algorithms_service, - organizations_service=organizations_service, - do_save=True, + return await update_handler( + request, + full_resource_path, + base_href, + current_state_str, + context_variables, + algorithms_service, + organizations_service, + tasks_service, ) - # set the value back to view mode if needed - # TODO: this needs to be fixed, because it only works for 'editables without children' - if editable.converter: - editable.value = await editable.converter.view(editable.value, **editable_context) - - context = { - "relative_resource_path": editable.relative_resource_path if editable.relative_resource_path else "", - "base_href": f"/algorithm/{algorithm_id}", - "resource_object": editable.resource_object, - "full_resource_path": full_resource_path, - "editable_object": editable, - "editables": get_resolved_editables(context_variables={"algorithm_id": algorithm_id}), - } - - return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) - @router.get("/{algorithm_id}/details") @permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) @@ -513,7 +481,7 @@ async def get_system_card( breadcrumbs = resolve_base_navigation_items( [ Navigation.ALGORITHMS_ROOT, - BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details/system_card"), + BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details"), Navigation.ALGORITHM_DETAILS, ], request, @@ -558,7 +526,7 @@ async def get_system_card_requirements( breadcrumbs = resolve_base_navigation_items( [ Navigation.ALGORITHMS_ROOT, - BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details/system_card"), + BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details"), Navigation.ALGORITHM_COMPLIANCE, ], request, @@ -874,7 +842,7 @@ async def get_algorithm_members( breadcrumbs = resolve_base_navigation_items( [ Navigation.ALGORITHMS_ROOT, - BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details/system_card"), + BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details"), Navigation.ALGORITHM_MEMBERS, ], request, @@ -911,7 +879,7 @@ async def get_assessment_card( breadcrumbs = resolve_base_navigation_items( [ Navigation.ALGORITHMS_ROOT, - BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details/system_card"), + BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details"), Navigation.ALGORITHM_ASSESSMENT_CARD, ], request, @@ -964,7 +932,7 @@ async def get_model_card( breadcrumbs = resolve_base_navigation_items( [ Navigation.ALGORITHMS_ROOT, - BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details/system_card"), + BaseNavigationItem(custom_display_text=algorithm.name, url="/algorithm/{algorithm_id}/details"), Navigation.ALGORITHM_MODEL_CARD, ], request, diff --git a/amt/api/routes/algorithms.py b/amt/api/routes/algorithms.py index 8af4f26c..756d6260 100644 --- a/amt/api/routes/algorithms.py +++ b/amt/api/routes/algorithms.py @@ -1,4 +1,5 @@ import logging +from copy import deepcopy from typing import Annotated, Any, cast from fastapi import APIRouter, Depends, Query, Request @@ -118,6 +119,8 @@ async def get_algorithms( skip=skip, limit=limit, search=search, filters=filters, sort=sort_by ) # pyright: ignore [reportAssignmentType] # todo: the lifecycle has to be 'localized', maybe for display 'Algorithm' should become a different object + # fixme: detach algorithms from the database to prevent commits back by the auto-commit + algorithms = deepcopy(algorithms) for algorithm in algorithms: algorithm.lifecycle = get_localized_lifecycle(algorithm.lifecycle, request) # pyright: ignore [reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownArgumentType] amount_algorithm_systems += len(algorithms) diff --git a/amt/api/routes/organizations.py b/amt/api/routes/organizations.py index 9acaffe5..f1e6a551 100644 --- a/amt/api/routes/organizations.py +++ b/amt/api/routes/organizations.py @@ -2,18 +2,17 @@ from typing import Annotated, Any from uuid import UUID -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Header, Query, Request from fastapi.responses import HTMLResponse, JSONResponse, Response from amt.api.decorators import permission from amt.api.deps import templates from amt.api.editable import ( - Editables, get_enriched_resolved_editable, get_resolved_editables, - save_editable, ) from amt.api.editable_classes import EditModes, ResolvedEditable +from amt.api.editable_route_utils import update_handler from amt.api.forms.organization import get_organization_form from amt.api.group_by_category import get_localized_group_by_categories from amt.api.lifecycles import get_localized_lifecycles @@ -26,10 +25,8 @@ ) from amt.api.organization_filter_options import OrganizationFilterOptions, get_localized_organization_filters from amt.api.risk_group import get_localized_risk_groups -from amt.api.routes.algorithm import get_user_id_or_error from amt.api.routes.algorithms import get_algorithms from amt.api.routes.shared import get_filters_and_sort_by -from amt.api.utils import SafeDict from amt.core.authorization import AuthorizationResource, AuthorizationVerb, get_user from amt.core.exceptions import AMTAuthorizationError, AMTNotFound, AMTRepositoryError from amt.core.internationalization import get_current_translation @@ -282,55 +279,22 @@ async def get_organization_update( organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], organization_slug: str, full_resource_path: str, + current_state_str: str = Header("VALIDATE", alias="X-Current-State"), ) -> HTMLResponse: organization = await get_organization_or_error(organizations_service, request, organization_slug) - new_values = await request.json() - - user_id = get_user_id_or_error(request) - context_variables: dict[str, str | int] = {"organization_id": organization.id} - editable: ResolvedEditable = await get_enriched_resolved_editable( - context_variables=context_variables, - full_resource_path=full_resource_path, - organizations_service=organizations_service, - edit_mode=EditModes.SAVE, - ) - - editable_context = { - "user_id": user_id, - "new_values": new_values, - "organizations_service": organizations_service, - } - - editable = await save_editable( - editable, - editable_context=editable_context, - organizations_service=organizations_service, - do_save=True, + return await update_handler( + request, + full_resource_path, + f"/organizations/{organization_slug}", + current_state_str, + context_variables, + None, + organizations_service, + None, ) - # set the value back to view mode if needed - if editable.converter: - editable.value = await editable.converter.view(editable.value, **editable_context) - - context = { - "relative_resource_path": editable.relative_resource_path.replace("/", ".") - if editable.relative_resource_path - else "", - "base_href": f"/organizations/{organization_slug}", - "resource_object": None, - "full_resource_path": full_resource_path, - "editable_object": editable, - "editables": get_resolved_editables(context_variables={"organization_id": organization.id}), - } - - # TODO: add a 'next action' to editable for f.e. redirect options, THIS IS A HACK - if full_resource_path == Editables.ORGANIZATION_SLUG.full_resource_path.format_map(SafeDict(context_variables)): - return templates.Redirect(request, f"/organizations/{editable.value}") - else: - return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) - @permission({AuthorizationResource.ORGANIZATION_INFO_SLUG: [AuthorizationVerb.LIST]}) @router.get("/{organization_slug}/algorithms") diff --git a/amt/api/routes/shared.py b/amt/api/routes/shared.py index 669e138d..6048d52c 100644 --- a/amt/api/routes/shared.py +++ b/amt/api/routes/shared.py @@ -9,11 +9,11 @@ from starlette.requests import Request from amt.api.editable_converters import StatusConverterForSystemcard -from amt.api.editable_util import extract_number_and_string from amt.api.lifecycles import Lifecycles, get_localized_lifecycle from amt.api.localizable import LocalizableEnum 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.api.update_utils import extract_number_and_string from amt.schema.localized_value_item import LocalizedValueItem from amt.schema.shared import IterMixin from amt.services.users import UsersService @@ -96,12 +96,12 @@ def get_nested(obj: Any, attr_path: str) -> Any: # noqa: ANN401 return obj -def nested_value(obj: Any, attr_path: str) -> Any: # noqa: ANN401 +def nested_value(obj: Any, attr_path: str, default_value: str | list[str] | None = "") -> Any: # noqa: ANN401 obj = get_nested(obj, attr_path) if isinstance(obj, Enum): return obj.value if obj is None: - return "" + return default_value return obj diff --git a/amt/api/template_classes.py b/amt/api/template_classes.py new file mode 100644 index 00000000..3236da1a --- /dev/null +++ b/amt/api/template_classes.py @@ -0,0 +1,61 @@ +from collections.abc import Sequence +from os import PathLike +from typing import Any, AnyStr + +from jinja2 import Environment +from starlette.background import BackgroundTask +from starlette.requests import Request +from starlette.responses import HTMLResponse +from starlette.templating import Jinja2Templates, _TemplateResponse # pyright: ignore[reportPrivateUsage] + +from amt.core.internationalization import ( + get_requested_language, + get_supported_translation, + get_translation, + supported_translations, +) + + +# we use a custom override so we can add the translation per request, which is parsed in the Request object in kwargs +class LocaleJinja2Templates(Jinja2Templates): + def _create_env( + self, + directory: str | PathLike[AnyStr] | Sequence[str | PathLike[AnyStr]], + **env_options: Any, # noqa: ANN401 + ) -> Environment: + env: Environment = super()._create_env(directory, **env_options) # pyright: ignore [reportUnknownMemberType, reportUnknownVariableType, reportArgumentType] + env.add_extension("jinja2.ext.i18n") # pyright: ignore [reportUnknownMemberType] + return env # pyright: ignore [reportUnknownVariableType] + + def TemplateResponse( # pyright: ignore [reportIncompatibleMethodOverride] + self, + request: Request, + name: str, + context: dict[str, Any] | None = None, + status_code: int = 200, + headers: dict[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> _TemplateResponse: + content_language = get_supported_translation(get_requested_language(request)) + translations = get_translation(content_language) + if headers is None: + headers = {} + headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + if "Content-Language" not in headers: + headers["Content-Language"] = ",".join(supported_translations) + self.env.install_gettext_translations(translations, newstyle=True) # pyright: ignore [reportUnknownMemberType] + + if context is None: + context = {} + + if hasattr(request.state, "csrftoken"): + context["csrftoken"] = request.state.csrftoken + else: + context["csrftoken"] = "" + + return super().TemplateResponse(request, name, context, status_code, headers, media_type, background) + + def Redirect(self, request: Request, url: str) -> HTMLResponse: + headers = {"HX-Redirect": url} + return self.TemplateResponse(request, "redirect.html.j2", headers=headers) diff --git a/amt/api/update_utils.py b/amt/api/update_utils.py new file mode 100644 index 00000000..ddd3bd80 --- /dev/null +++ b/amt/api/update_utils.py @@ -0,0 +1,51 @@ +import re +import typing +from typing import Any, cast + + +def set_path(obj: dict[str, Any] | object, path: str, value: typing.Any) -> None: # noqa: ANN401, C901 + if not path: + raise ValueError("Path cannot be empty") + + attrs = path.lstrip("/").split("/") + for attr in attrs[:-1]: + attr, index = extract_number_and_string(attr) + if isinstance(obj, dict): + obj = cast(dict[str, Any], obj) + if attr not in obj: + obj[attr] = {} + obj = obj[attr] + else: + if not hasattr(obj, attr): # pyright: ignore[reportUnknownArgumentType] + setattr(obj, attr, {}) # pyright: ignore[reportUnknownArgumentType] + obj = getattr(obj, attr) # pyright: ignore[reportUnknownArgumentType] + if obj and index is not None: + obj = cast(list[Any], obj)[index] # pyright: ignore[reportArgumentType, reportUnknownVariableType, reportUnknownArgumentType] + + if isinstance(obj, dict): + obj[attrs[-1]] = value + else: + attr, index = extract_number_and_string(attrs[-1]) + if index is not None: + cast(list[Any], getattr(obj, attr))[index] = value + else: + setattr(obj, attrs[-1], value) + + +def extract_number_and_string(input_string: str) -> tuple[str, int | None]: + """ + Extracts the number within square brackets and the string before the brackets + from a given input string. + + Returns: + A tuple containing: + - The string before the brackets. + - The number within the brackets (as an integer) or None if no number is found within brackets. + """ + match = re.search(r"(.+)\[(\d+)]", input_string) + if match: + string_before = match.group(1) + number_in_brackets = int(match.group(2)) + return string_before, number_in_brackets + else: + return input_string, None diff --git a/amt/api/utils.py b/amt/api/utils.py index 8b954762..c28a2dd6 100644 --- a/amt/api/utils.py +++ b/amt/api/utils.py @@ -1,3 +1,7 @@ +from collections.abc import Iterable +from typing import TypeVar + + class SafeDict(dict[str, str | int]): """ A dictionary that if the key is missing returns the key as 'python replacement string', e.g. {key} @@ -6,3 +10,27 @@ class SafeDict(dict[str, str | int]): def __missing__(self, key: str) -> str: return "{" + key + "}" + + +T = TypeVar("T") + + +def compare_lists( + current_list: Iterable[T], + new_list: Iterable[T], + current_attr_name: str, + new_attr_name: str, +) -> tuple[list[T], list[T]]: + """ + Compare two lists by attributes and return a tuple of two lists: added items and removed items. + """ + current_attributes = {getattr(item, current_attr_name, None) for item in current_list} + new_attributes = {getattr(item, new_attr_name, None) for item in new_list} + + added_attributes = new_attributes - current_attributes + removed_attributes = current_attributes - new_attributes + + added_items = [item for item in new_list if getattr(item, current_attr_name, None) in added_attributes] + removed_items = [item for item in current_list if getattr(item, current_attr_name, None) in removed_attributes] + + return added_items, removed_items diff --git a/amt/core/exception_handlers.py b/amt/core/exception_handlers.py index c5622e87..41d961f2 100644 --- a/amt/core/exception_handlers.py +++ b/amt/core/exception_handlers.py @@ -49,6 +49,8 @@ async def general_exception_handler(request: Request, exc: Exception) -> HTMLRes response_headers["HX-Retarget"] = "#general-error-container" elif isinstance(exc, AMTRepositoryError | AMTHTTPException): message = exc.getmessage(translations) + # we assume a generic error div is targeted by id, so we want to replace the inner content + response_headers["HX-Reswap"] = "innerHTML" elif isinstance(exc, StarletteHTTPException): message = AMTNotFound().getmessage(translations) if exc.status_code == status.HTTP_404_NOT_FOUND else exc.detail elif isinstance(exc, RequestValidationError): @@ -57,9 +59,10 @@ async def general_exception_handler(request: Request, exc: Exception) -> HTMLRes for err in message: err["msg"] = translate_pydantic_exception(err, translations) # Errors should be handled in appropriate "containers", - # so we always want to replace to content and not the container + # so we always want to replace the content and not the container response_headers["HX-Reswap"] = "innerHTML" + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR if isinstance(exc, StarletteHTTPException): status_code = exc.status_code @@ -90,6 +93,7 @@ async def general_exception_handler(request: Request, exc: Exception) -> HTMLRes fallback_template_name, {"message": message}, status_code=status_code, + headers=response_headers, ) return response diff --git a/amt/locale/base.pot b/amt/locale/base.pot index 30067beb..38b4137e 100644 --- a/amt/locale/base.pot +++ b/amt/locale/base.pot @@ -47,7 +47,7 @@ msgstr "" #: amt/api/group_by_category.py:15 #: amt/site/templates/algorithms/details_info.html.j2:97 -#: amt/site/templates/algorithms/new.html.j2:41 +#: amt/site/templates/algorithms/new.html.j2:42 #: amt/site/templates/macros/tasks.html.j2:80 #: amt/site/templates/parts/algorithm_search.html.j2:48 #: amt/site/templates/parts/filter_list.html.j2:71 @@ -380,6 +380,36 @@ msgstr "" msgid "Something went wrong storing your file. PLease try again later." msgstr "" +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:1 +msgid "Update requirements and measures" +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:3 +msgid "" +"Changing the AI-Act profile will lead to the following changes in " +"requirements and measures." +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:6 +msgid "New requirements" +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:19 +msgid "Removed requirements" +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:32 +msgid "New measures" +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:45 +msgid "Removed measures" +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:60 +msgid "Apply changes" +msgstr "" + #: amt/site/templates/algorithms/details_base.html.j2:13 msgid "Delete algoritmic system" msgstr "" @@ -393,14 +423,14 @@ msgid "Data will be stored for at least 45 days before permanent deletion." msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:26 -#: amt/site/templates/algorithms/new.html.j2:153 +#: amt/site/templates/algorithms/new.html.j2:154 #: amt/site/templates/macros/form_macros.html.j2:168 #: amt/site/templates/organizations/members.html.j2:34 msgid "Yes" msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:29 -#: amt/site/templates/algorithms/new.html.j2:163 +#: amt/site/templates/algorithms/new.html.j2:164 #: amt/site/templates/macros/form_macros.html.j2:173 #: amt/site/templates/organizations/members.html.j2:37 msgid "No" @@ -465,12 +495,12 @@ msgid "Read more on the algoritmekader" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:62 -#: amt/site/templates/macros/editable.html.j2:205 +#: amt/site/templates/macros/editable.html.j2:210 msgid "Save" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:66 -#: amt/site/templates/macros/editable.html.j2:210 +#: amt/site/templates/macros/editable.html.j2:215 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:29 msgid "Cancel" msgstr "" @@ -479,33 +509,33 @@ msgstr "" msgid "Add an algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:26 +#: amt/site/templates/algorithms/new.html.j2:27 #: amt/site/templates/parts/filter_list.html.j2:67 #: amt/site/templates/parts/filter_list.html.j2:93 msgid "Algorithm name" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:30 +#: amt/site/templates/algorithms/new.html.j2:31 msgid "Name of the algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:44 +#: amt/site/templates/algorithms/new.html.j2:45 msgid "Select the lifecycle your algorithm is currently in." msgstr "" -#: amt/site/templates/algorithms/new.html.j2:45 +#: amt/site/templates/algorithms/new.html.j2:46 msgid "For more information on lifecycle, read the" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:48 +#: amt/site/templates/algorithms/new.html.j2:49 msgid "Algorithm Framework" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:67 +#: amt/site/templates/algorithms/new.html.j2:68 msgid "AI Act Profile" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:69 +#: amt/site/templates/algorithms/new.html.j2:70 msgid "" "The AI Act profile provides insight into, among other things, the type of" " AI system and the associated obligations from the European AI Act. If " @@ -513,16 +543,16 @@ msgid "" "Otherwise, you can find your AI Act Profile with the AI Act Support Tool." msgstr "" -#: amt/site/templates/algorithms/new.html.j2:76 +#: amt/site/templates/algorithms/new.html.j2:77 #: amt/site/templates/macros/editable.html.j2:9 msgid "Find your AI Act profile" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:112 +#: amt/site/templates/algorithms/new.html.j2:113 msgid "Select Option" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:176 +#: amt/site/templates/algorithms/new.html.j2:177 #: amt/site/templates/parts/algorithm_search.html.j2:15 msgid "Add algorithm" msgstr "" diff --git a/amt/locale/en_US/LC_MESSAGES/messages.mo b/amt/locale/en_US/LC_MESSAGES/messages.mo index bc5b1686..b8969507 100644 Binary files a/amt/locale/en_US/LC_MESSAGES/messages.mo and b/amt/locale/en_US/LC_MESSAGES/messages.mo differ diff --git a/amt/locale/en_US/LC_MESSAGES/messages.po b/amt/locale/en_US/LC_MESSAGES/messages.po index 3cf9da0f..6f60a055 100644 --- a/amt/locale/en_US/LC_MESSAGES/messages.po +++ b/amt/locale/en_US/LC_MESSAGES/messages.po @@ -48,7 +48,7 @@ msgstr "" #: amt/api/group_by_category.py:15 #: amt/site/templates/algorithms/details_info.html.j2:97 -#: amt/site/templates/algorithms/new.html.j2:41 +#: amt/site/templates/algorithms/new.html.j2:42 #: amt/site/templates/macros/tasks.html.j2:80 #: amt/site/templates/parts/algorithm_search.html.j2:48 #: amt/site/templates/parts/filter_list.html.j2:71 @@ -381,6 +381,36 @@ msgstr "" msgid "Something went wrong storing your file. PLease try again later." msgstr "" +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:1 +msgid "Update requirements and measures" +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:3 +msgid "" +"Changing the AI-Act profile will lead to the following changes in " +"requirements and measures." +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:6 +msgid "New requirements" +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:19 +msgid "Removed requirements" +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:32 +msgid "New measures" +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:45 +msgid "Removed measures" +msgstr "" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:60 +msgid "Apply changes" +msgstr "" + #: amt/site/templates/algorithms/details_base.html.j2:13 msgid "Delete algoritmic system" msgstr "" @@ -394,14 +424,14 @@ msgid "Data will be stored for at least 45 days before permanent deletion." msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:26 -#: amt/site/templates/algorithms/new.html.j2:153 +#: amt/site/templates/algorithms/new.html.j2:154 #: amt/site/templates/macros/form_macros.html.j2:168 #: amt/site/templates/organizations/members.html.j2:34 msgid "Yes" msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:29 -#: amt/site/templates/algorithms/new.html.j2:163 +#: amt/site/templates/algorithms/new.html.j2:164 #: amt/site/templates/macros/form_macros.html.j2:173 #: amt/site/templates/organizations/members.html.j2:37 msgid "No" @@ -466,12 +496,12 @@ msgid "Read more on the algoritmekader" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:62 -#: amt/site/templates/macros/editable.html.j2:205 +#: amt/site/templates/macros/editable.html.j2:210 msgid "Save" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:66 -#: amt/site/templates/macros/editable.html.j2:210 +#: amt/site/templates/macros/editable.html.j2:215 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:29 msgid "Cancel" msgstr "" @@ -480,33 +510,33 @@ msgstr "" msgid "Add an algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:26 +#: amt/site/templates/algorithms/new.html.j2:27 #: amt/site/templates/parts/filter_list.html.j2:67 #: amt/site/templates/parts/filter_list.html.j2:93 msgid "Algorithm name" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:30 +#: amt/site/templates/algorithms/new.html.j2:31 msgid "Name of the algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:44 +#: amt/site/templates/algorithms/new.html.j2:45 msgid "Select the lifecycle your algorithm is currently in." msgstr "" -#: amt/site/templates/algorithms/new.html.j2:45 +#: amt/site/templates/algorithms/new.html.j2:46 msgid "For more information on lifecycle, read the" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:48 +#: amt/site/templates/algorithms/new.html.j2:49 msgid "Algorithm Framework" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:67 +#: amt/site/templates/algorithms/new.html.j2:68 msgid "AI Act Profile" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:69 +#: amt/site/templates/algorithms/new.html.j2:70 msgid "" "The AI Act profile provides insight into, among other things, the type of" " AI system and the associated obligations from the European AI Act. If " @@ -514,16 +544,16 @@ msgid "" "Otherwise, you can find your AI Act Profile with the AI Act Support Tool." msgstr "" -#: amt/site/templates/algorithms/new.html.j2:76 +#: amt/site/templates/algorithms/new.html.j2:77 #: amt/site/templates/macros/editable.html.j2:9 msgid "Find your AI Act profile" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:112 +#: amt/site/templates/algorithms/new.html.j2:113 msgid "Select Option" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:176 +#: amt/site/templates/algorithms/new.html.j2:177 #: amt/site/templates/parts/algorithm_search.html.j2:15 msgid "Add algorithm" msgstr "" diff --git a/amt/locale/nl_NL/LC_MESSAGES/messages.mo b/amt/locale/nl_NL/LC_MESSAGES/messages.mo index 9cf97083..ebe1a78f 100644 Binary files a/amt/locale/nl_NL/LC_MESSAGES/messages.mo and b/amt/locale/nl_NL/LC_MESSAGES/messages.mo differ diff --git a/amt/locale/nl_NL/LC_MESSAGES/messages.po b/amt/locale/nl_NL/LC_MESSAGES/messages.po index a1e250e7..e38a9d47 100644 --- a/amt/locale/nl_NL/LC_MESSAGES/messages.po +++ b/amt/locale/nl_NL/LC_MESSAGES/messages.po @@ -50,7 +50,7 @@ msgstr "Rol" #: amt/api/group_by_category.py:15 #: amt/site/templates/algorithms/details_info.html.j2:97 -#: amt/site/templates/algorithms/new.html.j2:41 +#: amt/site/templates/algorithms/new.html.j2:42 #: amt/site/templates/macros/tasks.html.j2:80 #: amt/site/templates/parts/algorithm_search.html.j2:48 #: amt/site/templates/parts/filter_list.html.j2:71 @@ -398,6 +398,38 @@ msgstr "" "Er is iets fout gegaan tijdens het opslaan van uw bestand. Probeer het " "later opnieuw." +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:1 +msgid "Update requirements and measures" +msgstr "Vereisten en maatregelen bijwerken" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:3 +msgid "" +"Changing the AI-Act profile will lead to the following changes in " +"requirements and measures." +msgstr "" +"Het wijzigen van het AI-Act profiel zal leiden tot de volgende " +"wijzigingen in vereisten en maatregelen" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:6 +msgid "New requirements" +msgstr "Nieuwe vereisten" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:19 +msgid "Removed requirements" +msgstr "Vervallen vereisten" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:32 +msgid "New measures" +msgstr "Nieuwe maatregelen" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:45 +msgid "Removed measures" +msgstr "Vervallen maatregelen" + +#: amt/site/templates/algorithms/ai_act_changes_modal.html.j2:60 +msgid "Apply changes" +msgstr "Wijzigingen doorvoeren" + #: amt/site/templates/algorithms/details_base.html.j2:13 msgid "Delete algoritmic system" msgstr "Verwijder algoritme" @@ -413,14 +445,14 @@ msgstr "" " verwijderd." #: amt/site/templates/algorithms/details_base.html.j2:26 -#: amt/site/templates/algorithms/new.html.j2:153 +#: amt/site/templates/algorithms/new.html.j2:154 #: amt/site/templates/macros/form_macros.html.j2:168 #: amt/site/templates/organizations/members.html.j2:34 msgid "Yes" msgstr "Ja" #: amt/site/templates/algorithms/details_base.html.j2:29 -#: amt/site/templates/algorithms/new.html.j2:163 +#: amt/site/templates/algorithms/new.html.j2:164 #: amt/site/templates/macros/form_macros.html.j2:173 #: amt/site/templates/organizations/members.html.j2:37 msgid "No" @@ -485,12 +517,12 @@ msgid "Read more on the algoritmekader" msgstr "Lees meer op het algoritmekader" #: amt/site/templates/algorithms/details_measure_modal.html.j2:62 -#: amt/site/templates/macros/editable.html.j2:205 +#: amt/site/templates/macros/editable.html.j2:210 msgid "Save" msgstr "Opslaan" #: amt/site/templates/algorithms/details_measure_modal.html.j2:66 -#: amt/site/templates/macros/editable.html.j2:210 +#: amt/site/templates/macros/editable.html.j2:215 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:29 msgid "Cancel" msgstr "Annuleren" @@ -499,33 +531,33 @@ msgstr "Annuleren" msgid "Add an algorithm" msgstr "Algoritme toevoegen" -#: amt/site/templates/algorithms/new.html.j2:26 +#: amt/site/templates/algorithms/new.html.j2:27 #: amt/site/templates/parts/filter_list.html.j2:67 #: amt/site/templates/parts/filter_list.html.j2:93 msgid "Algorithm name" msgstr "Algoritme naam" -#: amt/site/templates/algorithms/new.html.j2:30 +#: amt/site/templates/algorithms/new.html.j2:31 msgid "Name of the algorithm" msgstr "Nieuw algoritme" -#: amt/site/templates/algorithms/new.html.j2:44 +#: amt/site/templates/algorithms/new.html.j2:45 msgid "Select the lifecycle your algorithm is currently in." msgstr "Selecteer de levenscyclus waarin uw algoritme zich momenteel bevindt." -#: amt/site/templates/algorithms/new.html.j2:45 +#: amt/site/templates/algorithms/new.html.j2:46 msgid "For more information on lifecycle, read the" msgstr "Lees voor meer meer informatie over levenscyclus het" -#: amt/site/templates/algorithms/new.html.j2:48 +#: amt/site/templates/algorithms/new.html.j2:49 msgid "Algorithm Framework" msgstr "Algoritmekader" -#: amt/site/templates/algorithms/new.html.j2:67 +#: amt/site/templates/algorithms/new.html.j2:68 msgid "AI Act Profile" msgstr "AI Verordening Profiel" -#: amt/site/templates/algorithms/new.html.j2:69 +#: amt/site/templates/algorithms/new.html.j2:70 msgid "" "The AI Act profile provides insight into, among other things, the type of" " AI system and the associated obligations from the European AI Act. If " @@ -536,16 +568,16 @@ msgstr "" "onder andere het type AI systeem, de regelgeving die van toepassing is en" " de risicocategorie." -#: amt/site/templates/algorithms/new.html.j2:76 +#: amt/site/templates/algorithms/new.html.j2:77 #: amt/site/templates/macros/editable.html.j2:9 msgid "Find your AI Act profile" msgstr "Vind uw AI Act profiel" -#: amt/site/templates/algorithms/new.html.j2:112 +#: amt/site/templates/algorithms/new.html.j2:113 msgid "Select Option" msgstr "Selecteer optie" -#: amt/site/templates/algorithms/new.html.j2:176 +#: amt/site/templates/algorithms/new.html.j2:177 #: amt/site/templates/parts/algorithm_search.html.j2:15 msgid "Add algorithm" msgstr "Algoritme toevoegen" diff --git a/amt/repositories/algorithms.py b/amt/repositories/algorithms.py index e92e7d11..c5ec5a3b 100644 --- a/amt/repositories/algorithms.py +++ b/amt/repositories/algorithms.py @@ -5,14 +5,13 @@ from fastapi import Depends from sqlalchemy import func, select -from sqlalchemy.exc import NoResultFound, SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import NoResultFound from sqlalchemy_utils import escape_like # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType] from amt.api.risk_group import RiskGroup from amt.core.exceptions import AMTRepositoryError from amt.models import Algorithm, Organization, User -from amt.repositories.deps import get_session +from amt.repositories.deps import AsyncSessionWithCommitFlag, get_session logger = logging.getLogger(__name__) @@ -32,8 +31,9 @@ def sort_by_lifecycle_reversed(algorithm: Algorithm) -> int: class AlgorithmsRepository: - def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]) -> None: + def __init__(self, session: Annotated[AsyncSessionWithCommitFlag, Depends(get_session)]) -> None: self.session = session + logger.debug(f"Repository {self.__class__.__name__} using session ID: {self.session.info.get('id', 'unknown')}") async def find_all(self) -> Sequence[Algorithm]: result = await self.session.execute(select(Algorithm).where(Algorithm.deleted_at.is_(None))) @@ -45,24 +45,13 @@ async def delete(self, algorithm: Algorithm) -> None: :param status: the status to store :return: the updated status after storing """ - try: - await self.session.delete(algorithm) - await self.session.commit() - except Exception as e: - logger.exception("Error deleting algorithm") - await self.session.rollback() - raise AMTRepositoryError from e - return None + await self.session.delete(algorithm) + self.session.should_commit = True async def save(self, algorithm: Algorithm) -> Algorithm: - try: - self.session.add(algorithm) - await self.session.commit() - await self.session.refresh(algorithm) - except SQLAlchemyError as e: - logger.exception("Error saving algorithm") - await self.session.rollback() - raise AMTRepositoryError from e + self.session.add(algorithm) + await self.session.flush() + self.session.should_commit = True return algorithm async def find_by_id(self, algorithm_id: int) -> Algorithm: diff --git a/amt/repositories/authorizations.py b/amt/repositories/authorizations.py index a55e1cba..82dac067 100644 --- a/amt/repositories/authorizations.py +++ b/amt/repositories/authorizations.py @@ -28,6 +28,7 @@ def __init__(self, session: AsyncSession | None = None) -> None: async def init_session(self) -> None: if self.session is None: self.session = await get_session_non_generator() + logger.debug(f"Repository {self.__class__.__name__} using session ID: {self.session.info.get('id', 'unknown')}") async def get_user(self, user_id: UUID) -> User | None: try: diff --git a/amt/repositories/deps.py b/amt/repositories/deps.py index 90a2f334..392d1216 100644 --- a/amt/repositories/deps.py +++ b/amt/repositories/deps.py @@ -1,24 +1,75 @@ +import logging from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from amt.core.db import get_engine +from amt.core.exceptions import AMTRepositoryError +logger = logging.getLogger(__name__) -async def get_session() -> AsyncGenerator[AsyncSession, None]: + +class AsyncSessionWithCommitFlag(AsyncSession): + """ + Extended AsyncSession to include a flag to indicate if the session should be committed. + + We use this approach because we use a transaction manager that will commit at the end of a transaction. + However, changes can be made which should not be persisted, therefor + using the default checks, like session.dirty will not work. + + Methods that make changes and want to persist those must set the should_commit to True. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa ANN401 + super().__init__(*args, **kwargs) + self._should_commit: bool = False + + @property + def should_commit(self) -> bool: + """Returns whether this session should be committed""" + return self._should_commit + + @should_commit.setter + def should_commit(self, value: bool) -> None: + """Sets whether this session should be committed""" + self._should_commit = value + + +async def get_session() -> AsyncGenerator[AsyncSessionWithCommitFlag, None]: + """Provides either a read-only or auto-commit session based on the mode""" async_session_factory = async_sessionmaker( get_engine(), expire_on_commit=False, - class_=AsyncSession, + class_=AsyncSessionWithCommitFlag, ) - async with async_session_factory() as async_session: - yield async_session + + async with async_session_factory() as session, transaction_context(session) as tx_session: + tx_session.info["id"] = str(id(tx_session)) + " (auto-commit)" + yield tx_session -async def get_session_non_generator() -> AsyncSession: +async def get_session_non_generator() -> AsyncSessionWithCommitFlag: async_session_factory = async_sessionmaker( get_engine(), expire_on_commit=False, - class_=AsyncSession, + class_=AsyncSessionWithCommitFlag, ) - return async_session_factory() + async_session = async_session_factory() + async_session.info["id"] = id(async_session) + return async_session + + +@asynccontextmanager +async def transaction_context(session: AsyncSessionWithCommitFlag) -> AsyncGenerator[AsyncSessionWithCommitFlag, None]: + try: + yield session + if hasattr(session, "_should_commit"): + if session.should_commit: + await session.commit() + else: + logger.warning("No commit flag found, NOT committing transaction") + except Exception as e: + await session.rollback() + raise AMTRepositoryError from e diff --git a/amt/repositories/organizations.py b/amt/repositories/organizations.py index 6941908a..8a4247ef 100644 --- a/amt/repositories/organizations.py +++ b/amt/repositories/organizations.py @@ -5,22 +5,22 @@ from fastapi import Depends from sqlalchemy import Select, func, select -from sqlalchemy.exc import NoResultFound, SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import lazyload from sqlalchemy_utils import escape_like # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType] from amt.api.organization_filter_options import OrganizationFilterOptions from amt.core.exceptions import AMTRepositoryError from amt.models import Organization, User -from amt.repositories.deps import get_session +from amt.repositories.deps import AsyncSessionWithCommitFlag, get_session logger = logging.getLogger(__name__) class OrganizationsRepository: - def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]) -> None: + def __init__(self, session: Annotated[AsyncSessionWithCommitFlag, Depends(get_session)]) -> None: self.session = session + logger.debug(f"Repository {self.__class__.__name__} using session ID: {self.session.info.get('id', 'unknown')}") def _as_count_query(self, statement: Select[Any]) -> Select[Any]: statement = statement.with_only_columns(func.count()).order_by(None) @@ -93,14 +93,9 @@ async def find_by_as_count( return (await self.session.execute(statement)).scalars().first() async def save(self, organization: Organization) -> Organization: - try: - self.session.add(organization) - await self.session.commit() - await self.session.refresh(organization) - except SQLAlchemyError as e: - logger.exception("Error saving organization") - await self.session.rollback() - raise AMTRepositoryError from e + self.session.add(organization) + await self.session.flush() + self.session.should_commit = True return organization async def find_by_slug(self, slug: str) -> Organization: diff --git a/amt/repositories/tasks.py b/amt/repositories/tasks.py index e4334fc6..e092d66c 100644 --- a/amt/repositories/tasks.py +++ b/amt/repositories/tasks.py @@ -3,14 +3,13 @@ from typing import Annotated from fastapi import Depends -from sqlalchemy import and_, select, update +from sqlalchemy import and_, delete, desc, select, update from sqlalchemy.exc import NoResultFound -from sqlalchemy.ext.asyncio import AsyncSession from amt.core.exceptions import AMTRepositoryError from amt.enums.tasks import Status, TaskType from amt.models import Task -from amt.repositories.deps import get_session +from amt.repositories.deps import AsyncSessionWithCommitFlag, get_session from amt.schema.measure import MeasureTask logger = logging.getLogger(__name__) @@ -21,8 +20,9 @@ class TasksRepository: The TasksRepository provides access to the repository layer. """ - def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]) -> None: + def __init__(self, session: Annotated[AsyncSessionWithCommitFlag, Depends(get_session)]) -> None: self.session = session + logger.debug(f"Repository {self.__class__.__name__} using session ID: {self.session.info.get('id', 'unknown')}") async def find_all(self) -> Sequence[Task]: """ @@ -59,14 +59,9 @@ async def save(self, task: Task) -> Task: :param task: the task to store :return: the updated task after storing """ - try: - self.session.add(task) - await self.session.commit() - await self.session.refresh(task) - except Exception as e: - logger.exception("Could not store task") - await self.session.rollback() - raise AMTRepositoryError from e + self.session.add(task) + await self.session.flush() + self.session.should_commit = True return task async def save_all(self, tasks: Sequence[Task]) -> None: @@ -75,13 +70,9 @@ async def save_all(self, tasks: Sequence[Task]) -> None: :param tasks: the tasks to store :return: the updated tasks after storing """ - try: - self.session.add_all(tasks) - await self.session.commit() - except Exception as e: - logger.exception("Could not store all tasks") - await self.session.rollback() - raise AMTRepositoryError from e + self.session.add_all(tasks) + await self.session.flush() + self.session.should_commit = True async def delete(self, task: Task) -> None: """ @@ -89,14 +80,9 @@ async def delete(self, task: Task) -> None: :param task: the task to store :return: the updated task after storing """ - try: - await self.session.delete(task) - await self.session.commit() - except Exception as e: - logger.exception("Could not delete task") - await self.session.rollback() - raise AMTRepositoryError from e - return None + await self.session.delete(task) + await self.session.flush() + self.session.should_commit = True async def find_by_id(self, task_id: int) -> Task: """ @@ -111,20 +97,43 @@ async def find_by_id(self, task_id: int) -> Task: logger.exception("Task not found") raise AMTRepositoryError from e - async def add_tasks(self, algorithm_id: int, task_type: TaskType, tasks: list[MeasureTask]) -> None: - insert_list = [ - Task( - title="", - description="", - algorithm_id=algorithm_id, - type_id=task.urn, - type=task_type, - status_id=Status.TODO, - sort_order=(idx * 10), + async def get_last_task(self, algorithm_id: int) -> Task | None: + statement = select(Task).where(Task.algorithm_id == algorithm_id).order_by(desc(Task.sort_order)).limit(1) + return (await self.session.execute(statement)).scalar_one_or_none() + + async def add_tasks( + self, algorithm_id: int, task_type: TaskType, tasks: list[MeasureTask], start_at: float = 0 + ) -> None: + if tasks: + insert_list = [ + Task( + title="", + description="", + algorithm_id=algorithm_id, + type_id=task.urn, + type=task_type, + status_id=Status.TODO, + sort_order=(start_at + idx * 10), + ) + for idx, task in enumerate(tasks) + ] + await self.save_all(insert_list) + self.session.should_commit = True + + async def remove_tasks(self, algorithm_id: int, task_type: TaskType, tasks: list[MeasureTask]) -> None: + task_urns = [task.urn for task in tasks] + if task_urns: + statement = ( + delete(Task) + .where(Task.type_id.in_(task_urns)) + .where(Task.algorithm_id == algorithm_id) + .where(Task.type == task_type) ) - for idx, task in enumerate(tasks) - ] - await self.save_all(insert_list) + # reminder: session.execute does NOT commit changes + # this repository uses the get_session which commits for us + delete_result = await self.session.execute(statement) + logger.info(f"Removed {delete_result.rowcount} tasks for algorithm_id = {algorithm_id}") + self.session.should_commit = True async def find_by_algorithm_id_and_type(self, algorithm_id: int, task_type: TaskType | None) -> Sequence[Task]: statement = select(Task).where(Task.algorithm_id == algorithm_id) @@ -146,3 +155,4 @@ async def update_tasks_status(self, algorithm_id: int, task_type: TaskType, type .values(status_id=status) ) await self.session.execute(statement) + self.session.should_commit = True diff --git a/amt/repositories/users.py b/amt/repositories/users.py index 8ef78a63..d1db2bfd 100644 --- a/amt/repositories/users.py +++ b/amt/repositories/users.py @@ -6,13 +6,12 @@ from fastapi import Depends from sqlalchemy import func, select from sqlalchemy.exc import NoResultFound, SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import lazyload from sqlalchemy_utils import escape_like # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType] from amt.core.exceptions import AMTRepositoryError from amt.models import Organization, User -from amt.repositories.deps import get_session +from amt.repositories.deps import AsyncSessionWithCommitFlag, get_session logger = logging.getLogger(__name__) @@ -22,8 +21,9 @@ class UsersRepository: The UsersRepository provides access to the repository layer. """ - def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]) -> None: + def __init__(self, session: Annotated[AsyncSessionWithCommitFlag, Depends(get_session)]) -> None: self.session = session + logger.debug(f"Repository {self.__class__.__name__} using session ID: {self.session.info.get('id', 'unknown')}") async def find_all( self, @@ -85,7 +85,7 @@ async def upsert(self, user: User) -> User: self.session.add(existing_user) else: self.session.add(user) - await self.session.commit() + self.session.should_commit = True except SQLAlchemyError as e: # pragma: no cover logger.exception("Error saving user") await self.session.rollback() diff --git a/amt/services/algorithms.py b/amt/services/algorithms.py index bec5373b..8cca0019 100644 --- a/amt/services/algorithms.py +++ b/amt/services/algorithms.py @@ -117,17 +117,6 @@ async def create(self, algorithm_new: AlgorithmNew, user_id: UUID | str) -> Algo algorithm = await self.update(algorithm) - def sort_by_measure_name(measure_task: MeasureTask) -> tuple[int, int]: - """ - Sorts measures according to their prefix index (like org- or pba-), and then the index number, - like org-1, org2. - :param measure_task: the measure task to sort - :return: a tuple with the sort values - """ - key_index = {"org": 1, "pba": 2, "owp": 3, "dat": 4, "owk": 5, "ver": 6, "imp": 7, "mon": 8, "uit": 9} - name, index = measure_task.urn.split(":")[-1].split("-") - return key_index.get(name, 0), int(index) - measures_sorted: list[MeasureTask] = sorted( # pyright: ignore[reportUnknownVariableType, reportCallIssue] measures, key=lambda measure: (get_first_lifecycle_idx(measure.lifecycle), sort_by_measure_name(measure)), # pyright: ignore[reportArgumentType] @@ -159,3 +148,15 @@ def get_template_files() -> dict[str, dict[str, str]]: for i, k in enumerate(listdir(template_path)) if isfile(join(template_path, k)) } + + +def sort_by_measure_name(measure_task: MeasureTask) -> tuple[int, int]: + """ + Sorts measures according to their prefix index (like org- or pba-), and then the index number, + like org-1, org2. + :param measure_task: the measure task to sort + :return: a tuple with the sort values + """ + key_index = {"org": 1, "pba": 2, "owp": 3, "dat": 4, "owk": 5, "ver": 6, "imp": 7, "mon": 8, "uit": 9} + name, index = measure_task.urn.split(":")[-1].split("-") + return key_index.get(name, 0), int(index) diff --git a/amt/services/tasks.py b/amt/services/tasks.py index 486772b6..a20288c6 100644 --- a/amt/services/tasks.py +++ b/amt/services/tasks.py @@ -10,6 +10,7 @@ from amt.models.user import User from amt.repositories.tasks import TasksRepository from amt.schema.instrument import InstrumentTask +from amt.schema.measure import MeasureTask from amt.schema.system_card import SystemCard from amt.services.storage import StorageFactory @@ -99,3 +100,14 @@ async def update_tasks_status(self, algorithm_id: int, task_type: TaskType, type async def find_by_algorithm_id_and_status_id(self, algorithm_id: int, status_id: int) -> Sequence[Task]: return await self.repository.find_by_algorithm_id_and_status_id(algorithm_id, status_id) + + async def add_tasks( + self, algorithm_id: int, task_type: TaskType, tasks: list[MeasureTask], start_at: float = 0 + ) -> None: + return await self.repository.add_tasks(algorithm_id, task_type, tasks, start_at) + + async def get_last_task(self, algorithm_id: int) -> Task | None: + return await self.repository.get_last_task(algorithm_id) + + async def remove_tasks(self, algorithm_id: int, task_type: TaskType, tasks: list[MeasureTask]) -> None: + return await self.repository.remove_tasks(algorithm_id, task_type, tasks) diff --git a/amt/site/static/scss/layout.scss b/amt/site/static/scss/layout.scss index dbde0426..9939f1c7 100644 --- a/amt/site/static/scss/layout.scss +++ b/amt/site/static/scss/layout.scss @@ -39,493 +39,493 @@ main { line-height: var(--utrecht-document-line-height); } -.margin-bottom-large { - margin-bottom: 1em; -} +@keyframes fade-in { + 0% { + opacity: 0; + } -.margin-top-large { - margin-top: 1em; + 100% { + opacity: 1; + } } -.margin-top-middle { - margin-top: 0.5em; -} +@keyframes fade-out { + 0% { + opacity: 1; + } -.margin-bottom-small { - margin-bottom: 0.5em; + 100% { + opacity: 0; + } } -.margin-bottom-extra-small { - margin-bottom: 0.25em; -} +@keyframes zoom-in { + 0% { + transform: scale(0.9); + } -.progress-cards-container { - min-height: 30em; - background-color: var(--rvo-color-lichtblauw-150); - border-radius: 10px; - height: calc(100% - 30px); /* todo (robbert): this is a display hack */ - padding-top: 5px; + 100% { + transform: scale(1); + } } -.progress-card-container { - margin: 0.5em; - border: 1px solid; - filter: drop-shadow(0 4px 4px rgb(115 142 171 / 50%)); - border-color: var(--rvo-color-wit); - padding: 0.5em; - border-radius: 10px; - background-color: var(--rvo-color-wit); - cursor: move; - user-select: none; -} +@keyframes zoom-out { + 0% { + transform: scale(1); + } -.algorithm-system-card-container { - margin: 0.5em; - border: 1px solid; - filter: drop-shadow(0 4px 4px rgb(115 142 171 / 50%)); - border-color: var(--rvo-color-wit); - padding: 0.5em; - border-radius: 10px; - background-color: var(--rvo-color-wit); - cursor: move; - user-select: none; + 100% { + transform: scale(0.9); + } } -.progress-card-assignees-container { - display: flex; - justify-content: flex-end; -} +/* we override the default ROOS style because we want to display as column, not rows */ +/* stylelint-disable selector-class-pattern */ +.amt-theme { + & .rvo-accordion__item > .rvo-accordion__item-summary { + align-items: initial; + flex-direction: column; + } -.progress-card-assignees-image { - border-radius: 50%; - height: 35px; -} + & .rvo-accordion__item-title { + align-items: baseline; + } -.text-center-horizontal { - text-align: center; -} + /** TODO: this is a fix for width: 100% on a margin-left element which should be fixed by ROOS */ + & .rvo-header__logo-wrapper { + width: auto; + } -.as-inline-block { - display: inline-block; -} + & main { + margin-bottom: var(--rvo-size-2xl); + } -.navbar-fixed { - top: 0; - z-index: 100; - position: sticky; - background: #fff; -} + & .margin-bottom-large { + margin-bottom: 1em; + } -.form-input-container { - position: relative; -} + & .margin-top-large { + margin-top: 1em; + } -.form-input-container .form-input-clear { - position: absolute; - top: 15px; - right: 15px; - cursor: pointer; -} + & .margin-top-middle { + margin-top: 0.5em; + } -/* remove input clear button in certain browsers */ -[type="search"]::-webkit-search-cancel-button, -[type="search"]::-webkit-search-decoration { - appearance: none; -} + & .margin-bottom-small { + margin-bottom: 0.5em; + } -#amt-main-menu-mobile { - @media only screen and (width <= 912px) { - display: flex; + & .margin-bottom-extra-small { + margin-bottom: 0.25em; } - @media only screen and (width > 912px) { - display: none; + & .progress-cards-container { + min-height: 30em; + background-color: var(--rvo-color-lichtblauw-150); + border-radius: 10px; + height: calc(100% - 30px); /* todo (robbert): this is a display hack */ + padding-top: 5px; } -} -#amt-main-menu-desktop, -#amt-sub-menu-desktop { - @media only screen and (width <= 912px) { - display: none; + & .progress-card-container { + margin: 0.5em; + border: 1px solid; + filter: drop-shadow(0 4px 4px rgb(115 142 171 / 50%)); + border-color: var(--rvo-color-wit); + padding: 0.5em; + border-radius: 10px; + background-color: var(--rvo-color-wit); + cursor: move; + user-select: none; } - @media only screen and (width > 912px) { - display: block; + & .algorithm-system-card-container { + margin: 0.5em; + border: 1px solid; + filter: drop-shadow(0 4px 4px rgb(115 142 171 / 50%)); + border-color: var(--rvo-color-wit); + padding: 0.5em; + border-radius: 10px; + background-color: var(--rvo-color-wit); + cursor: move; + user-select: none; } -} -.amt-layout-grid { - display: grid; + & .progress-card-assignees-container { + display: flex; + justify-content: flex-end; + } - /* ROOS only has responsive grid, but here we want a fixed grid */ - /* stylelint-disable selector-class-pattern */ - &.amt-layout-grid-columns--two { - grid-template-columns: repeat(2, 1fr); + & .progress-card-assignees-image { + border-radius: 50%; + height: 35px; } - /* stylelint-enable */ -} + & .text-center-horizontal { + text-align: center; + } -/* Modal is not implemented in NLDS yet, so we implement it here. */ -.minbzk-modal { - /* Underlay covers entire screen. */ - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; + & .as-inline-block { + display: inline-block; + } - /* Change to appropriate color from NLDS. */ - background-color: rgba(0 0 0 / 50%); - z-index: 1000; + & .navbar-fixed { + top: 0; + z-index: 100; + position: sticky; + background: #fff; + } - /* Flexbox centers the .modal-content vertically and horizontally */ - display: flex; - flex-direction: column; - align-items: center; + & .form-input-container { + position: relative; + } - /* Animate when opening */ - animation-name: fade-in; - animation-duration: 150ms; - animation-timing-function: ease; -} + & .form-input-container .form-input-clear { + position: absolute; + top: 15px; + right: 15px; + cursor: pointer; + } -.minbzk-modal > .modal-underlay { - /* underlay takes up the entire viewport. This is only - required if you want to click to dismiss the popup */ - position: absolute; - z-index: -1; - top: 0; - bottom: 0; - left: 0; - right: 0; -} + /* remove input clear button in certain browsers */ + & [type="search"]::-webkit-search-cancel-button, + & [type="search"]::-webkit-search-decoration { + appearance: none; + } -.model-content-auto-size { - width: auto !important; - height: auto !important; -} + & #amt-main-menu-mobile { + @media only screen and (width <= 912px) { + display: flex; + } -.modal-content { - overflow: auto; - position: relative; - width: 100%; - height: 100%; -} + @media only screen and (width > 912px) { + display: none; + } + } -.modal-content-close { - position: absolute; - right: 30px; - top: 10px; - z-index: 1000; - cursor: pointer; - font-size: 40px; -} + & #amt-main-menu-desktop, + & #amt-sub-menu-desktop { + @media only screen and (width <= 912px) { + display: none; + } -.modal-content-container { - overflow: hidden; - position: relative; + @media only screen and (width > 912px) { + display: block; + } + } - /* Position visible dialog near the top of the window */ - margin-top: 5vh; - transition: - height 0.3s ease, - width 0.3s ease; + & .amt-layout-grid { + display: grid; - /* Sizing for visible dialog */ - width: 80%; - height: 80%; + /* ROOS only has responsive grid, but here we want a fixed grid */ + &.amt-layout-grid-columns--two { + grid-template-columns: repeat(2, 1fr); + } - /* Display properties for visible dialog */ - border: solid 1px #999; - border-radius: 1em; + /* stylelint-enable */ + } - /* Change to appropriate color from NLDS. */ - box-shadow: 0 0 1em 0 rgba(0 0 0 / 30%); - background-color: #fff; - padding: 1em; + /* Modal is not implemented in NLDS yet, so we implement it here. */ + & .minbzk-modal { + /* Underlay covers entire screen. */ + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; - /* Animate when opening */ - animation-name: zoom-in; - animation-duration: 150ms; - animation-timing-function: ease; -} + /* Change to appropriate color from NLDS. */ + background-color: rgba(0 0 0 / 50%); + z-index: 1000; -.display-none { - display: none !important; -} + /* Flexbox centers the .modal-content vertically and horizontally */ + display: flex; + flex-direction: column; + align-items: center; -@keyframes fade-in { - 0% { - opacity: 0; + /* Animate when opening */ + animation-name: fade-in; + animation-duration: 150ms; + animation-timing-function: ease; } - 100% { - opacity: 1; + & .minbzk-modal > .modal-underlay { + /* underlay takes up the entire viewport. This is only + required if you want to click to dismiss the popup */ + position: absolute; + z-index: -1; + top: 0; + bottom: 0; + left: 0; + right: 0; } -} -@keyframes fade-out { - 0% { - opacity: 1; + & .model-content-auto-size { + width: auto !important; + height: auto !important; } - 100% { - opacity: 0; + & .modal-content { + overflow: auto; + position: relative; + width: 100%; + height: 100%; } -} -@keyframes zoom-in { - 0% { - transform: scale(0.9); + & .modal-content-close { + position: absolute; + right: 30px; + top: 10px; + z-index: 1000; + cursor: pointer; + font-size: 40px; } - 100% { - transform: scale(1); + & .modal-content-container { + overflow: hidden; + position: relative; + + /* Position visible dialog near the top of the window */ + margin-top: 5vh; + transition: + height 0.3s ease, + width 0.3s ease; + + /* Sizing for visible dialog */ + width: 80%; + height: 80%; + + /* Display properties for visible dialog */ + border: solid 1px #999; + border-radius: 1em; + + /* Change to appropriate color from NLDS. */ + box-shadow: 0 0 1em 0 rgba(0 0 0 / 30%); + background-color: #fff; + padding: 1em; + + /* Animate when opening */ + animation-name: zoom-in; + animation-duration: 150ms; + animation-timing-function: ease; } -} -@keyframes zoom-out { - 0% { - transform: scale(1); + & .display-none { + display: none !important; } - 100% { - transform: scale(0.9); + & .amt-widget-title { + color: var(--rvo-color-hemelblauw); + font-weight: bold; } -} - -.amt-widget-title { - color: var(--rvo-color-hemelblauw); - font-weight: bold; -} -.rvo-card { - /* Overwrite the border of rvo-card to remove the hover function */ - border: var(--rvo-card-outline-border-width) solid - var(--rvo-card-outline-border-color); -} - -.rvo-table-row td:first-child { - width: 20%; - vertical-align: top; -} + & .rvo-card { + /* Overwrite the border of rvo-card to remove the hover function */ + border: var(--rvo-card-outline-border-width) solid + var(--rvo-card-outline-border-color); + } -.amt-error-message { - color: var(--rvo-form-feedback-error-color); - font-weight: var(--rvo-form-feedback-error-font-weight); -} + & .rvo-table-row td:first-child { + width: 20%; + vertical-align: top; + } -/* stylelint-disable selector-class-pattern */ + & .amt-error-message { + color: var(--rvo-form-feedback-error-color); + font-weight: var(--rvo-form-feedback-error-font-weight); + } -.amt-item-list__as_select { - border-width: var(--utrecht-form-control-border-width); - border-color: var(--utrecht-form-control-focus-border-color); - border-radius: var(--utrecht-form-control-border-radius, 0); - border-style: solid; - position: absolute; - background-color: var(--rvo-color-wit); - width: 100%; - top: 45px; - z-index: 100; -} + /* stylelint-disable selector-class-pattern */ -.amt-item-list__item_as_select { - &:hover { - background-color: var(--rvo-color-logoblauw-150); + & .amt-item-list__as_select { + border-width: var(--utrecht-form-control-border-width); + border-color: var(--utrecht-form-control-focus-border-color); + border-radius: var(--utrecht-form-control-border-radius, 0); + border-style: solid; + position: absolute; + background-color: var(--rvo-color-wit); + width: 100%; + top: 45px; + z-index: 100; } -} - -.amt-position-relative { - position: relative; -} -.amt-flex-container { - display: flex; - justify-content: space-between; -} + & .amt-item-list__item_as_select { + &:hover { + background-color: var(--rvo-color-logoblauw-150); + } + } -/* 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-position-relative { + position: relative; } - & .rvo-accordion__item-title { - align-items: baseline; + & .amt-flex-container { + display: flex; + justify-content: space-between; } - /** TODO: this is a fix for width: 100% on a margin-left element which should be fixed by ROOS */ - & .rvo-header__logo-wrapper { - width: auto; + & .amt-avatar-list { + display: inline-flex; } - & main { - margin-bottom: var(--rvo-size-2xl); + & .amt-avatar-list__item { + img { + border: 1px solid var(--rvo-color-hemelblauw-750); + border-radius: 50%; + } + + .amt-avatar-list__more { + display: inline-block; + min-width: 24px; + font-size: var(--rvo-size-sm); + height: 24px; + border: 1px solid var(--rvo-color-hemelblauw-750); + border-radius: 50%; + text-align: center; + background-color: var(--rvo-color-grijs-300); + } } -} -.amt-avatar-list { - display: inline-flex; -} + & .amt-avatar-list__item:not(:first-child) { + margin-left: -10px; + } -.amt-avatar-list__item { - img { - border: 1px solid var(--rvo-color-hemelblauw-750); - border-radius: 50%; + & .amt-tooltip { + position: relative; } - .amt-avatar-list__more { - display: inline-block; - min-width: 24px; - font-size: var(--rvo-size-sm); - height: 24px; - border: 1px solid var(--rvo-color-hemelblauw-750); - border-radius: 50%; + & .amt-tooltip .amt-tooltip__text { + visibility: hidden; + background-color: var(--rvo-color-hemelblauw); + color: #fff; + border-radius: 5px; + padding: 5px; + position: absolute; + z-index: 1; + top: -2em; text-align: center; - background-color: var(--rvo-color-grijs-300); } -} -.amt-avatar-list__item:not(:first-child) { - margin-left: -10px; -} - -.amt-tooltip { - position: relative; -} - -.amt-tooltip .amt-tooltip__text { - visibility: hidden; - background-color: var(--rvo-color-hemelblauw); - color: #fff; - border-radius: 5px; - padding: 5px; - position: absolute; - z-index: 1; - top: -2em; - text-align: center; -} - -.amt-tooltip:hover .amt-tooltip__text { - visibility: visible; -} - -.measure-function-circle { - display: flex; - align-items: center; -} + & .amt-tooltip:hover .amt-tooltip__text { + visibility: visible; + } -.measure-function-icon { - width: 25px; - height: 25px; - object-fit: cover; - border-radius: 50%; - z-index: 0; - filter: drop-shadow(0 4px 4px rgb(115 142 171 / 50%)); -} + & .measure-function-circle { + display: flex; + align-items: center; + } -.member-container { - position: relative; - display: inline-block; - align-items: center; -} + & .measure-function-icon { + width: 25px; + height: 25px; + object-fit: cover; + border-radius: 50%; + z-index: 0; + filter: drop-shadow(0 4px 4px rgb(115 142 171 / 50%)); + } -.member-container a { - display: none; - position: absolute; - background-color: #f9f9f9; - min-width: 160px; - box-shadow: 0 8px 16px 0 rgb(0 0 0 / 20%); - z-index: 1; - white-space: nowrap; - filter: drop-shadow(0 4px 4px rgb(115 142 171 / 50%)); -} + & .member-container { + position: relative; + display: inline-block; + align-items: center; + } -.dropdown-content a { - color: black; - padding: 12px 16px; - text-decoration: none; - display: block; -} + & .member-container a { + display: none; + position: absolute; + background-color: #f9f9f9; + min-width: 160px; + box-shadow: 0 8px 16px 0 rgb(0 0 0 / 20%); + z-index: 1; + white-space: nowrap; + filter: drop-shadow(0 4px 4px rgb(115 142 171 / 50%)); + } -.member-container:hover a { - display: block; -} + & .dropdown-content a { + color: black; + padding: 12px 16px; + text-decoration: none; + display: block; + } -.dropdown-content { - display: none; - position: absolute; - background-color: #f9f9f9; - min-width: 160px; - box-shadow: 0 8px 16px 0 rgb(0 0 0 / 20%); - z-index: 1; -} + & .member-container:hover a { + display: block; + } -.dropdown-content a:hover { - background-color: #f1f1f1; -} + & .dropdown-content { + display: none; + position: absolute; + background-color: #f9f9f9; + min-width: 160px; + box-shadow: 0 8px 16px 0 rgb(0 0 0 / 20%); + z-index: 1; + } -.dropdown:hover .dropdown-content { - display: block; -} + & .dropdown-content a:hover { + background-color: #f1f1f1; + } -.dropdown-underlay { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 1; -} + & .dropdown:hover .dropdown-content { + display: block; + } -.amt-cursor-pointer { - cursor: pointer; -} + & .dropdown-underlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + } -.rvo-layout-gap--0 { - gap: 0 !important; -} + & .amt-cursor-pointer { + cursor: pointer; + } -.amt-clear-float { - &::after { - content: ""; - display: block; - clear: both; + & .rvo-layout-gap--0 { + gap: 0 !important; } -} -.amt-blocks-with-vertical-spacing { - /* if floats are used in the child, we want to 'clear' in the after element so the block sizes to the float */ - & > *::after { - content: ""; - display: block; - clear: both; + & .amt-clear-float { + &::after { + content: ""; + display: block; + clear: both; + } } - & > *:not(:last-child) { - margin-bottom: var(--rvo-size-lg); + & .amt-blocks-with-vertical-spacing { + /* if floats are used in the child, we want to 'clear' in the after element so the block sizes to the float */ + & > *::after { + content: ""; + display: block; + clear: both; + } + + & > *:not(:last-child) { + margin-bottom: var(--rvo-size-lg); + } } -} -.amt-editable-block { - position: relative; + & .amt-editable-block { + position: relative; - &:last-child::after { - content: ""; - display: block; - clear: both; + &:last-child::after { + content: ""; + display: block; + clear: both; + } } -} -.amt-editable-block:not(:has(form)) { - &:hover { - background-color: var(--rvo-color-grijs-100); - box-shadow: 0 0 5px 5px var(--rvo-color-grijs-100); - cursor: pointer; + & .amt-editable-block:not(:has(form)) { + &:hover { + background-color: var(--rvo-color-grijs-100); + box-shadow: 0 0 5px 5px var(--rvo-color-grijs-100); + cursor: pointer; + } } -} -/* stylelint-enable */ + /* stylelint-enable */ +} diff --git a/amt/site/static/ts/amt.ts b/amt/site/static/ts/amt.ts index de60d950..32642469 100644 --- a/amt/site/static/ts/amt.ts +++ b/amt/site/static/ts/amt.ts @@ -168,6 +168,14 @@ window.htmx = htmx; export function openModal(id: string) { const el: Element | null = document.getElementById(id); if (el != null) { + const contentEl = el.querySelector(".modal-content"); + if (contentEl) { + const observer = new MutationObserver(() => { + contentEl.scrollTop = 0; + observer.disconnect(); + }); + observer.observe(el, { childList: true, subtree: true }); + } el.classList.remove("display-none"); } } @@ -579,6 +587,48 @@ export function updateInlineEditorAIActProfile() { } } +/** + * Updates the hx-headers attribute of an element + */ +export function updateHxHeaders( + elementId: string, + key: string, + value: string | number | boolean, +): void { + const element: HTMLElement | null = document.getElementById(elementId); + if (!element) { + console.error(`Element with ID "${elementId}" not found`); + return; + } + + // Get the current hx-headers attribute + const headersAttr: string | null = element.getAttribute("hx-headers"); + let headers: Record = {}; + + // Parse the existing headers if present + if (headersAttr) { + try { + headers = JSON.parse(headersAttr) as Record< + string, + string | number | boolean + >; + } catch (error) { + console.error("Invalid hx-headers JSON format:", error); + return; + } + } + headers[key] = value; + element.setAttribute("hx-headers", JSON.stringify(headers)); +} + +export function updateFormStateAndSubmit(formId: string, nextState: string) { + updateHxHeaders(formId, "X-Current-State", nextState); + const currentForm = document.getElementById(formId); + if (currentForm) { + htmx.trigger(currentForm, "submit", {}); + } +} + window.addEventListener("message", (event) => { if (event.data.event === "beslishulp-done") { console.log("Received beslishulp-done:", event.data.value); @@ -593,4 +643,8 @@ window.addEventListener("message", (event) => { } }); +window.addEventListener("openModal", () => { + openModal("modal"); +}); + // for debugging htmx use -> htmx.logAll(); diff --git a/amt/site/templates/algorithms/ai_act_changes_modal.html.j2 b/amt/site/templates/algorithms/ai_act_changes_modal.html.j2 new file mode 100644 index 00000000..7273458d --- /dev/null +++ b/amt/site/templates/algorithms/ai_act_changes_modal.html.j2 @@ -0,0 +1,62 @@ +

{% trans %}Update requirements and measures{% endtrans %}

+

+ {% trans %}Changing the AI-Act profile will lead to the following changes in requirements and measures.{% endtrans %} +

+{% if added_requirements|length > 0 %} +

{% trans %}New requirements{% endtrans %}

+ +{% endif %} +{% if removed_requirements|length > 0 %} +

{% trans %}Removed requirements{% endtrans %}

+ +{% endif %} +{% if added_measures|length > 0 %} +

{% trans %}New measures{% endtrans %}

+ +{% endif %} +{% if removed_measures|length > 0 %} +

{% trans %}Removed measures{% endtrans %}

+ +{% endif %} +
+ +
diff --git a/amt/site/templates/algorithms/new.html.j2 b/amt/site/templates/algorithms/new.html.j2 index 31f5b116..3b569413 100644 --- a/amt/site/templates/algorithms/new.html.j2 +++ b/amt/site/templates/algorithms/new.html.j2 @@ -14,6 +14,7 @@ hx-target-error="#errorContainer" hx-swap="innerHTML" method="post" + autocomplete="off" id="form-new-algorithm"> diff --git a/amt/site/templates/layouts/base.html.j2.webpack b/amt/site/templates/layouts/base.html.j2.webpack index 00c3287e..1f2652e5 100644 --- a/amt/site/templates/layouts/base.html.j2.webpack +++ b/amt/site/templates/layouts/base.html.j2.webpack @@ -39,7 +39,7 @@ {% block footer %} {% endblock %} - +