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