diff --git a/amt/api/deps.py b/amt/api/deps.py index 67cdae7c..3a1890ca 100644 --- a/amt/api/deps.py +++ b/amt/api/deps.py @@ -1,4 +1,5 @@ import logging +import re from collections.abc import Sequence from enum import Enum from os import PathLike @@ -9,13 +10,14 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from jinja2 import Environment, StrictUndefined, Undefined -from jinja2_base64_filters import jinja2_base64_filters # pyright: ignore #noqa from starlette.background import BackgroundTask from starlette.templating import _TemplateResponse # pyright: ignore [reportPrivateUsage] +from amt.api.editable import Editable 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 +from amt.api.routes.shared import is_nested_enum, nested_enum, nested_enum_value, nested_value from amt.core.authorization import AuthorizationVerb, get_user from amt.core.config import VERSION, get_settings from amt.core.internationalization import ( @@ -29,9 +31,8 @@ supported_translations, time_ago, ) -from amt.schema.localized_value_item import LocalizedValueItem from amt.schema.shared import IterMixin -from amt.schema.webform import WebFormFieldType +from amt.schema.webform import WebFormFieldImplementationType, WebFormFieldType T = TypeVar("T", bound=Enum | LocalizableEnum) @@ -54,6 +55,7 @@ def custom_context_processor( "user": get_user(request), "permissions": permissions, "WebFormFieldType": WebFormFieldType, + "WebFormFieldImplementationType": WebFormFieldImplementationType, } @@ -61,41 +63,12 @@ def get_undefined_behaviour() -> type[Undefined]: return StrictUndefined if get_settings().DEBUG else Undefined -def get_nested(obj: Any, attr_path: str) -> Any: # noqa: ANN401 - attrs = attr_path.lstrip(".").split(".") - for attr in attrs: - if hasattr(obj, attr): - obj = getattr(obj, attr) - elif isinstance(obj, dict) and attr in obj: - obj = obj[attr] - else: - obj = None - break - return obj - - -def nested_value(obj: Any, attr_path: str) -> Any: # noqa: ANN401 - obj = get_nested(obj, attr_path) - if isinstance(obj, Enum): - return obj.value - return obj - - -def is_nested_enum(obj: Any, attr_path: str) -> bool: # noqa: ANN401 - obj = get_nested(obj, attr_path) - return bool(isinstance(obj, Enum)) - - -def nested_enum(obj: Any, attr_path: str, language: str) -> list[LocalizedValueItem]: # noqa: ANN401 - nested_obj = get_nested(obj, attr_path) - if not isinstance(nested_obj, LocalizableEnum): - return [] - enum_class = type(nested_obj) - return [e.localize(language) for e in enum_class if isinstance(e, LocalizableEnum)] +def replace_digits_in_brackets(string: str) -> str: + return re.sub(r"\[(\d+)]", "[*]", string) -def nested_enum_value(obj: Any, attr_path: str, language: str) -> Any: # noqa: ANN401 - return get_nested(obj, attr_path).localize(language) +def is_editable_resource(full_resource_path: str, editables: list[Editable]) -> bool: + return replace_digits_in_brackets(full_resource_path) in editables def permission(permission: str, verb: AuthorizationVerb, permissions: dict[str, list[AuthorizationVerb]]) -> bool: @@ -178,6 +151,8 @@ def instance(obj: Class, type_string: str) -> bool: templates.env.globals.update(nested_enum=nested_enum) # pyright: ignore [reportUnknownMemberType] templates.env.globals.update(nested_enum_value=nested_enum_value) # pyright: ignore [reportUnknownMemberType] templates.env.globals.update(isinstance=instance) # pyright: ignore [reportUnknownMemberType] +templates.env.globals.update(is_editable_resource=is_editable_resource) # pyright: ignore [reportUnknownMemberType] +templates.env.globals.update(replace_digits_in_brackets=replace_digits_in_brackets) # pyright: ignore [reportUnknownMemberType] templates.env.globals.update(permission=permission) # pyright: ignore [reportUnknownMemberType] templates.env.tests["permission"] = permission # pyright: ignore [reportUnknownMemberType] templates.env.add_extension("jinja2_base64_filters.Base64Filters") # pyright: ignore [reportUnknownMemberType] diff --git a/amt/api/editable.py b/amt/api/editable.py new file mode 100644 index 00000000..98d2b524 --- /dev/null +++ b/amt/api/editable.py @@ -0,0 +1,417 @@ +import logging +import typing +from collections.abc import Generator +from enum import StrEnum +from typing import Any, Final, cast +from urllib.request import Request + +from amt.api.editable_converters import ( + EditableConverter, + EditableConverterForOrganizationInAlgorithm, + StatusConverterForSystemcard, +) +from amt.api.editable_enforcers import EditableEnforcer, EditableEnforcerForOrganizationInAlgorithm +from amt.api.editable_validators import EditableValidator, EditableValidatorMinMaxLength, EditableValidatorSlug +from amt.api.lifecycles import get_localized_lifecycles +from amt.api.routes.shared import UpdateFieldModel, nested_value +from amt.core.exceptions import AMTNotFound +from amt.models import Algorithm, Organization +from amt.models.base import Base +from amt.schema.webform import WebFormFieldImplementationType, WebFormOption +from amt.services.algorithms import AlgorithmsService +from amt.services.organizations import OrganizationsService + +type EditableType = Editable +type ResolvedEditableType = ResolvedEditable + + +class Editable: + """ + Editable contains all basic information for editing a field in a resources, like changing the name + of an algorithm. + + It requires the 'full_resource_path' in a resolvable format, like algorithm/{algorithm_id}/system_card/name. + The implementation_type tells how this field can be edited using WebFormFieldImplementationType, like a 'plain' + TEXT field or SELECT_MY_ORGANIZATIONS. + The couples links fields together, if one is changed, so is the other. + The children field is for editing multiple fields at one (to be implemented). + The enforcer checks permissions and business rules. + The converter converts data between read and write when needed. + """ + + full_resource_path: Final[str] + + def __init__( + self, + full_resource_path: str, + implementation_type: WebFormFieldImplementationType, + couples: list[EditableType] | None = None, + children: list[EditableType] | None = None, + converter: EditableConverter = None, + enforcer: EditableEnforcer | None = None, + validator: EditableValidator | None = None, + ) -> None: + self.full_resource_path = full_resource_path + self.implementation_type = implementation_type + self.couples = set() if couples is None else couples + self.children = set() if children is None else children + self.converter = converter + self.enforcer = enforcer + self.validator = validator + + def add_bidirectional_couple(self, target: EditableType) -> None: + """ + Changing an editable may require an update on another field as well, like when changing the name + of an algorithm; this is stored in two different places. Making it a couple ensures both values are + updated when one is changed. + :param target: the target editable type + :return: None + """ + self.couples.add(target) + target.couples.add(self) + + def add_child(self, target: EditableType) -> None: + """ + An editable can be a container (parent) for other elements. + :param target: the target editable type + :return: None + """ + self.children.add(target) + + +class ResolvedEditable: + value: Any | None + # TODO: find out of holding resource_object in memory for many editables is wise / needed + resource_object: Any | None + relative_resource_path: str | None + form_options: list[WebFormOption] | None + + def __init__( + self, + # fields copied from the Editable class + full_resource_path: str, + implementation_type: WebFormFieldImplementationType, + couples: list[ResolvedEditableType] | None = None, + children: list[ResolvedEditableType] | None = None, + converter: EditableConverter = None, + enforcer: EditableEnforcer | None = None, + validator: EditableValidator | None = None, + # resolved only fields + value: str | None = None, + resource_object: Base | None = None, + relative_resource_path: str | None = None, + ) -> None: + self.full_resource_path = full_resource_path + self.implementation_type = implementation_type + self.couples = set() if couples is None else couples + self.children = set() if children is None else children + self.converter = converter + self.enforcer = enforcer + self.validator = validator + # resolved only fields + self.value = value + self.resource_object = resource_object + self.relative_resource_path = relative_resource_path + + +class Editables: + ALGORITHM_EDITABLE_NAME: Editable = Editable( + full_resource_path="algorithm/{algorithm_id}/name", + implementation_type=WebFormFieldImplementationType.TEXT, + validator=EditableValidatorMinMaxLength(min_length=3, max_length=100), + ) + ALGORITHM_EDITABLE_SYSTEMCARD_NAME = Editable( + full_resource_path="algorithm/{algorithm_id}/system_card/name", + implementation_type=WebFormFieldImplementationType.TEXT, + validator=EditableValidatorMinMaxLength(min_length=3, max_length=100), + ) + ALGORITHM_EDITABLE_NAME.add_bidirectional_couple(ALGORITHM_EDITABLE_SYSTEMCARD_NAME) + + ALGORITHM_EDITABLE_SYSTEMCARD_MODEL = Editable( + full_resource_path="algorithm/{algorithm_id}/system_card/name", + implementation_type=WebFormFieldImplementationType.TEXT, + validator=EditableValidatorMinMaxLength(min_length=3, max_length=100), + ) + + ALGORITHM_EDITABLE_AUTHOR = Editable( + full_resource_path="algorithm/{algorithm_id}/system_card/provenance/author", + implementation_type=WebFormFieldImplementationType.TEXT, + ) + # TODO: parent below is not yet implemented + ALGORITHM_EDITABLE_SYSTEMCARD_OWNERS = Editable( + full_resource_path="DISABLED_algorithm/{algorithm_id}/system_card/owners[*]", + implementation_type=WebFormFieldImplementationType.PARENT, + children=[ + Editable( + full_resource_path="algorithm/{algorithm_id}/system_card/owners[*]/organization", + implementation_type=WebFormFieldImplementationType.TEXT, + ), + Editable( + full_resource_path="algorithm/{algorithm_id}/system_card/owners[*]/oin", + implementation_type=WebFormFieldImplementationType.TEXT, + ), + ], + ) + + ALGORITHM_EDITABLE_ORGANIZATION = Editable( + full_resource_path="algorithm/{algorithm_id}/organization", + implementation_type=WebFormFieldImplementationType.SELECT_MY_ORGANIZATIONS, + enforcer=EditableEnforcerForOrganizationInAlgorithm(), + converter=EditableConverterForOrganizationInAlgorithm(), + ) + ALGORITHM_EDITABLE_DESCRIPTION = Editable( + full_resource_path="algorithm/{algorithm_id}/system_card/description", + implementation_type=WebFormFieldImplementationType.TEXTAREA, + ) + ALGORITHM_EDITABLE_LIFECYCLE = Editable( + full_resource_path="algorithm/{algorithm_id}/lifecycle", + implementation_type=WebFormFieldImplementationType.SELECT_LIFECYCLE, + ) + ALGORITHM_EDITABLE_SYSTEMCARD_STATUS = Editable( + full_resource_path="algorithm/{algorithm_id}/system_card/status", + implementation_type=WebFormFieldImplementationType.SELECT_LIFECYCLE, + converter=StatusConverterForSystemcard(), + ) + ALGORITHM_EDITABLE_LIFECYCLE.add_bidirectional_couple(ALGORITHM_EDITABLE_SYSTEMCARD_STATUS) + + ORGANIZATION_NAME = Editable( + full_resource_path="organization/{organization_id}/name", + implementation_type=WebFormFieldImplementationType.TEXT, + validator=EditableValidatorMinMaxLength(min_length=3, max_length=100), + ) + + ORGANIZATION_SLUG = Editable( + full_resource_path="organization/{organization_id}/slug", + implementation_type=WebFormFieldImplementationType.TEXT, + validator=EditableValidatorSlug(), + ) + + # TODO: rethink if this is a wise solution.. we do this to keep all elements in 1 class and still + # be able to execute other code (like making relationships) + def __iter__(self) -> Generator[tuple[str, Any], Any, Any]: + yield from [ + getattr(self, attr) for attr in dir(self) if not attr.startswith("__") and not callable(getattr(self, attr)) + ] + + +editables = Editables() + + +class SafeDict(dict): + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +class EditModes(StrEnum): + EDIT = "EDIT" + VIEW = "VIEW" + SAVE = "SAVE" + + +async def get_enriched_resolved_editable( + context_variables: dict[str, str | int], + full_resource_path: str, + edit_mode: EditModes, + algorithms_service: AlgorithmsService | None = None, + organizations_service: OrganizationsService | None = None, + user_id: str | None = None, + request: Request | None = None, +) -> ResolvedEditable: + """ + Using the given full_resource_path, resolves the resource and current value. + For example, using /algorithm/1/systemcard/info, the value of the info field end the resource, + being an algorithm object, are available. The first is used in 'get' situations, the resource_object + can be used to store a new value. + + May raise an AMTNotFound error in case a resource can not be found. + + :param edit_mode: the edit mode + :param request: the current request + :param user_id: the current user + :param context_variables: a dictionary of context variables, f.e. {'algorithm_id': 1} + :param full_resource_path: the full path of the resource, e.g. /algorithm/1/systemcard/info + :param algorithms_service: the algorithm service + :param organizations_service: the organization service + :return: a ResolvedEditable instance enriched with the requested resource and current value + """ + + editable = get_resolved_editables(context_variables=context_variables).get(full_resource_path) + if not editable: + logging.error(f"Unknown editable for path: {full_resource_path}") + raise AMTNotFound() + + return await enrich_editable( + editable, + algorithms_service=algorithms_service, + organizations_service=organizations_service, + edit_mode=edit_mode, + user_id=user_id, + request=request, + ) + + +async def enrich_editable( # noqa: C901 + editable: ResolvedEditable, + edit_mode: EditModes, + algorithms_service: AlgorithmsService | None = None, + organizations_service: OrganizationsService | None = None, + user_id: str | None = None, + request: Request | None = None, +) -> ResolvedEditable: + resource_name, resource_id, relative_resource_path = editable.full_resource_path.split("/", 2) + editable.relative_resource_path = relative_resource_path + match resource_name: + case "algorithm": + if algorithms_service is None: + raise ValueError("Algorithms service is required when resolving an algorithm") + editable.resource_object = await algorithms_service.get(int(resource_id)) + case "organization": + if organizations_service is None: + raise ValueError("Organization service is required when resolving an organization") + editable.resource_object = await organizations_service.get_by_id(int(resource_id)) + case _: + logging.error(f"Unknown resource: {resource_name}") + raise AMTNotFound() + + editable.value = nested_value(editable.resource_object, relative_resource_path) + for child_editable in editable.children: + await enrich_editable( + child_editable, + edit_mode=edit_mode, + algorithms_service=algorithms_service, + organizations_service=organizations_service, + user_id=user_id, + request=request, + ) + for couple_editable in editable.couples: + await enrich_editable( + couple_editable, + edit_mode=edit_mode, + algorithms_service=algorithms_service, + organizations_service=organizations_service, + user_id=user_id, + request=request, + ) + + # TODO: can we move this to the editable object instead of here? + if edit_mode == EditModes.EDIT: + if editable.implementation_type == WebFormFieldImplementationType.SELECT_MY_ORGANIZATIONS: + my_organizations = await organizations_service.get_organizations_for_user(user_id=user_id) + editable.form_options = [ + WebFormOption(value=str(organization.id), display_value=organization.name) + for organization in my_organizations + ] + elif editable.implementation_type == WebFormFieldImplementationType.SELECT_LIFECYCLE: + editable.form_options = [ + WebFormOption(value=str(lifecycle.value), display_value=lifecycle.display_value) + for lifecycle in get_localized_lifecycles(request) + ] + + return editable + + +def get_resolved_editables(context_variables: dict[str, str | int]) -> dict[str, ResolvedEditable]: + """ + Returns a list of all known editables with the resource path resolved using the given context_variables. + :param context_variables: a dictionary of context variables, f.e. {'algorithm_id': 1} + :return: a dict of resolved editables, with the resolved path as key + """ + editables_resolved = [] + + def resolve_editable_path( + editable: Editable, context_variables: dict[str, str | int], include_couples: bool + ) -> ResolvedEditable: + couples = None + if include_couples: + couples = [resolve_editable_path(couple, context_variables, False) for couple in editable.couples] + children = [resolve_editable_path(child, context_variables, True) for child in editable.children] + + return ResolvedEditable( + full_resource_path=editable.full_resource_path.format_map(SafeDict(context_variables)), + implementation_type=editable.implementation_type, + couples=couples, + children=children, + converter=editable.converter, + enforcer=editable.enforcer, + validator=editable.validator, + ) + + for editable in editables: + editables_resolved.append(resolve_editable_path(editable, context_variables, True)) + return {editable.full_resource_path: editable for editable in editables_resolved} + + +async def save_editable( # noqa: C901 + editable: ResolvedEditable, + update_data: UpdateFieldModel, + editable_context: dict[str, Any], + do_save: bool, + algorithms_service: AlgorithmsService | None = None, + organizations_service: OrganizationsService | None = None, +) -> ResolvedEditable: + # 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: + await editable.validator.validate(update_data.value, editable.relative_resource_path) + + if editable.enforcer: + await editable.enforcer.enforce(**editable_context) + + editable.value = update_data.value + if editable.converter: + editable.value = await editable.converter.write(editable.value, **editable_context) + + set_path(editable.resource_object, editable.relative_resource_path, editable.value) + + # TODO: child objects not implemented, this should be done later + + for couple_editable in editable.couples: + # if couples are within the same resource_object, only 1 save is required + do_save_couple = editable.resource_object != couple_editable.resource_object + await save_editable( + couple_editable, + update_data=update_data, + editable_context=editable_context, + algorithms_service=algorithms_service, + organizations_service=organizations_service, + do_save=do_save_couple, + ) + + if do_save: + match editable.resource_object: + case Algorithm(): + if algorithms_service is None: + raise ValueError("Algorithms service is required when saving an algorithm") + editable.resource_object = await algorithms_service.update(editable.resource_object) + case Organization(): + if organizations_service is None: + raise ValueError("Organization service is required when saving an organization") + editable.resource_object = await organizations_service.update(editable.resource_object) + case _: + logging.error(f"Unknown resource type: {type(editable.resource_object)}") + raise AMTNotFound() + + return editable + + +def set_path(algorithm: dict[str, Any] | object, path: str, value: typing.Any) -> None: # noqa: ANN401 + if not path: + raise ValueError("Path cannot be empty") + + attrs = path.lstrip("/").split("/") + obj: Any = algorithm + for attr in attrs[:-1]: + 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): + setattr(obj, attr, {}) + obj = getattr(obj, attr) + + if isinstance(obj, dict): + obj[attrs[-1]] = value + else: + setattr(obj, attrs[-1], value) diff --git a/amt/api/editable_converters.py b/amt/api/editable_converters.py new file mode 100644 index 00000000..b4d0f788 --- /dev/null +++ b/amt/api/editable_converters.py @@ -0,0 +1,61 @@ +from typing import Any, Final + +from amt.api.lifecycles import Lifecycles +from amt.core.exceptions import AMTAuthorizationError +from amt.models import Organization + + +class EditableConverter: + """ + Converters are meant for converting data between read, write and view when needed. + An example is choosing an organization, the input value is the organization_id, + but the needed 'write' value is the organization object, the view value is + the name. + """ + + async def read(self, in_value: Any, **kwargs: Any) -> Any: # noqa: ANN401 + return in_value + + async def write(self, in_value: Any, **kwargs: Any) -> Any: # noqa: ANN401 + return in_value + + async def view(self, in_value: Any, **kwargs: Any) -> Any: # noqa: ANN401 + return in_value + + +class EditableConverterForOrganizationInAlgorithm(EditableConverter): + async def read(self, in_value: Organization, **kwargs: Any) -> Any: # noqa: ANN401 + return in_value.id + + async def write(self, in_value: str, **kwargs: Any) -> Organization: # noqa: ANN401 + organization = await kwargs["organizations_service"].find_by_id_and_user_id( + organization_id=int(in_value), user_id=kwargs["user_id"] + ) + if organization is None: + raise AMTAuthorizationError() + return organization + + async def view(self, in_value: Organization, **kwargs: Any) -> str: # noqa: ANN401 + return in_value.name + + +class StatusConverterForSystemcard(EditableConverter): + phases: Final[dict[str, Lifecycles]] = { + "Organisatieverantwoordelijkheden": Lifecycles.ORGANIZATIONAL_RESPONSIBILITIES, + "Probleemanalyse": Lifecycles.PROBLEM_ANALYSIS, + "Ontwerp": Lifecycles.DESIGN, + "Dataverkenning en datapreparatie": Lifecycles.DATA_EXPLORATION_AND_PREPARATION, + "Ontwikkelen": Lifecycles.DEVELOPMENT, + "Verificatie en validatie": Lifecycles.VERIFICATION_AND_VALIDATION, + "Implementatie": Lifecycles.IMPLEMENTATION, + "Monitoring en beheer": Lifecycles.MONITORING_AND_MANAGEMENT, + "Uitfaseren": Lifecycles.PHASING_OUT, + } + + async def read(self, in_value: str, **kwargs: Any) -> Any: # noqa: ANN401 + if self.phases.get(in_value) is not None: + return self.phases.get(in_value).value + return in_value + + async def write(self, in_value: Lifecycles, **kwargs: Any) -> str: # noqa: ANN401 + return next((k for k, v in self.phases.items() if v.value == in_value), "Unknown") diff --git a/amt/api/editable_enforcers.py b/amt/api/editable_enforcers.py new file mode 100644 index 00000000..7a070026 --- /dev/null +++ b/amt/api/editable_enforcers.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod +from typing import Any + +from amt.core.exceptions import AMTAuthorizationError + + +class EditableEnforcer(ABC): + """ + Enforcers are meant for checking authorization permissions and business rules. + """ + + @abstractmethod + async def enforce(self, **kwargs: Any) -> None: # noqa: ANN401 + pass + + +class EditableEnforcerForOrganizationInAlgorithm(EditableEnforcer): + async def enforce(self, **kwargs: Any) -> None: # noqa: ANN401 + organization = await kwargs["organizations_service"].find_by_id_and_user_id( + organization_id=int(kwargs["new_value"]), user_id=kwargs["user_id"] + ) + if organization is None: + raise AMTAuthorizationError() diff --git a/amt/api/editable_validators.py b/amt/api/editable_validators.py new file mode 100644 index 00000000..29ec16e8 --- /dev/null +++ b/amt/api/editable_validators.py @@ -0,0 +1,45 @@ +from abc import ABC, abstractmethod +from typing import Any + +from fastapi.exceptions import RequestValidationError +from pydantic import Field, ValidationError + +from amt.schema.organization import OrganizationSlug +from amt.schema.shared import BaseModel + + +class EditableValidator(ABC): + """ + Validators are used to validate (input) data for logical rules, like length and allowed characters. + """ + + @abstractmethod + async def validate(self, in_value: Any, relative_resource_path: str) -> None: # noqa: ANN401 + pass + + +class EditableValidatorMinMaxLength(EditableValidator): + def __init__(self, min_length: int, max_length: int) -> None: + class FieldValidator(BaseModel): + value: str = Field(min_length=min_length, max_length=max_length) + + self.field_validator: type[FieldValidator] = FieldValidator + + async def validate(self, in_value: str, relative_resource_path: str) -> None: + try: + self.field_validator(value=in_value) + except ValidationError as e: + errors = e.errors() + errors[0]["loc"] = (relative_resource_path.replace("/", "_"),) + raise RequestValidationError(errors) from e + + +class EditableValidatorSlug(EditableValidator): + async def validate(self, in_value: str, relative_resource_path: str) -> None: + try: + organization_slug: OrganizationSlug = OrganizationSlug(slug=in_value) + OrganizationSlug.model_validate(organization_slug) + except ValidationError as e: + errors = e.errors() + errors[0]["loc"] = (relative_resource_path.replace("/", "_"),) + raise RequestValidationError(errors) from e diff --git a/amt/api/forms/algorithm.py b/amt/api/forms/algorithm.py index e4eb925f..ec731473 100644 --- a/amt/api/forms/algorithm.py +++ b/amt/api/forms/algorithm.py @@ -16,25 +16,29 @@ async def get_algorithm_form( algorithm_form: WebForm = WebForm(id=id, post_url="") - user_id = UUID(user_id) if isinstance(user_id, str) else user_id + organization_select_field = await get_organization_select_field(_, organization_id, organizations_service, user_id) - my_organizations = await organizations_service.get_organizations_for_user(user_id=user_id) + algorithm_form.fields = [organization_select_field] - select_organization: WebFormOption = WebFormOption(value="", display_value=_("Select organization")) + return algorithm_form - algorithm_form.fields = [ - WebFormField( - type=WebFormFieldType.SELECT, - name="organization_id", - label=_("Organization"), - options=[select_organization] - + [ - WebFormOption(value=str(organization.id), display_value=organization.name) - for organization in my_organizations - ], - default_value=str(organization_id), - group="1", - ), - ] - return algorithm_form +async def get_organization_select_field( + _: NullTranslations, organization_id: int | None, organizations_service: OrganizationsService, user_id: str | UUID +) -> WebFormField: + user_id = UUID(user_id) if isinstance(user_id, str) else user_id + my_organizations = await organizations_service.get_organizations_for_user(user_id=user_id) + select_organization: WebFormOption = WebFormOption(value="", display_value=_("Select organization")) + organization_select_field = WebFormField( + type=WebFormFieldType.SELECT, + name="organization_id", + label=_("Organization"), + options=[select_organization] + + [ + WebFormOption(value=str(organization.id), display_value=organization.name) + for organization in my_organizations + ], + default_value=str(organization_id), + group="1", + ) + return organization_select_field diff --git a/amt/api/routes/algorithm.py b/amt/api/routes/algorithm.py index 3238e2ad..d303f3fe 100644 --- a/amt/api/routes/algorithm.py +++ b/amt/api/routes/algorithm.py @@ -3,15 +3,21 @@ import logging from collections import defaultdict from collections.abc import Sequence -from typing import Annotated, Any, cast +from typing import Annotated, Any import yaml from fastapi import APIRouter, Depends, File, Form, Query, Request, Response, UploadFile from fastapi.responses import FileResponse, HTMLResponse -from pydantic import BaseModel from ulid import ULID from amt.api.deps import templates +from amt.api.editable import ( + EditModes, + ResolvedEditable, + get_enriched_resolved_editable, + get_resolved_editables, + save_editable, +) from amt.api.forms.measure import get_measure_form from amt.api.navigation import ( BaseNavigationItem, @@ -20,7 +26,7 @@ resolve_base_navigation_items, resolve_navigation_items, ) -from amt.api.routes.shared import get_filters_and_sort_by +from amt.api.routes.shared import UpdateFieldModel, get_filters_and_sort_by from amt.core.authorization import get_user from amt.core.exceptions import AMTError, AMTNotFound, AMTRepositoryError from amt.core.internationalization import get_current_translation @@ -31,9 +37,8 @@ from amt.repositories.users import UsersRepository from amt.schema.measure import ExtendedMeasureTask, MeasureTask, Person from amt.schema.requirement import RequirementTask -from amt.schema.system_card import Owner, SystemCard +from amt.schema.system_card import SystemCard from amt.schema.task import MovedTask -from amt.schema.webform import WebFormOption from amt.services.algorithms import AlgorithmsService from amt.services.instruments_and_requirements_state import InstrumentStateService, RequirementsStateService from amt.services.measures import MeasuresService, create_measures_service @@ -233,112 +238,119 @@ async def get_algorithm_details( return templates.TemplateResponse(request, "algorithms/details_info.html.j2", context) -@router.get("/{algorithm_id}/edit/{path:path}") +@router.get("/{algorithm_id}/edit") async def get_algorithm_edit( request: Request, algorithm_id: int, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], - path: str, - edit_type: str = "systemcard", + full_resource_path: str, ) -> HTMLResponse: - algorithm, context = await get_algorithm_context(algorithm_id, algorithms_service, request) - context.update( - { - "path": path.replace("/", "."), - "edit_type": edit_type, - "object": algorithm, - "base_href": f"/algorithm/{ algorithm_id }", - } + user_id = get_user_id_or_error(request) + + 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.EDIT, + user_id=user_id, + request=request, ) - if edit_type == "select_my_organizations": - user = get_user(request) + editable_context = { + "organizations_service": organizations_service, + } - my_organizations = await organizations_service.get_organizations_for_user(user_id=user["sub"] if user else None) + if editable.converter: + editable.value = await editable.converter.read(editable.value, **editable_context) - context["select_options"] = [ - WebFormOption(value=str(organization.id), display_value=organization.name) - for organization in my_organizations - ] + context = { + "base_href": f"/algorithm/{ algorithm_id }", + "editable_object": editable, + } return templates.TemplateResponse(request, "parts/edit_cell.html.j2", context) -@router.get("/{algorithm_id}/cancel/{path:path}") +@router.get("/{algorithm_id}/cancel") async def get_algorithm_cancel( request: Request, algorithm_id: int, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], - path: str, - edit_type: str = "systemcard", + organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], + full_resource_path: str, ) -> HTMLResponse: - algorithm, context = await get_algorithm_context(algorithm_id, algorithms_service, request) - context.update( - { - "path": path.replace("/", "."), - "edit_type": edit_type, - "base_href": f"/algorithm/{ algorithm_id }", - "object": algorithm, - } + 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.VIEW, ) - return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) - - -class UpdateFieldModel(BaseModel): - value: str + editable_context = {"organizations_service": organizations_service, "algorithms_service": algorithms_service} -def set_path(algorithm: dict[str, Any] | object, path: str, value: str) -> None: - if not path: - raise ValueError("Path cannot be empty") + if editable.converter: + editable.value = await editable.converter.view(editable.value, **editable_context) - attrs = path.lstrip("/").split("/") - obj: Any = algorithm - for attr in attrs[:-1]: - 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): - setattr(obj, attr, {}) - obj = getattr(obj, attr) + context = { + "relative_resource_path": editable.relative_resource_path.replace("/", "."), + "base_href": f"/algorithm/{ algorithm_id }", + "resource_object": editable.resource_object, + "full_resource_path": full_resource_path, + "editable_object": editable, + } - if isinstance(obj, dict): - obj[attrs[-1]] = value - else: - setattr(obj, attrs[-1], value) + return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) -@router.put("/{algorithm_id}/update/{path:path}") +@router.put("/{algorithm_id}/update") async def get_algorithm_update( request: Request, algorithm_id: int, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], - organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], + organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], update_data: UpdateFieldModel, - path: str, - edit_type: str = "systemcard", + full_resource_path: str, ) -> HTMLResponse: - algorithm, context = await get_algorithm_context(algorithm_id, algorithms_service, request) - context.update( - {"path": path.replace("/", "."), "edit_type": edit_type, "base_href": f"/algorithm/{ algorithm_id }"} + user_id = get_user_id_or_error(request) + + 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, ) - if edit_type == "select_my_organizations": - organization = await organizations_repository.find_by_id(int(update_data.value)) - algorithm.organization = organization - # TODO: we need to know which organization to update and what to remove - if not algorithm.system_card.owners: - algorithm.system_card.owners = [Owner(organization=organization.name, oin=str(organization.id))] - algorithm.system_card.owners[0].organization = organization.name - else: - set_path(algorithm, path, update_data.value) - - algorithm = await algorithms_service.update(algorithm) - context.update({"object": algorithm}) + editable_context = { + "user_id": user_id, + "new_value": update_data.value, + "organizations_service": organizations_service, + } + + editable = await save_editable( + editable, + update_data=update_data, + editable_context=editable_context, + algorithms_service=algorithms_service, + organizations_service=organizations_service, + do_save=True, + ) + + # 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("/", "."), + "base_href": f"/algorithm/{ algorithm_id }", + "resource_object": editable.resource_object, + "full_resource_path": full_resource_path, + "editable_object": editable, + } + return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) @@ -352,6 +364,8 @@ async def get_system_card( instrument_state = await get_instrument_state(algorithm.system_card) requirements_state = await get_requirements_state(algorithm.system_card) + editables = get_resolved_editables(context_variables={"algorithm_id": algorithm_id}) + tab_items = get_algorithm_details_tabs(request) breadcrumbs = resolve_base_navigation_items( @@ -372,6 +386,8 @@ async def get_system_card( "algorithm_id": algorithm.id, "tab_items": tab_items, "breadcrumbs": breadcrumbs, + "editables": editables, + "base_href": f"/algorithm/{ algorithm_id }", } return templates.TemplateResponse(request, "pages/system_card.html.j2", context) diff --git a/amt/api/routes/organizations.py b/amt/api/routes/organizations.py index 533a358b..477e8845 100644 --- a/amt/api/routes/organizations.py +++ b/amt/api/routes/organizations.py @@ -3,11 +3,17 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query, Request -from fastapi.exceptions import RequestValidationError from fastapi.responses import HTMLResponse, JSONResponse, Response -from pydantic_core._pydantic_core import ValidationError # pyright: ignore from amt.api.deps import templates +from amt.api.editable import ( + Editables, + EditModes, + ResolvedEditable, + SafeDict, + get_enriched_resolved_editable, + save_editable, +) 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 @@ -20,16 +26,16 @@ ) from amt.api.organization_filter_options import get_localized_organization_filters from amt.api.risk_group import get_localized_risk_groups -from amt.api.routes.algorithm import UpdateFieldModel, set_path +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.routes.shared import UpdateFieldModel, get_filters_and_sort_by from amt.core.authorization import get_user from amt.core.exceptions import AMTAuthorizationError, AMTNotFound, AMTRepositoryError from amt.core.internationalization import get_current_translation from amt.models import Organization, User from amt.repositories.organizations import OrganizationsRepository from amt.repositories.users import UsersRepository -from amt.schema.organization import OrganizationBase, OrganizationNew, OrganizationSlug, OrganizationUsers +from amt.schema.organization import OrganizationNew, OrganizationUsers from amt.services.algorithms import AlgorithmsService from amt.services.organizations import OrganizationsService @@ -163,6 +169,7 @@ async def get_by_slug( context = { "base_href": f"/organizations/{ slug }", "organization": organization, + "organization_id": organization.id, "tab_items": tab_items, "breadcrumbs": breadcrumbs, } @@ -170,87 +177,131 @@ async def get_by_slug( async def get_organization_or_error( - organizations_repository: OrganizationsRepository, request: Request, slug: str + organizations_service: OrganizationsService, request: Request, slug: str ) -> Organization: try: - organization = await organizations_repository.find_by_slug(slug) + organization = await organizations_service.find_by_slug(slug) request.state.path_variables = {"organization_slug": organization.slug} except AMTRepositoryError as e: raise AMTNotFound from e return organization -@router.get("/{slug}/edit/{path:path}") +@router.get("/{slug}/edit") async def get_organization_edit( request: Request, - organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], - path: str, + organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], slug: str, - edit_type: str, + full_resource_path: str, ) -> HTMLResponse: - context: dict[str, Any] = {"base_href": f"/organizations/{ slug }"} - organization = await get_organization_or_error(organizations_repository, request, slug) - context.update({"path": path.replace("/", "."), "edit_type": edit_type, "object": organization}) + organization = await get_organization_or_error(organizations_service, request, slug) + + editable: ResolvedEditable = await get_enriched_resolved_editable( + context_variables={"organization_id": organization.id}, + full_resource_path=full_resource_path, + organizations_service=organizations_service, + edit_mode=EditModes.VIEW, + ) + + editable_context = {"organizations_service": organizations_service} + + # TODO: the converter could be done in the get_enriched_resolved_editable as it knows what editmode we are in + if editable.converter: + editable.value = await editable.converter.read(editable.value, **editable_context) + + context = { + "relative_resource_path": editable.relative_resource_path.replace("/", "."), + "base_href": f"/organizations/{ slug }", + "resource_object": editable.resource_object, + "full_resource_path": full_resource_path, + "editable_object": editable, + } + return templates.TemplateResponse(request, "parts/edit_cell.html.j2", context) -@router.get("/{slug}/cancel/{path:path}") +@router.get("/{slug}/cancel") async def get_organization_cancel( - organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], request: Request, - path: str, - edit_type: str, slug: str, + full_resource_path: str, + organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], ) -> HTMLResponse: - context: dict[str, Any] = { + organization = await get_organization_or_error(organizations_service, request, slug) + + editable: ResolvedEditable = await get_enriched_resolved_editable( + context_variables={"organization_id": organization.id}, + full_resource_path=full_resource_path, + organizations_service=organizations_service, + edit_mode=EditModes.VIEW, + ) + + editable_context = {"organizations_service": organizations_service} + + if editable.converter: + editable.value = await editable.converter.view(editable.value, **editable_context) + + context = { + "relative_resource_path": editable.relative_resource_path.replace("/", "."), "base_href": f"/organizations/{ slug }", - "path": path.replace("/", "."), - "edit_type": edit_type, + "resource_object": None, # TODO: this should become an optional parameter in the Jinja template + "full_resource_path": full_resource_path, + "editable_object": editable, } - organization = await get_organization_or_error(organizations_repository, request, slug) - context.update({"object": organization}) + return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) -@router.put("/{slug}/update/{path:path}") +@router.put("/{slug}/update") async def get_organization_update( request: Request, - organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], + organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], update_data: UpdateFieldModel, - path: str, - edit_type: str, slug: str, + full_resource_path: str, ) -> HTMLResponse: - context: dict[str, Any] = { + organization = await get_organization_or_error(organizations_service, request, slug) + + user_id = get_user_id_or_error(request) + + context_variables = {"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_value": update_data.value, + "organizations_service": organizations_service, + } + + editable = await save_editable( + editable, + update_data=update_data, + editable_context=editable_context, + organizations_service=organizations_service, + do_save=True, + ) + + # 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("/", "."), "base_href": f"/organizations/{ slug }", - "path": path.replace("/", "."), - "edit_type": edit_type, + "resource_object": None, + "full_resource_path": full_resource_path, + "editable_object": editable, } - organization = await get_organization_or_error(organizations_repository, request, slug) - context.update({"object": organization}) - - redirect_to: str | None = None - # TODO (Robbert) it would be nice to check this on the object.field type (instead of strings) - if path == "slug": - try: - organization_new1: OrganizationSlug = OrganizationSlug(slug=update_data.value) - OrganizationSlug.model_validate(organization_new1) - redirect_to = organization_new1.slug - except ValidationError as e: - raise RequestValidationError(e.errors()) from e - elif path == "name": - try: - organization_new: OrganizationBase = OrganizationBase(name=update_data.value) - OrganizationBase.model_validate(organization_new) - except ValidationError as e: - raise RequestValidationError(e.errors()) from e - - set_path(organization, path, update_data.value) - - await organizations_repository.save(organization) - - if redirect_to: - return templates.Redirect(request, f"/organizations/{redirect_to}") + + # 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) diff --git a/amt/api/routes/shared.py b/amt/api/routes/shared.py index c53b2420..15b99539 100644 --- a/amt/api/routes/shared.py +++ b/amt/api/routes/shared.py @@ -1,6 +1,11 @@ +from enum import Enum +from typing import Any + +from pydantic import BaseModel from starlette.requests import Request 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.schema.localized_value_item import LocalizedValueItem @@ -48,3 +53,44 @@ def get_localized_value(key: str, value: str, request: Request) -> LocalizedValu return localized return LocalizedValueItem(value=value, display_value="Unknown filter option") + + +def get_nested(obj: Any, attr_path: str) -> Any: # noqa: ANN401 + attrs = attr_path.lstrip("/").split("/") if "/" in attr_path else attr_path.lstrip(".").split(".") + for attr in attrs: + if hasattr(obj, attr): + obj = getattr(obj, attr) + elif isinstance(obj, dict) and attr in obj: + obj = obj[attr] + else: + obj = None + break + return obj + + +def nested_value(obj: Any, attr_path: str) -> Any: # noqa: ANN401 + obj = get_nested(obj, attr_path) + if isinstance(obj, Enum): + return obj.value + return obj + + +def is_nested_enum(obj: Any, attr_path: str) -> bool: # noqa: ANN401 + obj = get_nested(obj, attr_path) + return bool(isinstance(obj, Enum)) + + +def nested_enum(obj: Any, attr_path: str, language: str) -> list[LocalizedValueItem]: # noqa: ANN401 + nested_obj = get_nested(obj, attr_path) + if not isinstance(nested_obj, LocalizableEnum): + return [] + enum_class = type(nested_obj) + return [e.localize(language) for e in enum_class if isinstance(e, LocalizableEnum)] + + +def nested_enum_value(obj: Any, attr_path: str, language: str) -> Any: # noqa: ANN401 + return get_nested(obj, attr_path).localize(language) + + +class UpdateFieldModel(BaseModel): + value: str diff --git a/amt/locale/base.pot b/amt/locale/base.pot index 459fade8..3d4f4d23 100644 --- a/amt/locale/base.pot +++ b/amt/locale/base.pot @@ -46,7 +46,7 @@ msgid "Role" msgstr "" #: amt/api/group_by_category.py:15 -#: amt/site/templates/algorithms/details_info.html.j2:28 +#: amt/site/templates/algorithms/details_info.html.j2:34 #: amt/site/templates/algorithms/new.html.j2:41 #: amt/site/templates/parts/algorithm_search.html.j2:48 #: amt/site/templates/parts/filter_list.html.j2:71 @@ -155,7 +155,7 @@ msgstr "" msgid "Organizations" msgstr "" -#: amt/api/navigation.py:64 amt/site/templates/organizations/home.html.j2:33 +#: amt/api/navigation.py:64 amt/site/templates/organizations/home.html.j2:37 #: amt/site/templates/organizations/parts/members_results.html.j2:6 #: amt/site/templates/organizations/parts/overview_results.html.j2:132 msgid "Members" @@ -189,11 +189,11 @@ msgstr "" msgid "niet van toepassing" msgstr "" -#: amt/api/forms/algorithm.py:23 +#: amt/api/forms/algorithm.py:31 msgid "Select organization" msgstr "" -#: amt/api/forms/algorithm.py:29 +#: amt/api/forms/algorithm.py:35 #: amt/site/templates/algorithms/details_info.html.j2:12 msgid "Organization" msgstr "" @@ -257,7 +257,7 @@ msgid "The slug is the web path, like /organizations/my-organization-name" msgstr "" #: amt/api/forms/organization.py:33 -#: amt/site/templates/organizations/home.html.j2:17 +#: amt/site/templates/organizations/home.html.j2:19 msgid "Slug" msgstr "" @@ -477,34 +477,34 @@ msgstr "" msgid "Failed to estimate WOZ value: " msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:16 +#: amt/site/templates/algorithms/details_info.html.j2:18 #: amt/site/templates/algorithms/details_measure_modal.html.j2:27 msgid "Description" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:20 +#: amt/site/templates/algorithms/details_info.html.j2:24 msgid "Repository" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:24 +#: amt/site/templates/algorithms/details_info.html.j2:30 msgid "Algorithm code" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:32 +#: amt/site/templates/algorithms/details_info.html.j2:40 #: amt/site/templates/organizations/parts/overview_results.html.j2:134 #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:6 -#: amt/site/templates/pages/system_card.html.j2:4 +#: amt/site/templates/pages/system_card.html.j2:5 #: amt/site/templates/parts/filter_list.html.j2:75 #: amt/site/templates/parts/filter_list.html.j2:97 msgid "Last updated" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:36 +#: amt/site/templates/algorithms/details_info.html.j2:44 msgid "Labels" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:44 +#: amt/site/templates/algorithms/details_info.html.j2:52 msgid "References" msgstr "" @@ -513,12 +513,12 @@ msgid "Read more on the algoritmekader" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:64 -#: amt/site/templates/macros/editable.html.j2:82 +#: amt/site/templates/macros/editable.html.j2:109 msgid "Save" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:68 -#: amt/site/templates/macros/editable.html.j2:87 +#: amt/site/templates/macros/editable.html.j2:114 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:26 msgid "Cancel" msgstr "" @@ -528,8 +528,8 @@ msgid "measures executed" msgstr "" #: amt/site/templates/algorithms/details_requirements.html.j2:60 -#: amt/site/templates/macros/editable.html.j2:24 -#: amt/site/templates/macros/editable.html.j2:27 +#: amt/site/templates/macros/editable.html.j2:28 +#: amt/site/templates/macros/editable.html.j2:33 msgid "Edit" msgstr "" @@ -704,15 +704,15 @@ msgstr "" msgid "Unknown" msgstr "" -#: amt/site/templates/organizations/home.html.j2:21 +#: amt/site/templates/organizations/home.html.j2:25 msgid "Created at" msgstr "" -#: amt/site/templates/organizations/home.html.j2:25 +#: amt/site/templates/organizations/home.html.j2:29 msgid "Created by" msgstr "" -#: amt/site/templates/organizations/home.html.j2:29 +#: amt/site/templates/organizations/home.html.j2:33 msgid "Modified at" msgstr "" @@ -789,21 +789,21 @@ msgstr "" #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:7 -#: amt/site/templates/pages/system_card.html.j2:5 +#: amt/site/templates/pages/system_card.html.j2:6 msgid "ago" msgstr "" #: amt/site/templates/pages/assessment_card.html.j2:12 #: amt/site/templates/pages/model_card.html.j2:12 #: amt/site/templates/pages/model_card.html.j2:36 -#: amt/site/templates/pages/system_card.html.j2:48 +#: amt/site/templates/pages/system_card.html.j2:49 msgid "Attribute" msgstr "" #: amt/site/templates/pages/assessment_card.html.j2:13 #: amt/site/templates/pages/model_card.html.j2:13 #: amt/site/templates/pages/model_card.html.j2:37 -#: amt/site/templates/pages/system_card.html.j2:49 +#: amt/site/templates/pages/system_card.html.j2:50 msgid "Value" msgstr "" @@ -859,11 +859,11 @@ msgstr "" msgid "for more information on how the toolkit can support your organization." msgstr "" -#: amt/site/templates/pages/system_card.html.j2:9 +#: amt/site/templates/pages/system_card.html.j2:10 msgid "Assessment cards " msgstr "" -#: amt/site/templates/pages/system_card.html.j2:27 +#: amt/site/templates/pages/system_card.html.j2:28 msgid "Model cards" msgstr "" diff --git a/amt/locale/en_US/LC_MESSAGES/messages.po b/amt/locale/en_US/LC_MESSAGES/messages.po index eb276ba5..529535cf 100644 --- a/amt/locale/en_US/LC_MESSAGES/messages.po +++ b/amt/locale/en_US/LC_MESSAGES/messages.po @@ -47,7 +47,7 @@ msgid "Role" msgstr "" #: amt/api/group_by_category.py:15 -#: amt/site/templates/algorithms/details_info.html.j2:28 +#: amt/site/templates/algorithms/details_info.html.j2:34 #: amt/site/templates/algorithms/new.html.j2:41 #: amt/site/templates/parts/algorithm_search.html.j2:48 #: amt/site/templates/parts/filter_list.html.j2:71 @@ -156,7 +156,7 @@ msgstr "" msgid "Organizations" msgstr "" -#: amt/api/navigation.py:64 amt/site/templates/organizations/home.html.j2:33 +#: amt/api/navigation.py:64 amt/site/templates/organizations/home.html.j2:37 #: amt/site/templates/organizations/parts/members_results.html.j2:6 #: amt/site/templates/organizations/parts/overview_results.html.j2:132 msgid "Members" @@ -190,11 +190,11 @@ msgstr "Exception of application" msgid "niet van toepassing" msgstr "Not applicable" -#: amt/api/forms/algorithm.py:23 +#: amt/api/forms/algorithm.py:31 msgid "Select organization" msgstr "" -#: amt/api/forms/algorithm.py:29 +#: amt/api/forms/algorithm.py:35 #: amt/site/templates/algorithms/details_info.html.j2:12 msgid "Organization" msgstr "" @@ -258,7 +258,7 @@ msgid "The slug is the web path, like /organizations/my-organization-name" msgstr "" #: amt/api/forms/organization.py:33 -#: amt/site/templates/organizations/home.html.j2:17 +#: amt/site/templates/organizations/home.html.j2:19 msgid "Slug" msgstr "" @@ -478,34 +478,34 @@ msgstr "" msgid "Failed to estimate WOZ value: " msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:16 +#: amt/site/templates/algorithms/details_info.html.j2:18 #: amt/site/templates/algorithms/details_measure_modal.html.j2:27 msgid "Description" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:20 +#: amt/site/templates/algorithms/details_info.html.j2:24 msgid "Repository" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:24 +#: amt/site/templates/algorithms/details_info.html.j2:30 msgid "Algorithm code" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:32 +#: amt/site/templates/algorithms/details_info.html.j2:40 #: amt/site/templates/organizations/parts/overview_results.html.j2:134 #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:6 -#: amt/site/templates/pages/system_card.html.j2:4 +#: amt/site/templates/pages/system_card.html.j2:5 #: amt/site/templates/parts/filter_list.html.j2:75 #: amt/site/templates/parts/filter_list.html.j2:97 msgid "Last updated" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:36 +#: amt/site/templates/algorithms/details_info.html.j2:44 msgid "Labels" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:44 +#: amt/site/templates/algorithms/details_info.html.j2:52 msgid "References" msgstr "" @@ -514,12 +514,12 @@ msgid "Read more on the algoritmekader" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:64 -#: amt/site/templates/macros/editable.html.j2:82 +#: amt/site/templates/macros/editable.html.j2:109 msgid "Save" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:68 -#: amt/site/templates/macros/editable.html.j2:87 +#: amt/site/templates/macros/editable.html.j2:114 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:26 msgid "Cancel" msgstr "" @@ -529,8 +529,8 @@ msgid "measures executed" msgstr "" #: amt/site/templates/algorithms/details_requirements.html.j2:60 -#: amt/site/templates/macros/editable.html.j2:24 -#: amt/site/templates/macros/editable.html.j2:27 +#: amt/site/templates/macros/editable.html.j2:28 +#: amt/site/templates/macros/editable.html.j2:33 msgid "Edit" msgstr "" @@ -705,15 +705,15 @@ msgstr "" msgid "Unknown" msgstr "" -#: amt/site/templates/organizations/home.html.j2:21 +#: amt/site/templates/organizations/home.html.j2:25 msgid "Created at" msgstr "" -#: amt/site/templates/organizations/home.html.j2:25 +#: amt/site/templates/organizations/home.html.j2:29 msgid "Created by" msgstr "" -#: amt/site/templates/organizations/home.html.j2:29 +#: amt/site/templates/organizations/home.html.j2:33 msgid "Modified at" msgstr "" @@ -790,21 +790,21 @@ msgstr "" #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:7 -#: amt/site/templates/pages/system_card.html.j2:5 +#: amt/site/templates/pages/system_card.html.j2:6 msgid "ago" msgstr "" #: amt/site/templates/pages/assessment_card.html.j2:12 #: amt/site/templates/pages/model_card.html.j2:12 #: amt/site/templates/pages/model_card.html.j2:36 -#: amt/site/templates/pages/system_card.html.j2:48 +#: amt/site/templates/pages/system_card.html.j2:49 msgid "Attribute" msgstr "" #: amt/site/templates/pages/assessment_card.html.j2:13 #: amt/site/templates/pages/model_card.html.j2:13 #: amt/site/templates/pages/model_card.html.j2:37 -#: amt/site/templates/pages/system_card.html.j2:49 +#: amt/site/templates/pages/system_card.html.j2:50 msgid "Value" msgstr "" @@ -860,11 +860,11 @@ msgstr "" msgid "for more information on how the toolkit can support your organization." msgstr "" -#: amt/site/templates/pages/system_card.html.j2:9 +#: amt/site/templates/pages/system_card.html.j2:10 msgid "Assessment cards " msgstr "" -#: amt/site/templates/pages/system_card.html.j2:27 +#: amt/site/templates/pages/system_card.html.j2:28 msgid "Model cards" msgstr "" diff --git a/amt/locale/nl_NL/LC_MESSAGES/messages.po b/amt/locale/nl_NL/LC_MESSAGES/messages.po index 1eafeb63..c9fe6114 100644 --- a/amt/locale/nl_NL/LC_MESSAGES/messages.po +++ b/amt/locale/nl_NL/LC_MESSAGES/messages.po @@ -49,7 +49,7 @@ msgid "Role" msgstr "Rol" #: amt/api/group_by_category.py:15 -#: amt/site/templates/algorithms/details_info.html.j2:28 +#: amt/site/templates/algorithms/details_info.html.j2:34 #: amt/site/templates/algorithms/new.html.j2:41 #: amt/site/templates/parts/algorithm_search.html.j2:48 #: amt/site/templates/parts/filter_list.html.j2:71 @@ -158,7 +158,7 @@ msgstr "Instrumenten" msgid "Organizations" msgstr "Organisaties" -#: amt/api/navigation.py:64 amt/site/templates/organizations/home.html.j2:33 +#: amt/api/navigation.py:64 amt/site/templates/organizations/home.html.j2:37 #: amt/site/templates/organizations/parts/members_results.html.j2:6 #: amt/site/templates/organizations/parts/overview_results.html.j2:132 msgid "Members" @@ -192,11 +192,11 @@ msgstr "Uitzondering van toepassing" msgid "niet van toepassing" msgstr "Niet van toepassing" -#: amt/api/forms/algorithm.py:23 +#: amt/api/forms/algorithm.py:31 msgid "Select organization" msgstr "Selecteer organisatie" -#: amt/api/forms/algorithm.py:29 +#: amt/api/forms/algorithm.py:35 #: amt/site/templates/algorithms/details_info.html.j2:12 msgid "Organization" msgstr "Organisatie" @@ -264,7 +264,7 @@ msgstr "" "organisatie-naam" #: amt/api/forms/organization.py:33 -#: amt/site/templates/organizations/home.html.j2:17 +#: amt/site/templates/organizations/home.html.j2:19 msgid "Slug" msgstr "Slug" @@ -496,34 +496,34 @@ msgstr "Ongedefinieerd" msgid "Failed to estimate WOZ value: " msgstr "Fout bij het schatten van de WOZ-waarde: " -#: amt/site/templates/algorithms/details_info.html.j2:16 +#: amt/site/templates/algorithms/details_info.html.j2:18 #: amt/site/templates/algorithms/details_measure_modal.html.j2:27 msgid "Description" msgstr "Omschrijving" -#: amt/site/templates/algorithms/details_info.html.j2:20 +#: amt/site/templates/algorithms/details_info.html.j2:24 msgid "Repository" msgstr "Repository" -#: amt/site/templates/algorithms/details_info.html.j2:24 +#: amt/site/templates/algorithms/details_info.html.j2:30 msgid "Algorithm code" msgstr "Algoritme code" -#: amt/site/templates/algorithms/details_info.html.j2:32 +#: amt/site/templates/algorithms/details_info.html.j2:40 #: amt/site/templates/organizations/parts/overview_results.html.j2:134 #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:6 -#: amt/site/templates/pages/system_card.html.j2:4 +#: amt/site/templates/pages/system_card.html.j2:5 #: amt/site/templates/parts/filter_list.html.j2:75 #: amt/site/templates/parts/filter_list.html.j2:97 msgid "Last updated" msgstr "Laatst bijgewerkt" -#: amt/site/templates/algorithms/details_info.html.j2:36 +#: amt/site/templates/algorithms/details_info.html.j2:44 msgid "Labels" msgstr "Labels" -#: amt/site/templates/algorithms/details_info.html.j2:44 +#: amt/site/templates/algorithms/details_info.html.j2:52 msgid "References" msgstr "Referenties" @@ -532,12 +532,12 @@ msgid "Read more on the algoritmekader" msgstr "Lees meer op het algoritmekader" #: amt/site/templates/algorithms/details_measure_modal.html.j2:64 -#: amt/site/templates/macros/editable.html.j2:82 +#: amt/site/templates/macros/editable.html.j2:109 msgid "Save" msgstr "Opslaan" #: amt/site/templates/algorithms/details_measure_modal.html.j2:68 -#: amt/site/templates/macros/editable.html.j2:87 +#: amt/site/templates/macros/editable.html.j2:114 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:26 msgid "Cancel" msgstr "Annuleren" @@ -547,8 +547,8 @@ msgid "measures executed" msgstr "maatregelen uitgevoerd" #: amt/site/templates/algorithms/details_requirements.html.j2:60 -#: amt/site/templates/macros/editable.html.j2:24 -#: amt/site/templates/macros/editable.html.j2:27 +#: amt/site/templates/macros/editable.html.j2:28 +#: amt/site/templates/macros/editable.html.j2:33 msgid "Edit" msgstr "Bewerk" @@ -728,15 +728,15 @@ msgstr "Beoordelen" msgid "Unknown" msgstr "Onbekend" -#: amt/site/templates/organizations/home.html.j2:21 +#: amt/site/templates/organizations/home.html.j2:25 msgid "Created at" msgstr "Aangemaakt op" -#: amt/site/templates/organizations/home.html.j2:25 +#: amt/site/templates/organizations/home.html.j2:29 msgid "Created by" msgstr "Aangemaakt door" -#: amt/site/templates/organizations/home.html.j2:29 +#: amt/site/templates/organizations/home.html.j2:33 msgid "Modified at" msgstr "Bijgewerkt op" @@ -813,21 +813,21 @@ msgstr "Organisatie naam" #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:7 -#: amt/site/templates/pages/system_card.html.j2:5 +#: amt/site/templates/pages/system_card.html.j2:6 msgid "ago" msgstr "geleden" #: amt/site/templates/pages/assessment_card.html.j2:12 #: amt/site/templates/pages/model_card.html.j2:12 #: amt/site/templates/pages/model_card.html.j2:36 -#: amt/site/templates/pages/system_card.html.j2:48 +#: amt/site/templates/pages/system_card.html.j2:49 msgid "Attribute" msgstr "Attribuut" #: amt/site/templates/pages/assessment_card.html.j2:13 #: amt/site/templates/pages/model_card.html.j2:13 #: amt/site/templates/pages/model_card.html.j2:37 -#: amt/site/templates/pages/system_card.html.j2:49 +#: amt/site/templates/pages/system_card.html.j2:50 msgid "Value" msgstr "Waarde" @@ -890,11 +890,11 @@ msgstr "neem contact op" msgid "for more information on how the toolkit can support your organization." msgstr "voor meer informatie over hoe de toolkit uw organisatie kan ondersteunen." -#: amt/site/templates/pages/system_card.html.j2:9 +#: amt/site/templates/pages/system_card.html.j2:10 msgid "Assessment cards " msgstr "Assessmentkaart" -#: amt/site/templates/pages/system_card.html.j2:27 +#: amt/site/templates/pages/system_card.html.j2:28 msgid "Model cards" msgstr "Modelkaart" diff --git a/amt/migrations/versions/e16bb3d53cd6_authorization_system.py b/amt/migrations/versions/e16bb3d53cd6_authorization_system.py index 0bae0c71..3c770647 100644 --- a/amt/migrations/versions/e16bb3d53cd6_authorization_system.py +++ b/amt/migrations/versions/e16bb3d53cd6_authorization_system.py @@ -21,7 +21,6 @@ depends_on: str | Sequence[str] | None = None - def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### role_table = op.create_table( @@ -55,56 +54,157 @@ def upgrade() -> None: op.bulk_insert( role_table, [ - {'id': 1, 'name': 'Organization Maintainer'}, - {'id': 2, 'name': 'Organization Member'}, - {'id': 3, 'name': 'Organization Viewer'}, - {'id': 4, 'name': 'Algorithm Maintainer'}, - {'id': 5, 'name': 'Algorithm Member'}, - {'id': 6, 'name': 'Algorithm Viewer'}, - ] + {"id": 1, "name": "Organization Maintainer"}, + {"id": 2, "name": "Organization Member"}, + {"id": 3, "name": "Organization Viewer"}, + {"id": 4, "name": "Algorithm Maintainer"}, + {"id": 5, "name": "Algorithm Member"}, + {"id": 6, "name": "Algorithm Viewer"}, + ], ) op.bulk_insert( rule_table, [ - {'id': 1, 'resource': AuthorizationResource.ORGANIZATION_INFO, 'verbs': [AuthorizationVerb.CREATE, AuthorizationVerb.READ, AuthorizationVerb.UPDATE], 'role_id': 1}, - {'id': 2, 'resource': AuthorizationResource.ORGANIZATION_ALGORITHM, 'verbs': [AuthorizationVerb.LIST, AuthorizationVerb.CREATE, AuthorizationVerb.UPDATE, AuthorizationVerb.DELETE], 'role_id': 1}, - {'id': 3, 'resource': AuthorizationResource.ORGANIZATION_MEMBER, 'verbs': [AuthorizationVerb.LIST, AuthorizationVerb.CREATE, AuthorizationVerb.UPDATE, AuthorizationVerb.DELETE], 'role_id': 1}, - {'id': 4, 'resource': AuthorizationResource.ORGANIZATION_INFO, 'verbs': [AuthorizationVerb.READ], 'role_id': 2}, - {'id': 5, 'resource': AuthorizationResource.ORGANIZATION_ALGORITHM, 'verbs': [AuthorizationVerb.LIST, AuthorizationVerb.CREATE], 'role_id': 2}, - {'id': 6, 'resource': AuthorizationResource.ORGANIZATION_MEMBER, 'verbs': [AuthorizationVerb.LIST], 'role_id': 2}, - {'id': 7, 'resource': AuthorizationResource.ORGANIZATION_INFO, 'verbs': [AuthorizationVerb.READ], 'role_id': 3}, - {'id': 8, 'resource': AuthorizationResource.ORGANIZATION_ALGORITHM, 'verbs': [AuthorizationVerb.LIST], 'role_id': 3}, - {'id': 9, 'resource': AuthorizationResource.ORGANIZATION_MEMBER, 'verbs': [AuthorizationVerb.LIST], 'role_id': 3}, - {'id': 10, 'resource': AuthorizationResource.ALGORITHM, 'verbs': [AuthorizationVerb.CREATE, AuthorizationVerb.READ, AuthorizationVerb.DELETE], 'role_id': 4}, - {'id': 11, 'resource': AuthorizationResource.ALGORITHM_SYSTEMCARD, 'verbs': [AuthorizationVerb.READ, AuthorizationVerb.CREATE, AuthorizationVerb.UPDATE], 'role_id': 4}, - {'id': 12, 'resource': AuthorizationResource.ALGORITHM_MEMBER, 'verbs': [AuthorizationVerb.CREATE, AuthorizationVerb.READ, AuthorizationVerb.UPDATE, AuthorizationVerb.DELETE], 'role_id': 4}, - {'id': 13, 'resource': AuthorizationResource.ALGORITHM, 'verbs': [AuthorizationVerb.READ, AuthorizationVerb.CREATE], 'role_id': 5}, - {'id': 14, 'resource': AuthorizationResource.ALGORITHM_SYSTEMCARD, 'verbs': [AuthorizationVerb.READ, AuthorizationVerb.CREATE, AuthorizationVerb.UPDATE], 'role_id': 5}, - {'id': 15, 'resource': AuthorizationResource.ALGORITHM_MEMBER, 'verbs': [AuthorizationVerb.READ], 'role_id': 5}, - {'id': 16, 'resource': AuthorizationResource.ALGORITHM, 'verbs': [AuthorizationVerb.READ], 'role_id': 6}, - {'id': 17, 'resource': AuthorizationResource.ALGORITHM_SYSTEMCARD, 'verbs': [AuthorizationVerb.READ], 'role_id': 6}, - {'id': 18, 'resource': AuthorizationResource.ALGORITHM_MEMBER, 'verbs': [AuthorizationVerb.READ], 'role_id': 6}, - ] + { + "id": 1, + "resource": AuthorizationResource.ORGANIZATION_INFO, + "verbs": [AuthorizationVerb.CREATE, AuthorizationVerb.READ, AuthorizationVerb.UPDATE], + "role_id": 1, + }, + { + "id": 2, + "resource": AuthorizationResource.ORGANIZATION_ALGORITHM, + "verbs": [ + AuthorizationVerb.LIST, + AuthorizationVerb.CREATE, + AuthorizationVerb.UPDATE, + AuthorizationVerb.DELETE, + ], + "role_id": 1, + }, + { + "id": 3, + "resource": AuthorizationResource.ORGANIZATION_MEMBER, + "verbs": [ + AuthorizationVerb.LIST, + AuthorizationVerb.CREATE, + AuthorizationVerb.UPDATE, + AuthorizationVerb.DELETE, + ], + "role_id": 1, + }, + { + "id": 4, + "resource": AuthorizationResource.ORGANIZATION_INFO, + "verbs": [AuthorizationVerb.READ], + "role_id": 2, + }, + { + "id": 5, + "resource": AuthorizationResource.ORGANIZATION_ALGORITHM, + "verbs": [AuthorizationVerb.LIST, AuthorizationVerb.CREATE], + "role_id": 2, + }, + { + "id": 6, + "resource": AuthorizationResource.ORGANIZATION_MEMBER, + "verbs": [AuthorizationVerb.LIST], + "role_id": 2, + }, + { + "id": 7, + "resource": AuthorizationResource.ORGANIZATION_INFO, + "verbs": [AuthorizationVerb.READ], + "role_id": 3, + }, + { + "id": 8, + "resource": AuthorizationResource.ORGANIZATION_ALGORITHM, + "verbs": [AuthorizationVerb.LIST], + "role_id": 3, + }, + { + "id": 9, + "resource": AuthorizationResource.ORGANIZATION_MEMBER, + "verbs": [AuthorizationVerb.LIST], + "role_id": 3, + }, + { + "id": 10, + "resource": AuthorizationResource.ALGORITHM, + "verbs": [AuthorizationVerb.CREATE, AuthorizationVerb.READ, AuthorizationVerb.DELETE], + "role_id": 4, + }, + { + "id": 11, + "resource": AuthorizationResource.ALGORITHM_SYSTEMCARD, + "verbs": [AuthorizationVerb.READ, AuthorizationVerb.CREATE, AuthorizationVerb.UPDATE], + "role_id": 4, + }, + { + "id": 12, + "resource": AuthorizationResource.ALGORITHM_MEMBER, + "verbs": [ + AuthorizationVerb.CREATE, + AuthorizationVerb.READ, + AuthorizationVerb.UPDATE, + AuthorizationVerb.DELETE, + ], + "role_id": 4, + }, + { + "id": 13, + "resource": AuthorizationResource.ALGORITHM, + "verbs": [AuthorizationVerb.READ, AuthorizationVerb.CREATE], + "role_id": 5, + }, + { + "id": 14, + "resource": AuthorizationResource.ALGORITHM_SYSTEMCARD, + "verbs": [AuthorizationVerb.READ, AuthorizationVerb.CREATE, AuthorizationVerb.UPDATE], + "role_id": 5, + }, + { + "id": 15, + "resource": AuthorizationResource.ALGORITHM_MEMBER, + "verbs": [AuthorizationVerb.READ], + "role_id": 5, + }, + {"id": 16, "resource": AuthorizationResource.ALGORITHM, "verbs": [AuthorizationVerb.READ], "role_id": 6}, + { + "id": 17, + "resource": AuthorizationResource.ALGORITHM_SYSTEMCARD, + "verbs": [AuthorizationVerb.READ], + "role_id": 6, + }, + { + "id": 18, + "resource": AuthorizationResource.ALGORITHM_MEMBER, + "verbs": [AuthorizationVerb.READ], + "role_id": 6, + }, + ], ) session = Session(bind=op.get_bind()) - first_user = session.query(User).first() # first user is always present due to other migration + first_user = session.query(User).first() # first user is always present due to other migration organizations = session.query(Organization).all() authorizations = [] # lets add user 1 to all organizations bij default for organization in organizations: authorizations.append( - {'user_id': first_user.id, 'role_id': 1, 'type': AuthorizationType.ORGANIZATION, 'type_id': organization.id}, + { + "user_id": first_user.id, + "role_id": 1, + "type": AuthorizationType.ORGANIZATION, + "type_id": organization.id, + }, ) - op.bulk_insert( - authorization_table, - authorizations - ) - + op.bulk_insert(authorization_table, authorizations) def downgrade() -> None: diff --git a/amt/models/algorithm.py b/amt/models/algorithm.py index a28f97b6..d6f7b9ba 100644 --- a/amt/models/algorithm.py +++ b/amt/models/algorithm.py @@ -1,5 +1,5 @@ import json -from datetime import datetime +from datetime import date, datetime from enum import Enum from typing import Any, TypeVar @@ -19,6 +19,8 @@ class CustomJSONEncoder(json.JSONEncoder): def default(self, o: Any) -> Any: # noqa: ANN401 if isinstance(o, datetime): return o.isoformat() + if isinstance(o, date): + return o.isoformat() if isinstance(o, Enum): return o.name return super().default(o) diff --git a/amt/schema/algorithm.py b/amt/schema/algorithm.py index c78ec313..b1b0ae95 100644 --- a/amt/schema/algorithm.py +++ b/amt/schema/algorithm.py @@ -1,3 +1,5 @@ +from dataclasses import field + from pydantic import BaseModel, Field from pydantic.functional_validators import field_validator @@ -8,19 +10,19 @@ class AlgorithmBase(BaseModel): class AlgorithmNew(AlgorithmBase): - instruments: list[str] | str = [] + instruments: list[str] | str = field(default_factory=list) type: str | None = Field(default=None) open_source: str | None = Field(default=None) risk_group: str | None = Field(default=None) conformity_assessment_body: str | None = Field(default=None) systemic_risk: str | None = Field(default=None) transparency_obligations: str | None = Field(default=None) - role: list[str] | str = [] + role: list[str] | str = field(default_factory=list) template_id: str | None = Field(default=None) organization_id: int = Field() - @field_validator("organization_id", mode="before") @classmethod + @field_validator("organization_id", mode="before") def ensure_required(cls, v: int | str) -> int: if isinstance(v, str) and v == "": # this is always a string # TODO (Robbert): the error message from pydantic becomes 'Value error, @@ -29,7 +31,7 @@ def ensure_required(cls, v: int | str) -> int: else: return int(v) - @field_validator("instruments", "role") @classmethod + @field_validator("instruments", "role") def ensure_list(cls, v: list[str] | str) -> list[str]: return v if isinstance(v, list) else [v] diff --git a/amt/schema/system_card.py b/amt/schema/system_card.py index 9c587d89..d8d1e656 100644 --- a/amt/schema/system_card.py +++ b/amt/schema/system_card.py @@ -1,4 +1,5 @@ -from typing import Any +from datetime import date, datetime +from enum import Enum from pydantic import Field @@ -11,33 +12,177 @@ from amt.schema.shared import BaseModel +class Provenance(BaseModel): + git_commit_hash: str | None = Field( + None, + description="Git commit hash of the commit which contains the transformation file used to create this card", + ) + timestamp: datetime | None = Field( + None, + description="A timestamp of the date, time and timezone of generation of this System Card in ISO 8601 format", + ) + uri: str | None = Field(None, description="URI to the tool that was used to perform the transformations") + author: str | None = Field(None, description="Name of person that initiated the transformations") + + +class Type(Enum): + AI_systeem = "AI-systeem" + AI_systeem_voor_algemene_doeleinden = "AI-systeem voor algemene doeleinden" + AI_model_voor_algemene_doeleinden = "AI-model voor algemene doeleinden" + geen_algoritme = "geen algoritme" + + +class OpenSource(Enum): + open_source = "open-source" + geen_open_source = "geen open-source" + + +class PublicationCategory(Enum): + impactvol_algoritme = "impactvol algoritme" + niet_impactvol_algoritme = "niet-impactvol algoritme" + hoog_risico_AI = "hoog-risico AI" + geen_hoog_risico_AI = "geen hoog-risico AI" + verboden_AI = "verboden AI" + uitzondering_van_toepassing = "uitzondering van toepassing" + + +class SystemicRisk(Enum): + systeemrisico = "systeemrisico" + geen_systeemrisico = "geen systeemrisico" + + +class TransparencyObligations(Enum): + transparantieverplichtingen = "transparantieverplichtingen" + geen_transparantieverplichtingen = "geen transparantieverplichtingen" + + +class Role(Enum): + aanbieder = "aanbieder" + gebruiksverantwoordelijke = "gebruiksverantwoordelijke" + aanbieder___gebruiksverantwoordelijke = "aanbieder + gebruiksverantwoordelijke" + + +class DecisionTree(BaseModel): + version: str | None = Field(None, description="The version of the decision tree") + + +class Label(BaseModel): + name: str | None = Field(None, description="Name of the label") + value: str | None = Field(None, description="Value of the label") + + +class LegalBaseItem(BaseModel): + name: str | None = Field(None, description="Name of the law") + link: str | None = Field(None, description="URI pointing towards the contents of the law") + + +class ExternalProvider(BaseModel): + name: str | None = Field(None, description="Name of the external provider") + version: str | None = Field( + None, + description="Version of the external provider reflecting its relation to previous versions", + ) + + +class UserInterfaceItem(BaseModel): + description: str | None = Field(None, description="A description of the provided user interface") + link: str | None = Field(None, description="A link to the user interface can be included") + snapshot: str | None = Field( + None, + description="A snapshot/screenshot of the user interface can be included with the use of a hyperlink", + ) + + class Reference(BaseModel): name: str | None = Field(default=None) link: str | None = Field(default=None) -# TODO: consider reusing classes, Owner is now also defined for Models class Owner(BaseModel): - organization: str | None = Field(default=None) - oin: str | None = Field(default=None) - - def __init__(self, organization: str, oin: str | None = None, **data) -> None: # pyright: ignore # noqa - super().__init__(**data) - self.organization = organization + oin: str | None = Field(None, description="If applicable the Organisatie-identificatienummer (OIN)") + organization: str | None = Field( + None, + description="Name of the organization that owns the model. If ion is NOT provided this field is REQUIRED", + ) + name: str | None = Field(None, description="Name of a contact person within the organization") + email: str | None = Field(None, description="Email address of the contact person or organization") + role: str | None = Field( + None, + description="Role of the contact person. This field should only be set when the name field is set", + ) class SystemCard(BaseModel): - schema_version: str = Field(default="0.1a10") - name: str | None = Field(default=None) - ai_act_profile: AiActProfile | None = Field(default=None) - provenance: dict[str, Any] = Field(default={}) - description: str | None = Field(default=None) - labels: list[dict[str, Any]] = Field(default=[]) - status: str | None = Field(default=None) + version: str = Field(..., description="The version of the schema used") + provenance: Provenance | None = None + name: str | None = Field(None, description="Name used to describe the system") instruments: list[InstrumentBase] = Field(default=[]) + upl: str | None = Field( + None, + description="If this algorithm is part of a product offered by the Dutch Government," + "it should contain a URI from the Uniform Product List", + ) + owners: list[Owner] | None = None + description: str | None = Field(None, description="A short description of the system") + ai_act_profile: AiActProfile | None = None + labels: list[Label] | None = Field(None, description="Labels to store meta information about the system") + status: str | None = Field(None, description="Status of the system") + begin_date: date | None = Field( + None, + description="The first date the system was used, in ISO 8601 format, i.e. YYYY-MM-DD." + "Left out if not yet in use.", + ) + end_date: date | None = Field( + None, + description="The last date the system was used, in ISO 8601 format, i.e. YYYY-MM-DD. Left out if still in use.", + ) + goal_and_impact: str | None = Field( + None, + description="The purpose of the system and the impact it has on citizens and companies", + ) + considerations: str | None = Field(None, description="The pro's and con's of using the system") + risk_management: str | None = Field(None, description="Description of the risks associated with the system") + human_intervention: str | None = Field( + None, + description="A description to want extend there is human involvement in the system", + ) + legal_base: list[LegalBaseItem] | None = Field( + None, description="Relevant laws for the process the system is embedded in" + ) + used_data: str | None = Field(None, description="An overview of the data that is used in the system") + technical_design: str | None = Field(None, description="Description on how the system works") + external_providers: list[str] | None = Field(None, description="Information on external providers") + interaction_details: list[str] | None = Field( + None, + description="How the AI system interacts with hardware or software, including other AI systems", + ) + version_requirements: list[str] | None = Field( + None, + description="The versions of the relevant software or firmware," + "and any requirements related to version updates", + ) + deployment_variants: list[str] | None = Field( + None, + description="All the forms in which the AI system is placed on the market or put into service," + "such as software packages embedded into hardware, downloads, or APIs", + ) + hardware_requirements: list[str] | None = Field( + None, + description="Description of the hardware on which the AI system must be run", + ) + product_markings: list[str] | None = Field( + None, + description="If the AI system is a component of products, photos, or illustrations, " + "the external features, markings, and internal layout of those products", + ) + user_interface: list[UserInterfaceItem] | None = Field( + None, + description="Information on the user interface provided to the user responsible for its operation", + ) + + schema_version: str = Field(default="0.1a10") requirements: list[RequirementTask] = Field(default=[]) measures: list[MeasureTask] = Field(default=[]) assessments: list[AssessmentCard] = Field(default=[]) references: list[Reference] = Field(default=[]) models: list[ModelCardSchema] = Field(default=[]) - owners: list[Owner] = Field(default=[]) diff --git a/amt/schema/webform.py b/amt/schema/webform.py index 4645cce8..0134fbf4 100644 --- a/amt/schema/webform.py +++ b/amt/schema/webform.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from enum import Enum from typing import Any @@ -15,6 +16,20 @@ class WebFormFieldType(Enum): SUBMIT = "submit" +@dataclass +class WebFormFieldImplementationTypeFields: + name: str + type: WebFormFieldType | None + + +class WebFormFieldImplementationType: + TEXT = WebFormFieldImplementationTypeFields("text", WebFormFieldType.TEXT) + PARENT = WebFormFieldImplementationTypeFields("parent", None) + TEXTAREA = WebFormFieldImplementationTypeFields("textarea", WebFormFieldType.TEXTAREA) + SELECT_MY_ORGANIZATIONS = WebFormFieldImplementationTypeFields("select_my_organizations", WebFormFieldType.SELECT) + SELECT_LIFECYCLE = WebFormFieldImplementationTypeFields("select_lifecycle", WebFormFieldType.SELECT) + + class WebFormOption: value: str display_value: str diff --git a/amt/services/algorithms.py b/amt/services/algorithms.py index e3408eab..a8afb769 100644 --- a/amt/services/algorithms.py +++ b/amt/services/algorithms.py @@ -11,12 +11,12 @@ from fastapi import Depends from amt.core.exceptions import AMTNotFound -from amt.models import Algorithm +from amt.models import Algorithm, Organization from amt.repositories.algorithms import AlgorithmsRepository from amt.repositories.organizations import OrganizationsRepository from amt.schema.algorithm import AlgorithmNew from amt.schema.instrument import InstrumentBase -from amt.schema.system_card import AiActProfile, SystemCard +from amt.schema.system_card import AiActProfile, Owner, SystemCard from amt.services.instruments import InstrumentsService, create_instrument_service from amt.services.task_registry import get_requirements_and_measures @@ -49,6 +49,10 @@ async def delete(self, algorithm_id: int) -> Algorithm: return algorithm async def create(self, algorithm_new: AlgorithmNew, user_id: UUID | str) -> Algorithm: + organization: Organization = await self.organizations_repository.find_by_id_and_user_id( + algorithm_new.organization_id, user_id + ) + system_card_from_template = None if algorithm_new.template_id: template_files = get_template_files() @@ -80,6 +84,8 @@ async def create(self, algorithm_new: AlgorithmNew, user_id: UUID | str) -> Algo instruments=instruments, requirements=requirements, measures=measures, + owners=[Owner(organization=organization.name)], + version="0.0.0", ) if system_card_from_template is not None: @@ -103,9 +109,7 @@ async def create(self, algorithm_new: AlgorithmNew, user_id: UUID | str) -> Algo system_card = SystemCard.model_validate(system_card_merged) algorithm = Algorithm(name=algorithm_new.name, lifecycle=algorithm_new.lifecycle, system_card=system_card) - algorithm.organization = await self.organizations_repository.find_by_id_and_user_id( - algorithm_new.organization_id, user_id - ) + algorithm.organization = organization algorithm = await self.update(algorithm) @@ -119,7 +123,8 @@ async def paginate( async def update(self, algorithm: Algorithm) -> Algorithm: # TODO: Is this the right place to sync system cards: system_card and system_card_json? - algorithm.sync_system_card() + # TODO: when system card is missing things break, so we call it here to make sure it exists?? + dummy = algorithm.system_card # noqa: F841 algorithm = await self.repository.save(algorithm) return algorithm diff --git a/amt/services/organizations.py b/amt/services/organizations.py index 1b0ee64d..5270767d 100644 --- a/amt/services/organizations.py +++ b/amt/services/organizations.py @@ -57,3 +57,12 @@ async def add_users(self, organization: Organization, user_ids: list[str] | str) async def find_by_slug(self, slug: str) -> Organization: return await self.organizations_repository.find_by_slug(slug) + + async def get_by_id(self, organization_id: id) -> Organization: + return await self.organizations_repository.find_by_id(organization_id) + + async def find_by_id_and_user_id(self, organization_id: int, user_id: str | UUID) -> Organization: + return await self.organizations_repository.find_by_id_and_user_id(organization_id, user_id) + + async def update(self, organization: Organization) -> Organization: + return await self.organizations_repository.save(organization) diff --git a/amt/services/tasks.py b/amt/services/tasks.py index b62aa4da..d3bafed5 100644 --- a/amt/services/tasks.py +++ b/amt/services/tasks.py @@ -23,7 +23,7 @@ def __init__( ) -> None: self.repository = repository self.storage_writer = StorageFactory.init(storage_type="file", location="./output", filename="system_card.yaml") - self.system_card = SystemCard() + self.system_card = SystemCard(version="0.0.0") async def get_tasks(self, status_id: int) -> Sequence[Task]: task = await self.repository.find_by_status_id(status_id) diff --git a/amt/site/static/scss/layout.scss b/amt/site/static/scss/layout.scss index 299be045..c592ec6e 100644 --- a/amt/site/static/scss/layout.scss +++ b/amt/site/static/scss/layout.scss @@ -298,6 +298,7 @@ main { .rvo-table-row td:first-child { width: 20%; + vertical-align: top; } .amt-error-message { @@ -467,4 +468,9 @@ main { gap: 0 !important; } +/** TODO: this is a fix for width: 100% on a margin-left element which should be fixed by ROOS */ +.amt-theme .rvo-header__logo-wrapper { + width: auto; +} + /* stylelint-enable */ diff --git a/amt/site/templates/algorithms/details_info.html.j2 b/amt/site/templates/algorithms/details_info.html.j2 index 340d0ee5..35852f28 100644 --- a/amt/site/templates/algorithms/details_info.html.j2 +++ b/amt/site/templates/algorithms/details_info.html.j2 @@ -6,19 +6,25 @@