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: | 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/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/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/forms/measure.py b/amt/api/forms/measure.py index 8d9418f1..2e147c28 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 a73bc70b..4a8dac75 100644 --- a/amt/api/routes/algorithm.py +++ b/amt/api/routes/algorithm.py @@ -1,15 +1,16 @@ -import asyncio import datetime import logging +import urllib.parse from collections import defaultdict from collections.abc import Sequence -from typing import Annotated, Any +from typing import Annotated, Any, cast 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 from amt.api.deps import templates from amt.api.editable import ( EditModes, @@ -18,7 +19,8 @@ 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.lifecycles import Lifecycles, get_localized_lifecycles from amt.api.navigation import ( BaseNavigationItem, Navigation, @@ -27,25 +29,28 @@ 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.exceptions import AMTError, AMTNotFound, AMTRepositoryError +from amt.core.authorization import AuthorizationResource, AuthorizationVerb, get_user +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, life_cycle_mapper, 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.localized_value_item import LocalizedValueItem 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 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 +from amt.services.users import UsersService router = APIRouter() logger = logging.getLogger(__name__) @@ -62,7 +67,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] ) @@ -122,26 +126,84 @@ 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)], + search: str = Query(""), ) -> HTMLResponse: + filters, drop_filters, localized_filters, _ = await get_filters_and_sort_by(request, users_service) + if search: + filters["search"] = search + 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) + ) + + def filters_match(display_task: DisplayTask) -> bool: + if len(filters) == 0: + return True + return all( + ( + ( + "assignee" not in filters + or any( + str(user.id) == filters.get("assignee") + for user in display_task.type_object.users # pyright: ignore[reportOptionalMemberAccess] + if "assignee" in filters and display_task.type_object is not None + ) + ), + ( + "lifecycle" not in filters + or any( + lifecycle == life_cycle_mapper[Lifecycles(filters.get("lifecycle"))] + for lifecycle in display_task.type_object.lifecycle # pyright: ignore[reportOptionalMemberAccess] + if "lifecycle" in filters and display_task.type_object is not None + ) + ), + ( + "search" not in filters + or ( + "search" in filters + and display_task.type_object is not None + and ( + cast(str, filters["search"]).casefold() in display_task.type_object.name.casefold() # pyright: ignore[reportOptionalMemberAccess] + or cast(str, filters["search"]).casefold() + in display_task.type_object.description.casefold() # pyright: ignore[reportOptionalMemberAccess] + ) + ) + ), + ) + ) + + tasks_by_status: dict[Status, list[DisplayTask]] = {} + for status in Status: + tasks_by_status[status] = [] + all_tasks = [ + # 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 + ] + tasks_by_status[status] = [task for task in all_tasks if filters_match(task)] + # 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( [ @@ -152,38 +214,87 @@ async def get_tasks( request, ) + members = await users_service.find_all(filters={"organization-id": str(algorithm.organization.id)}) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] + context = { - "instrument_state": instrument_state, - "requirements_state": requirements_state, "tasks_by_status": tasks_by_status, "statuses": Status, "algorithm": algorithm, "algorithm_id": algorithm.id, "breadcrumbs": breadcrumbs, "tab_items": tab_items, + "base_href": f"/algorithm/{algorithm_id}/details/tasks", + "search": search, + "lifecycles": get_localized_lifecycles(request), + "assignees": [LocalizedValueItem(value=str(member.id), display_value=member.name) for member in members], + "filters": localized_filters, } - return templates.TemplateResponse(request, "algorithms/tasks.html.j2", context) + headers = {"HX-Replace-Url": request.url.path + "?" + request.url.query} + if request.state.htmx and drop_filters: + return templates.TemplateResponse(request, "parts/tasks_search.html.j2", context, headers=headers) + elif request.state.htmx: + return templates.TemplateResponse(request, "parts/tasks_board.html.j2", context, headers=headers) + else: + return templates.TemplateResponse(request, "algorithms/tasks.html.j2", context, headers=headers) -@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, + lifecycle=resolved_measure.lifecycle, + ) + 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, @@ -191,7 +302,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) @@ -215,6 +347,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 +369,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 +405,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 +440,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 +492,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,20 +535,19 @@ 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, organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], - users_repository: Annotated[UsersRepository, Depends(UsersRepository)], + users_service: Annotated[UsersService, Depends(UsersService)], 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) 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, _, _, _ = await get_filters_and_sort_by(request, users_service) organization = await organizations_repository.find_by_id(algorithm.organization_id) filters["organization-id"] = str(organization.id) @@ -453,7 +589,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 = 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_service) context = { "instrument_state": instrument_state, @@ -469,30 +605,18 @@ 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], -) -> 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], -) -> dict[str, list[Any]]: - measure_task_functions: dict[str, list[Any]] = defaultdict(list) + users_service: Annotated[UsersService, Depends(UsersService)], +) -> 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"] + person_types = ["responsible_persons", "reviewer_persons", "accountable_persons"] 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_service.find_by_id(person_list[0].uuid) if member: measure_task_functions[measure_task.urn].append(member) return measure_task_functions @@ -518,8 +642,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 @@ -533,6 +655,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,18 +666,19 @@ 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)], - users_repository: Annotated[UsersRepository, Depends(UsersRepository)], + users_service: Annotated[UsersService, Depends(UsersService)], 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) + filters, _, _, sort_by = await get_filters_and_sort_by(request, users_service) algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) measure = await measures_service.fetch_measures([measure_urn]) measure_task = get_measure_task_or_error(algorithm.system_card, measure_urn) @@ -566,7 +690,7 @@ async def get_measure( organization = await organizations_repository.find_by_id(algorithm.organization_id) filters["organization-id"] = str(organization.id) - members = await users_repository.find_all(search=search, sort=sort_by, filters=filters) + members = await users_service.find_all(search=search, sort=sort_by, filters=filters) measure_accountable = measure_task.accountable_persons[0].name if measure_task.accountable_persons else "" # pyright: ignore [reportOptionalMemberAccess] measure_reviewer = measure_task.reviewer_persons[0].name if measure_task.reviewer_persons else "" # pyright: ignore [reportOptionalMemberAccess] @@ -587,7 +711,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) @@ -596,32 +725,32 @@ async def get_users_from_function_name( measure_accountable: Annotated[str | None, Form()], measure_reviewer: Annotated[str | None, Form()], measure_responsible: Annotated[str | None, Form()], - users_repository: Annotated[UsersRepository, Depends(UsersRepository)], + users_service: Annotated[UsersService, Depends(UsersService)], 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: - accountable_member = await users_repository.find_all(search=measure_accountable, sort=sort_by, filters=filters) + accountable_member = await users_service.find_all(search=measure_accountable, sort=sort_by, filters=filters) accountable_persons = [Person(name=accountable_member[0].name, uuid=str(accountable_member[0].id))] # pyright: ignore [reportOptionalMemberAccess] if measure_reviewer: - reviewer_member = await users_repository.find_all(search=measure_reviewer, sort=sort_by, filters=filters) + reviewer_member = await users_service.find_all(search=measure_reviewer, sort=sort_by, filters=filters) reviewer_persons = [Person(name=reviewer_member[0].name, uuid=str(reviewer_member[0].id))] # pyright: ignore [reportOptionalMemberAccess] if measure_responsible: - responsible_member = await users_repository.find_all(search=measure_responsible, sort=sort_by, filters=filters) + responsible_member = await users_service.find_all(search=measure_responsible, sort=sort_by, filters=filters) responsible_persons = [Person(name=responsible_member[0].name, uuid=str(responsible_member[0].id))] # pyright: ignore [reportOptionalMemberAccess] return accountable_persons, reviewer_persons, responsible_persons @router.post("/{algorithm_id}/measure/{measure_urn}") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) 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)], + users_service: Annotated[UsersService, Depends(UsersService)], algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], - requirements_service: Annotated[RequirementsService, Depends(create_requirements_service)], + 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, @@ -630,12 +759,12 @@ 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) + filters, _, _, sort_by = await get_filters_and_sort_by(request, users_service) 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 @@ -644,41 +773,91 @@ async def update_measure_value( else None ) accountable_persons, reviewer_persons, responsible_persons = await get_users_from_function_name( - measure_accountable, measure_reviewer, measure_responsible, users_repository, sort_by, filters + measure_accountable, measure_reviewer, measure_responsible, users_service, sort_by, filters ) 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(":","_")}" + ) + referer = urllib.parse.urlparse(request.headers.get("referer", "")) + + if referer.path.endswith("/details/tasks"): + encoded_url = urllib.parse.quote_plus(referer.path + "?" + referer.query) + 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: - 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 + return algorithm - 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") + +@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. + """ + + if not to.startswith("/"): + raise AMTPermissionDenied + + return RedirectResponse( # NOSONAR + status_code=302, # NOSONAR + url=to, # NOSONAR + ) # NOSONAR @router.get("/{algorithm_id}/members") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def get_algorithm_members( request: Request, algorithm_id: int, @@ -712,6 +891,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 +945,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,20 +1000,19 @@ 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: 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) - try: - return FileResponse(filename, filename=filename) - except AMTRepositoryError as e: - raise AMTNotFound from e + yaml.dump(algorithm.system_card.model_dump(), outfile, sort_keys=False) + return FileResponse(filename, filename=filename, media_type="application/yaml; charset=utf-8") @router.get("/{algorithm_id}/file/{ulid}") +@permission({AuthorizationResource.ALGORITHM: [AuthorizationVerb.READ]}) async def get_file( request: Request, algorithm_id: int, @@ -854,6 +1034,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..5d6b8018 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,30 +15,39 @@ 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.exceptions import AMTAuthorizationError +from amt.core.authorization import AuthorizationResource, AuthorizationVerb, get_user from amt.core.internationalization import get_current_translation from amt.models import Algorithm 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 +from amt.services.users import UsersService router = APIRouter() logger = logging.getLogger(__name__) @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)], + users_service: Annotated[UsersService, Depends(UsersService)], skip: int = Query(0, ge=0), limit: int = Query(5000, ge=1), # todo: fix infinite scroll search: str = Query(""), display_type: str = Query(""), ) -> HTMLResponse: - filters, drop_filters, localized_filters, sort_by = get_filters_and_sort_by(request) + filters, drop_filters, localized_filters, sort_by = await get_filters_and_sort_by(request, users_service) + + 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,9 +125,9 @@ 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)], organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], organization_id: int = Query(None), ) -> HTMLResponse: @@ -139,10 +150,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, @@ -156,6 +165,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, @@ -163,8 +173,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/organizations.py b/amt/api/routes/organizations.py index 7ce126ee..f67436af 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 @@ -38,6 +39,7 @@ from amt.schema.organization import OrganizationNew, OrganizationUsers from amt.services.algorithms import AlgorithmsService from amt.services.organizations import OrganizationsService +from amt.services.users import UsersService router = APIRouter() @@ -90,24 +92,33 @@ async def get_new( @router.get("/") +@permission({AuthorizationResource.ORGANIZATIONS: [AuthorizationVerb.LIST]}) async def root( request: Request, organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], + users_service: Annotated[UsersService, Depends(UsersService)], skip: int = Query(0, ge=0), limit: int = Query(5000, ge=1), # todo: fix infinite scroll search: str = Query(""), ) -> HTMLResponse: - filters, drop_filters, localized_filters, sort_by = get_filters_and_sort_by(request) + filters, drop_filters, localized_filters, sort_by = await get_filters_and_sort_by(request, users_service) user = get_user(request) 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 ) + # 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, @@ -119,7 +130,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: @@ -132,6 +143,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 +162,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 +178,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 +190,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 +227,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 +236,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 +262,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 +271,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 +315,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,19 +328,21 @@ 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, + users_service: Annotated[UsersService, Depends(UsersService)], + 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) - filters, drop_filters, localized_filters, sort_by = get_filters_and_sort_by(request) + organization = await get_organization_or_error(organizations_service, request, organization_slug) + filters, drop_filters, localized_filters, sort_by = await get_filters_and_sort_by(request, users_service) filters["organization-id"] = str(organization.id) algorithms, amount_algorithm_systems = await get_algorithms( @@ -332,7 +350,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 +377,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 +389,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)], + users_service: Annotated[UsersService, Depends(UsersService)], 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) - filters, drop_filters, localized_filters, sort_by = get_filters_and_sort_by(request) - tab_items = get_organization_tabs(request, organization_slug=slug) + organization = await get_organization_or_error(organizations_service, request, organization_slug) + filters, drop_filters, localized_filters, sort_by = await get_filters_and_sort_by(request, users_service) + tab_items = get_organization_tabs(request, organization_slug=organization_slug) breadcrumbs = resolve_base_navigation_items( [ Navigation.ORGANIZATIONS_ROOT, @@ -433,7 +455,7 @@ async def get_members( ) filters["organization-id"] = str(organization.id) - members = await users_repository.find_all(search=search, sort=sort_by, filters=filters) + members = await users_service.find_all(search=search, sort=sort_by, filters=filters) context: dict[str, Any] = { "slug": organization.slug, diff --git a/amt/api/routes/shared.py b/amt/api/routes/shared.py index 576225c8..3dcbf0b7 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 @@ -10,11 +10,12 @@ from amt.api.risk_group import RiskGroup, get_localized_risk_group from amt.schema.localized_value_item import LocalizedValueItem from amt.schema.shared import IterMixin +from amt.services.users import UsersService -def get_filters_and_sort_by( - request: Request, -) -> tuple[dict[str, str], list[str], dict[str, LocalizedValueItem], dict[str, str]]: +async def get_filters_and_sort_by( + request: Request, users_service: UsersService +) -> 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,10 +30,18 @@ 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} - localized_filters: dict[str, LocalizedValueItem] = { - k: get_localized_value(k, v, request) for k, v in filters.items() + 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] = {} + if "assignee" in filters: + user_id = filters.get("assignee") + user = await users_service.find_by_id(cast(str, user_id)) + if user is not None: + localized_filters["assignee"] = LocalizedValueItem(display_value=user.name, value=str(user_id)) + localized_filters.update( + {k: get_localized_value(k, cast(str, v), request) for k, v in filters.items() if k != "assignee"} + ) 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 != "" } @@ -109,21 +118,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/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/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/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/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..b770c4a9 --- /dev/null +++ b/amt/enums/tasks.py @@ -0,0 +1,42 @@ +from enum import IntEnum, StrEnum + +from amt.api.forms.measure import MeasureStatusOptions +from amt.api.lifecycles import Lifecycles + + +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) + + +life_cycle_mapper: dict[Lifecycles, str] = { + Lifecycles.ORGANIZATIONAL_RESPONSIBILITIES: "organisatieverantwoordelijkheden", + Lifecycles.PROBLEM_ANALYSIS: "probleemanalyse", + Lifecycles.DESIGN: "ontwerp", + Lifecycles.DATA_EXPLORATION_AND_PREPARATION: "dataverkenning en datapreparatie", + Lifecycles.DEVELOPMENT: "ontwikkelen", + Lifecycles.VERIFICATION_AND_VALIDATION: "verificatie en validatie", + Lifecycles.IMPLEMENTATION: "implementatie", + Lifecycles.MONITORING_AND_MANAGEMENT: "monitoring en beheer", + Lifecycles.PHASING_OUT: "uitfaseren", +} diff --git a/amt/locale/base.pot b/amt/locale/base.pot index 12482b99..aad8ea4c 100644 --- a/amt/locale/base.pot +++ b/amt/locale/base.pot @@ -46,10 +46,11 @@ 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 +#: amt/site/templates/parts/tasks_search.html.j2:41 msgid "Lifecycle" msgstr "" @@ -145,7 +146,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 "" @@ -197,54 +198,81 @@ 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:59 +#: amt/site/templates/algorithms/details_info.html.j2:44 +#: amt/site/templates/macros/tasks.html.j2:12 +msgid "To do" +msgstr "" + +#: amt/api/forms/measure.py:60 +#: amt/site/templates/algorithms/details_info.html.j2:46 +#: amt/site/templates/macros/tasks.html.j2:19 +msgid "In progress" +msgstr "" + +#: amt/api/forms/measure.py:61 amt/site/templates/macros/tasks.html.j2:26 +msgid "In review" +msgstr "" + +#: amt/api/forms/measure.py:62 +#: 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:33 +msgid "Done" +msgstr "" + +#: amt/api/forms/measure.py:63 amt/site/templates/macros/tasks.html.j2:40 +msgid "Not implemented" +msgstr "" + +#: 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 @@ -314,43 +342,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 "" @@ -368,14 +393,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,13 +409,14 @@ 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 +#: amt/site/templates/macros/tasks.html.j2:82 msgid "Edit" msgstr "" @@ -398,38 +424,24 @@ msgstr "" 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/macros/tasks.html.j2:32 -msgid "Done" -msgstr "" - -#: amt/site/templates/algorithms/details_info.html.j2:42 -msgid "To do" -msgstr "" - -#: amt/site/templates/algorithms/details_info.html.j2:44 -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 @@ -439,11 +451,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 "" @@ -512,21 +524,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 "" @@ -573,9 +575,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" @@ -593,27 +628,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 "" @@ -621,19 +656,7 @@ 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" -msgstr "" - -#: amt/site/templates/macros/tasks.html.j2:25 -msgid "Reviewing" -msgstr "" - -#: amt/site/templates/macros/tasks.html.j2:35 +#: amt/site/templates/macros/tasks.html.j2:43 msgid "Unknown" msgstr "" @@ -665,6 +688,7 @@ msgstr "" #: amt/site/templates/organizations/parts/members_results.html.j2:29 #: amt/site/templates/organizations/parts/overview_results.html.j2:26 #: amt/site/templates/parts/algorithm_search.html.j2:25 +#: amt/site/templates/parts/tasks_search.html.j2:18 msgid "Search" msgstr "" @@ -817,6 +841,7 @@ msgid "Find algorithm..." msgstr "" #: amt/site/templates/parts/algorithm_search.html.j2:53 +#: amt/site/templates/parts/tasks_search.html.j2:46 msgid "Select lifecycle" msgstr "" @@ -871,3 +896,23 @@ msgid "" " open manner." msgstr "" +#: amt/site/templates/parts/tasks_board.html.j2:5 +msgid "Results for" +msgstr "" + +#: amt/site/templates/parts/tasks_search.html.j2:9 +msgid "Taken" +msgstr "" + +#: amt/site/templates/parts/tasks_search.html.j2:25 +msgid "Find tasks..." +msgstr "" + +#: amt/site/templates/parts/tasks_search.html.j2:55 +msgid "Assignee" +msgstr "" + +#: amt/site/templates/parts/tasks_search.html.j2:60 +msgid "Select assignee" +msgstr "" + diff --git a/amt/locale/en_US/LC_MESSAGES/messages.mo b/amt/locale/en_US/LC_MESSAGES/messages.mo index 249ef0d7..a93e9e28 100644 Binary files a/amt/locale/en_US/LC_MESSAGES/messages.mo and b/amt/locale/en_US/LC_MESSAGES/messages.mo differ diff --git a/amt/locale/en_US/LC_MESSAGES/messages.po b/amt/locale/en_US/LC_MESSAGES/messages.po index 5cfec7e3..21db9509 100644 --- a/amt/locale/en_US/LC_MESSAGES/messages.po +++ b/amt/locale/en_US/LC_MESSAGES/messages.po @@ -47,10 +47,11 @@ 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 +#: amt/site/templates/parts/tasks_search.html.j2:41 msgid "Lifecycle" msgstr "" @@ -146,7 +147,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 "" @@ -198,54 +199,81 @@ 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:59 +#: amt/site/templates/algorithms/details_info.html.j2:44 +#: amt/site/templates/macros/tasks.html.j2:12 +msgid "To do" +msgstr "" + +#: amt/api/forms/measure.py:60 +#: amt/site/templates/algorithms/details_info.html.j2:46 +#: amt/site/templates/macros/tasks.html.j2:19 +msgid "In progress" +msgstr "" + +#: amt/api/forms/measure.py:61 amt/site/templates/macros/tasks.html.j2:26 +msgid "In review" +msgstr "" + +#: amt/api/forms/measure.py:62 +#: 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:33 +msgid "Done" +msgstr "" + +#: amt/api/forms/measure.py:63 amt/site/templates/macros/tasks.html.j2:40 +msgid "Not implemented" +msgstr "" + +#: 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 @@ -315,43 +343,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 "" @@ -369,14 +394,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 "" @@ -385,13 +410,14 @@ 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 +#: amt/site/templates/macros/tasks.html.j2:82 msgid "Edit" msgstr "" @@ -399,38 +425,24 @@ msgstr "" 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/macros/tasks.html.j2:32 -msgid "Done" -msgstr "" - -#: amt/site/templates/algorithms/details_info.html.j2:42 -msgid "To do" -msgstr "" - -#: amt/site/templates/algorithms/details_info.html.j2:44 -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 @@ -440,11 +452,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 "" @@ -513,21 +525,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 "" @@ -574,9 +576,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" @@ -594,27 +629,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 "" @@ -622,19 +657,7 @@ 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" -msgstr "" - -#: amt/site/templates/macros/tasks.html.j2:25 -msgid "Reviewing" -msgstr "" - -#: amt/site/templates/macros/tasks.html.j2:35 +#: amt/site/templates/macros/tasks.html.j2:43 msgid "Unknown" msgstr "" @@ -666,6 +689,7 @@ msgstr "" #: amt/site/templates/organizations/parts/members_results.html.j2:29 #: amt/site/templates/organizations/parts/overview_results.html.j2:26 #: amt/site/templates/parts/algorithm_search.html.j2:25 +#: amt/site/templates/parts/tasks_search.html.j2:18 msgid "Search" msgstr "" @@ -818,6 +842,7 @@ msgid "Find algorithm..." msgstr "" #: amt/site/templates/parts/algorithm_search.html.j2:53 +#: amt/site/templates/parts/tasks_search.html.j2:46 msgid "Select lifecycle" msgstr "" @@ -872,3 +897,23 @@ msgid "" " open manner." msgstr "" +#: amt/site/templates/parts/tasks_board.html.j2:5 +msgid "Results for" +msgstr "" + +#: amt/site/templates/parts/tasks_search.html.j2:9 +msgid "Taken" +msgstr "" + +#: amt/site/templates/parts/tasks_search.html.j2:25 +msgid "Find tasks..." +msgstr "" + +#: amt/site/templates/parts/tasks_search.html.j2:55 +msgid "Assignee" +msgstr "" + +#: amt/site/templates/parts/tasks_search.html.j2:60 +msgid "Select assignee" +msgstr "" + diff --git a/amt/locale/nl_NL/LC_MESSAGES/messages.mo b/amt/locale/nl_NL/LC_MESSAGES/messages.mo index ec2e0270..6d5eccc6 100644 Binary files a/amt/locale/nl_NL/LC_MESSAGES/messages.mo and b/amt/locale/nl_NL/LC_MESSAGES/messages.mo differ diff --git a/amt/locale/nl_NL/LC_MESSAGES/messages.po b/amt/locale/nl_NL/LC_MESSAGES/messages.po index 0a4f0415..2db7ea21 100644 --- a/amt/locale/nl_NL/LC_MESSAGES/messages.po +++ b/amt/locale/nl_NL/LC_MESSAGES/messages.po @@ -49,10 +49,11 @@ msgid "Role" msgstr "Rol" #: 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 +#: amt/site/templates/parts/tasks_search.html.j2:41 msgid "Lifecycle" msgstr "Levenscyclus" @@ -148,7 +149,7 @@ msgstr "Data" msgid "Model" msgstr "Model" -#: amt/api/navigation.py:62 amt/site/templates/algorithms/new.html.j2:170 +#: amt/api/navigation.py:62 msgid "Instruments" msgstr "Instrumenten" @@ -200,31 +201,58 @@ msgid "Select organization" msgstr "Selecteer organisatie" #: 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 "Organisatie" -#: amt/api/forms/measure.py:24 +#: amt/api/forms/measure.py:33 msgid "Responsible" msgstr "Uitvoerende" -#: amt/api/forms/measure.py:32 +#: amt/api/forms/measure.py:41 msgid "Reviewer" msgstr "Controlerende" -#: amt/api/forms/measure.py:40 +#: amt/api/forms/measure.py:49 msgid "Accountable" msgstr "Verantwoordelijke" -#: amt/api/forms/measure.py:48 +#: amt/api/forms/measure.py:57 msgid "Status" msgstr "Status" -#: amt/api/forms/measure.py:63 +#: amt/api/forms/measure.py:59 +#: amt/site/templates/algorithms/details_info.html.j2:44 +#: amt/site/templates/macros/tasks.html.j2:12 +msgid "To do" +msgstr "Te doen" + +#: amt/api/forms/measure.py:60 +#: amt/site/templates/algorithms/details_info.html.j2:46 +#: amt/site/templates/macros/tasks.html.j2:19 +msgid "In progress" +msgstr "Onderhanden" + +#: amt/api/forms/measure.py:61 amt/site/templates/macros/tasks.html.j2:26 +msgid "In review" +msgstr "Beoordelen" + +#: amt/api/forms/measure.py:62 +#: 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:33 +msgid "Done" +msgstr "Afgerond" + +#: amt/api/forms/measure.py:63 amt/site/templates/macros/tasks.html.j2:40 +msgid "Not implemented" +msgstr "Niet van toepassing" + +#: amt/api/forms/measure.py:72 msgid "Information on how this measure is implemented" msgstr "Informatie over hoe deze maatregel is geïmplementeerd" -#: 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." @@ -232,24 +260,24 @@ msgstr "" "Selecteer één of meer bestanden om te uploaden. De bestanden zullen " "worden opgeslagen wanneer de Opslaan knop wordt ingedrukt." -#: amt/api/forms/measure.py:75 +#: amt/api/forms/measure.py:84 msgid "Add files" msgstr "Bestanden toevoegen" -#: amt/api/forms/measure.py:76 +#: amt/api/forms/measure.py:85 msgid "No files selected." msgstr "Geen bestanden geselecteerd." -#: amt/api/forms/measure.py:80 +#: amt/api/forms/measure.py:89 msgid "Add URI" msgstr "Voeg URI toe" -#: amt/api/forms/measure.py:83 +#: amt/api/forms/measure.py:92 msgid "Add links to documents" msgstr "Voeg link naar bestanden toe" #: 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 @@ -325,35 +353,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 +391,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 " @@ -387,14 +413,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 "Ja" #: 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 "Nee" @@ -403,13 +429,14 @@ msgstr "Nee" msgid "Download as YAML" msgstr "Download naar YAML" -#: amt/site/templates/algorithms/details_compliance.html.j2:34 +#: amt/site/templates/algorithms/details_compliance.html.j2:36 msgid "measure executed" msgstr "maatregelen uitgevoerd" -#: 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 +#: amt/site/templates/macros/tasks.html.j2:82 msgid "Edit" msgstr "Bewerk" @@ -417,38 +444,24 @@ msgstr "Bewerk" msgid "Does the algorithm meet the requirements?" msgstr "Voldoet het algoritme aan de vereisten?" -#: amt/site/templates/algorithms/details_info.html.j2:20 -#: amt/site/templates/algorithms/details_info.html.j2:46 -#: amt/site/templates/macros/tasks.html.j2:32 -msgid "Done" -msgstr "Afgerond" - -#: amt/site/templates/algorithms/details_info.html.j2:42 -msgid "To do" -msgstr "Te doen" - -#: amt/site/templates/algorithms/details_info.html.j2:44 -msgid "In progress" -msgstr "Onderhanden" - -#: amt/site/templates/algorithms/details_info.html.j2:60 +#: amt/site/templates/algorithms/details_info.html.j2:65 msgid "Go to all requirements" msgstr "Ga naar alle Vereisten" -#: 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 "Omschrijving" -#: amt/site/templates/algorithms/details_info.html.j2:86 +#: amt/site/templates/algorithms/details_info.html.j2:91 msgid "Repository" msgstr "Repository" -#: amt/site/templates/algorithms/details_info.html.j2:92 +#: amt/site/templates/algorithms/details_info.html.j2:97 msgid "Algorithm code" msgstr "Algoritme code" -#: 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 @@ -458,11 +471,11 @@ msgstr "Algoritme code" msgid "Last updated" msgstr "Laatst bijgewerkt" -#: amt/site/templates/algorithms/details_info.html.j2:106 +#: amt/site/templates/algorithms/details_info.html.j2:111 msgid "Labels" msgstr "Labels" -#: amt/site/templates/algorithms/details_info.html.j2:114 +#: amt/site/templates/algorithms/details_info.html.j2:119 msgid "References" msgstr "Referenties" @@ -534,23 +547,11 @@ msgstr "Vind uw AI Act profiel" msgid "Select Option" msgstr "Selecteer optie" -#: 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 "" -"Overzicht van aanbevolen instrument voor het verantwoord ontwikkelen, " -"gebruiken, beoordelen en monitoren van algoritmes en AI-systemen." - -#: amt/site/templates/algorithms/new.html.j2:180 -msgid "Choose one or more instruments" -msgstr "Kies één of meerdere instrumenten" - -#: amt/site/templates/algorithms/new.html.j2:204 +#: amt/site/templates/algorithms/new.html.j2:176 msgid "Create Algorithm" msgstr "Creëer algoritme" -#: amt/site/templates/algorithms/new.html.j2:221 +#: amt/site/templates/algorithms/new.html.j2:193 msgid "Copy results and close" msgstr "Resultaten overnemen en sluiten" @@ -597,9 +598,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" @@ -617,27 +651,27 @@ msgstr "Er is één fout:" msgid "Algorithmic Management Toolkit (AMT)" msgstr "Algoritme Management Toolkit" -#: 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 "Weet u zeker dat u " -#: amt/site/templates/macros/form_macros.html.j2:58 +#: amt/site/templates/macros/form_macros.html.j2:60 msgid " from this organization? " msgstr " wilt verwijderen van deze organisatie?" -#: amt/site/templates/macros/form_macros.html.j2:61 +#: amt/site/templates/macros/form_macros.html.j2:63 msgid "Delete" msgstr "Verwijder" -#: amt/site/templates/macros/form_macros.html.j2:62 +#: amt/site/templates/macros/form_macros.html.j2:64 msgid "Delete member" msgstr "Verwijder persoon" -#: amt/site/templates/macros/form_macros.html.j2:150 +#: amt/site/templates/macros/form_macros.html.j2:152 msgid "Delete file" msgstr "Verwijder bestand" -#: 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 "Weet u zeker dat u het bestand wilt verwijderen" @@ -645,19 +679,7 @@ msgstr "Weet u zeker dat u het bestand wilt verwijderen" msgid " ago" msgstr "geleden" -#: amt/site/templates/macros/tasks.html.j2:11 -msgid "Todo" -msgstr "Te doen" - -#: amt/site/templates/macros/tasks.html.j2:18 -msgid "Doing" -msgstr "Onderhanden" - -#: amt/site/templates/macros/tasks.html.j2:25 -msgid "Reviewing" -msgstr "Beoordelen" - -#: amt/site/templates/macros/tasks.html.j2:35 +#: amt/site/templates/macros/tasks.html.j2:43 msgid "Unknown" msgstr "Onbekend" @@ -689,6 +711,7 @@ msgstr "Voeg personen toe" #: amt/site/templates/organizations/parts/members_results.html.j2:29 #: amt/site/templates/organizations/parts/overview_results.html.j2:26 #: amt/site/templates/parts/algorithm_search.html.j2:25 +#: amt/site/templates/parts/tasks_search.html.j2:18 msgid "Search" msgstr "Zoek" @@ -848,6 +871,7 @@ msgid "Find algorithm..." msgstr "Vind algoritme..." #: amt/site/templates/parts/algorithm_search.html.j2:53 +#: amt/site/templates/parts/tasks_search.html.j2:46 msgid "Select lifecycle" msgstr "Selecteer levenscyclus" @@ -910,3 +934,23 @@ msgid "" " open manner." msgstr "Deze website is in ontwikkeling. Alle versies ontstaan op een open manier." +#: amt/site/templates/parts/tasks_board.html.j2:5 +msgid "Results for" +msgstr "Resultaten voor" + +#: amt/site/templates/parts/tasks_search.html.j2:9 +msgid "Taken" +msgstr "Taken" + +#: amt/site/templates/parts/tasks_search.html.j2:25 +msgid "Find tasks..." +msgstr "Vind taken..." + +#: amt/site/templates/parts/tasks_search.html.j2:55 +msgid "Assignee" +msgstr "Toegewezen aan" + +#: amt/site/templates/parts/tasks_search.html.j2:60 +msgid "Select assignee" +msgstr "Selecteer toegewezen aan" + 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/migrations/versions/40fd30e75959_extend_the_tasks_object.py b/amt/migrations/versions/40fd30e75959_extend_the_tasks_object.py new file mode 100644 index 00000000..11f74a35 --- /dev/null +++ b/amt/migrations/versions/40fd30e75959_extend_the_tasks_object.py @@ -0,0 +1,28 @@ +"""Extend the tasks object + +Revision ID: 40fd30e75959 +Revises: e16bb3d53cd6 +Create Date: 2025-01-21 10:42:12.306602 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "40fd30e75959" +down_revision: str | None = "e16bb3d53cd6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> 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/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/task_registry.py b/amt/repositories/task_registry.py index a972a6a3..0ec2f73c 100644 --- a/amt/repositories/task_registry.py +++ b/amt/repositories/task_registry.py @@ -67,3 +67,6 @@ async def _fetch_tasks_by_urns(self, task_type: TaskType, urns: Sequence[str]) - logger.warning("Cannot find all tasks") return tasks + + +task_registry_repository = TaskRegistryRepository(client=TaskRegistryAPIClient()) 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/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/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 88cd2b71..a224a597 100644 --- a/amt/services/algorithms.py +++ b/amt/services/algorithms.py @@ -12,13 +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 import InstrumentsService, create_instrument_service +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,12 +33,12 @@ class AlgorithmsService: def __init__( self, repository: Annotated[AlgorithmsRepository, Depends(AlgorithmsRepository)], - instrument_service: Annotated[InstrumentsService, Depends(create_instrument_service)], organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], + tasks_repository: Annotated[TasksRepository, Depends(TasksRepository)], ) -> None: self.repository = repository - self.instrument_service = instrument_service 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) @@ -114,18 +117,26 @@ 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( - 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/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..9359e93d 100644 --- a/amt/services/instruments_and_requirements_state.py +++ b/amt/services/instruments_and_requirements_state.py @@ -8,21 +8,21 @@ 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__) 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", ) @@ -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/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/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..e629ac07 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,9 +65,14 @@ 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) + 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..dcff8103 100644 --- a/amt/services/users.py +++ b/amt/services/users.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Sequence from typing import Annotated from uuid import UUID @@ -16,13 +17,24 @@ 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] + + async def find_all( + self, + search: str | None = None, + sort: dict[str, str] | None = None, + filters: dict[str, str | list[str | int]] | None = None, + skip: int | None = None, + limit: int | None = None, + ) -> Sequence[User]: + return await self.repository.find_all(search, sort, filters, skip, limit) 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/static/ts/amt.ts b/amt/site/static/ts/amt.ts index c6fa62be..bbe1f168 100644 --- a/amt/site/static/ts/amt.ts +++ b/amt/site/static/ts/amt.ts @@ -8,27 +8,45 @@ import "../scss/layout.scss"; _hyperscript.browserInit(); -const keysToRemove = [ - "labelsbysubcategory", - "answers", - "categoryState", - "categoryTrace", - "currentCategory", - "currentconclusion", - "currentquestion", - "currentSubCategory", - "labels", - "previousCategory", - "previousSubCategory", - "subCategoryTrace", -]; +const currentSortables: Sortable[] = []; + if (window.location.pathname === "/algorithms/new") { + const keysToRemove = [ + "labelsbysubcategory", + "answers", + "categoryState", + "categoryTrace", + "currentCategory", + "currentconclusion", + "currentquestion", + "currentSubCategory", + "labels", + "previousCategory", + "previousSubCategory", + "subCategoryTrace", + ]; keysToRemove.forEach((key) => { sessionStorage.removeItem(key); }); } -document.addEventListener("DOMContentLoaded", function () { +document.addEventListener("htmx:afterSwap", (e) => { + if ( + ["tasks-search-results", "search-tasks-container"].includes( + (e.target as HTMLElement).getAttribute("id") as string, + ) + ) { + while (currentSortables.length > 0) { + const sortable = currentSortables.shift(); + sortable!.destroy(); + } + initPage(); + } +}); + +document.addEventListener("DOMContentLoaded", initPage); + +function initPage() { // TODO (robbert): we need (better) event handling and displaying of server errors document.body.addEventListener("htmx:sendError", function () { document.getElementById("errorContainer")!.innerHTML = @@ -40,44 +58,45 @@ document.addEventListener("DOMContentLoaded", function () { ) as HTMLCollectionOf; for (const column of columns) { // prettier-ignore - new Sortable(column, { //NOSONAR - group: "shared", // set both lists to same group - animation: 150, - onEnd: function(evt) { - if (evt.oldIndex !== evt.newIndex || evt.from !== evt.to) { - const previousSiblingId = evt.item.previousElementSibling - ? evt.item.previousElementSibling.getAttribute("data-id") - : "-1"; - const nextSiblingId = evt.item.nextElementSibling - ? evt.item.nextElementSibling.getAttribute("data-id") - : "-1"; - const targetId = "#" + evt.item.getAttribute("data-target-id"); - const toStatusId = evt.to.getAttribute("data-id"); - const form = (document.getElementById("cardMovedForm") ?? - "") as HTMLFormElement; - - (document.getElementsByName("taskId")[0] as HTMLInputElement).value = - evt.item.getAttribute("data-id") ?? ""; - ( - document.getElementsByName("statusId")[0] as HTMLInputElement - ).value = toStatusId ?? ""; - ( - document.getElementsByName( - "previousSiblingId", - )[0] as HTMLInputElement - ).value = previousSiblingId ?? ""; - ( - document.getElementsByName("nextSiblingId")[0] as HTMLInputElement - ).value = nextSiblingId ?? ""; - form.setAttribute("hx-target", targetId); - - // @ts-expect-error Description: Ignoring type error because the htmx.trigger function is not recognized by TypeScript. - htmx.trigger("#cardMovedForm", "cardmoved"); - } - }, - }); + currentSortables.push(Sortable.create(column, { //NOSONAR + group: "shared", // set both lists to same group + animation: 150, + onEnd: function (evt) { + if (evt.oldIndex !== evt.newIndex || evt.from !== evt.to) { + const previousSiblingId = evt.item.previousElementSibling + ? evt.item.previousElementSibling.getAttribute("data-id") + : "-1"; + const nextSiblingId = evt.item.nextElementSibling + ? evt.item.nextElementSibling.getAttribute("data-id") + : "-1"; + const targetId = "#" + evt.item.getAttribute("data-target-id"); + const toStatusId = evt.to.getAttribute("data-id"); + const form = (document.getElementById("cardMovedForm") ?? + "") as HTMLFormElement; + + (document.getElementsByName("taskId")[0] as HTMLInputElement).value = + evt.item.getAttribute("data-id") ?? ""; + ( + document.getElementsByName("statusId")[0] as HTMLInputElement + ).value = toStatusId ?? ""; + ( + document.getElementsByName( + "previousSiblingId", + )[0] as HTMLInputElement + ).value = previousSiblingId ?? ""; + ( + document.getElementsByName("nextSiblingId")[0] as HTMLInputElement + ).value = nextSiblingId ?? ""; + form.setAttribute("hx-target", targetId); + + // @ts-expect-error Description: Ignoring type error because the htmx.trigger function is not recognized by TypeScript. + htmx.trigger("#cardMovedForm", "cardmoved"); + } + }, + }) + ) } -}); +} export function setCookie( cookieName: string, @@ -427,4 +446,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_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_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..0556e1f7 100644 --- a/amt/site/templates/algorithms/details_info.html.j2 +++ b/amt/site/templates/algorithms/details_info.html.j2 @@ -28,23 +28,28 @@ 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" %}
{% 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 @@
+
{% 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 d437f55c..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 %} -
+
@@ -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 %} -
-
-