From e22b30c8c123d061f3a743f9b530acaf0987c67b Mon Sep 17 00:00:00 2001 From: robbertuittenbroek Date: Thu, 16 Jan 2025 11:00:54 +0100 Subject: [PATCH 1/7] Add basic authorization roles and enforce them. --- amt/api/decorators.py | 8 +- amt/api/editable.py | 6 +- amt/api/routes/algorithm.py | 32 ++++-- amt/api/routes/algorithms.py | 23 +++- amt/api/routes/organizations.py | 98 ++++++++++-------- amt/api/routes/shared.py | 10 +- amt/api/utils.py | 8 ++ amt/core/authorization.py | 11 +- amt/core/exceptions.py | 12 ++- amt/locale/base.pot | 58 ++++++++--- amt/locale/en_US/LC_MESSAGES/messages.mo | Bin 989 -> 989 bytes amt/locale/en_US/LC_MESSAGES/messages.po | 58 ++++++++--- amt/locale/nl_NL/LC_MESSAGES/messages.mo | Bin 14016 -> 14729 bytes amt/locale/nl_NL/LC_MESSAGES/messages.po | 63 ++++++++--- amt/middleware/authorization.py | 18 +++- amt/repositories/algorithms.py | 23 +++- amt/repositories/authorizations.py | 55 +++++++++- amt/repositories/organizations.py | 4 + amt/repositories/users.py | 8 +- amt/services/algorithms.py | 5 +- amt/services/authorization.py | 16 ++- .../templates/errors/AMTNotFound_404.html.j2 | 6 ++ .../errors/AMTPermissionDenied_404.html.j2 | 6 ++ amt/site/templates/errors/Exception.html.j2 | 2 +- .../errors/HTTPException_404.html.j2 | 6 ++ .../errors/RequestValidationError_400.html.j2 | 2 +- .../templates/errors/_404_Exception.html.j2 | 17 +++ amt/site/templates/macros/form_macros.html.j2 | 2 +- amt/site/templates/pages/system_card.html.j2 | 8 +- tests/api/routes/test_algorithm.py | 15 +-- tests/api/test_decorator.py | 16 +-- tests/conftest.py | 14 ++- tests/constants.py | 4 + tests/core/test_exceptions.py | 11 +- tests/database_e2e_setup.py | 7 +- tests/e2e/test_change_lang.py | 4 +- tests/e2e/test_create_algorithm.py | 6 -- tests/e2e/test_create_organization.py | 6 -- tests/repositories/test_authorizations.py | 20 +++- 39 files changed, 484 insertions(+), 184 deletions(-) create mode 100644 amt/api/utils.py create mode 100644 amt/site/templates/errors/AMTNotFound_404.html.j2 create mode 100644 amt/site/templates/errors/AMTPermissionDenied_404.html.j2 create mode 100644 amt/site/templates/errors/HTTPException_404.html.j2 create mode 100644 amt/site/templates/errors/_404_Exception.html.j2 diff --git a/amt/api/decorators.py b/amt/api/decorators.py index c4fbe22e..6237f86a 100644 --- a/amt/api/decorators.py +++ b/amt/api/decorators.py @@ -4,22 +4,20 @@ from fastapi import HTTPException, Request +from amt.api.utils import SafeDict from amt.core.exceptions import AMTPermissionDenied -def add_permissions(permissions: dict[str, list[str]]) -> Callable[[Callable[..., Any]], Callable[..., Any]]: +def permission(permissions: dict[str, list[str]]) -> Callable[[Callable[..., Any]], Callable[..., Any]]: def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 request = kwargs.get("request") - organization_id = kwargs.get("organization_id") - algoritme_id = kwargs.get("algoritme_id") if not isinstance(request, Request): # todo: change exception to custom exception raise HTTPException(status_code=400, detail="Request object is missing") for permission, verbs in permissions.items(): - permission = permission.format(organization_id=organization_id) - permission = permission.format(algoritme_id=algoritme_id) + permission = permission.format_map(SafeDict(kwargs)) request_permissions: dict[str, list[str]] = ( request.state.permissions if hasattr(request.state, "permissions") else {} ) diff --git a/amt/api/editable.py b/amt/api/editable.py index da3f4853..551b19e2 100644 --- a/amt/api/editable.py +++ b/amt/api/editable.py @@ -15,6 +15,7 @@ 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.api.utils import SafeDict from amt.core.exceptions import AMTNotFound from amt.models import Algorithm, Organization from amt.models.base import Base @@ -198,11 +199,6 @@ def __iter__(self) -> Generator[tuple[str, Any], Any, Any]: editables = Editables() -class SafeDict(dict[str, str | int]): - def __missing__(self, key: str) -> str: - return "{" + key + "}" - - class EditModes(StrEnum): EDIT = "EDIT" VIEW = "VIEW" diff --git a/amt/api/routes/algorithm.py b/amt/api/routes/algorithm.py index a73bc70b..580775a4 100644 --- a/amt/api/routes/algorithm.py +++ b/amt/api/routes/algorithm.py @@ -10,6 +10,7 @@ from fastapi.responses import FileResponse, HTMLResponse from ulid import ULID +from amt.api.decorators import permission from amt.api.deps import templates from amt.api.editable import ( EditModes, @@ -27,7 +28,7 @@ resolve_navigation_items, ) from amt.api.routes.shared import UpdateFieldModel, get_filters_and_sort_by, replace_none_with_empty_string_inplace -from amt.core.authorization import get_user +from amt.core.authorization import AuthorizationResource, AuthorizationVerb, get_user from amt.core.exceptions import AMTError, AMTNotFound, AMTRepositoryError from amt.core.internationalization import get_current_translation from amt.enums.status import Status @@ -215,6 +216,7 @@ async def get_algorithm_context( @router.get("/{algorithm_id}/details") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def get_algorithm_details( request: Request, algorithm_id: int, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)] ) -> HTMLResponse: @@ -236,6 +238,7 @@ async def get_algorithm_details( @router.get("/{algorithm_id}/edit") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.UPDATE]}) async def get_algorithm_edit( request: Request, algorithm_id: int, @@ -271,6 +274,7 @@ async def get_algorithm_edit( @router.get("/{algorithm_id}/cancel") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.UPDATE]}) async def get_algorithm_cancel( request: Request, algorithm_id: int, @@ -305,6 +309,7 @@ async def get_algorithm_cancel( @router.put("/{algorithm_id}/update") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.UPDATE]}) async def get_algorithm_update( request: Request, algorithm_id: int, @@ -356,6 +361,7 @@ async def get_algorithm_update( @router.get("/{algorithm_id}/details/system_card") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def get_system_card( request: Request, algorithm_id: int, @@ -398,6 +404,7 @@ async def get_system_card( @router.get("/{algorithm_id}/details/system_card/compliance") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def get_system_card_requirements( request: Request, algorithm_id: int, @@ -453,7 +460,9 @@ async def get_system_card_requirements( extended_linked_measures.append(ext_measure_task) requirements_and_measures.append((requirement, completed_measures_count, extended_linked_measures)) # pyright: ignore [reportUnknownMemberType] - measure_task_functions = await get_measure_task_functions(measure_tasks, users_repository, sort_by, filters) + measure_task_functions: dict[str, list[User]] = await get_measure_task_functions( + measure_tasks, users_repository, sort_by, filters + ) context = { "instrument_state": instrument_state, @@ -473,7 +482,7 @@ async def _fetch_members( users_repository: UsersRepository, search_name: str, sort_by: dict[str, str], - filters: dict[str, str], + filters: dict[str, str | list[str | int]], ) -> User | None: members = await users_repository.find_all(search=search_name, sort=sort_by, filters=filters) return members[0] if members else None @@ -483,9 +492,9 @@ async def get_measure_task_functions( measure_tasks: list[MeasureTask], users_repository: Annotated[UsersRepository, Depends(UsersRepository)], sort_by: dict[str, str], - filters: dict[str, str], -) -> dict[str, list[Any]]: - measure_task_functions: dict[str, list[Any]] = defaultdict(list) + filters: dict[str, str | list[str | int]], +) -> dict[str, list[User]]: + measure_task_functions: dict[str, list[User]] = defaultdict(list) for measure_task in measure_tasks: person_types = ["accountable_persons", "reviewer_persons", "responsible_persons"] @@ -533,6 +542,7 @@ async def find_requirement_tasks_by_measure_urn(system_card: SystemCard, measure @router.delete("/{algorithm_id}") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.DELETE]}) async def delete_algorithm( request: Request, algorithm_id: int, @@ -543,6 +553,7 @@ async def delete_algorithm( @router.get("/{algorithm_id}/measure/{measure_urn}") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def get_measure( request: Request, organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], @@ -598,7 +609,7 @@ async def get_users_from_function_name( measure_responsible: Annotated[str | None, Form()], users_repository: Annotated[UsersRepository, Depends(UsersRepository)], sort_by: dict[str, str], - filters: dict[str, str], + filters: dict[str, str | list[str | int]], ) -> tuple[list[Person], list[Person], list[Person]]: accountable_persons, reviewer_persons, responsible_persons = [], [], [] if measure_accountable: @@ -614,6 +625,7 @@ async def get_users_from_function_name( @router.post("/{algorithm_id}/measure/{measure_urn}") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def update_measure_value( request: Request, algorithm_id: int, @@ -679,6 +691,7 @@ async def update_measure_value( @router.get("/{algorithm_id}/members") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def get_algorithm_members( request: Request, algorithm_id: int, @@ -712,6 +725,7 @@ async def get_algorithm_members( @router.get("/{algorithm_id}/details/system_card/assessments/{assessment_card}") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def get_assessment_card( request: Request, algorithm_id: int, @@ -765,6 +779,7 @@ async def get_assessment_card( @router.get("/{algorithm_id}/details/system_card/models/{model_card}") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def get_model_card( request: Request, algorithm_id: int, @@ -819,6 +834,7 @@ async def get_model_card( @router.get("/{algorithm_id}/details/system_card/download") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def download_algorithm_system_card_as_yaml( algorithm_id: int, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], request: Request ) -> FileResponse: @@ -833,6 +849,7 @@ async def download_algorithm_system_card_as_yaml( @router.get("/{algorithm_id}/file/{ulid}") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def get_file( request: Request, algorithm_id: int, @@ -854,6 +871,7 @@ async def get_file( @router.delete("/{algorithm_id}/file/{ulid}") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def delete_file( request: Request, algorithm_id: int, diff --git a/amt/api/routes/algorithms.py b/amt/api/routes/algorithms.py index 1a80bf99..e2d6e3de 100644 --- a/amt/api/routes/algorithms.py +++ b/amt/api/routes/algorithms.py @@ -1,10 +1,11 @@ import logging -from typing import Annotated, Any +from typing import Annotated, Any, cast from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import HTMLResponse from amt.api.ai_act_profile import get_ai_act_profile_selector +from amt.api.decorators import permission from amt.api.deps import templates from amt.api.forms.algorithm import get_algorithm_form from amt.api.group_by_category import get_localized_group_by_categories @@ -14,7 +15,7 @@ get_localized_risk_groups, ) from amt.api.routes.shared import get_filters_and_sort_by -from amt.core.authorization import get_user +from amt.core.authorization import AuthorizationResource, AuthorizationVerb, get_user from amt.core.exceptions import AMTAuthorizationError from amt.core.internationalization import get_current_translation from amt.models import Algorithm @@ -29,9 +30,11 @@ @router.get("/") +@permission({AuthorizationResource.ALGORITHMS: [AuthorizationVerb.LIST]}) async def get_root( request: Request, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], + organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], skip: int = Query(0, ge=0), limit: int = Query(5000, ge=1), # todo: fix infinite scroll search: str = Query(""), @@ -39,6 +42,13 @@ async def get_root( ) -> HTMLResponse: filters, drop_filters, localized_filters, sort_by = get_filters_and_sort_by(request) + session_user = get_user(request) + user_id: str | None = session_user["sub"] if session_user else None # pyright: ignore[reportUnknownVariableType] + + filters["organization-id"] = [ + organization.id for organization in await organizations_service.get_organizations_for_user(user_id) + ] + algorithms, amount_algorithm_systems = await get_algorithms( algorithms_service, display_type, filters, limit, request, search, skip, sort_by ) @@ -76,7 +86,7 @@ async def get_root( async def get_algorithms( algorithms_service: AlgorithmsService, display_type: str, - filters: dict[str, str], + filters: dict[str, str | list[str | int]], limit: int, request: Request, search: str, @@ -84,6 +94,7 @@ async def get_algorithms( sort_by: dict[str, str], ) -> tuple[dict[str, list[Algorithm]], int | Any]: amount_algorithm_systems: int = 0 + if display_type == "LIFECYCLE": algorithms: dict[str, list[Algorithm]] = {} @@ -91,10 +102,10 @@ async def get_algorithms( if "lifecycle" in filters: for lifecycle in Lifecycles: algorithms[lifecycle.name] = [] - algorithms[filters["lifecycle"]] = await algorithms_service.paginate( + algorithms[cast(str, filters["lifecycle"])] = await algorithms_service.paginate( skip=skip, limit=limit, search=search, filters=filters, sort=sort_by ) - amount_algorithm_systems += len(algorithms[filters["lifecycle"]]) + amount_algorithm_systems += len(algorithms[cast(str, filters["lifecycle"])]) else: for lifecycle in Lifecycles: filters["lifecycle"] = lifecycle.name @@ -114,6 +125,7 @@ async def get_algorithms( @router.get("/new") +@permission({AuthorizationResource.ALGORITHMS: [AuthorizationVerb.CREATE]}) async def get_new( request: Request, instrument_service: Annotated[InstrumentsService, Depends(create_instrument_service)], @@ -156,6 +168,7 @@ async def get_new( @router.post("/new", response_class=HTMLResponse) +@permission({AuthorizationResource.ALGORITHMS: [AuthorizationVerb.CREATE]}) async def post_new( request: Request, algorithm_new: AlgorithmNew, diff --git a/amt/api/routes/organizations.py b/amt/api/routes/organizations.py index 7ce126ee..55418a26 100644 --- a/amt/api/routes/organizations.py +++ b/amt/api/routes/organizations.py @@ -5,12 +5,12 @@ from fastapi import APIRouter, Depends, 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, EditModes, ResolvedEditable, - SafeDict, get_enriched_resolved_editable, save_editable, ) @@ -24,12 +24,13 @@ resolve_base_navigation_items, resolve_navigation_items, ) -from amt.api.organization_filter_options import get_localized_organization_filters +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 UpdateFieldModel, get_filters_and_sort_by -from amt.core.authorization import get_user +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 from amt.models import Organization, User @@ -90,6 +91,7 @@ async def get_new( @router.get("/") +@permission({AuthorizationResource.ORGANIZATIONS: [AuthorizationVerb.LIST]}) async def root( request: Request, organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], @@ -104,6 +106,8 @@ async def root( breadcrumbs = resolve_base_navigation_items( [Navigation.ORGANIZATIONS_ROOT, Navigation.ORGANIZATIONS_OVERVIEW], request ) + # TODO: we only show organizations you are a member of (request for the pilots) + filters = {"organization-type": OrganizationFilterOptions.MY_ORGANIZATIONS.value} organizations: Sequence[Organization] = await organizations_repository.find_by( search=search, sort=sort_by, filters=filters, user_id=user["sub"] if user else None ) @@ -132,6 +136,7 @@ async def root( @router.post("/new", response_class=HTMLResponse) +@permission({AuthorizationResource.ORGANIZATIONS: [AuthorizationVerb.CREATE]}) async def post_new( request: Request, organization_new: OrganizationNew, @@ -150,13 +155,14 @@ async def post_new( return response -@router.get("/{slug}") +@router.get("/{organization_slug}") +@permission({AuthorizationResource.ORGANIZATION_INFO_SLUG: [AuthorizationVerb.READ]}) async def get_by_slug( request: Request, - slug: str, + organization_slug: str, organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], ) -> HTMLResponse: - organization = await get_organization_or_error(organizations_service, request, slug) + organization = await get_organization_or_error(organizations_service, request, organization_slug) breadcrumbs = resolve_base_navigation_items( [ Navigation.ORGANIZATIONS_ROOT, @@ -165,9 +171,9 @@ async def get_by_slug( request, ) - tab_items = get_organization_tabs(request, organization_slug=slug) + tab_items = get_organization_tabs(request, organization_slug=organization_slug) context = { - "base_href": f"/organizations/{ slug }", + "base_href": f"/organizations/{ organization_slug }", "organization": organization, "organization_id": organization.id, "tab_items": tab_items, @@ -177,24 +183,25 @@ async def get_by_slug( async def get_organization_or_error( - organizations_service: OrganizationsService, request: Request, slug: str + organizations_service: OrganizationsService, request: Request, organization_slug: str ) -> Organization: try: - organization = await organizations_service.find_by_slug(slug) + organization = await organizations_service.find_by_slug(organization_slug) request.state.path_variables = {"organization_slug": organization.slug} except AMTRepositoryError as e: raise AMTNotFound from e return organization -@router.get("/{slug}/edit") +@router.get("/{organization_slug}/edit") +@permission({AuthorizationResource.ORGANIZATION_INFO_SLUG: [AuthorizationVerb.UPDATE]}) async def get_organization_edit( request: Request, organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], - slug: str, + organization_slug: str, full_resource_path: str, ) -> HTMLResponse: - organization = await get_organization_or_error(organizations_service, request, slug) + organization = await get_organization_or_error(organizations_service, request, organization_slug) editable: ResolvedEditable = await get_enriched_resolved_editable( context_variables={"organization_id": organization.id}, @@ -213,7 +220,7 @@ async def get_organization_edit( "relative_resource_path": editable.relative_resource_path.replace("/", ".") if editable.relative_resource_path else "", - "base_href": f"/organizations/{ slug }", + "base_href": f"/organizations/{ organization_slug }", "resource_object": editable.resource_object, "full_resource_path": full_resource_path, "editable_object": editable, @@ -222,14 +229,15 @@ async def get_organization_edit( return templates.TemplateResponse(request, "parts/edit_cell.html.j2", context) -@router.get("/{slug}/cancel") +@router.get("/{organization_slug}/cancel") +@permission({AuthorizationResource.ORGANIZATION_INFO_SLUG: [AuthorizationVerb.UPDATE]}) async def get_organization_cancel( request: Request, - slug: str, + organization_slug: str, full_resource_path: str, organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], ) -> HTMLResponse: - organization = await get_organization_or_error(organizations_service, request, slug) + organization = await get_organization_or_error(organizations_service, request, organization_slug) editable: ResolvedEditable = await get_enriched_resolved_editable( context_variables={"organization_id": organization.id}, @@ -247,7 +255,7 @@ async def get_organization_cancel( "relative_resource_path": editable.relative_resource_path.replace("/", ".") if editable.relative_resource_path else "", - "base_href": f"/organizations/{ slug }", + "base_href": f"/organizations/{ organization_slug }", "resource_object": None, # TODO: this should become an optional parameter in the Jinja template "full_resource_path": full_resource_path, "editable_object": editable, @@ -256,15 +264,16 @@ async def get_organization_cancel( return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) -@router.put("/{slug}/update") +@router.put("/{organization_slug}/update") +@permission({AuthorizationResource.ORGANIZATION_INFO_SLUG: [AuthorizationVerb.UPDATE]}) async def get_organization_update( request: Request, organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], update_data: UpdateFieldModel, - slug: str, + organization_slug: str, full_resource_path: str, ) -> HTMLResponse: - organization = await get_organization_or_error(organizations_service, request, slug) + organization = await get_organization_or_error(organizations_service, request, organization_slug) user_id = get_user_id_or_error(request) @@ -299,7 +308,7 @@ async def get_organization_update( "relative_resource_path": editable.relative_resource_path.replace("/", ".") if editable.relative_resource_path else "", - "base_href": f"/organizations/{ slug }", + "base_href": f"/organizations/{ organization_slug }", "resource_object": None, "full_resource_path": full_resource_path, "editable_object": editable, @@ -312,18 +321,19 @@ async def get_organization_update( return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) -@router.get("/{slug}/algorithms") +@permission({AuthorizationResource.ORGANIZATION_INFO_SLUG: [AuthorizationVerb.LIST]}) +@router.get("/{organization_slug}/algorithms") async def show_algorithms( request: Request, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], - slug: str, + organization_slug: str, skip: int = Query(0, ge=0), limit: int = Query(5000, ge=1), # todo: fix infinite scroll search: str = Query(""), display_type: str = Query(""), ) -> HTMLResponse: - organization = await get_organization_or_error(organizations_service, request, slug) + organization = await get_organization_or_error(organizations_service, request, organization_slug) filters, drop_filters, localized_filters, sort_by = get_filters_and_sort_by(request) filters["organization-id"] = str(organization.id) @@ -332,7 +342,7 @@ async def show_algorithms( ) next = skip + limit - tab_items = get_organization_tabs(request, organization_slug=slug) + tab_items = get_organization_tabs(request, organization_slug=organization_slug) breadcrumbs = resolve_base_navigation_items( [ @@ -359,7 +369,7 @@ async def show_algorithms( "filters": localized_filters, "sort_by": sort_by, "display_type": display_type, - "base_href": f"/organizations/{slug}/algorithms", + "base_href": f"/organizations/{organization_slug}/algorithms", "organization_id": organization.id, } @@ -371,58 +381,62 @@ async def show_algorithms( return templates.TemplateResponse(request, "organizations/algorithms.html.j2", context) -@router.delete("/{slug}/members/{user_id}") +@router.delete("/{organization_slug}/members/{user_id}") +@permission({AuthorizationResource.ORGANIZATION_INFO_SLUG: [AuthorizationVerb.UPDATE]}) async def remove_member( request: Request, - slug: str, + organization_slug: str, user_id: UUID, organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], users_repository: Annotated[UsersRepository, Depends(UsersRepository)], ) -> HTMLResponse: # TODO (Robbert): add authorization and check if user and organization exist? - organization = await get_organization_or_error(organizations_service, request, slug) + organization = await get_organization_or_error(organizations_service, request, organization_slug) user: User | None = await users_repository.find_by_id(user_id) if user: await organizations_service.remove_user(organization, user) - return templates.Redirect(request, f"/organizations/{slug}/members") + return templates.Redirect(request, f"/organizations/{organization_slug}/members") raise AMTAuthorizationError -@router.get("/{slug}/members/form") +@router.get("/{organization_slug}/members/form") +@permission({AuthorizationResource.ORGANIZATION_INFO_SLUG: [AuthorizationVerb.UPDATE]}) async def get_members_form( request: Request, - slug: str, + organization_slug: str, ) -> HTMLResponse: form = get_organization_form(id="organization", translations=get_current_translation(request), user=None) - context: dict[str, Any] = {"form": form, "slug": slug} + context: dict[str, Any] = {"form": form, "slug": organization_slug} return templates.TemplateResponse(request, "organizations/parts/add_members_modal.html.j2", context) -@router.put("/{slug}/members", response_class=HTMLResponse) +@router.put("/{organization_slug}/members", response_class=HTMLResponse) +@permission({AuthorizationResource.ORGANIZATION_INFO_SLUG: [AuthorizationVerb.UPDATE]}) async def add_new_members( request: Request, - slug: str, + organization_slug: str, organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], organization_users: OrganizationUsers, ) -> HTMLResponse: - organization = await get_organization_or_error(organizations_service, request, slug) + organization = await get_organization_or_error(organizations_service, request, organization_slug) await organizations_service.add_users(organization, organization_users.user_ids) - return templates.Redirect(request, f"/organizations/{slug}/members") + return templates.Redirect(request, f"/organizations/{organization_slug}/members") -@router.get("/{slug}/members") +@router.get("/{organization_slug}/members") +@permission({AuthorizationResource.ORGANIZATION_INFO_SLUG: [AuthorizationVerb.LIST]}) async def get_members( request: Request, - slug: str, + organization_slug: str, organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], users_repository: Annotated[UsersRepository, Depends(UsersRepository)], skip: int = Query(0, ge=0), limit: int = Query(5000, ge=1), # todo: fix infinite scroll search: str = Query(""), ) -> HTMLResponse: - organization = await get_organization_or_error(organizations_service, request, slug) + organization = await get_organization_or_error(organizations_service, request, organization_slug) filters, drop_filters, localized_filters, sort_by = get_filters_and_sort_by(request) - tab_items = get_organization_tabs(request, organization_slug=slug) + tab_items = get_organization_tabs(request, organization_slug=organization_slug) breadcrumbs = resolve_base_navigation_items( [ Navigation.ORGANIZATIONS_ROOT, diff --git a/amt/api/routes/shared.py b/amt/api/routes/shared.py index 576225c8..9a87d8c0 100644 --- a/amt/api/routes/shared.py +++ b/amt/api/routes/shared.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any +from typing import Any, cast from pydantic import BaseModel from starlette.requests import Request @@ -14,7 +14,7 @@ def get_filters_and_sort_by( request: Request, -) -> tuple[dict[str, str], list[str], dict[str, LocalizedValueItem], dict[str, str]]: +) -> tuple[dict[str, str | list[str | int]], list[str], dict[str, LocalizedValueItem], dict[str, str]]: active_filters: dict[str, str] = { k.removeprefix("active-filter-"): v for k, v in request.query_params.items() @@ -29,9 +29,11 @@ def get_filters_and_sort_by( if "organization-type" in add_filters and add_filters["organization-type"] == OrganizationFilterOptions.ALL.value: del add_filters["organization-type"] drop_filters: list[str] = [v for k, v in request.query_params.items() if k.startswith("drop-filter") and v != ""] - filters: dict[str, str] = {k: v for k, v in (active_filters | add_filters).items() if k not in drop_filters} + filters: dict[str, str | list[str | int]] = { + k: v for k, v in (active_filters | add_filters).items() if k not in drop_filters + } localized_filters: dict[str, LocalizedValueItem] = { - k: get_localized_value(k, v, request) for k, v in filters.items() + k: get_localized_value(k, cast(str, v), request) for k, v in filters.items() } sort_by: dict[str, str] = { k.removeprefix("sort-by-"): v for k, v in request.query_params.items() if k.startswith("sort-by-") and v != "" diff --git a/amt/api/utils.py b/amt/api/utils.py new file mode 100644 index 00000000..8b954762 --- /dev/null +++ b/amt/api/utils.py @@ -0,0 +1,8 @@ +class SafeDict(dict[str, str | int]): + """ + A dictionary that if the key is missing returns the key as 'python replacement string', e.g. {key} + instead of throwing an exception. + """ + + def __missing__(self, key: str) -> str: + return "{" + key + "}" diff --git a/amt/core/authorization.py b/amt/core/authorization.py index 9756361f..9a9bc283 100644 --- a/amt/core/authorization.py +++ b/amt/core/authorization.py @@ -21,12 +21,17 @@ class AuthorizationType(StrEnum): class AuthorizationResource(StrEnum): + ORGANIZATIONS = "organizations/" ORGANIZATION_INFO = "organization/{organization_id}" ORGANIZATION_ALGORITHM = "organization/{organization_id}/algorithm" ORGANIZATION_MEMBER = "organization/{organization_id}/member" - ALGORITHM = "algoritme/{algoritme_id}" - ALGORITHM_SYSTEMCARD = "algoritme/{algoritme_id}/systemcard" - ALGORITHM_MEMBER = "algoritme/{algoritme_id}/user" + ORGANIZATION_INFO_SLUG = "organization/{organization_slug}" + ORGANIZATION_ALGORITHM_SLUG = "organization/{organization_slug}/algorithm" + ORGANIZATION_MEMBER_SLUG = "organization/{organization_slug}/member" + ALGORITHMS = "algorithms/" + ALGORITHM = "algorithm/{algorithm_id}" + ALGORITHM_SYSTEMCARD = "algorithm/{algorithm_id}/systemcard" + ALGORITHM_MEMBER = "algorithm/{algorithm_id}/user" def get_user(request: Request) -> dict[str, Any] | None: diff --git a/amt/core/exceptions.py b/amt/core/exceptions.py index 1a39cae9..5bb41e9f 100644 --- a/amt/core/exceptions.py +++ b/amt/core/exceptions.py @@ -38,7 +38,9 @@ def __init__(self) -> None: class AMTNotFound(AMTHTTPException): def __init__(self) -> None: self.detail: str = _( - "The requested page or resource could not be found. Please check the URL or query and try again." + "The requested page or resource could not be found, " + "or you do not have the correct permissions to access it. Please check the " + "URL or query and try again." ) super().__init__(status.HTTP_404_NOT_FOUND, self.detail) @@ -81,8 +83,12 @@ def __init__(self) -> None: class AMTPermissionDenied(AMTHTTPException): def __init__(self) -> None: - self.detail: str = _("You do not have the correct permissions to access this resource.") - super().__init__(status.HTTP_401_UNAUTHORIZED, self.detail) + self.detail: str = _( + "The requested page or resource could not be found, " + "or you do not have the correct permissions to access it. Please check the " + "URL or query and try again." + ) + super().__init__(status.HTTP_404_NOT_FOUND, self.detail) class AMTStorageError(AMTHTTPException): diff --git a/amt/locale/base.pot b/amt/locale/base.pot index 12482b99..bd333727 100644 --- a/amt/locale/base.pot +++ b/amt/locale/base.pot @@ -314,43 +314,40 @@ msgstr "" msgid "An error occurred while processing the instrument. Please try again later." msgstr "" -#: amt/core/exceptions.py:40 +#: amt/core/exceptions.py:40 amt/core/exceptions.py:86 msgid "" -"The requested page or resource could not be found. Please check the URL " -"or query and try again." +"The requested page or resource could not be found, or you do not have the" +" correct permissions to access it. Please check the URL or query and try " +"again." msgstr "" -#: amt/core/exceptions.py:48 +#: amt/core/exceptions.py:50 msgid "CSRF check failed." msgstr "" -#: amt/core/exceptions.py:54 +#: amt/core/exceptions.py:56 msgid "Only static files are supported." msgstr "" -#: amt/core/exceptions.py:60 +#: amt/core/exceptions.py:62 msgid "Key not correct: {field}" msgstr "" -#: amt/core/exceptions.py:66 +#: amt/core/exceptions.py:68 msgid "Value not correct: {field}" msgstr "" -#: amt/core/exceptions.py:72 +#: amt/core/exceptions.py:74 msgid "Failed to authorize, please login and try again." msgstr "" -#: amt/core/exceptions.py:78 +#: amt/core/exceptions.py:80 msgid "" "Something went wrong during the authorization flow. Please try again " "later." msgstr "" -#: amt/core/exceptions.py:84 -msgid "You do not have the correct permissions to access this resource." -msgstr "" - -#: amt/core/exceptions.py:90 +#: amt/core/exceptions.py:96 msgid "Something went wrong storing your file. PLease try again later." msgstr "" @@ -573,9 +570,42 @@ msgstr "" #: amt/site/templates/errors/Exception.html.j2:5 #: amt/site/templates/errors/RequestValidationError_400.html.j2:5 +#: amt/site/templates/errors/_404_Exception.html.j2:1 msgid "An error occurred" msgstr "" +#: amt/site/templates/errors/_404_Exception.html.j2:3 +msgid "We couldn't find what you were looking for. This might be because:" +msgstr "" + +#: amt/site/templates/errors/_404_Exception.html.j2:6 +msgid "The link isn't correct (anymore)" +msgstr "" + +#: amt/site/templates/errors/_404_Exception.html.j2:7 +msgid "The page has moved or been removed" +msgstr "" + +#: amt/site/templates/errors/_404_Exception.html.j2:8 +msgid "You don't have access to this page" +msgstr "" + +#: amt/site/templates/errors/_404_Exception.html.j2:10 +msgid "What now?" +msgstr "" + +#: amt/site/templates/errors/_404_Exception.html.j2:12 +msgid "Double-check if you typed the URL correctly" +msgstr "" + +#: amt/site/templates/errors/_404_Exception.html.j2:14 +msgid "Head back to the overview page" +msgstr "" + +#: amt/site/templates/errors/_404_Exception.html.j2:16 +msgid "Contact your admin" +msgstr "" + #: amt/site/templates/errors/_AMTCSRFProtectError_401.html.j2:11 #: amt/site/templates/errors/_Exception.html.j2:5 msgid "An error occurred. Please try again later" diff --git a/amt/locale/en_US/LC_MESSAGES/messages.mo b/amt/locale/en_US/LC_MESSAGES/messages.mo index 249ef0d7b233cfe2b8b32d341a3a0f9019f77f6b..738f59d744c7bbb468cf5ea758aea138d08b66f8 100644 GIT binary patch delta 16 Ycmcc1ewTg28OF(97{xX}V*Jer074%Jb^rhX delta 32 ocmcc1ewTg28Af3v10z#i14CUyQw2ioverview page" +msgstr "" + +#: amt/site/templates/errors/_404_Exception.html.j2:16 +msgid "Contact your admin" +msgstr "" + #: amt/site/templates/errors/_AMTCSRFProtectError_401.html.j2:11 #: amt/site/templates/errors/_Exception.html.j2:5 msgid "An error occurred. Please try again later" diff --git a/amt/locale/nl_NL/LC_MESSAGES/messages.mo b/amt/locale/nl_NL/LC_MESSAGES/messages.mo index ec2e02707cbfb4458f007d28d4e2c66f32b39d6c..38eee2419e7d6b5a5bb5eab7e5a423ad7fe4409b 100644 GIT binary patch delta 3875 zcmZwJe{7Xk9mnz07D@{p8<)1g#*af=pcg36LMbpx5w^k@DF({#Kzw@d(|a%Ny-#!R z)0UF4%Up#q(8N^yF{Xx0!kn%w-1&!FG|*|L`$L^`n2op?(G6poEsE%d0pFkAXBra; z<#o<^&OPV*{hssOayfZ2lYK8V?u_Bj2tPCVSzN5%fB&p4GiH!(Gw#9>9EUUR;Rk2q zK3s*JIE0h&6RgHkI+L&twe)J7fa|f;n5@}MqmqH`I1v-55BPW=9>b~lWmJIYk-uhy zf2QD@LH|0|(ElrHooV;xpH~I0z!>ALxCMtX%>L$M8haS1W;aFn7|y{bu?e3;i`S5; z=3l6d%lL;3%xt76rWQlkh+1bemf$v=jeBrB_M-wkiXrwlPti~X&LV4@OL~B>1>;vy z9~ce%C+Y}_m}Id8H)0sIaT2GakE-Msk<&2W2>KUL^KzKgS-cWF7{N{Se}PodR8sda z&O=4M4Yl!3T!i~j0Ur;>zlI9*S=9UsxC~!Jt@j=hyBQ7occ)VS3I-;Z=OeB`E!>Q% zNCH>kF;ppkh|6&V704~r!naYGPGm6!T#0(V9+%+5XwgT_JA*pna~0HIxA+PJx~{(@TICMv^n(vfpe8!beAz6mvdBdT&+QHk!#n>G7s zsFa^Wk}@$=K#!wdx6^^oq5}9ncHxhZdt}1*=QC`^74)~EHWISc*4M0o_4m zP`uEX-55g6JA@=>`cconjV1U!T#hfJF7*x6Yj+zJaN(l-QBOneM%K)tp)=friYOL1 zfP6QZQ>aVx9P0gkCFp;ME9n<6&IiTkjE_ynplCvXy;L5gI~;W&H)SL)DzNkgws zab13)FzUTsi8`8gsW^G1kc|`Rb&)Z(Oam%^czVQ<0M_W zG8z+cI^KiTs8ZFV7H&mtvFn$VE^0Qck!+0Doq3+abZm!nfg$$V} zX7!aiL_;O|4yykWTD*eF=ntq2KExCFF)H9EmhpcBJdO9_B~(Bos7w1Z)VeoN^KYWo zxf_g^)>D5)R8gOwP>qUw3F`7SpfX>By0tB+jdq|a6bYUmM%|I4cnrUSdP^!A^79tq z!}Qmq);o@Ec&dT=@1*fA13J@XTwQ`QO~{amqbhI~mD%%n9*0pIv~wf*Ha3TF9-hQu zyokDN2OIf?geOq*e~VLa6!p2gSsLXuW^;9w@$$e2F+_hOD&t+K%hZVq)I*iH4|Ny5 zimP!LwayLHSNKO*gMSOgr#I!-v#3h6FQf4YjYgb`Sv+09oxsWTEvr`IQq=QS)Di7N zZEz6vyTL)7^-&ypX>Mb#plrtY`%=CW@$G=uo3>6gnMmcPO&KW&_jYu9UDV zJ+=Noj~lgq+_gKmZ?zFGopvLB_dxDyXk9^r>qKpb!(`vnl=-QSOwWUB9UD)(oy}EC ztJZmaZn`hw_S+sO=B{1ptjmSU-guhT_E<9T++^tG+Pd=Rz}g6l8b1EZ5r|)w-RDRMhszov|Ek zzngY#x94>wQZd`&6vFuAnIwkxYOra zClYZpnS8Q*T0t5o=9!2$R?XZGW<6FowAWVVHrW@7ZJX;Ri6>uZug4}`-}d&iRor#c zQ8%6YXnuL&@{LSLd)*ANZ7k_H-5Kk3TK4^)ircu`;oF$Yarn8SaHL@6Cdc}2x;JK1 zj+18g|1bWLL?rHKh%}LM#_I1b9G&o)vHGVH1XP=3=kR|{A(1f;ja8rN9FXPsdb%CQ z&-e<8UVq{cp+|?>8XCus#W_^AVnc4QVXz=U^}D=Olw%rOxsL;&-A_Gx?GcYKme_|9 zzU^{S%uDhp>Urs?<5P6iytl<{am-EN_!5e#sS6vvg=r}c6ve5a8@16T-x8;7 zcD=J!w&+E)lA70*FH_vgnTAcxsnuq)^}?bu%cj|^zCWL{#emN_&vTx0{^x)G&xO{; zldA&PlH4J~*D-zu@pCIuz5lWG;M*G58ni0XMVs!+z+&A*cY0kZ)7TuS8s8`;C}Ee*0WTfi$>ha#0gZ)D4`C8o0u`9+mnQ^x|fmjC)ZF z{)7YZCMtt5Ohz(IGOC|}8kdJUf?^ElMj4H1I1|Z^31Sw$fr_{bweUq$pjT|a8x`O$ zsBw2N5C1{Ulgqggi+Kpue+&oUQ>Z}Kd&qw_jTg9}9UsLKynsqwOn+x#4{CyesDOu~ z0xYoi7vX67)#$~YsBs5TM|lEOtk+OQc?T81paJAxDJdc>EjS)^mQzq?TZy{A5Vgal zwqJ)z;Tn6r36+UwQ4604ig*P_I)gzm$nw)D9=%{n|hUG6zYHS&kaF*}B`u z`75>ixu6|%+JT?jevch^*Y@KwoQYCVnJGa{I2k#1GZzE68lS`)ScDasj+;C=*D{V;##c67g2%yi3;>z)P$Z9j+v;G7o)~Kj8RyD3Va^U z$7!K}9+kBe4Kg3#E9!=um-dw1$w64%3dxR43|n zy=?ojBaInDe<%`2z*N!D#I>kQ)T1JP28qRNL1pB9)PskRx-&hfog`#CuVX6e2uhJ- zH;e4`4cMFh>!^9!Q31b)ajKRRcED$-3|z!M_%$l9@2x!;OaCUu;5}5p(W9IUC8H+J zMlCb}b)@B}j8vlLTVb!S!6a2{3yoeF#4WfRRXl@poCU_A{<=*^eKIzo0%^7V5PIqF z#W*~T+Q21z9-w{a1{qf7jlR&UFI! zpo%R8wexgTk!E0DEJ4-KBdGg}P_>Xn?=KIm2nFzX4UK z$eU0*++hu268$~c7e7GN&Ph~YXHXeEk2?F`Fao1_6((aWYQ9-ZX=sA^sGTmu=~#~* z{1CUh_^ieRjpG$i>S|HL?Fku*_E!%=eXt!*N&K;XR3+uJBgR zI9K>~{|iyUxU`&bL)vUtWK~V0sS1{*`4iSHuWR%+)HJSM->|f1bhspaYlJ61FTW@! z&!6Kj^7`|}7LLiz^M}6}a-es(ZG(Po!>#u76(*j-p2 JPAP7P_z$CMN1p%y diff --git a/amt/locale/nl_NL/LC_MESSAGES/messages.po b/amt/locale/nl_NL/LC_MESSAGES/messages.po index 0a4f0415..a7a6defa 100644 --- a/amt/locale/nl_NL/LC_MESSAGES/messages.po +++ b/amt/locale/nl_NL/LC_MESSAGES/messages.po @@ -325,35 +325,37 @@ msgstr "" "Er is een fout opgetreden tijdens het verwerken van het instrument. " "Probeer het later opnieuw." -#: amt/core/exceptions.py:40 +#: amt/core/exceptions.py:40 amt/core/exceptions.py:86 msgid "" -"The requested page or resource could not be found. Please check the URL " -"or query and try again." +"The requested page or resource could not be found, or you do not have the" +" correct permissions to access it. Please check the URL or query and try " +"again." msgstr "" -"De gevraagde pagina of bron kon niet worden gevonden. Controleer de URL " -"of query en probeer het opnieuw." +"De gevraagde pagina of bron kon niet worden gevonden of u beschikt niet " +"over de juiste machtigingen om deze bron te openen. Controleer de URL of " +"query en probeer het opnieuw." -#: amt/core/exceptions.py:48 +#: amt/core/exceptions.py:50 msgid "CSRF check failed." msgstr "CSRF-controle mislukt." -#: amt/core/exceptions.py:54 +#: amt/core/exceptions.py:56 msgid "Only static files are supported." msgstr "Alleen statische bestanden worden ondersteund." -#: amt/core/exceptions.py:60 +#: amt/core/exceptions.py:62 msgid "Key not correct: {field}" msgstr "Sleutel niet correct: {field}" -#: amt/core/exceptions.py:66 +#: amt/core/exceptions.py:68 msgid "Value not correct: {field}" msgstr "Waarde is niet correct: {field}" -#: amt/core/exceptions.py:72 +#: amt/core/exceptions.py:74 msgid "Failed to authorize, please login and try again." msgstr "Autoriseren is mislukt. Meld u aan en probeer het opnieuw." -#: amt/core/exceptions.py:78 +#: amt/core/exceptions.py:80 msgid "" "Something went wrong during the authorization flow. Please try again " "later." @@ -361,11 +363,7 @@ msgstr "" "Er is iets fout gegaan tijdens de autorisatiestroom. Probeer het later " "opnieuw" -#: amt/core/exceptions.py:84 -msgid "You do not have the correct permissions to access this resource." -msgstr "U beschikt niet over de juiste machtigingen om deze bron te openen." - -#: amt/core/exceptions.py:90 +#: amt/core/exceptions.py:96 msgid "Something went wrong storing your file. PLease try again later." msgstr "" "Er is iets fout gegaan tijdens het opslaan van uw bestand. Probeer het " @@ -597,9 +595,42 @@ msgstr "Inloggen" #: amt/site/templates/errors/Exception.html.j2:5 #: amt/site/templates/errors/RequestValidationError_400.html.j2:5 +#: amt/site/templates/errors/_404_Exception.html.j2:1 msgid "An error occurred" msgstr "Er is een fout opgetreden" +#: amt/site/templates/errors/_404_Exception.html.j2:3 +msgid "We couldn't find what you were looking for. This might be because:" +msgstr "We konden niet vinden wat u zocht. Dit kan komen doordat:" + +#: amt/site/templates/errors/_404_Exception.html.j2:6 +msgid "The link isn't correct (anymore)" +msgstr "De link niet (meer) correct is" + +#: amt/site/templates/errors/_404_Exception.html.j2:7 +msgid "The page has moved or been removed" +msgstr "De pagina is verplaatst of verwijderd" + +#: amt/site/templates/errors/_404_Exception.html.j2:8 +msgid "You don't have access to this page" +msgstr "U heeft geen toegang tot deze pagina" + +#: amt/site/templates/errors/_404_Exception.html.j2:10 +msgid "What now?" +msgstr "Wat nu?" + +#: amt/site/templates/errors/_404_Exception.html.j2:12 +msgid "Double-check if you typed the URL correctly" +msgstr "Controleer nogmaals of u de URL correct hebt getypt" + +#: amt/site/templates/errors/_404_Exception.html.j2:14 +msgid "Head back to the overview page" +msgstr "Ga terug naar de overzichtspagina" + +#: amt/site/templates/errors/_404_Exception.html.j2:16 +msgid "Contact your admin" +msgstr "Neem contact op met uw beheerder" + #: amt/site/templates/errors/_AMTCSRFProtectError_401.html.j2:11 #: amt/site/templates/errors/_Exception.html.j2:5 msgid "An error occurred. Please try again later" diff --git a/amt/middleware/authorization.py b/amt/middleware/authorization.py index a5ec48fe..f3e64400 100644 --- a/amt/middleware/authorization.py +++ b/amt/middleware/authorization.py @@ -1,11 +1,13 @@ import os import typing +from uuid import UUID from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import RedirectResponse, Response from amt.core.authorization import get_user +from amt.models import User from amt.services.authorization import AuthorizationService RequestResponseEndpoint = typing.Callable[[Request], typing.Awaitable[Response]] @@ -19,14 +21,24 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - if request.url.path.startswith("/static/"): return await call_next(request) + authorization_service = AuthorizationService() + disable_auth_str = os.environ.get("DISABLE_AUTH") auth_disable = False if disable_auth_str is None else disable_auth_str.lower() == "true" if auth_disable: auto_login_uuid: str | None = os.environ.get("AUTO_LOGIN_UUID", None) if auto_login_uuid: - request.session["user"] = {"sub": auto_login_uuid} - - authorization_service = AuthorizationService() + user_object: User | None = await authorization_service.get_user(UUID(auto_login_uuid)) + if user_object: + request.session["user"] = { + "sub": str(user_object.id), + "email": user_object.email, + "name": user_object.name, + "email_hash": user_object.email_hash, + "name_encoded": user_object.name_encoded, + } + else: + request.session["user"] = {"sub": auto_login_uuid} user = get_user(request) diff --git a/amt/repositories/algorithms.py b/amt/repositories/algorithms.py index e4edc874..e92e7d11 100644 --- a/amt/repositories/algorithms.py +++ b/amt/repositories/algorithms.py @@ -1,6 +1,7 @@ import logging from collections.abc import Sequence -from typing import Annotated +from typing import Annotated, cast +from uuid import UUID from fastapi import Depends from sqlalchemy import func, select @@ -10,7 +11,7 @@ from amt.api.risk_group import RiskGroup from amt.core.exceptions import AMTRepositoryError -from amt.models import Algorithm +from amt.models import Algorithm, Organization, User from amt.repositories.deps import get_session logger = logging.getLogger(__name__) @@ -74,7 +75,7 @@ async def find_by_id(self, algorithm_id: int) -> Algorithm: raise AMTRepositoryError from e async def paginate( # noqa - self, skip: int, limit: int, search: str, filters: dict[str, str], sort: dict[str, str] + self, skip: int, limit: int, search: str, filters: dict[str, str | list[str | int]], sort: dict[str, str] ) -> list[Algorithm]: try: statement = select(Algorithm) @@ -83,15 +84,18 @@ async def paginate( # noqa if filters: for key, value in filters.items(): match key: + case "id": + statement = statement.where(Algorithm.id == int(cast(str, value))) case "lifecycle": statement = statement.filter(Algorithm.lifecycle == value) case "risk-group": statement = statement.filter( Algorithm.system_card_json["ai_act_profile"]["risk_group"].as_string() - == RiskGroup[value].value + == RiskGroup[cast(str, value)].value ) case "organization-id": - statement = statement.filter(Algorithm.organization_id == int(value)) + value = [int(value)] if not isinstance(value, list) else [int(v) for v in value] + statement = statement.filter(Algorithm.organization_id.in_(value)) case _: raise TypeError(f"Unknown filter type with key: {key}") # noqa if sort: @@ -120,3 +124,12 @@ async def paginate( # noqa except Exception as e: logger.exception("Error paginating algorithms") raise AMTRepositoryError from e + + async def get_by_user(self, user_id: UUID) -> Sequence[Algorithm]: + statement = ( + select(Algorithm) + .join(Organization, Organization.id == Algorithm.organization_id) + .where(Organization.users.any(User.id == user_id)) # pyright: ignore[reportUnknownMemberType] + ) + + return (await self.session.execute(statement)).scalars().all() diff --git a/amt/repositories/authorizations.py b/amt/repositories/authorizations.py index e39711ab..a55e1cba 100644 --- a/amt/repositories/authorizations.py +++ b/amt/repositories/authorizations.py @@ -4,13 +4,16 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from amt.core.authorization import AuthorizationVerb -from amt.models import Authorization, Role, Rule +from amt.core.authorization import AuthorizationResource, AuthorizationType, AuthorizationVerb +from amt.models import Authorization, Role, Rule, User +from amt.repositories.algorithms import AlgorithmsRepository from amt.repositories.deps import get_session_non_generator +from amt.repositories.organizations import OrganizationsRepository +from amt.repositories.users import UsersRepository logger = logging.getLogger(__name__) -PermissionTuple = tuple[str, list[AuthorizationVerb], str, int] +PermissionTuple = tuple[AuthorizationResource, list[AuthorizationVerb], AuthorizationType, str | int] PermissionsList = list[PermissionTuple] @@ -26,7 +29,53 @@ async def init_session(self) -> None: if self.session is None: self.session = await get_session_non_generator() + async def get_user(self, user_id: UUID) -> User | None: + try: + await self.init_session() + return await UsersRepository(session=self.session).find_by_id(user_id) # pyright: ignore[reportArgumentType] + finally: + if self.session is not None: + await self.session.close() + async def find_by_user(self, user: UUID) -> PermissionsList | None: + """ + Returns all authorization for a user. + :return: all authorization for the user + """ + try: + await self.init_session() + authorization_verbs: list[AuthorizationVerb] = [ + AuthorizationVerb.READ, + AuthorizationVerb.UPDATE, + AuthorizationVerb.CREATE, + AuthorizationVerb.LIST, + AuthorizationVerb.DELETE, + ] + my_algorithms: PermissionsList = [ + (AuthorizationResource.ALGORITHMS, authorization_verbs, AuthorizationType.ALGORITHM, "*"), + ] + my_algorithms += [ + (AuthorizationResource.ALGORITHM, authorization_verbs, AuthorizationType.ALGORITHM, algorithm.id) + for algorithm in await AlgorithmsRepository(session=self.session).get_by_user(user) # pyright: ignore[reportArgumentType] + ] + my_organizations: PermissionsList = [ + (AuthorizationResource.ORGANIZATIONS, authorization_verbs, AuthorizationType.ORGANIZATION, "*"), + ] + my_organizations += [ + ( + AuthorizationResource.ORGANIZATION_INFO_SLUG, + authorization_verbs, + AuthorizationType.ORGANIZATION, + organization.slug, + ) + for organization in await OrganizationsRepository(session=self.session).get_by_user(user) # pyright: ignore[reportArgumentType] + ] + return my_algorithms + my_organizations + finally: + if self.session is not None: + await self.session.close() + + async def find_by_user_original(self, user: UUID) -> PermissionsList | None: """ Returns all authorization for a user. :return: all authorization for the user diff --git a/amt/repositories/organizations.py b/amt/repositories/organizations.py index afe9b8c1..6941908a 100644 --- a/amt/repositories/organizations.py +++ b/amt/repositories/organizations.py @@ -141,3 +141,7 @@ async def remove_user(self, organization: Organization, user: User) -> Organizat await self.session.commit() await self.session.refresh(organization) return organization + + async def get_by_user(self, user_id: UUID) -> Sequence[Organization]: + statement = select(Organization).where(Organization.users.any(User.id == user_id)) # pyright: ignore[reportUnknownMemberType] + return (await self.session.execute(statement)).scalars().all() diff --git a/amt/repositories/users.py b/amt/repositories/users.py index 09c38a51..8ef78a63 100644 --- a/amt/repositories/users.py +++ b/amt/repositories/users.py @@ -1,6 +1,6 @@ import logging from collections.abc import Sequence -from typing import Annotated +from typing import Annotated, cast from uuid import UUID from fastapi import Depends @@ -29,7 +29,7 @@ async def find_all( self, search: str | None = None, sort: dict[str, str] | None = None, - filters: dict[str, str] | None = None, + filters: dict[str, str | list[str | int]] | None = None, skip: int | None = None, limit: int | None = None, ) -> Sequence[User]: @@ -37,7 +37,9 @@ async def find_all( if search: statement = statement.filter(User.name.ilike(f"%{escape_like(search)}%")) if filters and "organization-id" in filters: - statement = statement.where(User.organizations.any(Organization.id == int(filters["organization-id"]))) + statement = statement.where( + User.organizations.any(Organization.id == int(cast(str, filters["organization-id"]))) + ) if sort: if "name" in sort and sort["name"] == "ascending": statement = statement.order_by(func.lower(User.name).asc()) diff --git a/amt/services/algorithms.py b/amt/services/algorithms.py index 88cd2b71..7df306c8 100644 --- a/amt/services/algorithms.py +++ b/amt/services/algorithms.py @@ -117,15 +117,16 @@ async def create(self, algorithm_new: AlgorithmNew, user_id: UUID | str) -> Algo return algorithm async def paginate( - self, skip: int, limit: int, search: str, filters: dict[str, str], sort: dict[str, str] + self, skip: int, limit: int, search: str, filters: dict[str, str | list[str | int]], sort: dict[str, str] ) -> list[Algorithm]: algorithms = await self.repository.paginate(skip=skip, limit=limit, search=search, filters=filters, sort=sort) return algorithms 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 # pyright: ignore[reportUnusedVariable] + dummy = algorithm.system_card # noqa: F841 # pyright: ignore[reportUnusedVariable] algorithm = await self.repository.save(algorithm) return algorithm diff --git a/amt/services/authorization.py b/amt/services/authorization.py index 7a835cbb..0fcb2b3a 100644 --- a/amt/services/authorization.py +++ b/amt/services/authorization.py @@ -1,8 +1,9 @@ -import contextlib from typing import Any from uuid import UUID +from amt.api.utils import SafeDict from amt.core.authorization import AuthorizationType, AuthorizationVerb +from amt.models import User from amt.repositories.authorizations import AuthorizationRepository from amt.schema.permission import Permission @@ -14,6 +15,9 @@ class AuthorizationService: def __init__(self) -> None: self.repository = AuthorizationRepository() + async def get_user(self, user_id: UUID) -> User | None: + return await self.repository.get_user(user_id) + async def find_by_user(self, user: dict[str, Any] | None) -> dict[str, list[AuthorizationVerb]]: if not user: return {} @@ -23,18 +27,20 @@ async def find_by_user(self, user: dict[str, Any] | None) -> dict[str, list[Auth uuid = UUID(user["sub"]) authorizations: PermissionsList = await self.repository.find_by_user(uuid) # type: ignore for auth in authorizations: - auth_dict: dict[str, int] = {"organization_id": -1, "algoritme_id": -1} + auth_dict: dict[str, int | str] = {} if auth[2] == AuthorizationType.ORGANIZATION: + # TODO: check the path if we need the slug or the id? auth_dict["organization_id"] = auth[3] + auth_dict["organization_slug"] = auth[3] if auth[2] == AuthorizationType.ALGORITHM: - auth_dict["algoritme_id"] = auth[3] + auth_dict["algorithm_id"] = auth[3] resource: str = auth[0] verbs: list[AuthorizationVerb] = auth[1] - with contextlib.suppress(Exception): - resource = resource.format(**auth_dict) + + resource = resource.format_map(SafeDict(auth_dict)) permission: Permission = Permission(resource=resource, verb=verbs) diff --git a/amt/site/templates/errors/AMTNotFound_404.html.j2 b/amt/site/templates/errors/AMTNotFound_404.html.j2 new file mode 100644 index 00000000..d84d7538 --- /dev/null +++ b/amt/site/templates/errors/AMTNotFound_404.html.j2 @@ -0,0 +1,6 @@ +{% extends 'layouts/base.html.j2' %} +{% block content %} +
+
{% include 'errors/_404_Exception.html.j2' %}
+
+{% endblock %} diff --git a/amt/site/templates/errors/AMTPermissionDenied_404.html.j2 b/amt/site/templates/errors/AMTPermissionDenied_404.html.j2 new file mode 100644 index 00000000..d84d7538 --- /dev/null +++ b/amt/site/templates/errors/AMTPermissionDenied_404.html.j2 @@ -0,0 +1,6 @@ +{% extends 'layouts/base.html.j2' %} +{% block content %} +
+
{% include 'errors/_404_Exception.html.j2' %}
+
+{% endblock %} diff --git a/amt/site/templates/errors/Exception.html.j2 b/amt/site/templates/errors/Exception.html.j2 index 8cf47fa0..60e15690 100644 --- a/amt/site/templates/errors/Exception.html.j2 +++ b/amt/site/templates/errors/Exception.html.j2 @@ -1,7 +1,7 @@ {% extends 'layouts/base.html.j2' %} {% block content %}
-
+

{% trans %}An error occurred{% endtrans %}

{% include 'errors/_Exception.html.j2' %}
diff --git a/amt/site/templates/errors/HTTPException_404.html.j2 b/amt/site/templates/errors/HTTPException_404.html.j2 new file mode 100644 index 00000000..d84d7538 --- /dev/null +++ b/amt/site/templates/errors/HTTPException_404.html.j2 @@ -0,0 +1,6 @@ +{% extends 'layouts/base.html.j2' %} +{% block content %} +
+
{% include 'errors/_404_Exception.html.j2' %}
+
+{% endblock %} diff --git a/amt/site/templates/errors/RequestValidationError_400.html.j2 b/amt/site/templates/errors/RequestValidationError_400.html.j2 index d19aa57b..a03a5721 100644 --- a/amt/site/templates/errors/RequestValidationError_400.html.j2 +++ b/amt/site/templates/errors/RequestValidationError_400.html.j2 @@ -1,7 +1,7 @@ {% extends 'layouts/base.html.j2' %} {% block content %}
-
+

{% trans %}An error occurred{% endtrans %}

{% include 'errors/_RequestValidationError_400.html.j2' %}
diff --git a/amt/site/templates/errors/_404_Exception.html.j2 b/amt/site/templates/errors/_404_Exception.html.j2 new file mode 100644 index 00000000..8b3daeea --- /dev/null +++ b/amt/site/templates/errors/_404_Exception.html.j2 @@ -0,0 +1,17 @@ +

{% trans %}An error occurred{% endtrans %}

+

+ {% trans %}We couldn't find what you were looking for. This might be because:{% endtrans %} +

+
    +
  • {% trans %}The link isn't correct (anymore){% endtrans %}
  • +
  • {% trans %}The page has moved or been removed{% endtrans %}
  • +
  • {% trans %}You don't have access to this page{% endtrans %}
  • +
+

{% trans %}What now?{% endtrans %}

+
    +
  • {% trans %}Double-check if you typed the URL correctly{% endtrans %}
  • +
  • + {% trans %}Head back to the overview page{% endtrans %} +
  • +
  • {% trans %}Contact your admin{% endtrans %}
  • +
diff --git a/amt/site/templates/macros/form_macros.html.j2 b/amt/site/templates/macros/form_macros.html.j2 index 1f013897..a04a3d45 100644 --- a/amt/site/templates/macros/form_macros.html.j2 +++ b/amt/site/templates/macros/form_macros.html.j2 @@ -293,7 +293,7 @@
- + {# TODO: Fix when we upgrade to higher @nl-rvo npm package #} {# {% trans %}Still{% endtrans %} 300 {% trans %}Characters remaining{% endtrans %}#}
diff --git a/amt/site/templates/pages/system_card.html.j2 b/amt/site/templates/pages/system_card.html.j2 index ad687812..3fb237ef 100644 --- a/amt/site/templates/pages/system_card.html.j2 +++ b/amt/site/templates/pages/system_card.html.j2 @@ -5,7 +5,7 @@ {% trans %}Last updated{% endtrans %}: {{ last_edited | time_ago(language) }} {% trans %}ago{% endtrans %}
- {% if system_card.assessments is defined and system_card.assessments|length > 0 %} + {% if "DISABLED" == "FOR NOW" and system_card.assessments is defined and system_card.assessments|length > 0 %}

{% trans %}Assessment cards {% endtrans %}

    @@ -23,7 +23,7 @@
{% endif %} - {% if system_card.models is defined and system_card.models|length > 0 %} + {% if "DISABLED" == "FOR NOW" and system_card.models is defined and system_card.models|length > 0 %}

{% trans %}Model cards{% endtrans %}

    @@ -42,7 +42,7 @@
{% endif %}
-
+
@@ -51,7 +51,7 @@ {% for key, value in system_card %} - {% if key not in ["requirements","measures"] %} + {% if key not in ["requirements","measures", "assessments", "models"] %}
diff --git a/tests/api/routes/test_algorithm.py b/tests/api/routes/test_algorithm.py index 0d8d23c9..de436eee 100644 --- a/tests/api/routes/test_algorithm.py +++ b/tests/api/routes/test_algorithm.py @@ -28,6 +28,7 @@ from tests.constants import ( default_algorithm, default_algorithm_with_system_card, + default_not_found_no_permission_msg, default_task, default_user, ) @@ -42,7 +43,7 @@ async def test_get_unknown_algorithm(client: AsyncClient) -> None: # then assert response.status_code == 404 assert response.headers["content-type"] == "text/html; charset=utf-8" - assert b"The requested page or resource could not be found." in response.content + assert default_not_found_no_permission_msg() in response.content @pytest.mark.asyncio @@ -117,7 +118,7 @@ async def test_get_algorithm_non_existing_algorithm(client: AsyncClient, db: Dat # then assert response.status_code == 404 - assert b"The requested page or resource could not be found." in response.content + assert default_not_found_no_permission_msg() in response.content @pytest.mark.asyncio @@ -169,7 +170,7 @@ async def test_get_system_card_unknown_algorithm(client: AsyncClient) -> None: # then assert response.status_code == 404 assert response.headers["content-type"] == "text/html; charset=utf-8" - assert b"The requested page or resource could not be found." in response.content + assert default_not_found_no_permission_msg() in response.content @pytest.mark.asyncio @@ -198,7 +199,7 @@ async def test_get_assessment_card_unknown_algorithm(client: AsyncClient, db: Da # then assert response.status_code == 404 assert response.headers["content-type"] == "text/html; charset=utf-8" - assert b"The requested page or resource could not be found." in response.content + assert default_not_found_no_permission_msg() in response.content @pytest.mark.asyncio @@ -212,7 +213,7 @@ async def test_get_assessment_card_unknown_assessment(client: AsyncClient, db: D # then assert response.status_code == 404 assert response.headers["content-type"] == "text/html; charset=utf-8" - assert b"The requested page or resource could not be found." in response.content + assert default_not_found_no_permission_msg() in response.content @pytest.mark.asyncio @@ -237,7 +238,7 @@ async def test_get_model_card_unknown_algorithm(client: AsyncClient) -> None: # then assert response.status_code == 404 assert response.headers["content-type"] == "text/html; charset=utf-8" - assert b"The requested page or resource could not be found." in response.content + assert default_not_found_no_permission_msg() in response.content @pytest.mark.asyncio @@ -251,7 +252,7 @@ async def test_get_assessment_card_unknown_model_card(client: AsyncClient, db: D # then assert response.status_code == 404 assert response.headers["content-type"] == "text/html; charset=utf-8" - assert b"The requested page or resource could not be found." in response.content + assert default_not_found_no_permission_msg() in response.content @pytest.mark.asyncio diff --git a/tests/api/test_decorator.py b/tests/api/test_decorator.py index a5032566..7b7c4328 100644 --- a/tests/api/test_decorator.py +++ b/tests/api/test_decorator.py @@ -1,7 +1,7 @@ import json import typing -from amt.api.decorators import add_permissions +from amt.api.decorators import permission from amt.core.authorization import AuthorizationResource, AuthorizationVerb from fastapi import FastAPI, Request from fastapi.testclient import TestClient @@ -13,25 +13,25 @@ @app.get("/unauthorized") -@add_permissions(permissions={"algoritme/1": [AuthorizationVerb.CREATE]}) +@permission({"algoritme/1": [AuthorizationVerb.CREATE]}) async def unauthorized(request: Request): return {"message": "Hello World"} @app.get("/authorized") -@add_permissions(permissions={}) +@permission({}) async def authorized(request: Request): return {"message": "Hello World"} @app.get("/norequest") -@add_permissions(permissions={}) +@permission({}) async def norequest(): return {"message": "Hello World"} @app.get("/authorizedparameters/{organization_id}") -@add_permissions(permissions={AuthorizationResource.ORGANIZATION_INFO: [AuthorizationVerb.CREATE]}) +@permission({AuthorizationResource.ORGANIZATION_INFO: [AuthorizationVerb.CREATE]}) async def authorizedparameters(request: Request, organization_id: int): return {"message": "Hello World"} @@ -52,7 +52,7 @@ def test_permission_decorator_norequest(): def test_permission_decorator_unauthorized(): client = TestClient(app, base_url="https://testserver") response = client.get("/unauthorized") - assert response.status_code == 401 + assert response.status_code == 404 def test_permission_decorator_authorized(): @@ -74,7 +74,7 @@ def test_permission_decorator_authorized_permission_missing(): client = TestClient(app, base_url="https://testserver") response = client.get("/unauthorized", headers={"X-Permissions": '{"algoritme/1": ["Read"]}'}) - assert response.status_code == 401 + assert response.status_code == 404 def test_permission_decorator_authorized_permission_variable(): @@ -88,4 +88,4 @@ def test_permission_decorator_unauthorized_permission_variable(): client = TestClient(app, base_url="https://testserver") response = client.get("/authorizedparameters/4453546", headers={"X-Permissions": '{"organization/1": ["Create"]}'}) - assert response.status_code == 401 + assert response.status_code == 404 diff --git a/tests/conftest.py b/tests/conftest.py index 83a495e7..cd12a7fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,7 @@ from amt.models.base import Base from amt.server import create_app from httpx import ASGITransport, AsyncClient -from playwright.sync_api import Browser, Page +from playwright.sync_api import Browser, BrowserContext, Page from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio.session import async_sessionmaker @@ -146,6 +146,18 @@ def browser_context_args(browser_context_args: dict[str, Any]) -> dict[str, Any] return {**browser_context_args, "base_url": "http://127.0.0.1:3462"} +@pytest.fixture +def page(context: BrowserContext) -> Page: + page = context.new_page() + do_e2e_login(page) + return page + + +@pytest.fixture +def page_no_login(context: BrowserContext) -> Page: + return context.new_page() + + @pytest.fixture(scope="session") def browser( launch_browser: Callable[[], Browser], diff --git a/tests/constants.py b/tests/constants.py index b423daff..3ba745ab 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -339,3 +339,7 @@ def default_systemcard_dic() -> dict[str, str | list[Any] | None]: "references": [], "models": [], } + + +def default_not_found_no_permission_msg() -> bytes: + return b"We couldn't find what you were looking for. This might be because:" diff --git a/tests/core/test_exceptions.py b/tests/core/test_exceptions.py index 8a101960..795ab03f 100644 --- a/tests/core/test_exceptions.py +++ b/tests/core/test_exceptions.py @@ -34,8 +34,9 @@ def test_RepositoryNoResultFound(): raise AMTNotFound() assert ( - exc_info.value.detail - == "The requested page or resource could not be found. Please check the URL or query and try again." + exc_info.value.detail == "The requested page or resource could not be found, " + "or you do not have the correct permissions to access it. " + "Please check the URL or query and try again." ) @@ -58,7 +59,11 @@ def test_AMTPermissionDenied(): with pytest.raises(AMTPermissionDenied) as exc_info: raise AMTPermissionDenied() - assert exc_info.value.detail == "You do not have the correct permissions to access this resource." + assert ( + exc_info.value.detail == "The requested page or resource could not be found, " + "or you do not have the correct permissions to access it. " + "Please check the URL or query and try again." + ) def test_AMTAuthorizationFlowError(): diff --git a/tests/database_e2e_setup.py b/tests/database_e2e_setup.py index 42619556..9942078e 100644 --- a/tests/database_e2e_setup.py +++ b/tests/database_e2e_setup.py @@ -1,6 +1,6 @@ from amt.api.lifecycles import Lifecycles from amt.enums.status import Status -from amt.models import Algorithm +from amt.models import Algorithm, Organization from sqlalchemy.ext.asyncio.session import AsyncSession from tests.constants import default_algorithm_with_lifecycle, default_task, default_user @@ -11,7 +11,10 @@ async def setup_database_e2e(session: AsyncSession) -> None: db_e2e = DatabaseTestUtils(session) await db_e2e.given([default_user()]) - await db_e2e.given([default_user(id="4738b1e151dc46219556a5662b26517c", name="Test User", organizations=[])]) + default_organization_db = (await db_e2e.get(Organization, "id", 1))[0] + await db_e2e.given( + [default_user(id="4738b1e151dc46219556a5662b26517c", name="Test User", organizations=[default_organization_db])] + ) algorithms: list[Algorithm] = [] for idx in range(120): diff --git a/tests/e2e/test_change_lang.py b/tests/e2e/test_change_lang.py index fcca6111..f63d62b2 100644 --- a/tests/e2e/test_change_lang.py +++ b/tests/e2e/test_change_lang.py @@ -3,7 +3,9 @@ @pytest.mark.slow -def test_e2e_change_language(page: Page): +def test_e2e_change_language(page_no_login: Page): + page = page_no_login + def get_lang_cookie(page: Page) -> Cookie | None: for cookie in page.context.cookies(): if "name" in cookie and cookie["name"] == "lang": diff --git a/tests/e2e/test_create_algorithm.py b/tests/e2e/test_create_algorithm.py index 0dd6dc28..a2b0602a 100644 --- a/tests/e2e/test_create_algorithm.py +++ b/tests/e2e/test_create_algorithm.py @@ -1,13 +1,9 @@ import pytest from playwright.sync_api import Page, expect -from tests.conftest import do_e2e_login - @pytest.mark.slow def test_e2e_create_algorithm(page: Page) -> None: - do_e2e_login(page) - page.goto("/algorithms/new") page.fill("#name", "My new algorithm") @@ -40,8 +36,6 @@ def test_e2e_create_algorithm(page: Page) -> None: @pytest.mark.slow def test_e2e_create_algorithm_invalid(page: Page): - do_e2e_login(page) - page.goto("/algorithms/new") page.locator("#transparency_obligations").select_option("geen transparantieverplichting") diff --git a/tests/e2e/test_create_organization.py b/tests/e2e/test_create_organization.py index 76d4a421..8f1b7222 100644 --- a/tests/e2e/test_create_organization.py +++ b/tests/e2e/test_create_organization.py @@ -1,13 +1,9 @@ import pytest from playwright.sync_api import Page, expect -from tests.conftest import do_e2e_login - @pytest.mark.slow def test_e2e_create_organization(page: Page) -> None: - do_e2e_login(page) - page.goto("/organizations/new") page.get_by_placeholder("Name of the organization").click() @@ -23,8 +19,6 @@ def test_e2e_create_organization(page: Page) -> None: @pytest.mark.slow def test_e2e_create_organization_error(page: Page) -> None: - do_e2e_login(page) - page.goto("/organizations/new") page.get_by_placeholder("Name of the organization").click() diff --git a/tests/repositories/test_authorizations.py b/tests/repositories/test_authorizations.py index 7488fef7..9a394713 100644 --- a/tests/repositories/test_authorizations.py +++ b/tests/repositories/test_authorizations.py @@ -28,12 +28,24 @@ async def test_authorization_basic(db: DatabaseTestUtils): authorization_repository = AuthorizationRepository(session=db.session) results = await authorization_repository.find_by_user(UUID(default_auth_user()["sub"])) + all_authorization_verbs: list[AuthorizationVerb] = [ + AuthorizationVerb.READ, + AuthorizationVerb.UPDATE, + AuthorizationVerb.CREATE, + AuthorizationVerb.LIST, + AuthorizationVerb.DELETE, + ] + # REMINDER: authorizations are generated, not taken from the database, + # see amt.repositories.authorizations.AuthorizationRepository.find_by_user assert results == [ + (AuthorizationResource.ALGORITHMS, all_authorization_verbs, AuthorizationType.ALGORITHM, "*"), + (AuthorizationResource.ALGORITHM, all_authorization_verbs, AuthorizationType.ALGORITHM, 1), + (AuthorizationResource.ORGANIZATIONS, all_authorization_verbs, AuthorizationType.ORGANIZATION, "*"), ( - AuthorizationResource.ORGANIZATION_INFO, - [AuthorizationVerb.CREATE, AuthorizationVerb.READ], + AuthorizationResource.ORGANIZATION_INFO_SLUG, + all_authorization_verbs, AuthorizationType.ORGANIZATION, - 1, - ) + "default-organization", + ), ] From 42be4c6475e151cf7dc508473f1bd14c2ac171f5 Mon Sep 17 00:00:00 2001 From: robbertuittenbroek Date: Fri, 17 Jan 2025 08:13:58 +0100 Subject: [PATCH 2/7] Updated GitHub actions/upload-artifact to v4.6.0 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9bfad3e..da576b3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,7 +158,7 @@ jobs: - name: Upload playwright tracing if: failure() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4.6.0 with: name: playwright-${{ github.sha }} path: test-results/ @@ -173,7 +173,7 @@ jobs: - name: Upload code coverage report if: matrix.python-version == '3.12' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4.6.0 with: name: codecoverage-${{ github.sha }} path: htmlcov/ @@ -295,7 +295,7 @@ jobs: TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - name: Upload SBOM & License - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v4.6.0 with: name: sbom-licence-${{ github.sha }}.json path: | From 4d986c199e7e68b1a1638c576460707c1b2a481b Mon Sep 17 00:00:00 2001 From: robbertuittenbroek Date: Fri, 17 Jan 2025 11:53:54 +0100 Subject: [PATCH 3/7] Downloading system card does not sort keys --- amt/api/routes/algorithm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amt/api/routes/algorithm.py b/amt/api/routes/algorithm.py index 580775a4..ee627d75 100644 --- a/amt/api/routes/algorithm.py +++ b/amt/api/routes/algorithm.py @@ -841,7 +841,7 @@ async def download_algorithm_system_card_as_yaml( algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) filename = algorithm.name + "_" + datetime.datetime.now(datetime.UTC).isoformat() + ".yaml" with open(filename, "w") as outfile: - yaml.dump(algorithm.system_card.model_dump(), outfile) + yaml.dump(algorithm.system_card.model_dump(), outfile, sort_keys=False) try: return FileResponse(filename, filename=filename) except AMTRepositoryError as e: From f09e2309879c5f6012dad1d75d57286052436f6b Mon Sep 17 00:00:00 2001 From: robbertuittenbroek Date: Fri, 17 Jan 2025 11:54:22 +0100 Subject: [PATCH 4/7] Remove instruments from the Algorithm form and systemcard display --- amt/site/templates/algorithms/new.html.j2 | 28 -------------------- amt/site/templates/pages/system_card.html.j2 | 2 +- tests/api/routes/test_algorithms.py | 10 ------- tests/e2e/test_create_algorithm.py | 6 ----- 4 files changed, 1 insertion(+), 45 deletions(-) diff --git a/amt/site/templates/algorithms/new.html.j2 b/amt/site/templates/algorithms/new.html.j2 index d437f55c..3eb60883 100644 --- a/amt/site/templates/algorithms/new.html.j2 +++ b/amt/site/templates/algorithms/new.html.j2 @@ -167,34 +167,6 @@ {% endfor %} -

{% trans %}Instruments{% endtrans %}

-

- {% trans %}Overview of instruments for the responsible development, deployment, assessment, and monitoring of algorithms and AI-systems.{% endtrans %} -

-
-
-
-
- -
- {% for instrument in instruments %} -
- -
- {% endfor %} -
-
-

diff --git a/tests/api/routes/test_algorithms.py b/tests/api/routes/test_algorithms.py index 13e81b90..48524ff9 100644 --- a/tests/api/routes/test_algorithms.py +++ b/tests/api/routes/test_algorithms.py @@ -91,16 +91,6 @@ async def test_get_new_algorithms(client: AsyncClient, mocker: MockFixture, db: response = await client.get("/algorithms/new") assert response.status_code == 200 assert response.headers["content-type"] == "text/html; charset=utf-8" - content = " ".join(response.content.decode().split()) - - assert ( - ' name1' - in content - ) - assert ( - ' name2' - in content - ) @pytest.mark.asyncio diff --git a/tests/e2e/test_create_algorithm.py b/tests/e2e/test_create_algorithm.py index a2b0602a..68db06b0 100644 --- a/tests/e2e/test_create_algorithm.py +++ b/tests/e2e/test_create_algorithm.py @@ -13,12 +13,6 @@ def test_e2e_create_algorithm(page: Page) -> None: page.locator("#algorithmorganization_id").select_option("default organization") - impact_assessment = page.get_by_label("AI Impact Assessment (AIIA)") - - expect(impact_assessment).not_to_be_checked() - - impact_assessment.check() - page.locator("#role-aanbieder").check() page.locator("#type").select_option("AI-systeem voor algemene doeleinden") page.locator("#risk_group").select_option("verboden AI") From dc71435cdb0879679eb28177202761376d16cc13 Mon Sep 17 00:00:00 2001 From: robbertuittenbroek Date: Mon, 20 Jan 2025 11:39:40 +0100 Subject: [PATCH 5/7] Fix requirements widget and speed up dealing with requirements --- amt/api/forms/measure.py | 19 +++-- amt/api/routes/algorithm.py | 80 ++++++++++++------ amt/api/routes/algorithms.py | 6 +- amt/api/routes/organizations.py | 7 +- amt/cli/check_state.py | 3 +- amt/locale/base.pot | 72 +++++++--------- amt/locale/en_US/LC_MESSAGES/messages.mo | Bin 989 -> 989 bytes amt/locale/en_US/LC_MESSAGES/messages.po | 72 +++++++--------- amt/locale/nl_NL/LC_MESSAGES/messages.mo | Bin 14729 -> 14316 bytes amt/locale/nl_NL/LC_MESSAGES/messages.po | 74 +++++++--------- amt/repositories/task_registry.py | 3 + amt/services/algorithms.py | 3 - amt/services/instruments.py | 8 +- .../instruments_and_requirements_state.py | 8 +- amt/services/measures.py | 8 +- amt/services/requirements.py | 8 +- amt/services/task_registry.py | 8 +- amt/site/static/ts/amt.ts | 30 ++++++- .../algorithms/details_compliance.html.j2 | 9 +- .../templates/algorithms/details_info.html.j2 | 21 +++-- .../algorithms/details_measure_modal.html.j2 | 2 +- tests/api/routes/test_algorithm.py | 2 +- tests/clients/test_clients.py | 10 +-- tests/repositories/test_task_registry.py | 14 +-- tests/services/test_algorithms_service.py | 7 +- tests/services/test_task_registry_service.py | 12 +-- 26 files changed, 251 insertions(+), 235 deletions(-) diff --git a/amt/api/forms/measure.py b/amt/api/forms/measure.py index 8d9418f1..c4868627 100644 --- a/amt/api/forms/measure.py +++ b/amt/api/forms/measure.py @@ -1,10 +1,19 @@ from collections.abc import Sequence +from enum import StrEnum from gettext import NullTranslations from amt.models import User from amt.schema.webform import WebForm, WebFormField, WebFormFieldType, WebFormOption, WebFormTextCloneableField +class MeasureStatusOptions(StrEnum): + TODO = "to do" + IN_PROGRESS = "in progress" + IN_REVIEW = "in review" + DONE = "done" + NOT_IMPLEMENTED = "not implemented" + + async def get_measure_form( id: str, current_values: dict[str, str | list[str] | list[tuple[str, str]]], @@ -47,11 +56,11 @@ async def get_measure_form( name="measure_state", label=_("Status"), options=[ - WebFormOption(value="to do", display_value="to do"), - WebFormOption(value="in progress", display_value="in progress"), - WebFormOption(value="in review", display_value="in review"), - WebFormOption(value="done", display_value="done"), - WebFormOption(value="not implemented", display_value="not implemented"), + WebFormOption(value=MeasureStatusOptions.TODO, display_value="to do"), + WebFormOption(value=MeasureStatusOptions.IN_PROGRESS, display_value="in progress"), + WebFormOption(value=MeasureStatusOptions.IN_REVIEW, display_value="in review"), + WebFormOption(value=MeasureStatusOptions.DONE, display_value="done"), + WebFormOption(value=MeasureStatusOptions.NOT_IMPLEMENTED, display_value="not implemented"), ], default_value=current_values.get("measure_state"), group="1", diff --git a/amt/api/routes/algorithm.py b/amt/api/routes/algorithm.py index ee627d75..145c3415 100644 --- a/amt/api/routes/algorithm.py +++ b/amt/api/routes/algorithm.py @@ -1,13 +1,14 @@ import asyncio import datetime import logging +import urllib.parse from collections import defaultdict from collections.abc import Sequence 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 fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from ulid import ULID from amt.api.decorators import permission @@ -19,7 +20,7 @@ get_resolved_editables, save_editable, ) -from amt.api.forms.measure import get_measure_form +from amt.api.forms.measure import MeasureStatusOptions, get_measure_form from amt.api.navigation import ( BaseNavigationItem, Navigation, @@ -42,10 +43,10 @@ from amt.schema.task import MovedTask 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 +from amt.services.measures import measures_service from amt.services.object_storage import ObjectStorageService, create_object_storage_service from amt.services.organizations import OrganizationsService -from amt.services.requirements import RequirementsService, create_requirements_service +from amt.services.requirements import requirements_service from amt.services.tasks import TasksService router = APIRouter() @@ -63,7 +64,6 @@ async def get_instrument_state(system_card: SystemCard) -> dict[str, Any]: async def get_requirements_state(system_card: SystemCard) -> dict[str, Any]: - requirements_service = create_requirements_service() requirements = await requirements_service.fetch_requirements( [requirement.urn for requirement in system_card.requirements] ) @@ -411,8 +411,6 @@ async def get_system_card_requirements( organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], users_repository: Annotated[UsersRepository, Depends(UsersRepository)], algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], - requirements_service: Annotated[RequirementsService, Depends(create_requirements_service)], - measures_service: Annotated[MeasuresService, Depends(create_measures_service)], ) -> HTMLResponse: algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) instrument_state = await get_instrument_state(algorithm.system_card) @@ -497,7 +495,7 @@ async def get_measure_task_functions( measure_task_functions: dict[str, list[User]] = defaultdict(list) for measure_task in measure_tasks: - person_types = ["accountable_persons", "reviewer_persons", "responsible_persons"] + person_types = ["responsible_persons", "reviewer_persons", "accountable_persons"] for person_type in person_types: person_list = getattr(measure_task, person_type) if person_list: @@ -527,8 +525,6 @@ async def find_requirement_tasks_by_measure_urn(system_card: SystemCard, measure requirement_mapper[requirement_task.urn] = requirement_task requirement_tasks: list[RequirementTask] = [] - measures_service = create_measures_service() - requirements_service = create_requirements_service() measure = await measures_service.fetch_measures(measure_urn) for requirement_urn in measure[0].links: # TODO: This is because measure are linked to too many requirement not applicable in our use case @@ -561,9 +557,9 @@ async def get_measure( algorithm_id: int, measure_urn: str, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], - measures_service: Annotated[MeasuresService, Depends(create_measures_service)], object_storage_service: Annotated[ObjectStorageService, Depends(create_object_storage_service)], search: str = Query(""), + requirement_urn: str = "", ) -> HTMLResponse: filters, _, _, sort_by = get_filters_and_sort_by(request) algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) @@ -598,7 +594,12 @@ async def get_measure( translations=get_current_translation(request), ) - context = {"measure": measure[0], "algorithm_id": algorithm_id, "form": measure_form} + context = { + "measure": measure[0], + "algorithm_id": algorithm_id, + "form": measure_form, + "requirement_urn": requirement_urn, + } return templates.TemplateResponse(request, "algorithms/details_measure_modal.html.j2", context) @@ -633,7 +634,6 @@ async def update_measure_value( organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], users_repository: Annotated[UsersRepository, Depends(UsersRepository)], algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], - requirements_service: Annotated[RequirementsService, Depends(create_requirements_service)], object_storage_service: Annotated[ObjectStorageService, Depends(create_object_storage_service)], measure_state: Annotated[str, Form()], measure_responsible: Annotated[str | None, Form()] = None, @@ -642,6 +642,7 @@ async def update_measure_value( measure_value: Annotated[str | None, Form()] = None, measure_links: Annotated[list[str] | None, Form()] = None, measure_files: Annotated[list[UploadFile] | None, File()] = None, + requirement_urn: str = "", ) -> HTMLResponse: filters, _, _, sort_by = get_filters_and_sort_by(request) algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) @@ -670,24 +671,51 @@ async def update_measure_value( requirement_urns = [requirement_task.urn for requirement_task in requirement_tasks] requirements = await requirements_service.fetch_requirements(requirement_urns) + state_order_list = set(MeasureStatusOptions) for requirement in requirements: - count_completed = 0 + state_count: dict[str, int] = {} for link_measure_urn in requirement.links: link_measure_task = find_measure_task(algorithm.system_card, link_measure_urn) - if link_measure_task: # noqa: SIM102 - if link_measure_task.state == "done": - count_completed += 1 + if link_measure_task: + state_count[link_measure_task.state] = state_count.get(link_measure_task.state, 0) + 1 requirement_task = find_requirement_task(algorithm.system_card, requirement.urn) - if count_completed == len(requirement.links): - requirement_task.state = "done" # pyright: ignore [reportOptionalMemberAccess] - elif count_completed == 0 and len(requirement.links) > 0: - requirement_task.state = "to do" # pyright: ignore [reportOptionalMemberAccess] - else: - requirement_task.state = "in progress" # pyright: ignore [reportOptionalMemberAccess] + full_match = False + for state in state_order_list: + # if all measures are in the same state, the requirement is set to that state + if requirement_task and state_count.get(state, 0) == len(requirement.links): + requirement_task.state = state + full_match = True + break + # a requirement is considered 'in progress' if any measure is of any state other than todo + if requirement_task and not full_match: + if len([key for key in state_count if key != MeasureStatusOptions.TODO]) > 0: + requirement_task.state = MeasureStatusOptions.IN_PROGRESS + else: + requirement_task.state = MeasureStatusOptions.TODO await algorithms_service.update(algorithm) - # TODO: FIX THIS!! The page now reloads at the top, which is annoying - return templates.Redirect(request, f"/algorithm/{algorithm_id}/details/system_card/compliance") + + # the redirect 'to same page' does not trigger a javascript reload, so we let us redirect by a different server URL + encoded_url = urllib.parse.quote_plus( + f"/algorithm/{algorithm_id}/details/system_card/compliance#{requirement_urn.replace(":","_")}" + ) + return templates.Redirect( + request, + f"/algorithm/{algorithm_id}/redirect?to={encoded_url}", + ) + + +@router.get("/{algorithm_id}/redirect") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) +async def redirect_to(request: Request, algorithm_id: str, to: str) -> RedirectResponse: + """ + Redirects to the requested URL. We only have and use this because HTMX and javascript redirects do + not work when redirecting to the same URL, even if query params are changed. + """ + return RedirectResponse( + status_code=302, + url=to, + ) @router.get("/{algorithm_id}/members") @@ -843,7 +871,7 @@ async def download_algorithm_system_card_as_yaml( with open(filename, "w") as outfile: yaml.dump(algorithm.system_card.model_dump(), outfile, sort_keys=False) try: - return FileResponse(filename, filename=filename) + return FileResponse(filename, filename=filename, media_type="application/yaml; charset=utf-8") except AMTRepositoryError as e: raise AMTNotFound from e diff --git a/amt/api/routes/algorithms.py b/amt/api/routes/algorithms.py index e2d6e3de..22754d71 100644 --- a/amt/api/routes/algorithms.py +++ b/amt/api/routes/algorithms.py @@ -22,7 +22,6 @@ from amt.schema.algorithm import AlgorithmNew from amt.schema.webform import WebForm from amt.services.algorithms import AlgorithmsService, get_template_files -from amt.services.instruments import InstrumentsService, create_instrument_service from amt.services.organizations import OrganizationsService router = APIRouter() @@ -128,7 +127,6 @@ async def get_algorithms( @permission({AuthorizationResource.ALGORITHMS: [AuthorizationVerb.CREATE]}) async def get_new( request: Request, - instrument_service: Annotated[InstrumentsService, Depends(create_instrument_service)], organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], organization_id: int = Query(None), ) -> HTMLResponse: @@ -151,10 +149,8 @@ async def get_new( template_files = get_template_files() - instruments = await instrument_service.fetch_instruments() - context: dict[str, Any] = { - "instruments": instruments, + "instruments": [], "ai_act_profile": ai_act_profile, "breadcrumbs": breadcrumbs, "sub_menu_items": {}, # sub_menu_items disabled for now, diff --git a/amt/api/routes/organizations.py b/amt/api/routes/organizations.py index 55418a26..3f8dbd34 100644 --- a/amt/api/routes/organizations.py +++ b/amt/api/routes/organizations.py @@ -112,6 +112,11 @@ async def root( search=search, sort=sort_by, filters=filters, user_id=user["sub"] if user else None ) + # we only can show organization you belong to, so the all organizations option is disabled + organization_filters = [ + f for f in get_localized_organization_filters(request) if f and f.value != OrganizationFilterOptions.ALL.value + ] + context: dict[str, Any] = { "breadcrumbs": breadcrumbs, "organizations": organizations, @@ -123,7 +128,7 @@ async def root( "organizations_length": len(organizations), "filters": localized_filters, "include_filters": False, - "organization_filters": get_localized_organization_filters(request), + "organization_filters": organization_filters, } if request.state.htmx: diff --git a/amt/cli/check_state.py b/amt/cli/check_state.py index 1415f510..3c7fb889 100644 --- a/amt/cli/check_state.py +++ b/amt/cli/check_state.py @@ -8,7 +8,7 @@ from amt.schema.instrument import Instrument from amt.schema.system_card import SystemCard -from amt.services.instruments import create_instrument_service +from amt.services.instruments import instruments_service from amt.services.instruments_and_requirements_state import all_lifecycles, get_all_next_tasks from amt.services.storage import StorageFactory @@ -30,7 +30,6 @@ def get_requested_instruments(all_instruments: list[Instrument], urns: list[str] def get_tasks_by_priority(urns: list[str], system_card_path: Path) -> None: try: system_card = get_system_card(system_card_path) - instruments_service = create_instrument_service() all_instruments = asyncio.run(instruments_service.fetch_instruments()) instruments = get_requested_instruments(all_instruments, urns) next_tasks = get_all_next_tasks(instruments, system_card) diff --git a/amt/locale/base.pot b/amt/locale/base.pot index bd333727..8ccc24ca 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:96 +#: amt/site/templates/algorithms/details_info.html.j2:101 #: 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 @@ -145,7 +145,7 @@ msgstr "" msgid "Model" msgstr "" -#: amt/api/navigation.py:62 amt/site/templates/algorithms/new.html.j2:170 +#: amt/api/navigation.py:62 msgid "Instruments" msgstr "" @@ -164,11 +164,7 @@ msgstr "" msgid "Compliance" msgstr "" -#: amt/api/organization_filter_options.py:17 -msgid "All organizations" -msgstr "" - -#: amt/api/organization_filter_options.py:17 +#: amt/api/organization_filter_options.py:16 msgid "My organizations" msgstr "" @@ -197,54 +193,54 @@ msgid "Select organization" msgstr "" #: amt/api/forms/algorithm.py:42 -#: amt/site/templates/algorithms/details_info.html.j2:74 +#: amt/site/templates/algorithms/details_info.html.j2:79 msgid "Organization" msgstr "" -#: amt/api/forms/measure.py:24 +#: amt/api/forms/measure.py:33 msgid "Responsible" msgstr "" -#: amt/api/forms/measure.py:32 +#: amt/api/forms/measure.py:41 msgid "Reviewer" msgstr "" -#: amt/api/forms/measure.py:40 +#: amt/api/forms/measure.py:49 msgid "Accountable" msgstr "" -#: amt/api/forms/measure.py:48 +#: amt/api/forms/measure.py:57 msgid "Status" msgstr "" -#: amt/api/forms/measure.py:63 +#: amt/api/forms/measure.py:72 msgid "Information on how this measure is implemented" msgstr "" -#: amt/api/forms/measure.py:70 +#: amt/api/forms/measure.py:79 msgid "" "Select one or more to upload. The files will be saved once you confirm " "changes by pressing the save button." msgstr "" -#: amt/api/forms/measure.py:75 +#: amt/api/forms/measure.py:84 msgid "Add files" msgstr "" -#: amt/api/forms/measure.py:76 +#: amt/api/forms/measure.py:85 msgid "No files selected." msgstr "" -#: amt/api/forms/measure.py:80 +#: amt/api/forms/measure.py:89 msgid "Add URI" msgstr "" -#: amt/api/forms/measure.py:83 +#: amt/api/forms/measure.py:92 msgid "Add links to documents" msgstr "" #: amt/api/forms/organization.py:23 -#: amt/site/templates/algorithms/details_info.html.j2:70 +#: amt/site/templates/algorithms/details_info.html.j2:75 #: amt/site/templates/auth/profile.html.j2:34 #: amt/site/templates/organizations/home.html.j2:13 #: amt/site/templates/organizations/parts/members_results.html.j2:118 @@ -381,11 +377,11 @@ msgstr "" msgid "Download as YAML" msgstr "" -#: amt/site/templates/algorithms/details_compliance.html.j2:34 +#: amt/site/templates/algorithms/details_compliance.html.j2:36 msgid "measure executed" msgstr "" -#: amt/site/templates/algorithms/details_compliance.html.j2:67 +#: amt/site/templates/algorithms/details_compliance.html.j2:69 #: amt/site/templates/macros/editable.html.j2:28 #: amt/site/templates/macros/editable.html.j2:33 msgid "Edit" @@ -396,37 +392,37 @@ msgid "Does the algorithm meet the requirements?" msgstr "" #: amt/site/templates/algorithms/details_info.html.j2:20 -#: amt/site/templates/algorithms/details_info.html.j2:46 +#: amt/site/templates/algorithms/details_info.html.j2:48 #: amt/site/templates/macros/tasks.html.j2:32 msgid "Done" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:42 +#: amt/site/templates/algorithms/details_info.html.j2:44 msgid "To do" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:44 +#: amt/site/templates/algorithms/details_info.html.j2:46 msgid "In progress" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:60 +#: amt/site/templates/algorithms/details_info.html.j2:65 msgid "Go to all requirements" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:80 +#: amt/site/templates/algorithms/details_info.html.j2:85 #: amt/site/templates/algorithms/details_measure_modal.html.j2:27 msgid "Description" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:86 +#: amt/site/templates/algorithms/details_info.html.j2:91 msgid "Repository" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:92 +#: amt/site/templates/algorithms/details_info.html.j2:97 msgid "Algorithm code" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:102 +#: amt/site/templates/algorithms/details_info.html.j2:107 #: 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 @@ -436,11 +432,11 @@ msgstr "" msgid "Last updated" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:106 +#: amt/site/templates/algorithms/details_info.html.j2:111 msgid "Labels" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:114 +#: amt/site/templates/algorithms/details_info.html.j2:119 msgid "References" msgstr "" @@ -509,21 +505,11 @@ msgstr "" msgid "Select Option" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:172 -msgid "" -"Overview of instruments for the responsible development, deployment, " -"assessment, and monitoring of algorithms and AI-systems." -msgstr "" - -#: amt/site/templates/algorithms/new.html.j2:180 -msgid "Choose one or more instruments" -msgstr "" - -#: amt/site/templates/algorithms/new.html.j2:204 +#: amt/site/templates/algorithms/new.html.j2:176 msgid "Create Algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:221 +#: amt/site/templates/algorithms/new.html.j2:193 msgid "Copy results and close" msgstr "" diff --git a/amt/locale/en_US/LC_MESSAGES/messages.mo b/amt/locale/en_US/LC_MESSAGES/messages.mo index 738f59d744c7bbb468cf5ea758aea138d08b66f8..22706da26b9703f5192b5571d8e66a8c8bb036c7 100644 GIT binary patch delta 32 ocmcc1ewTg28Af3v10z#i14CUS0|i4vD_Pn_&G}GDk!C|nrp@a8>36mm+}HX3e&=`2_k7Q}KbF6E zZC>bna%8jN=TrXD`Rfy{=>H$L+nA+<2{;+oU?g_pJ$N0bVQhji&)`h#g~u=p&tN>> zMGYOt%^2*BamIv98jZeO$i$vlgnD2M_Qvs;f>o#l=Oe#n4IfFk$;M&y5+6p5^RvBw z1D_?1Of+UJ)?g0q#Y*NkmuM)nVrI+0kywaT=)o4Gi`kEw@EDT0IfpFXbYL>xM2!_;r5Ng52*qiyyllDfny)hs4K%@0-RLM7^2e;yA{1P>xn^Ds+1y#8r zNVUyFHZDc=n}90)RC~P&LuFi;OM@zzJ*d(gL1lg&HSr&qi?@+Qnr!w%*N34JeHhih z6n*#vYP>~Aj;7JZZJ0{D6P57iDb!yBpW}i`65EHYF#}b?N-V%C)P&8bfm=~4-G)kd zkG+2ZA0)nv9!z02^&5)X+7YNj`!wos)}>N^Wzxn4m1r+&;)AHYJ&M}v)As&()Jm_| z_$sQTH|+I0sETMcnmFFt4>fTXs)8k`1yzJ-@Qj&=N@O-h;H#)TY(PEm7P_z<7vX!z zZkU1noPI^TLWvssb&jardDX8ahftnSP7+<2lrXf7uRhveJX8sFek+ zBTy5Rqn@us4LA!`vAL)fzGQs`RpBK_?q(U1NXTrYp|96Y>j6{(CvXm)M~;UXOZ~L6 z88{ScQ4_RdIc~?{cndYruzQ`eQ-XttpGPIyg4)7%%y)RwJ6otaG-#r$R)4RSEMk-5z= z)I=SqQsywb&O`w!u|gY9Lv6_n8_&j4;<>1beTdV0@SZ?@YrGz(vIVI7V=&~RQAtBi zMfH7dbxP!s)xI;?-9wkC?@t5S)m757J74_F_;7~)b?;uYw@ zIhcWsAsQNZ2Wq7Uk(ZY_jryK;q9*LdNQ@cm4B*CU;yl!ZZ=w=yMUA)1`WdR!CsF+_ zU<`Jm5)XCJm_p+&s^g>_XHTn99adm0uEhe}h&simFd92h3IB%LTh{~5S%^h#*=STk z6Rfk4SBqJIR4imx(a`s~-ML`S;xOVaR6>Juoj8b^paxZ$7cd?dBe|PKRE74Sp5Kq0 zOVfoKr_T`QYdQdRD2Jn0r(&kPu?)4MEvSKZqB8#g-FO6b|2tGgzQ;Jcgi7qHwF~2k zyRj!mlaADorJ7z*>8K0jlH;7=bO=fE!SU%FDmJ1RRO{ znsI#S4O)*%&sW>) zHK;@u+WQTtgjb^uT{Eh(8&HRJb3XM?r123KRGKerhaXU1r;E54yHVecg+8a>Dtwgq z9n^rGSb^7Z5|;R#t!hFpnN|$oan#EHMpZaIK>gckBym?0>_y&|<^-w|*KijmbM$q{ z4&gZb71h6(lbeL&QO{MQ7P17j;x3HJ`6*`q&gSzkCMnija05!oO)c=4Z zsJ*_3O~bQ-O@XXY;jp{b6%HnPUEzgE`=Z0U(~{y+0=_^n&*#qz_&k1paloJJ^M_9j W_$MNKIeVfjJbiFmOn8YeHtt{jjY6OR delta 3572 zcmY+`d2EzL7{~FUh0=v`6lc9#~2utkwu z6I1H}C?QAym10=_5u$;jLHR>P1p`F>5Cx19h$6@l@cZMPL?o2Y%)GNR&-2W@TmFb_ zi6t&)Hmx`OY2>FXKLgU#`|r=_3}ZIXEx|Hu#HQG}6+hS==i>-mignl;?_)0d=(NNF z)Y2odIgUl2F$pt?Mm7VpumuKCABbaHti|^DIx4_@$Y0aQKke|W?O(w>`hTL<>Db15 zo?{(`m5i6-6l}nJ_BZ!v%wr&z-4x-A*aKh2V%&=kUP7jthp3G+_=gNkccdt$FJ@v9 zYMn`#jx(@3&ckL{g9>mhX0pH8OhXmeiL7mo=mDOxYk6iP?66-Z9EtI<9t-W8}0a8 zs6cn2=I_TrcnG!LWh8cU!}cGyr~X+CwC>nBU4`0rLxP4%_#P_a!^qy|C@PRMn2J|WXLt=Y|8Gpe zw2oXP^dYBaHlgP2KnD+?O8ylpflIc39aVwEeH!{e7j{#oeNhVzMn!xWb$gGaHol0O z_d9BV+o%jXkdEwu+NdAu^Tnw76Ht|#hDx-|Ghr6cP$?H9NtsGiK_lyOD_sEZg=+qK=}(_9tN}{b{Jc>Tm%G?#F~)pVXdS zX){q1axn#qt)ox@l%Oj00@gP%#>K7lAF%rr+{i1W1kPd~ynv&yNuC$LXjDaCMjn|R zdDLH}`HTT&{4MJ8oJR$61@)bO7d5X#FVEhnQjb6-Fda2-q3y3h&EI1CJMHriP?b4t zpMTdY;k}2yFhJ8h!bP~QH}5$9WNlA6y2X=mAkM-Pdo?1nJhsEYms9zdr%vkL}mUx@}*~fL%l7JP#b6DdzY&#I`n&D zIgUpk?n4E97`0yFlx>_tmHry~@HQ%-N2m)W}@aTLy|K!sOP&e9Y4gOcocQ1 zucKbO2dIEk`g=#+5xE-)(~XACa2_h6O6zLmyUA=rU7Ee9_xqUb-^Ahc(*}3}m7w}l zuo6iM=-^3IMn9u6xQUza9xCAV zgZRGzZpSuw1Qk#t>e7CRTK76?{%zDckL|c`F!fhNS%bX^xv0nsQI~HBD)UjOTRR4| z(QH(Ome}X3Pjt#cjq6@Clz@Gm>wsn}c3K~-YTAR3ct z6k&Tz;Pxim32aT@VbyFLhcg4Hlt(H~3L!y%y^h=M*K!WgJOq6AU_$Xr(_K zSmTceBH`q%)_qdCRYf8(*NKGri8`T3)O7;kSUkEiqOnyibyyXC=a?$gIjKdxlD1R{&2j8ZUq&J*94X?cbPlDsdUSuD+9~bEO+%-!w!FKR None: self.repository = repository - self.instrument_service = instrument_service self.organizations_repository = organizations_repository async def get(self, algorithm_id: int) -> Algorithm: diff --git a/amt/services/instruments.py b/amt/services/instruments.py index 4ee19ffc..b90f83b9 100644 --- a/amt/services/instruments.py +++ b/amt/services/instruments.py @@ -1,8 +1,8 @@ import logging from collections.abc import Sequence -from amt.clients.clients import TaskType, task_registry_api_client -from amt.repositories.task_registry import TaskRegistryRepository +from amt.clients.clients import TaskType +from amt.repositories.task_registry import TaskRegistryRepository, task_registry_repository from amt.schema.instrument import Instrument logger = logging.getLogger(__name__) @@ -23,6 +23,4 @@ async def fetch_instruments(self, urns: str | Sequence[str] | None = None) -> li return [Instrument(**data) for data in task_data] -def create_instrument_service() -> InstrumentsService: - repository = TaskRegistryRepository(task_registry_api_client) - return InstrumentsService(repository) +instruments_service = InstrumentsService(task_registry_repository) diff --git a/amt/services/instruments_and_requirements_state.py b/amt/services/instruments_and_requirements_state.py index 5d50c99c..e34161e1 100644 --- a/amt/services/instruments_and_requirements_state.py +++ b/amt/services/instruments_and_requirements_state.py @@ -8,7 +8,7 @@ from amt.schema.instrument import Instrument, InstrumentTask from amt.schema.requirement import Requirement from amt.schema.system_card import SystemCard -from amt.services.instruments import create_instrument_service +from amt.services.instruments import instruments_service logger = logging.getLogger(__name__) @@ -108,8 +108,9 @@ def get_requirements_state(self, requirements: list[Requirement]) -> list[dict[s saved_requirements = self.system_card.requirements for requirement in saved_requirements: - urn = requirement.urn - self.requirements_state.append({"name": requirements_mapping[urn], "state": requirement.state}) + self.requirements_state.append( + {"name": requirements_mapping[requirement.urn], "state": requirement.state, "urn": requirement.urn} + ) return self.requirements_state def get_amount_total_requirements(self) -> int: @@ -134,7 +135,6 @@ async def get_state_per_instrument(self) -> list[dict[str, int]]: # Otherwise the instrument is completed as there are not any tasks left. urns = [instrument.urn for instrument in self.system_card.instruments] - instruments_service = create_instrument_service() instruments = await instruments_service.fetch_instruments(urns) # TODO: refactor this data structure in 3 lines below (also change in get_all_next_tasks + check_state.py) instruments_dict = {} diff --git a/amt/services/measures.py b/amt/services/measures.py index a26c0c2f..0f73f3ba 100644 --- a/amt/services/measures.py +++ b/amt/services/measures.py @@ -1,8 +1,8 @@ import logging from collections.abc import Sequence -from amt.clients.clients import TaskType, task_registry_api_client -from amt.repositories.task_registry import TaskRegistryRepository +from amt.clients.clients import TaskType +from amt.repositories.task_registry import TaskRegistryRepository, task_registry_repository from amt.schema.measure import Measure logger = logging.getLogger(__name__) @@ -23,6 +23,4 @@ async def fetch_measures(self, urns: str | Sequence[str] | None = None) -> list[ return [Measure(**data) for data in task_data] -def create_measures_service() -> MeasuresService: - repository = TaskRegistryRepository(task_registry_api_client) - return MeasuresService(repository) +measures_service = MeasuresService(task_registry_repository) diff --git a/amt/services/requirements.py b/amt/services/requirements.py index 675aa426..7549bc85 100644 --- a/amt/services/requirements.py +++ b/amt/services/requirements.py @@ -1,8 +1,8 @@ import logging from collections.abc import Sequence -from amt.clients.clients import TaskType, task_registry_api_client -from amt.repositories.task_registry import TaskRegistryRepository +from amt.clients.clients import TaskType +from amt.repositories.task_registry import TaskRegistryRepository, task_registry_repository from amt.schema.requirement import Requirement logger = logging.getLogger(__name__) @@ -23,6 +23,4 @@ async def fetch_requirements(self, urns: str | Sequence[str] | None = None) -> l return [Requirement(**data) for data in task_data] -def create_requirements_service() -> RequirementsService: - repository = TaskRegistryRepository(task_registry_api_client) - return RequirementsService(repository) +requirements_service = RequirementsService(task_registry_repository) diff --git a/amt/services/task_registry.py b/amt/services/task_registry.py index 71e16ee0..102ba4be 100644 --- a/amt/services/task_registry.py +++ b/amt/services/task_registry.py @@ -3,8 +3,8 @@ from amt.schema.measure import MeasureTask from amt.schema.requirement import Requirement, RequirementTask from amt.schema.system_card import AiActProfile -from amt.services.measures import create_measures_service -from amt.services.requirements import create_requirements_service +from amt.services.measures import measures_service +from amt.services.requirements import requirements_service logger = logging.getLogger(__name__) @@ -53,8 +53,6 @@ def is_requirement_applicable(requirement: Requirement, ai_act_profile: AiActPro async def get_requirements_and_measures( ai_act_profile: AiActProfile, ) -> tuple[list[RequirementTask], list[MeasureTask]]: - requirements_service = create_requirements_service() - measure_service = create_measures_service() all_requirements = await requirements_service.fetch_requirements() applicable_requirements: list[RequirementTask] = [] @@ -67,7 +65,7 @@ async def get_requirements_and_measures( for measure_urn in requirement.links: if measure_urn not in measure_urns: - measure = await measure_service.fetch_measures(measure_urn) + measure = await measures_service.fetch_measures(measure_urn) applicable_measures.append( MeasureTask(urn=measure_urn, state="to do", version=measure[0].schema_version) ) diff --git a/amt/site/static/ts/amt.ts b/amt/site/static/ts/amt.ts index c6fa62be..3ed28b8b 100644 --- a/amt/site/static/ts/amt.ts +++ b/amt/site/static/ts/amt.ts @@ -43,7 +43,7 @@ document.addEventListener("DOMContentLoaded", function () { new Sortable(column, { //NOSONAR group: "shared", // set both lists to same group animation: 150, - onEnd: function(evt) { + onEnd: function (evt) { if (evt.oldIndex !== evt.newIndex || evt.from !== evt.to) { const previousSiblingId = evt.item.previousElementSibling ? evt.item.previousElementSibling.getAttribute("data-id") @@ -427,4 +427,32 @@ export function getFiles(element: HTMLInputElement, target_id: string) { } } +function getAnchor() { + const currentUrl = document.URL, + urlParts = currentUrl.split("#"); + return urlParts.length > 1 ? urlParts[1] : null; +} + +function scrollElementIntoViewClickAndBlur(id: string | null) { + if (!id) { + return; + } + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ + behavior: "smooth", + block: "start", + inline: "start", + }); + element.click(); + element.blur(); + } +} + +export function showRequirementDetails() { + document.addEventListener("DOMContentLoaded", function () { + scrollElementIntoViewClickAndBlur(getAnchor()); + }); +} + // for debugging htmx use -> htmx.logAll(); diff --git a/amt/site/templates/algorithms/details_compliance.html.j2 b/amt/site/templates/algorithms/details_compliance.html.j2 index 6c869028..0ff30eb2 100644 --- a/amt/site/templates/algorithms/details_compliance.html.j2 +++ b/amt/site/templates/algorithms/details_compliance.html.j2 @@ -11,7 +11,9 @@ {% for (requirement, completed_measures_count, measures) in requirements_and_measures %}
- +

{% trans %}Edit{% endtrans %} - {{ macros.user_avatars(measure_task_functions[measure.urn]) }} +
{{ macros.user_avatars(measure_task_functions[measure.urn]) }}
@@ -74,4 +76,5 @@

{% endfor %} + {% endblock %} diff --git a/amt/site/templates/algorithms/details_info.html.j2 b/amt/site/templates/algorithms/details_info.html.j2 index 4ba713b0..471092bb 100644 --- a/amt/site/templates/algorithms/details_info.html.j2 +++ b/amt/site/templates/algorithms/details_info.html.j2 @@ -35,16 +35,21 @@ {% else %}
{% endif %} - {{ requirement.name }} + {{ requirement.name }}
- {% if requirement.state == "to do" %} - {% trans %}To do{% endtrans %} - {% elif requirement.state == "in progress" %} - {% trans %}In progress{% endtrans %} - {% else %} - {% trans %}Done{% endtrans %} - {% endif %} + + {% if requirement.state == "to do" or requirement.state == "" %} + {% trans %}To do{% endtrans %} + {% elif requirement.state == "in progress" %} + {% trans %}In progress{% endtrans %} + {% elif requirement.state == "done" %} + {% trans %}Done{% endtrans %} + {% else %} + {{ requirement.state }} + {% endif %} +
diff --git a/amt/site/templates/algorithms/details_measure_modal.html.j2 b/amt/site/templates/algorithms/details_measure_modal.html.j2 index 9635f392..7eaf354f 100644 --- a/amt/site/templates/algorithms/details_measure_modal.html.j2 +++ b/amt/site/templates/algorithms/details_measure_modal.html.j2 @@ -8,7 +8,7 @@
Date: Wed, 22 Jan 2025 11:34:32 +0100 Subject: [PATCH 6/7] Add tasks for measures when creating an algorithm --- amt/api/deps.py | 16 ++ amt/api/routes/algorithm.py | 208 ++++++++++++------ amt/api/routes/algorithms.py | 9 +- amt/api/routes/shared.py | 18 +- amt/enums/status.py | 8 - amt/enums/tasks.py | 28 +++ amt/locale/base.pot | 41 ++-- amt/locale/en_US/LC_MESSAGES/messages.mo | Bin 989 -> 989 bytes amt/locale/en_US/LC_MESSAGES/messages.po | 41 ++-- amt/locale/nl_NL/LC_MESSAGES/messages.mo | Bin 14316 -> 14357 bytes amt/locale/nl_NL/LC_MESSAGES/messages.po | 45 ++-- .../40fd30e75959_extend_the_tasks_object.py | 28 +++ amt/models/task.py | 3 +- amt/models/user.py | 1 + amt/repositories/tasks.py | 40 +++- amt/schema/measure.py | 39 ++-- amt/schema/measure_display.py | 13 ++ amt/schema/task.py | 42 +++- amt/schema/user.py | 26 +++ amt/services/algorithms.py | 13 ++ .../instruments_and_requirements_state.py | 20 +- amt/services/organizations.py | 4 +- amt/services/task_registry.py | 7 +- amt/services/tasks.py | 24 +- amt/services/users.py | 11 +- amt/site/static/scss/layout.scss | 2 +- .../templates/algorithms/details_base.html.j2 | 2 +- .../templates/algorithms/details_info.html.j2 | 2 +- amt/site/templates/algorithms/index.html.j2 | 2 +- amt/site/templates/algorithms/new.html.j2 | 2 +- amt/site/templates/algorithms/tasks.html.j2 | 11 +- amt/site/templates/auth/profile.html.j2 | 2 +- .../errors/AMTAuthorizationError_401.html.j2 | 2 +- .../templates/errors/AMTNotFound_404.html.j2 | 2 +- .../errors/AMTPermissionDenied_404.html.j2 | 2 +- amt/site/templates/errors/Exception.html.j2 | 2 +- .../errors/HTTPException_404.html.j2 | 2 +- .../errors/RequestValidationError_400.html.j2 | 2 +- amt/site/templates/macros/form_macros.html.j2 | 40 ++-- amt/site/templates/macros/tasks.html.j2 | 48 ++-- .../organizations/algorithms.html.j2 | 2 +- amt/site/templates/organizations/home.html.j2 | 2 +- .../templates/organizations/index.html.j2 | 2 +- .../templates/organizations/members.html.j2 | 2 +- amt/site/templates/organizations/new.html.j2 | 2 +- .../templates/pages/assessment_card.html.j2 | 2 +- amt/site/templates/pages/index.html.j2 | 2 +- amt/site/templates/pages/landingpage.html.j2 | 4 +- .../pages/under_construction.html.j2 | 2 +- amt/site/templates/parts/header.html.j2 | 6 +- amt/site/templates/parts/task.html.j2 | 2 +- .../system_card_templates/AMT_Template_1.json | 21 +- tests/api/routes/test_algorithm.py | 47 +++- tests/api/routes/test_algorithms.py | 7 +- tests/api/routes/test_organizations.py | 18 ++ tests/api/routes/test_shared.py | 34 +++ tests/api/test_deps.py | 21 +- tests/api/test_editable_validators.py | 29 +++ tests/constants.py | 5 + tests/database_e2e_setup.py | 2 +- tests/e2e/test_move_task.py | 2 +- tests/repositories/test_tasks.py | 2 +- tests/services/test_algorithms_service.py | 5 + tests/services/test_instruments_state.py | 22 +- tests/services/test_tasks_service.py | 2 +- tests/services/test_users_service.py | 2 +- 66 files changed, 769 insertions(+), 286 deletions(-) delete mode 100644 amt/enums/status.py create mode 100644 amt/enums/tasks.py create mode 100644 amt/migrations/versions/40fd30e75959_extend_the_tasks_object.py create mode 100644 amt/schema/measure_display.py create mode 100644 amt/schema/user.py create mode 100644 tests/api/routes/test_shared.py create mode 100644 tests/api/test_editable_validators.py diff --git a/amt/api/deps.py b/amt/api/deps.py index e6e6577e..89ebf636 100644 --- a/amt/api/deps.py +++ b/amt/api/deps.py @@ -138,6 +138,21 @@ def instance(obj: Class, type_string: str) -> bool: raise TypeError("Unsupported type: " + type_string) +def hasattr_jinja(obj: object, attributes: str) -> bool: + """ + Convenience method that checks whether an object has the given attributes. + :param obj: the object to check + :param attributes: the attributes, seperated by dots, like field1.field2.field3 + :return: True if the object has the given attribute and its value is not None, False otherwise + """ + for attribute in attributes.split("."): + if hasattr(obj, attribute) and getattr(obj, attribute) is not None: + obj = getattr(obj, attribute) + else: + return False + return True + + templates = LocaleJinja2Templates( directory="amt/site/templates/", context_processors=[custom_context_processor], undefined=get_undefined_behaviour() ) @@ -153,5 +168,6 @@ def instance(obj: Class, type_string: str) -> bool: 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.globals.update(hasattr=hasattr_jinja) # 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/routes/algorithm.py b/amt/api/routes/algorithm.py index 145c3415..93602550 100644 --- a/amt/api/routes/algorithm.py +++ b/amt/api/routes/algorithm.py @@ -1,4 +1,3 @@ -import asyncio import datetime import logging import urllib.parse @@ -30,17 +29,19 @@ ) from amt.api.routes.shared import UpdateFieldModel, get_filters_and_sort_by, replace_none_with_empty_string_inplace from amt.core.authorization import AuthorizationResource, AuthorizationVerb, get_user -from amt.core.exceptions import AMTError, AMTNotFound, AMTRepositoryError +from amt.core.exceptions import AMTError, AMTNotFound, AMTPermissionDenied, AMTRepositoryError from amt.core.internationalization import get_current_translation -from amt.enums.status import Status +from amt.enums.tasks import Status, TaskType, measure_state_to_status, status_mapper from amt.models import Algorithm, User from amt.models.task import Task from amt.repositories.organizations import OrganizationsRepository from amt.repositories.users import UsersRepository from amt.schema.measure import ExtendedMeasureTask, MeasureTask, Person +from amt.schema.measure_display import DisplayMeasureTask from amt.schema.requirement import RequirementTask from amt.schema.system_card import SystemCard -from amt.schema.task import MovedTask +from amt.schema.task import DisplayTask, MovedTask +from amt.schema.user import User as UserSchema from amt.services.algorithms import AlgorithmsService from amt.services.instruments_and_requirements_state import InstrumentStateService, RequirementsStateService from amt.services.measures import measures_service @@ -48,6 +49,7 @@ from amt.services.organizations import OrganizationsService from amt.services.requirements import requirements_service from amt.services.tasks import TasksService +from amt.services.users import UsersService router = APIRouter() logger = logging.getLogger(__name__) @@ -123,26 +125,41 @@ def get_algorithms_submenu_items() -> list[BaseNavigationItem]: ] -async def gather_algorithm_tasks(algorithm_id: int, task_service: TasksService) -> dict[Status, Sequence[Task]]: - fetch_tasks = [task_service.get_tasks_for_algorithm(algorithm_id, status + 0) for status in Status] - - results = await asyncio.gather(*fetch_tasks) - - return dict(zip(Status, results, strict=True)) - - @router.get("/{algorithm_id}/details/tasks") async def get_tasks( request: Request, algorithm_id: int, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], + users_service: Annotated[UsersService, Depends(UsersService)], tasks_service: Annotated[TasksService, Depends(TasksService)], ) -> HTMLResponse: algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) - instrument_state = await get_instrument_state(algorithm.system_card) - requirements_state = await get_requirements_state(algorithm.system_card) tab_items = get_algorithm_details_tabs(request) - tasks_by_status = await gather_algorithm_tasks(algorithm_id, task_service=tasks_service) + + tasks_db: Sequence[Task] = await tasks_service.get_tasks_for_algorithm(algorithm_id, None) + + # resolve measure tasks + urns: set[str] = {task.type_id for task in tasks_db if task.type == TaskType.MEASURE and task.type_id is not None} + resolved_measures: dict[str, DisplayMeasureTask] = ( + {} if len(urns) == 0 else await resolve_and_enrich_measures(algorithm, urns, users_service) + ) + + tasks_by_status: dict[Status, list[DisplayTask]] = {} + for status in Status: + tasks_by_status[status] = [] + tasks_by_status[status] += [ + # we create display task for Measures, + # this could be extended in the future to support other types + DisplayTask.create_from_model(task, resolved_measures.get(task.type_id)) + for task in tasks_db + if task.status_id == status and task.type == TaskType.MEASURE and task.type_id is not None + ] + # we also append all tasks that have no related object + tasks_by_status[status] += [ + DisplayTask.create_from_model(task, None) + for task in tasks_db + if task.status_id == status and task.type is None + ] breadcrumbs = resolve_base_navigation_items( [ @@ -154,8 +171,6 @@ async def get_tasks( ) context = { - "instrument_state": instrument_state, - "requirements_state": requirements_state, "tasks_by_status": tasks_by_status, "statuses": Status, "algorithm": algorithm, @@ -167,24 +182,60 @@ async def get_tasks( return templates.TemplateResponse(request, "algorithms/tasks.html.j2", context) -@router.patch("/move_task") +async def resolve_and_enrich_measures( + algorithm: Algorithm, urns: set[str], users_service: UsersService +) -> dict[str, DisplayMeasureTask]: + """ + Using the given algorithm and list of measure urns, retrieve all those measures + and combine the information from the task registry with the system card information + and return it. + :param algorithm: the algorithm + :param urns: the list of measure urns + :param users_service: the user service + :return: a list of enriched measure tasks + """ + enriched_resolved_measures: dict[str, DisplayMeasureTask] = {} + resolved_measures = await measures_service.fetch_measures(list(urns)) + for resolved_measure in resolved_measures: + system_measure = find_measure_task(algorithm.system_card, resolved_measure.urn) + if system_measure is not None and system_measure.urn not in enriched_resolved_measures: + all_users: list[UserSchema] = [] + for person_type in ["responsible_persons", "reviewer_persons", "accountable_persons"]: + persons = getattr(system_measure, person_type, []) + for person in persons if persons is not None else []: + user = UserSchema.create_from_model(await users_service.find_by_id(person.uuid)) + if user is not None: + all_users.append(user) + measure_task_display = DisplayMeasureTask( + name=resolved_measure.name, + description=resolved_measure.description, + urn=resolved_measure.urn, + state=system_measure.state, + value=system_measure.value, + version=system_measure.version, + users=all_users, + ) + enriched_resolved_measures[system_measure.urn] = measure_task_display + return enriched_resolved_measures + + +@router.patch("/{algorithm_id}/move_task") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.UPDATE]}) async def move_task( request: Request, + algorithm_id: int, + algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], moved_task: MovedTask, + users_service: Annotated[UsersService, Depends(UsersService)], tasks_service: Annotated[TasksService, Depends(TasksService)], ) -> HTMLResponse: - """ - Move a task through an API call. - :param tasks_service: the task service - :param request: the request object - :param moved_task: the move task object - :return: a HTMLResponse object, in this case the html code of the card that was moved - """ # because htmx form always sends a value and siblings are optional, we use -1 for None and convert it here + if moved_task.next_sibling_id == -1: moved_task.next_sibling_id = None if moved_task.previous_sibling_id == -1: moved_task.previous_sibling_id = None + task = await tasks_service.move_task( moved_task.id, moved_task.status_id, @@ -192,7 +243,28 @@ async def move_task( moved_task.next_sibling_id, ) - context = {"task": task} + algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) + + if task.type == TaskType.MEASURE and task.type_id is not None: + measure_task = get_measure_task_or_error(algorithm.system_card, task.type_id) + measure_task.update(state=status_mapper[Status(moved_task.status_id)]) + + await update_requirements_state(algorithm, measure_task.urn) + + await algorithms_service.update(algorithm) + + unique_resolved_measures: dict[str, DisplayMeasureTask] = await resolve_and_enrich_measures( + algorithm, {measure_task.urn}, users_service + ) + resolved_measure: DisplayMeasureTask | None = unique_resolved_measures.get(measure_task.urn) + if resolved_measure is None: + raise AMTError(f"No measure found for {measure_task.urn}") + + display_task: DisplayTask = DisplayTask.create_from_model(task, resolved_measure) + else: + display_task: DisplayTask = DisplayTask.create_from_model(task) + + context: dict[str, int | DisplayTask] = {"algorithm_id": algorithm_id, "task": display_task} return templates.TemplateResponse(request, "parts/task.html.j2", context=context) @@ -416,7 +488,7 @@ async def get_system_card_requirements( instrument_state = await get_instrument_state(algorithm.system_card) requirements_state = await get_requirements_state(algorithm.system_card) tab_items = get_algorithm_details_tabs(request) - filters, _, _, sort_by = get_filters_and_sort_by(request) + filters, _, _, _ = get_filters_and_sort_by(request) organization = await organizations_repository.find_by_id(algorithm.organization_id) filters["organization-id"] = str(organization.id) @@ -458,9 +530,7 @@ async def get_system_card_requirements( extended_linked_measures.append(ext_measure_task) requirements_and_measures.append((requirement, completed_measures_count, extended_linked_measures)) # pyright: ignore [reportUnknownMemberType] - measure_task_functions: dict[str, list[User]] = await get_measure_task_functions( - measure_tasks, users_repository, sort_by, filters - ) + measure_task_functions: dict[str, list[User]] = await get_measure_task_functions(measure_tasks, users_repository) context = { "instrument_state": instrument_state, @@ -476,21 +546,9 @@ async def get_system_card_requirements( return templates.TemplateResponse(request, "algorithms/details_compliance.html.j2", context) -async def _fetch_members( - users_repository: UsersRepository, - search_name: str, - sort_by: dict[str, str], - filters: dict[str, str | list[str | int]], -) -> User | None: - members = await users_repository.find_all(search=search_name, sort=sort_by, filters=filters) - return members[0] if members else None - - async def get_measure_task_functions( measure_tasks: list[MeasureTask], users_repository: Annotated[UsersRepository, Depends(UsersRepository)], - sort_by: dict[str, str], - filters: dict[str, str | list[str | int]], ) -> dict[str, list[User]]: measure_task_functions: dict[str, list[User]] = defaultdict(list) @@ -499,7 +557,7 @@ async def get_measure_task_functions( for person_type in person_types: person_list = getattr(measure_task, person_type) if person_list: - member = await _fetch_members(users_repository, person_list[0].name, sort_by, filters) + member = await users_repository.find_by_id(person_list[0].uuid) if member: measure_task_functions[measure_task.urn].append(member) return measure_task_functions @@ -631,9 +689,9 @@ async def update_measure_value( request: Request, algorithm_id: int, measure_urn: str, - organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], users_repository: Annotated[UsersRepository, Depends(UsersRepository)], algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], + tasks_service: Annotated[TasksService, Depends(TasksService)], object_storage_service: Annotated[ObjectStorageService, Depends(create_object_storage_service)], measure_state: Annotated[str, Form()], measure_responsible: Annotated[str | None, Form()] = None, @@ -648,7 +706,6 @@ async def update_measure_value( algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) user_id = get_user_id_or_error(request) measure_task = get_measure_task_or_error(algorithm.system_card, measure_urn) - paths = ( object_storage_service.upload_files( algorithm.organization_id, algorithm.id, measure_urn, user_id, measure_files @@ -663,14 +720,40 @@ async def update_measure_value( measure_task.update( measure_state, measure_value, measure_links, paths, responsible_persons, accountable_persons, reviewer_persons ) - organization = await organizations_repository.find_by_id(algorithm.organization_id) - filters["organization-id"] = str(organization.id) + # update the tasks + await tasks_service.update_tasks_status( + algorithm_id, TaskType.MEASURE, measure_task.urn, measure_state_to_status(measure_task.state) + ) + + await update_requirements_state(algorithm, measure_urn) + + await algorithms_service.update(algorithm) + + # the redirect 'to same page' does not trigger a javascript reload, so we let us redirect by a different server URL + encoded_url = urllib.parse.quote_plus( + f"/algorithm/{algorithm_id}/details/system_card/compliance#{requirement_urn.replace(":","_")}" + ) + if request.headers.get("referer", "").endswith("/details/tasks"): + encoded_url = urllib.parse.urlparse(request.headers.get("referer")).path + return templates.Redirect( + request, + f"/algorithm/{algorithm_id}/redirect?to={encoded_url}", + ) + + +async def update_requirements_state(algorithm: Algorithm, measure_urn: str) -> Algorithm: + """ + Update the state of requirements depending on the given measure. Note this method does not save the algorithm + but returns the updated algorithm. + :param algorithm: the algorithm to update + :param measure_urn: the measure urn + :return: the updated algorithm + """ # update for the linked requirements the state based on all it's measures requirement_tasks = await find_requirement_tasks_by_measure_urn(algorithm.system_card, measure_urn) requirement_urns = [requirement_task.urn for requirement_task in requirement_tasks] requirements = await requirements_service.fetch_requirements(requirement_urns) - state_order_list = set(MeasureStatusOptions) for requirement in requirements: state_count: dict[str, int] = {} @@ -692,17 +775,7 @@ async def update_measure_value( requirement_task.state = MeasureStatusOptions.IN_PROGRESS else: requirement_task.state = MeasureStatusOptions.TODO - - await algorithms_service.update(algorithm) - - # the redirect 'to same page' does not trigger a javascript reload, so we let us redirect by a different server URL - encoded_url = urllib.parse.quote_plus( - f"/algorithm/{algorithm_id}/details/system_card/compliance#{requirement_urn.replace(":","_")}" - ) - return templates.Redirect( - request, - f"/algorithm/{algorithm_id}/redirect?to={encoded_url}", - ) + return algorithm @router.get("/{algorithm_id}/redirect") @@ -712,10 +785,14 @@ async def redirect_to(request: Request, algorithm_id: str, to: str) -> RedirectR Redirects to the requested URL. We only have and use this because HTMX and javascript redirects do not work when redirecting to the same URL, even if query params are changed. """ - return RedirectResponse( - status_code=302, - url=to, - ) + + if not to.startswith("/"): + raise AMTPermissionDenied + + return RedirectResponse( # NOSONAR + status_code=302, # NOSONAR + url=to, # NOSONAR + ) # NOSONAR @router.get("/{algorithm_id}/members") @@ -870,10 +947,7 @@ async def download_algorithm_system_card_as_yaml( filename = algorithm.name + "_" + datetime.datetime.now(datetime.UTC).isoformat() + ".yaml" with open(filename, "w") as outfile: yaml.dump(algorithm.system_card.model_dump(), outfile, sort_keys=False) - try: - return FileResponse(filename, filename=filename, media_type="application/yaml; charset=utf-8") - except AMTRepositoryError as e: - raise AMTNotFound from e + return FileResponse(filename, filename=filename, media_type="application/yaml; charset=utf-8") @router.get("/{algorithm_id}/file/{ulid}") diff --git a/amt/api/routes/algorithms.py b/amt/api/routes/algorithms.py index 22754d71..17439d19 100644 --- a/amt/api/routes/algorithms.py +++ b/amt/api/routes/algorithms.py @@ -16,7 +16,6 @@ ) from amt.api.routes.shared import get_filters_and_sort_by from amt.core.authorization import AuthorizationResource, AuthorizationVerb, get_user -from amt.core.exceptions import AMTAuthorizationError from amt.core.internationalization import get_current_translation from amt.models import Algorithm from amt.schema.algorithm import AlgorithmNew @@ -172,8 +171,6 @@ async def post_new( ) -> HTMLResponse: user: dict[str, Any] | None = get_user(request) # TODO (Robbert): we need to handle (show) repository or service errors in the forms - if user: - algorithm = await algorithms_service.create(algorithm_new, user["sub"]) - response = templates.Redirect(request, f"/algorithm/{algorithm.id}/details") - return response - raise AMTAuthorizationError + algorithm = await algorithms_service.create(algorithm_new, user["sub"]) # pyright: ignore[reportOptionalSubscript, reportUnknownArgumentType] + response = templates.Redirect(request, f"/algorithm/{algorithm.id}/details") + return response diff --git a/amt/api/routes/shared.py b/amt/api/routes/shared.py index 9a87d8c0..d689e3b3 100644 --- a/amt/api/routes/shared.py +++ b/amt/api/routes/shared.py @@ -111,21 +111,19 @@ def replace_none_with_empty_string_inplace(obj: dict[Any, Any] | list[Any] | Ite """ if isinstance(obj, list): for i, item in enumerate(obj): - if item is None and isinstance(item, str): + if item is None: obj[i] = "" - elif isinstance(item, list | dict | IterMixin): + else: replace_none_with_empty_string_inplace(item) # pyright: ignore[reportUnknownArgumentType] - elif isinstance(obj, dict): for key, value in obj.items(): - if value is None and isinstance(value, str): + if value is None: obj[key] = "" - elif isinstance(value, (list, dict, IterMixin)): # noqa: UP038 - replace_none_with_empty_string_inplace(value) # pyright: ignore[reportUnknownArgumentType] - + else: + replace_none_with_empty_string_inplace(obj[key]) # pyright: ignore[reportUnknownArgumentType] elif isinstance(obj, IterMixin): for item in obj: - if isinstance(item, tuple) and item[1] is None: + if getattr(obj, item[0]) is None: setattr(obj, item[0], "") - if isinstance(item, list | dict | IterMixin): - replace_none_with_empty_string_inplace(item) # pyright: ignore[reportUnknownArgumentType] + else: + replace_none_with_empty_string_inplace(getattr(obj, item[0])) # pyright: ignore[reportUnknownArgumentType] diff --git a/amt/enums/status.py b/amt/enums/status.py deleted file mode 100644 index 6ed44c45..00000000 --- a/amt/enums/status.py +++ /dev/null @@ -1,8 +0,0 @@ -from enum import IntEnum - - -class Status(IntEnum): - TODO = 1 - DOING = 2 - REVIEW = 3 - DONE = 4 diff --git a/amt/enums/tasks.py b/amt/enums/tasks.py new file mode 100644 index 00000000..88c55dfd --- /dev/null +++ b/amt/enums/tasks.py @@ -0,0 +1,28 @@ +from enum import IntEnum, StrEnum + +from amt.api.forms.measure import MeasureStatusOptions + + +class Status(IntEnum): + TODO = 1 + IN_PROGRESS = 2 + IN_REVIEW = 3 + DONE = 4 + NOT_IMPLEMENTED = 5 + + +class TaskType(StrEnum): + MEASURE = "measure" + + +status_mapper: dict[Status, MeasureStatusOptions] = { + Status.TODO: MeasureStatusOptions.TODO, + Status.IN_PROGRESS: MeasureStatusOptions.IN_PROGRESS, + Status.IN_REVIEW: MeasureStatusOptions.IN_REVIEW, + Status.DONE: MeasureStatusOptions.DONE, + Status.NOT_IMPLEMENTED: MeasureStatusOptions.NOT_IMPLEMENTED, +} + + +def measure_state_to_status(state: str) -> Status: + return next((k for k, v in status_mapper.items() if v.value == state), Status.TODO) diff --git a/amt/locale/base.pot b/amt/locale/base.pot index 8ccc24ca..23479e81 100644 --- a/amt/locale/base.pot +++ b/amt/locale/base.pot @@ -164,7 +164,11 @@ msgstr "" msgid "Compliance" msgstr "" -#: amt/api/organization_filter_options.py:16 +#: amt/api/organization_filter_options.py:17 +msgid "All organizations" +msgstr "" + +#: amt/api/organization_filter_options.py:17 msgid "My organizations" msgstr "" @@ -361,14 +365,14 @@ msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:28 #: amt/site/templates/algorithms/new.html.j2:153 -#: amt/site/templates/macros/form_macros.html.j2:168 +#: amt/site/templates/macros/form_macros.html.j2:170 #: amt/site/templates/organizations/members.html.j2:33 msgid "Yes" msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:31 #: amt/site/templates/algorithms/new.html.j2:163 -#: amt/site/templates/macros/form_macros.html.j2:173 +#: amt/site/templates/macros/form_macros.html.j2:175 #: amt/site/templates/organizations/members.html.j2:36 msgid "No" msgstr "" @@ -384,6 +388,7 @@ msgstr "" #: amt/site/templates/algorithms/details_compliance.html.j2:69 #: amt/site/templates/macros/editable.html.j2:28 #: amt/site/templates/macros/editable.html.j2:33 +#: amt/site/templates/macros/tasks.html.j2:81 msgid "Edit" msgstr "" @@ -393,15 +398,17 @@ msgstr "" #: amt/site/templates/algorithms/details_info.html.j2:20 #: amt/site/templates/algorithms/details_info.html.j2:48 -#: amt/site/templates/macros/tasks.html.j2:32 +#: amt/site/templates/macros/tasks.html.j2:33 msgid "Done" msgstr "" #: amt/site/templates/algorithms/details_info.html.j2:44 +#: amt/site/templates/macros/tasks.html.j2:12 msgid "To do" msgstr "" #: amt/site/templates/algorithms/details_info.html.j2:46 +#: amt/site/templates/macros/tasks.html.j2:19 msgid "In progress" msgstr "" @@ -609,27 +616,27 @@ msgstr "" msgid "Algorithmic Management Toolkit (AMT)" msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:58 +#: amt/site/templates/macros/form_macros.html.j2:60 msgid "Are you sure you want to remove " msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:58 +#: amt/site/templates/macros/form_macros.html.j2:60 msgid " from this organization? " msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:61 +#: amt/site/templates/macros/form_macros.html.j2:63 msgid "Delete" msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:62 +#: amt/site/templates/macros/form_macros.html.j2:64 msgid "Delete member" msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:150 +#: amt/site/templates/macros/form_macros.html.j2:152 msgid "Delete file" msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:157 +#: amt/site/templates/macros/form_macros.html.j2:159 msgid "Are you sure you want to delete" msgstr "" @@ -637,19 +644,15 @@ msgstr "" msgid " ago" msgstr "" -#: amt/site/templates/macros/tasks.html.j2:11 -msgid "Todo" -msgstr "" - -#: amt/site/templates/macros/tasks.html.j2:18 -msgid "Doing" +#: amt/site/templates/macros/tasks.html.j2:26 +msgid "In review" msgstr "" -#: amt/site/templates/macros/tasks.html.j2:25 -msgid "Reviewing" +#: amt/site/templates/macros/tasks.html.j2:40 +msgid "Not implemented" msgstr "" -#: amt/site/templates/macros/tasks.html.j2:35 +#: amt/site/templates/macros/tasks.html.j2:43 msgid "Unknown" msgstr "" diff --git a/amt/locale/en_US/LC_MESSAGES/messages.mo b/amt/locale/en_US/LC_MESSAGES/messages.mo index 22706da26b9703f5192b5571d8e66a8c8bb036c7..738f59d744c7bbb468cf5ea758aea138d08b66f8 100644 GIT binary patch delta 16 Ycmcc1ewTg28OF(97{xX}V*Jer074%Jb^rhX delta 32 ocmcc1ewTg28Af3v10z#i14CUS0|i4vDDE$YNjYUk>%3m%h*hcE4%S>5m1{;AKjV; zYU#+vTsm7?spXmvwiMH_(m6^uUvjz3nF*HUn5Qn*_iA9)-WyrhcRd>9}9p8YOzteRuM$>P{ARfkPcp0@} zI*Sg#ER4s=n24nT%Nbb6fF@RAZ>)AFHsVbB7W(lNDlwDibLw#uQ=>Ds`1O7Hd%(et=r|Bh(2G zp{}446>t}7-rtyu4={*UHoNMDQqF6aIZ9Ek9 zd<^QniKr8nyDmkozYO*M8r1w2jAnn+ss^^9QoI8R-t0yN(1H5APP_hu+W00`;{&8j z%u>>)qFROHa1(0XPMnP=@p%jka`u^vmWpT*ji+%lD#AmkyXwSD4^@u}sC%%JI?oWs z>1SdamY_273l`&J)Rj$1a{`->{F&GIM*+TtT7P>Q`B#Ly7|>nqK^4oF?u4(Az0FBv zZ}TfY*#K2^Q`l9tP>KrZ1-HKpbp^}a{#u+t|4me;y0DysT}>zd`fMf!os^cMCX}NW zBd!go0G6XNv=z6pzz%GoKbGA~@fhj=K0X~~G#Mvg5h{QtR3%3K$tQe2NZzmIr2>! z`aU0`CdOtv0R>Qz55fgF95rtZvbWiYv1sF1Jc=sfJJ<`Oo^b+=!x;J*s9MNEUD*;$ zR>~u4;5y{D#k8VQwIB5zpK$y4uzxp6&Su(C8M=ac z?;2VZtQq*Mv*0A;%QCZ2)meqqiP`9me~voY8Pq}-QGs8^1iXcM{tqfMW~7r*A1c7U zt^+Zie#S^jP7&rXkcb7SyPb|&coAx&YE)6xqcXG>wP2e&z7v)D0~m#!xE{Yl)l^xo zbN69Pq`w;ZGaGZszkW)O&{ITRsQx7k;tkZve3ZNoQ&9m2F$;4r31377RDmkmD%5%# zQ1e?+@9%WSKS2f3ZqZQ0C$Kl3M4k8ys#?!u5?)7T<{s*K!YHRkl5s8l5vZ?YFKXU# zEWrz?`GZFD>xdcn64s%v%KDZDb4(YG!AGbQ=h4xLCgC0|Ms08d`SmpaViu-R`Uh|t zs>tr*92`vLYJN2)<2uxPt(bxbkpo-i6pa7_XHh4;hAN)Bs0bgTQtHij?miQX=ubdp ztO>pN1`b2Z9p8;w?^9F;j^ZrrL}fOfPhgX0GKo`D5&l$CX6=4_X*a_W%F@ delta 3017 zcmXZdeUQyn9LMo<@7*WvUAKMkP!`wP#kQ8cyDr(alhNu=3SJ2cY!<9B9u?(3ZMJNKOL`JV6ZZsW}B zwaYI#Q;ab&L1RW?4QAm!EW?W!hXwZ;(*uieFqUHon~*7{1(Wa?w!^a+z$=)HH&N@v zF)0BPF%~-|8OOAzk;Z@~_Q6hAfPBY1YsV|>_{*sI_0}~QLw_@da4U|$qo@r7EZPNA z(2xBv5g)Ss;$+92IEI0E9!#(g%5fz9>DU4HqB3(B75NR+#(!Zp-a-YOnc|KQLIwIT zYJM^1;!~*g<|8qhddCiI#EuN?L`D1sYT>h}j3lHIHTFQIunhCD92H0-YT-@HmgDPDvGZS|&+zA_a=owzG%JkR!|D&XHySL=Pyt%U^Cm5sn8 zrGAVWSc&|$nAxb5HK5+-X4^l5gXp)S0_vOX_9LhbYEYS(g??O!#BS~xU zhV9XpL;e+Uha5LmX{d$6sEtOT?)V8*X39_t&a~sRQ7NxOA2wkfZa~#k2LJLBu?YDy zqxsM;=mJzAZ}%tv8d%Rj2zOurPoPe41{LWg%)~!X0d*YU29k*?)@;;*C8+u1QQxny z<29&2=Gy0VsDM{EG*onrs1t8MRqJL9;>V~W`U>^@Csb`*z=ha`dOPOky7LuBWBIF35`f2b7u8EnQR)CT*IUrloYm5J-P3zI2*71<#i zjlZGh7f`uL_zdcM6{v$OLLGRut7A6ONM>LQ25~>Ch`vS@)hSepe?-;5EgXVL5qF^y z^wJ-T8TgzXe+9MPYp6`D!Y6SpD#Jfxz2{N>Ez;=v?#XWP eMi=)V=4n5?s None: + op.add_column("task", sa.Column("type", sa.String(), nullable=True)) + op.add_column("task", sa.Column("type_id", sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("task", "type_id") + op.drop_column("task", "type") diff --git a/amt/models/task.py b/amt/models/task.py index c5086afd..a2aa7945 100644 --- a/amt/models/task.py +++ b/amt/models/task.py @@ -19,4 +19,5 @@ class Task(Base): ## which is needed for alembic. This results in changing the migration file ## manually to give the restrain a name. algorithm_id: Mapped[int | None] = mapped_column(ForeignKey("algorithm.id")) - # todo(robbert) Tasks probably are grouped (and sub-grouped), so we probably need a reference to a group_id + type: Mapped[str | None] + type_id: Mapped[str | None] diff --git a/amt/models/user.py b/amt/models/user.py index ae3c53ec..65040ac7 100644 --- a/amt/models/user.py +++ b/amt/models/user.py @@ -15,6 +15,7 @@ class User(Base): email_hash: Mapped[str] = mapped_column(default=None) name_encoded: Mapped[str] = mapped_column(default=None) email: Mapped[str] = mapped_column(default=None) + # TODO: most fields below are often not required or needed and should be 'lazy loaded' organizations: Mapped[list["Organization"]] = relationship( "Organization", secondary="users_and_organizations", back_populates="users", lazy="selectin" ) diff --git a/amt/repositories/tasks.py b/amt/repositories/tasks.py index 22058e4f..e4334fc6 100644 --- a/amt/repositories/tasks.py +++ b/amt/repositories/tasks.py @@ -3,13 +3,15 @@ from typing import Annotated from fastapi import Depends -from sqlalchemy import and_, select +from sqlalchemy import and_, 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.schema.measure import MeasureTask logger = logging.getLogger(__name__) @@ -108,3 +110,39 @@ async def find_by_id(self, task_id: int) -> Task: except NoResultFound as e: 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), + ) + for idx, task in enumerate(tasks) + ] + await self.save_all(insert_list) + + 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) + if task_type: + statement = statement.where(Task.type == task_type) + statement = statement.order_by(Task.sort_order) + try: + return (await self.session.execute(statement)).scalars().all() + except NoResultFound: + logger.exception("No tasks found for algorithm " + str(algorithm_id) + " of type " + str(task_type)) + return [] + + async def update_tasks_status(self, algorithm_id: int, task_type: TaskType, type_id: str, status: Status) -> None: + statement = ( + update(Task) + .where(Task.algorithm_id == algorithm_id) + .where(Task.type == task_type) + .where(Task.type_id == type_id) + .values(status_id=status) + ) + await self.session.execute(statement) diff --git a/amt/schema/measure.py b/amt/schema/measure.py index 7ff8024d..7dc46c1e 100644 --- a/amt/schema/measure.py +++ b/amt/schema/measure.py @@ -15,22 +15,23 @@ class Person(BaseModel): class MeasureTask(MeasureBase): state: str = Field(default="") value: str = Field(default="") - links: list[str] = Field(default=[]) - files: list[str] = Field(default=[]) + links: list[str] = Field(default_factory=list) + files: list[str] = Field(default_factory=list) version: str - accountable_persons: list[Person] | None = Field(default=[]) - reviewer_persons: list[Person] | None = Field(default=[]) - responsible_persons: list[Person] | None = Field(default=[]) + accountable_persons: list[Person] | None = Field(default_factory=list) + reviewer_persons: list[Person] | None = Field(default_factory=list) + responsible_persons: list[Person] | None = Field(default_factory=list) + lifecycle: list[str] = Field(default_factory=list) def update( self, - state: str | None, - value: str | None, - links: list[str] | None, - new_files: list[str] | None, - responsible_persons: list[Person] | None, - reviewer_persons: list[Person] | None, - accountable_persons: list[Person] | None, + state: str | None = None, + value: str | None = None, + links: list[str] | None = None, + new_files: list[str] | None = None, + responsible_persons: list[Person] | None = None, + reviewer_persons: list[Person] | None = None, + accountable_persons: list[Person] | None = None, ) -> None: if state: self.state = state @@ -43,17 +44,23 @@ def update( if new_files: self.files.extend(new_files) - self.responsible_persons = responsible_persons - self.reviewer_persons = reviewer_persons - self.accountable_persons = accountable_persons + if responsible_persons is not None: + self.responsible_persons = responsible_persons + + if reviewer_persons is not None: + self.reviewer_persons = reviewer_persons + + if accountable_persons is not None: + self.accountable_persons = accountable_persons class Measure(MeasureBase): name: str schema_version: str description: str - links: list[str] = Field(default=[]) + links: list[str] = Field(default_factory=list) url: str + lifecycle: list[str] = Field(default_factory=list) class ExtendedMeasureTask(MeasureTask): diff --git a/amt/schema/measure_display.py b/amt/schema/measure_display.py new file mode 100644 index 00000000..b6daa4a6 --- /dev/null +++ b/amt/schema/measure_display.py @@ -0,0 +1,13 @@ +from pydantic import Field + +from amt.schema.measure import ExtendedMeasureTask +from amt.schema.user import User + + +class DisplayMeasureTask(ExtendedMeasureTask): + """ + Class used to display a measure task, so it includes resolved fields that should + not be stored! + """ + + users: list[User] = Field(default_factory=list) diff --git a/amt/schema/task.py b/amt/schema/task.py index b497ca65..a3432bf3 100644 --- a/amt/schema/task.py +++ b/amt/schema/task.py @@ -1,9 +1,47 @@ +from uuid import UUID + from pydantic import BaseModel from pydantic import Field as PydanticField +from amt.models import Task +from amt.schema.measure_display import DisplayMeasureTask + class MovedTask(BaseModel): - id: int | None = PydanticField(None, alias="taskId", strict=False) - status_id: int | None = PydanticField(None, alias="statusId", strict=False) + id: int = PydanticField(alias="taskId", strict=False) + status_id: int = PydanticField(alias="statusId", strict=False) previous_sibling_id: int | None = PydanticField(None, alias="previousSiblingId", strict=False) next_sibling_id: int | None = PydanticField(None, alias="nextSiblingId", strict=False) + + +type DisplayTaskType = DisplayTask + + +class DisplayTask(BaseModel): + id: int | None = None + title: str | None = None + description: str | None = None + sort_order: float | None = None + status_id: int | None = None + user_id: UUID | None = None + algorithm_id: int | None = None + type: str | None = None + type_id: str | None = None + type_object: DisplayMeasureTask | None = None + + @staticmethod + def create_from_model(task: Task, type_object: DisplayMeasureTask | None = None) -> DisplayTaskType: + display_task = DisplayTask() + display_task.id = task.id + display_task.title = task.title + display_task.description = task.description + display_task.sort_order = task.sort_order + display_task.status_id = task.status_id + display_task.user_id = task.user_id + display_task.algorithm_id = task.algorithm_id + display_task.type = task.type + display_task.type_id = task.type_id + if type_object is not None: + display_task.type_object = type_object + display_task.title = type_object.name + return display_task diff --git a/amt/schema/user.py b/amt/schema/user.py new file mode 100644 index 00000000..6619e9a4 --- /dev/null +++ b/amt/schema/user.py @@ -0,0 +1,26 @@ +from uuid import UUID + +from amt.models import User as UserModel +from amt.schema.shared import BaseModel + +type UserSchema = User + + +class User(BaseModel): + id: UUID | None = None + name: str | None = None + email_hash: str | None = None + name_encoded: str | None = None + email: str | None = None + + @staticmethod + def create_from_model(user_model: UserModel | None) -> UserSchema | None: + if user_model is None: + return None + user_schema = User() + user_schema.id = user_model.id + user_schema.name = user_model.name + user_schema.email_hash = user_model.email_hash + user_schema.name_encoded = user_model.name_encoded + user_schema.email = user_model.email + return user_schema diff --git a/amt/services/algorithms.py b/amt/services/algorithms.py index a2010efc..a224a597 100644 --- a/amt/services/algorithms.py +++ b/amt/services/algorithms.py @@ -12,12 +12,16 @@ from fastapi import Depends from amt.core.exceptions import AMTNotFound +from amt.enums.tasks import TaskType from amt.models import Algorithm, Organization from amt.repositories.algorithms import AlgorithmsRepository from amt.repositories.organizations import OrganizationsRepository +from amt.repositories.tasks import TasksRepository from amt.schema.algorithm import AlgorithmNew from amt.schema.instrument import InstrumentBase +from amt.schema.measure import MeasureTask from amt.schema.system_card import AiActProfile, Owner, SystemCard +from amt.services.instruments_and_requirements_state import get_first_lifecycle_idx from amt.services.task_registry import get_requirements_and_measures logger = logging.getLogger(__name__) @@ -30,9 +34,11 @@ def __init__( self, repository: Annotated[AlgorithmsRepository, Depends(AlgorithmsRepository)], organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], + tasks_repository: Annotated[TasksRepository, Depends(TasksRepository)], ) -> None: self.repository = repository self.organizations_repository = organizations_repository + self.tasks_repository = tasks_repository async def get(self, algorithm_id: int) -> Algorithm: algorithm = await self.repository.find_by_id(algorithm_id) @@ -111,6 +117,13 @@ async def create(self, algorithm_new: AlgorithmNew, user_id: UUID | str) -> Algo algorithm = await self.update(algorithm) + measures_sorted_by_lifecycle: list[MeasureTask] = sorted( # pyright: ignore[reportUnknownVariableType, reportCallIssue] + measures, + key=lambda measure: get_first_lifecycle_idx(measure.lifecycle), # pyright: ignore[reportArgumentType] + ) + + await self.tasks_repository.add_tasks(algorithm.id, TaskType.MEASURE, measures_sorted_by_lifecycle) # pyright: ignore[reportUnknownArgumentType] + return algorithm async def paginate( diff --git a/amt/services/instruments_and_requirements_state.py b/amt/services/instruments_and_requirements_state.py index e34161e1..9359e93d 100644 --- a/amt/services/instruments_and_requirements_state.py +++ b/amt/services/instruments_and_requirements_state.py @@ -13,16 +13,16 @@ logger = logging.getLogger(__name__) all_lifecycles = ( - "Geen", - "Probleemanalyse", - "Ontwerp", - "Monitoren", - "Beheer", - "Ontwikkelen", - "Dataverkenning en -preparatie", - "Verificatie", - "Validatie", - "Implementatie", + "geen", + "organisatieverantwoordelijkheden", + "probleemanalyse", + "ontwerp", + "dataverkenning en datapreparatie", + "ontwikkelen", + "verificatie en validatie", + "implementatie", + "monitoring en beheer", + "uitfaseren", ) diff --git a/amt/services/organizations.py b/amt/services/organizations.py index 98c70445..b53bf3f6 100644 --- a/amt/services/organizations.py +++ b/amt/services/organizations.py @@ -28,9 +28,9 @@ async def save(self, name: str, slug: str, user_ids: list[str], created_by_user_ organization = Organization() organization.name = name organization.slug = slug - users: list[User | None] = [await self.users_service.get(user_id) for user_id in user_ids] + users: list[User | None] = [await self.users_service.find_by_id(user_id) for user_id in user_ids] organization.users = users - created_by_user = (await self.users_service.get(created_by_user_id)) if created_by_user_id else None + created_by_user = (await self.users_service.find_by_id(created_by_user_id)) if created_by_user_id else None organization.created_by = created_by_user return await self.organizations_repository.save(organization) diff --git a/amt/services/task_registry.py b/amt/services/task_registry.py index 102ba4be..e629ac07 100644 --- a/amt/services/task_registry.py +++ b/amt/services/task_registry.py @@ -67,7 +67,12 @@ async def get_requirements_and_measures( if measure_urn not in measure_urns: measure = await measures_service.fetch_measures(measure_urn) applicable_measures.append( - MeasureTask(urn=measure_urn, state="to do", version=measure[0].schema_version) + MeasureTask( + urn=measure_urn, + state="to do", + version=measure[0].schema_version, + lifecycle=measure[0].lifecycle, + ) ) measure_urns.add(measure_urn) diff --git a/amt/services/tasks.py b/amt/services/tasks.py index 15b12fb6..486772b6 100644 --- a/amt/services/tasks.py +++ b/amt/services/tasks.py @@ -4,7 +4,7 @@ from fastapi import Depends -from amt.enums.status import Status +from amt.enums.tasks import Status, TaskType from amt.models.algorithm import Algorithm from amt.models.task import Task from amt.models.user import User @@ -29,14 +29,12 @@ async def get_tasks(self, status_id: int) -> Sequence[Task]: task = await self.repository.find_by_status_id(status_id) return task - async def get_tasks_for_algorithm(self, algorithm_id: int, status_id: int) -> Sequence[Task]: - tasks = await self.repository.find_by_algorithm_id_and_status_id(algorithm_id, status_id) - return tasks + async def get_tasks_for_algorithm(self, algorithm_id: int, task_type: TaskType | None) -> Sequence[Task]: + return await self.repository.find_by_algorithm_id_and_type(algorithm_id, task_type) async def assign_task(self, task: Task, user: User) -> Task: task.user_id = user.id - task = await self.repository.save(task) - return task + return await self.repository.save(task) async def create_instrument_tasks(self, tasks: Sequence[InstrumentTask], algorithm: Algorithm) -> None: # TODO: (Christopher) At this moment a status has to be retrieved from the DB. In the future @@ -76,11 +74,6 @@ async def move_task( raise ValueError("task_id or status_id must not be None") task = await self.repository.find_by_id(task_id) - if status_id == Status.DONE: - # TODO: This seems off, tasks should be written to the correct location in the system card. - self.system_card.name = task.title - self.storage_writer.write(self.system_card.model_dump()) - # update the status for the task (this may not be needed if the status has not changed) task.status_id = status_id @@ -99,5 +92,10 @@ async def move_task( else: task.sort_order = 10 - task = await self.repository.save(task) - return task + return await self.repository.save(task) + + async def update_tasks_status(self, algorithm_id: int, task_type: TaskType, type_id: str, status: Status) -> None: + await self.repository.update_tasks_status(algorithm_id, task_type, type_id, status) + + 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) diff --git a/amt/services/users.py b/amt/services/users.py index dd1a7411..086a64aa 100644 --- a/amt/services/users.py +++ b/amt/services/users.py @@ -16,13 +16,14 @@ def __init__( repository: Annotated[UsersRepository, Depends(UsersRepository)], ) -> None: self.repository = repository - - async def get(self, id: str | UUID) -> User | None: - id = UUID(id) if isinstance(id, str) else id - return await self.repository.find_by_id(id) + self.cache: dict[UUID, User | None] = {} async def create_or_update(self, user: User) -> User: return await self.repository.upsert(user) async def find_by_id(self, id: UUID | str) -> User | None: - return await self.repository.find_by_id(id) + id = UUID(id) if isinstance(id, str) else id + if id not in self.cache: + new_user = await self.repository.find_by_id(id) + self.cache[id] = new_user + return self.cache[id] diff --git a/amt/site/static/scss/layout.scss b/amt/site/static/scss/layout.scss index b011c6e2..a31fad4a 100644 --- a/amt/site/static/scss/layout.scss +++ b/amt/site/static/scss/layout.scss @@ -63,8 +63,8 @@ main { min-height: 30em; background-color: var(--rvo-color-lichtblauw-150); border-radius: 10px; - overflow: hidden; height: calc(100% - 30px); /* todo (robbert): this is a display hack */ + padding: 0.01px; /* this is an avoid margin collapse hack */ } .progress-card-container { diff --git a/amt/site/templates/algorithms/details_base.html.j2 b/amt/site/templates/algorithms/details_base.html.j2 index 223255f1..7f381f08 100644 --- a/amt/site/templates/algorithms/details_base.html.j2 +++ b/amt/site/templates/algorithms/details_base.html.j2 @@ -32,7 +32,7 @@

-
+

{{ algorithm.name }}

diff --git a/amt/site/templates/algorithms/details_info.html.j2 b/amt/site/templates/algorithms/details_info.html.j2 index 471092bb..0556e1f7 100644 --- a/amt/site/templates/algorithms/details_info.html.j2 +++ b/amt/site/templates/algorithms/details_info.html.j2 @@ -28,7 +28,7 @@ style="display: flex; justify-content: space-between">
- {% if requirement.state == "to do" %} + {% if requirement.state == "to do" or requirement.state == "" %}
{% elif requirement.state == "in progress" %}
diff --git a/amt/site/templates/algorithms/index.html.j2 b/amt/site/templates/algorithms/index.html.j2 index b13d6942..d95ac7e1 100644 --- a/amt/site/templates/algorithms/index.html.j2 +++ b/amt/site/templates/algorithms/index.html.j2 @@ -1,6 +1,6 @@ {% extends 'layouts/base.html.j2' %} {% block content %} -
+
{% include 'parts/algorithm_search.html.j2' %}
{% endblock %} diff --git a/amt/site/templates/algorithms/new.html.j2 b/amt/site/templates/algorithms/new.html.j2 index 3eb60883..d3b0f88a 100644 --- a/amt/site/templates/algorithms/new.html.j2 +++ b/amt/site/templates/algorithms/new.html.j2 @@ -1,7 +1,7 @@ {% import "macros/form_macros.html.j2" as macros with context %} {% extends "layouts/base.html.j2" %} {% block content %} -
+
diff --git a/amt/site/templates/algorithms/tasks.html.j2 b/amt/site/templates/algorithms/tasks.html.j2 index 6e248c05..ca826114 100644 --- a/amt/site/templates/algorithms/tasks.html.j2 +++ b/amt/site/templates/algorithms/tasks.html.j2 @@ -2,9 +2,16 @@ {# disable macro caching, see // https://jinja.palletsprojects.com/en/3.0.x/templates/#import-context-behavior #} {% import 'macros/tasks.html.j2' as render with context %} {% block detail_content %} +
-
+
{% for status in statuses %}{{ render.column(algorithm, status, translations, tasks_by_status) }}{% endfor %}
diff --git a/amt/site/templates/auth/profile.html.j2 b/amt/site/templates/auth/profile.html.j2 index ecd53a31..7d217a2f 100644 --- a/amt/site/templates/auth/profile.html.j2 +++ b/amt/site/templates/auth/profile.html.j2 @@ -1,7 +1,7 @@ {% extends 'layouts/base.html.j2' %} {% block content %} {% set language_mappings = {"nl": "Nederlands", "en": "English"} %} -
+
+

{% trans %}Not logged in{% endtrans %}

diff --git a/amt/site/templates/errors/AMTNotFound_404.html.j2 b/amt/site/templates/errors/AMTNotFound_404.html.j2 index d84d7538..2f4aca32 100644 --- a/amt/site/templates/errors/AMTNotFound_404.html.j2 +++ b/amt/site/templates/errors/AMTNotFound_404.html.j2 @@ -1,6 +1,6 @@ {% extends 'layouts/base.html.j2' %} {% block content %} -

+
{% include 'errors/_404_Exception.html.j2' %}
{% endblock %} diff --git a/amt/site/templates/errors/AMTPermissionDenied_404.html.j2 b/amt/site/templates/errors/AMTPermissionDenied_404.html.j2 index d84d7538..2f4aca32 100644 --- a/amt/site/templates/errors/AMTPermissionDenied_404.html.j2 +++ b/amt/site/templates/errors/AMTPermissionDenied_404.html.j2 @@ -1,6 +1,6 @@ {% extends 'layouts/base.html.j2' %} {% block content %} -
+
{% include 'errors/_404_Exception.html.j2' %}
{% endblock %} diff --git a/amt/site/templates/errors/Exception.html.j2 b/amt/site/templates/errors/Exception.html.j2 index 60e15690..8ade7499 100644 --- a/amt/site/templates/errors/Exception.html.j2 +++ b/amt/site/templates/errors/Exception.html.j2 @@ -1,6 +1,6 @@ {% extends 'layouts/base.html.j2' %} {% block content %} -
+

{% trans %}An error occurred{% endtrans %}

{% include 'errors/_Exception.html.j2' %} diff --git a/amt/site/templates/errors/HTTPException_404.html.j2 b/amt/site/templates/errors/HTTPException_404.html.j2 index d84d7538..2f4aca32 100644 --- a/amt/site/templates/errors/HTTPException_404.html.j2 +++ b/amt/site/templates/errors/HTTPException_404.html.j2 @@ -1,6 +1,6 @@ {% extends 'layouts/base.html.j2' %} {% block content %} -
+
{% include 'errors/_404_Exception.html.j2' %}
{% endblock %} diff --git a/amt/site/templates/errors/RequestValidationError_400.html.j2 b/amt/site/templates/errors/RequestValidationError_400.html.j2 index a03a5721..97bb65ee 100644 --- a/amt/site/templates/errors/RequestValidationError_400.html.j2 +++ b/amt/site/templates/errors/RequestValidationError_400.html.j2 @@ -1,6 +1,6 @@ {% extends 'layouts/base.html.j2' %} {% block content %} -
+

{% trans %}An error occurred{% endtrans %}

{% include 'errors/_RequestValidationError_400.html.j2' %} diff --git a/amt/site/templates/macros/form_macros.html.j2 b/amt/site/templates/macros/form_macros.html.j2 index a04a3d45..01093c36 100644 --- a/amt/site/templates/macros/form_macros.html.j2 +++ b/amt/site/templates/macros/form_macros.html.j2 @@ -1,23 +1,25 @@ {% macro user_avatars(users) %} -
- {# this solution is not very elegant, a limit and count query would be better #} - {% for user in users[0:5] %} - - User icon {{ user.name }} - {{ user.name }} - - {% endfor %} - {% if users|length > 5 %} - - +{{ users|length - 5 }} - - {% endif %} -
+ {% if users|length > 0 %} +
+ {# this solution is not very elegant, a limit and count query would be better #} + {% for user in users[0:5] %} + + User icon {{ user.name }} + {{ user.name }} + + {% endfor %} + {% if users|length > 5 %} + + +{{ users|length - 5 }} + + {% endif %} +
+ {% endif %} {% endmacro %} {% macro form_field(form, field) %} {% if field.type == WebFormFieldType.TEXT %} diff --git a/amt/site/templates/macros/tasks.html.j2 b/amt/site/templates/macros/tasks.html.j2 index a9871fa8..51569d96 100644 --- a/amt/site/templates/macros/tasks.html.j2 +++ b/amt/site/templates/macros/tasks.html.j2 @@ -1,3 +1,4 @@ +{% import "macros/form_macros.html.j2" as macros with context %} {% macro column(algorithm, status, translations, tasks_service) -%} {% elif status == 2 %} {% elif status == 3 %} {% elif status == 4 %}
@@ -31,6 +32,13 @@ aria-label="reviewing-icon"> {% trans %}Done{% endtrans %}
+ {% elif status == 5 %} + {% else %} {% trans %}Unknown{% endtrans %} {% endif %} @@ -51,18 +59,30 @@ class="progress-card-container" data-target-id="card-content-{{ task.id }}" id="card-container-{{ task.id }}" - data-id="{{ task.id }}">{{ render_task_card_content(task) }}
+ data-id="{{ task.id }}">{{ render_task_card_content(algorithm_id, task) }}
{% endmacro %} -{% macro render_task_card_content(task) -%} +{% macro render_task_card_content(algorithm_id, task) -%}
-
{{ task.title | truncate(100) }}
-
{{ task.description }}
- {% if task.user_id %} -
- Assigned to Avatar -
- {% endif %} +
+ {{ task.title | truncate(100) }} + {% if hasattr(task, "type_object.users") and task.type_object.users | length > 0 %} +
{{ macros.user_avatars(task.type_object.users) }}
+ {% endif %} + {% if hasattr(task, "type_object") and task.type == "measure" %} + + {% endif %} +
{% endmacro %} diff --git a/amt/site/templates/organizations/algorithms.html.j2 b/amt/site/templates/organizations/algorithms.html.j2 index 454bc8e9..6a34d000 100644 --- a/amt/site/templates/organizations/algorithms.html.j2 +++ b/amt/site/templates/organizations/algorithms.html.j2 @@ -2,7 +2,7 @@ {% import "macros/tabs.html.j2" as tabs with context %} {% extends "layouts/base.html.j2" %} {% block content %} -
+
{{ tabs.show_tabs(tab_items) }}
{% include 'parts/algorithm_search.html.j2' %}
diff --git a/amt/site/templates/organizations/home.html.j2 b/amt/site/templates/organizations/home.html.j2 index c48e4806..70a8822c 100644 --- a/amt/site/templates/organizations/home.html.j2 +++ b/amt/site/templates/organizations/home.html.j2 @@ -3,7 +3,7 @@ {% import "macros/form_macros.html.j2" as macros with context %} {% import "macros/tabs.html.j2" as tabs with context %} {% block content %} -
+
{{ tabs.show_tabs(tab_items) }}

{% trans %}Info{% endtrans %}

diff --git a/amt/site/templates/organizations/index.html.j2 b/amt/site/templates/organizations/index.html.j2 index ab5b3198..2600a39f 100644 --- a/amt/site/templates/organizations/index.html.j2 +++ b/amt/site/templates/organizations/index.html.j2 @@ -1,6 +1,6 @@ {% extends 'layouts/base.html.j2' %} {% block content %} -
+
{% include 'organizations/parts/overview_results.html.j2' %}
diff --git a/amt/site/templates/organizations/members.html.j2 b/amt/site/templates/organizations/members.html.j2 index 34581fee..477c2673 100644 --- a/amt/site/templates/organizations/members.html.j2 +++ b/amt/site/templates/organizations/members.html.j2 @@ -2,7 +2,7 @@ {% import "macros/tabs.html.j2" as tabs with context %} {% extends "layouts/base.html.j2" %} {% block content %} -
+
{{ tabs.show_tabs(tab_items) }}
{% include 'organizations/parts/members_results.html.j2' %} diff --git a/amt/site/templates/organizations/new.html.j2 b/amt/site/templates/organizations/new.html.j2 index dcb679a1..62157012 100644 --- a/amt/site/templates/organizations/new.html.j2 +++ b/amt/site/templates/organizations/new.html.j2 @@ -1,7 +1,7 @@ {% import "macros/form_macros.html.j2" as macros with context %} {% extends "layouts/base.html.j2" %} {% block content %} -
+
diff --git a/amt/site/templates/pages/assessment_card.html.j2 b/amt/site/templates/pages/assessment_card.html.j2 index 249f04cb..eed3d0e8 100644 --- a/amt/site/templates/pages/assessment_card.html.j2 +++ b/amt/site/templates/pages/assessment_card.html.j2 @@ -1,7 +1,7 @@ {% extends 'layouts/base.html.j2' %} {% import 'macros/cards.html.j2' as render with context %} {% block content %} -
+

Assessment card

{% trans %}Last updated{% endtrans %}: {{ last_edited | time_ago(language) }} {% trans %}ago{% endtrans %} diff --git a/amt/site/templates/pages/index.html.j2 b/amt/site/templates/pages/index.html.j2 index e3128433..e9e8e6ce 100644 --- a/amt/site/templates/pages/index.html.j2 +++ b/amt/site/templates/pages/index.html.j2 @@ -3,7 +3,7 @@ {% trans %}AMT Placeholder informatie pagina's{% endtrans %} {% endblock %} {% block content %} -
+

Pagina's

Op dit pad / in deze folder komen de informatieve pagina's zoals contact, disclaimer etc.

diff --git a/amt/site/templates/pages/landingpage.html.j2 b/amt/site/templates/pages/landingpage.html.j2 index 922ad334..a8967d5b 100644 --- a/amt/site/templates/pages/landingpage.html.j2 +++ b/amt/site/templates/pages/landingpage.html.j2 @@ -1,6 +1,6 @@ {% extends 'layouts/base.html.j2' %} {% block content %} -
+
-

{% trans %}Grip on algorithms with the Algorithm Management Toolkit{% endtrans %}

diff --git a/amt/site/templates/pages/under_construction.html.j2 b/amt/site/templates/pages/under_construction.html.j2 index eed18da9..86238e25 100644 --- a/amt/site/templates/pages/under_construction.html.j2 +++ b/amt/site/templates/pages/under_construction.html.j2 @@ -3,7 +3,7 @@ {% trans %}AMT Placeholder informatie pagina's{% endtrans %} {% endblock %} {% block content %} -

+

{% trans %}Under construction{% endtrans %}

{% trans %}This page is yet to be build.{% endtrans %}

diff --git a/amt/site/templates/parts/header.html.j2 b/amt/site/templates/parts/header.html.j2 index 13f62ae3..1bfa620a 100644 --- a/amt/site/templates/parts/header.html.j2 +++ b/amt/site/templates/parts/header.html.j2 @@ -104,7 +104,7 @@