diff --git a/amt/api/forms/measure.py b/amt/api/forms/measure.py new file mode 100644 index 00000000..a4103954 --- /dev/null +++ b/amt/api/forms/measure.py @@ -0,0 +1,58 @@ +from gettext import NullTranslations + +from amt.schema.webform import WebForm, WebFormField, WebFormFieldType, WebFormOption, WebFormTextCloneableField + + +async def get_measure_form( + id: str, current_values: dict[str, str | list[str] | list[tuple[str, str]]], translations: NullTranslations +) -> WebForm: + _ = translations.gettext + + measure_form: WebForm = WebForm(id="", post_url="") + + measure_form.fields = [ + WebFormField( + type=WebFormFieldType.SELECT, + 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"), + ], + default_value=current_values.get("measure_state"), + group="1", + ), + WebFormField( + type=WebFormFieldType.TEXTAREA, + name="measure_value", + default_value=current_values.get("measure_value"), + label=_("Information on how this measure is implemented"), + placeholder="", + group="1", + ), + WebFormField( + type=WebFormFieldType.FILE, + name="measure_files", + description=_( + "Select one or more to upload. The files will be saved once you confirm changes by pressing the save " + "button." + ), + default_value=current_values.get("measure_files"), + label=_("Add files"), + placeholder=_("No files selected."), + group="1", + ), + WebFormTextCloneableField( + clone_button_name=_("Add URI"), + name="measure_links", + default_value=current_values.get("measure_links"), + label=_("Add links to documents"), + placeholder="", + group="1", + ), + ] + + return measure_form diff --git a/amt/api/forms/organization.py b/amt/api/forms/organization.py index 115c9679..0039b259 100644 --- a/amt/api/forms/organization.py +++ b/amt/api/forms/organization.py @@ -24,6 +24,7 @@ def get_organization_form(id: str, translations: NullTranslations, user: User | placeholder=_("Name of the organization"), attributes={"onkeyup": "amt.generate_slug('" + id + "name', '" + id + "slug')"}, group="1", + required=True, ), WebFormField( type=WebFormFieldType.TEXT, @@ -32,6 +33,7 @@ def get_organization_form(id: str, translations: NullTranslations, user: User | label=_("Slug"), placeholder=_("The slug for this organization"), group="1", + required=True, ), WebFormSearchField( name="user_ids", diff --git a/amt/api/routes/algorithm.py b/amt/api/routes/algorithm.py index 126f1d3e..0a32be7c 100644 --- a/amt/api/routes/algorithm.py +++ b/amt/api/routes/algorithm.py @@ -5,11 +5,13 @@ from typing import Annotated, Any, cast import yaml -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile from fastapi.responses import FileResponse, HTMLResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel +from ulid import ULID from amt.api.deps import templates +from amt.api.forms.measure import get_measure_form from amt.api.navigation import ( BaseNavigationItem, Navigation, @@ -18,7 +20,8 @@ resolve_navigation_items, ) from amt.core.authorization import get_user -from amt.core.exceptions import AMTNotFound, AMTRepositoryError +from amt.core.exceptions import AMTError, AMTNotFound, AMTRepositoryError +from amt.core.internationalization import get_current_translation from amt.enums.status import Status from amt.models import Algorithm from amt.models.task import Task @@ -31,6 +34,7 @@ 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.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.tasks import TasksService @@ -76,6 +80,20 @@ async def get_algorithm_or_error( return algorithm +def get_user_id_or_error(request: Request) -> str: + user = get_user(request) + if user is None or user["sub"] is None: + raise AMTError + return user["sub"] + + +def get_measure_task_or_error(system_card: SystemCard, measure_urn: str) -> MeasureTask: + measure_task = find_measure_task(system_card, measure_urn) + if not measure_task: + raise AMTNotFound + return measure_task + + def get_algorithm_details_tabs(request: Request) -> list[NavigationItem]: return resolve_navigation_items( [ @@ -503,41 +521,63 @@ async def get_measure( algorithm_id: int, measure_urn: str, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], + measures_service: Annotated[MeasuresService, Depends(create_measures_service)], + object_storage_service: Annotated[ObjectStorageService, Depends(create_object_storage_service)], ) -> HTMLResponse: algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) - measures_service = create_measures_service() measure = await measures_service.fetch_measures([measure_urn]) - measure_task = find_measure_task(algorithm.system_card, measure_urn) + measure_task = get_measure_task_or_error(algorithm.system_card, measure_urn) + + filenames: list[tuple[str, str]] = [] + for file in measure_task.files: + metadata = object_storage_service.get_file_metadata_from_object_name(file) + filenames.append((file.split("/")[-1], f"{metadata.filename}.{metadata.ext}")) + + measure_form = await get_measure_form( + id="measure_state", + current_values={ + "measure_state": measure_task.state, + "measure_value": measure_task.value, + "measure_links": measure_task.links, + "measure_files": filenames, + }, + translations=get_current_translation(request), + ) context = { "measure": measure[0], - "measure_state": measure_task.state, # pyright: ignore [reportOptionalMemberAccess] - "measure_value": measure_task.value, # pyright: ignore [reportOptionalMemberAccess] "algorithm_id": algorithm_id, + "form": measure_form, } return templates.TemplateResponse(request, "algorithms/details_measure_modal.html.j2", context) -class MeasureUpdate(BaseModel): - measure_state: str = Field(default=None) - measure_value: str = Field(default=None) - - @router.post("/{algorithm_id}/measure/{measure_urn}") async def update_measure_value( request: Request, algorithm_id: int, measure_urn: str, - measure_update: MeasureUpdate, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], requirements_service: Annotated[RequirementsService, Depends(create_requirements_service)], + object_storage_service: Annotated[ObjectStorageService, Depends(create_object_storage_service)], + measure_state: Annotated[str, Form()], + measure_value: Annotated[str | None, Form()] = None, + measure_links: Annotated[list[str] | None, Form()] = None, + measure_files: Annotated[list[UploadFile] | None, File()] = None, ) -> HTMLResponse: algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) - - measure_task = find_measure_task(algorithm.system_card, measure_urn) - measure_task.state = measure_update.measure_state # pyright: ignore [reportOptionalMemberAccess] - measure_task.value = measure_update.measure_value # pyright: ignore [reportOptionalMemberAccess] + 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 + ) + if measure_files + else None + ) + measure_task.update(measure_state, measure_value, measure_links, paths) # 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) @@ -564,11 +604,6 @@ async def update_measure_value( return templates.Redirect(request, f"/algorithm/{algorithm_id}/details/system_card/requirements") -# !!! -# Implementation of this endpoint is for now independent of the algorithm ID, meaning -# that the same system card is rendered for all algorithm ID's. This is due to the fact -# that the logical process flow of a system card is not complete. -# !!! @router.get("/{algorithm_id}/details/system_card/data") async def get_system_card_data_page( request: Request, @@ -602,11 +637,6 @@ async def get_system_card_data_page( return templates.TemplateResponse(request, "algorithms/details_data.html.j2", context) -# !!! -# Implementation of this endpoint is for now independent of the algorithm ID, meaning -# that the same system card is rendered for all algorithm ID's. This is due to the fact -# that the logical process flow of a system card is not complete. -# !!! @router.get("/{algorithm_id}/details/system_card/instruments") async def get_system_card_instruments( request: Request, @@ -685,11 +715,6 @@ async def get_assessment_card( return templates.TemplateResponse(request, "pages/assessment_card.html.j2", context) -# !!! -# Implementation of this endpoint is for now independent of the algorithm ID, meaning -# that the same system card is rendered for all algorithm ID's. This is due to the fact -# that the logical process flow of a system card is not complete. -# !!! @router.get("/{algorithm_id}/details/system_card/models/{model_card}") async def get_model_card( request: Request, @@ -752,3 +777,43 @@ async def download_algorithm_system_card_as_yaml( return FileResponse(filename, filename=filename) except AMTRepositoryError as e: raise AMTNotFound from e + + +@router.get("/{algorithm_id}/file/{ulid}") +async def get_file( + request: Request, + algorithm_id: int, + ulid: ULID, + algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], + object_storage_service: Annotated[ObjectStorageService, Depends(create_object_storage_service)], +) -> Response: + algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) + file = object_storage_service.get_file(algorithm.organization_id, algorithm_id, ulid) + file_metadata = object_storage_service.get_file_metadata(algorithm.organization_id, algorithm_id, ulid) + + return Response( + content=file.read(decode_content=True), + headers={ + "Content-Disposition": f"attachment;filename={file_metadata.filename}.{file_metadata.ext}", + "Content-Type": "application/octet-stream", + }, + ) + + +@router.delete("/{algorithm_id}/file/{ulid}") +async def delete_file( + request: Request, + algorithm_id: int, + ulid: ULID, + algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], + object_storage_service: Annotated[ObjectStorageService, Depends(create_object_storage_service)], +) -> HTMLResponse: + algorithm = await get_algorithm_or_error(algorithm_id, algorithms_service, request) + metadata = object_storage_service.get_file_metadata(algorithm.organization_id, algorithm_id, ulid) + measure_task = get_measure_task_or_error(algorithm.system_card, metadata.measure_urn) + + entry_to_delete = object_storage_service.delete_file(algorithm.organization_id, algorithm_id, ulid) + measure_task.files.remove(entry_to_delete) + await algorithms_service.update(algorithm) + + return HTMLResponse(content="", status_code=200) diff --git a/amt/core/config.py b/amt/core/config.py index 28f662df..040afcf2 100644 --- a/amt/core/config.py +++ b/amt/core/config.py @@ -63,6 +63,11 @@ class Settings(BaseSettings): TASK_REGISTRY_URL: str = "https://task-registry.apps.digilab.network" + OBJECT_STORE_URL: str = "localhost:9000" + OBJECT_STORE_USER: str = "amt" + OBJECT_STORE_PASSWORD: str = "changeme" + OBJECT_STORE_BUCKET_NAME: str = "amt" + @computed_field def SQLALCHEMY_ECHO(self) -> bool: return self.DEBUG diff --git a/amt/core/exceptions.py b/amt/core/exceptions.py index 286035e1..9f5e18fe 100644 --- a/amt/core/exceptions.py +++ b/amt/core/exceptions.py @@ -77,3 +77,9 @@ class AMTAuthorizationFlowError(AMTHTTPException): def __init__(self) -> None: self.detail: str = _("Something went wrong during the authorization flow. Please try again later.") super().__init__(status.HTTP_401_UNAUTHORIZED, self.detail) + + +class AMTStorageError(AMTHTTPException): + def __init__(self) -> None: + self.detail: str = _("Something went wrong storing your file. PLease try again later.") + super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, self.detail) diff --git a/amt/locale/base.pot b/amt/locale/base.pot index 7f859eea..1fc801d2 100644 --- a/amt/locale/base.pot +++ b/amt/locale/base.pot @@ -198,6 +198,36 @@ msgstr "" msgid "Organization" msgstr "" +#: amt/api/forms/measure.py:17 +msgid "Status" +msgstr "" + +#: amt/api/forms/measure.py:32 +msgid "Information on how this measure is implemented" +msgstr "" + +#: amt/api/forms/measure.py:39 +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:44 +msgid "Add files" +msgstr "" + +#: amt/api/forms/measure.py:45 +msgid "No files selected." +msgstr "" + +#: amt/api/forms/measure.py:49 +msgid "Add URI" +msgstr "" + +#: amt/api/forms/measure.py:52 +msgid "Add links to documents" +msgstr "" + #: amt/api/forms/organization.py:23 #: amt/site/templates/algorithms/details_info.html.j2:8 #: amt/site/templates/auth/profile.html.j2:34 @@ -210,30 +240,30 @@ msgstr "" msgid "Name of the organization" msgstr "" -#: amt/api/forms/organization.py:31 +#: amt/api/forms/organization.py:32 msgid "The slug is the web path, like /organizations/my-organization-name" msgstr "" -#: amt/api/forms/organization.py:32 +#: amt/api/forms/organization.py:33 #: amt/site/templates/organizations/home.html.j2:16 msgid "Slug" msgstr "" -#: amt/api/forms/organization.py:33 +#: amt/api/forms/organization.py:34 msgid "The slug for this organization" msgstr "" -#: amt/api/forms/organization.py:38 +#: amt/api/forms/organization.py:40 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:4 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:23 msgid "Add members" msgstr "" -#: amt/api/forms/organization.py:39 +#: amt/api/forms/organization.py:41 msgid "Search for a person..." msgstr "" -#: amt/api/forms/organization.py:45 +#: amt/api/forms/organization.py:47 msgid "Add organization" msgstr "" @@ -297,6 +327,10 @@ msgid "" "later." msgstr "" +#: amt/core/exceptions.py:84 +msgid "Something went wrong storing your file. PLease try again later." +msgstr "" + #: amt/site/templates/algorithms/details_base.html.j2:19 msgid "Delete algoritmic system" msgstr "" @@ -313,12 +347,14 @@ msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:39 #: amt/site/templates/algorithms/new.html.j2:153 +#: amt/site/templates/macros/form_macros.html.j2:168 #: amt/site/templates/organizations/members.html.j2:33 msgid "Yes" msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:44 #: amt/site/templates/algorithms/new.html.j2:163 +#: amt/site/templates/macros/form_macros.html.j2:173 #: amt/site/templates/organizations/members.html.j2:36 msgid "No" msgstr "" @@ -422,7 +458,7 @@ msgid "Failed to estimate WOZ value: " msgstr "" #: amt/site/templates/algorithms/details_info.html.j2:16 -#: amt/site/templates/algorithms/details_measure_modal.html.j2:19 +#: amt/site/templates/algorithms/details_measure_modal.html.j2:26 msgid "Description" msgstr "" @@ -452,45 +488,16 @@ msgstr "" msgid "References" msgstr "" -#: amt/site/templates/algorithms/details_measure_modal.html.j2:29 +#: amt/site/templates/algorithms/details_measure_modal.html.j2:36 msgid "Read more on the algoritmekader" msgstr "" -#: amt/site/templates/algorithms/details_measure_modal.html.j2:38 -msgid "Status" -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:45 #: amt/site/templates/algorithms/details_measure_modal.html.j2:47 -msgid "to do" -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:50 -#: amt/site/templates/algorithms/details_measure_modal.html.j2:52 -msgid "in progress" -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:55 -#: amt/site/templates/algorithms/details_measure_modal.html.j2:57 -msgid "done" -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:68 -msgid "Information on how this measure is implemented" -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:74 -msgid "" -"Describe how the measure has been implemented, including challenges and " -"solutions." -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:84 #: amt/site/templates/macros/editable.html.j2:82 msgid "Save" msgstr "" -#: amt/site/templates/algorithms/details_measure_modal.html.j2:88 +#: amt/site/templates/algorithms/details_measure_modal.html.j2:51 #: amt/site/templates/macros/editable.html.j2:87 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:26 msgid "Cancel" @@ -500,7 +507,7 @@ msgstr "" msgid "measures executed" msgstr "" -#: amt/site/templates/algorithms/details_requirements.html.j2:55 +#: amt/site/templates/algorithms/details_requirements.html.j2:59 #: amt/site/templates/macros/editable.html.j2:24 #: amt/site/templates/macros/editable.html.j2:27 msgid "Edit" @@ -632,22 +639,30 @@ msgstr "" msgid "Algorithmic Management Toolkit (AMT)" msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:52 +#: amt/site/templates/macros/form_macros.html.j2:58 msgid "Are you sure you want to remove " msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:52 +#: amt/site/templates/macros/form_macros.html.j2:58 msgid " from this organization? " msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:55 +#: amt/site/templates/macros/form_macros.html.j2:61 msgid "Delete" msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:56 +#: amt/site/templates/macros/form_macros.html.j2:62 msgid "Delete member" msgstr "" +#: amt/site/templates/macros/form_macros.html.j2:150 +msgid "Delete file" +msgstr "" + +#: amt/site/templates/macros/form_macros.html.j2:157 +msgid "Are you sure you want to delete" +msgstr "" + #: amt/site/templates/macros/table_row.html.j2:19 msgid " ago" msgstr "" diff --git a/amt/locale/en_US/LC_MESSAGES/messages.mo b/amt/locale/en_US/LC_MESSAGES/messages.mo index ac7ba388..a583a5e5 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 99aaa49f..e939c29b 100644 --- a/amt/locale/en_US/LC_MESSAGES/messages.po +++ b/amt/locale/en_US/LC_MESSAGES/messages.po @@ -199,6 +199,36 @@ msgstr "" msgid "Organization" msgstr "" +#: amt/api/forms/measure.py:17 +msgid "Status" +msgstr "" + +#: amt/api/forms/measure.py:32 +msgid "Information on how this measure is implemented" +msgstr "" + +#: amt/api/forms/measure.py:39 +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:44 +msgid "Add files" +msgstr "" + +#: amt/api/forms/measure.py:45 +msgid "No files selected." +msgstr "" + +#: amt/api/forms/measure.py:49 +msgid "Add URI" +msgstr "" + +#: amt/api/forms/measure.py:52 +msgid "Add links to documents" +msgstr "" + #: amt/api/forms/organization.py:23 #: amt/site/templates/algorithms/details_info.html.j2:8 #: amt/site/templates/auth/profile.html.j2:34 @@ -211,30 +241,30 @@ msgstr "" msgid "Name of the organization" msgstr "" -#: amt/api/forms/organization.py:31 +#: amt/api/forms/organization.py:32 msgid "The slug is the web path, like /organizations/my-organization-name" msgstr "" -#: amt/api/forms/organization.py:32 +#: amt/api/forms/organization.py:33 #: amt/site/templates/organizations/home.html.j2:16 msgid "Slug" msgstr "" -#: amt/api/forms/organization.py:33 +#: amt/api/forms/organization.py:34 msgid "The slug for this organization" msgstr "" -#: amt/api/forms/organization.py:38 +#: amt/api/forms/organization.py:40 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:4 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:23 msgid "Add members" msgstr "" -#: amt/api/forms/organization.py:39 +#: amt/api/forms/organization.py:41 msgid "Search for a person..." msgstr "" -#: amt/api/forms/organization.py:45 +#: amt/api/forms/organization.py:47 msgid "Add organization" msgstr "" @@ -298,6 +328,10 @@ msgid "" "later." msgstr "" +#: amt/core/exceptions.py:84 +msgid "Something went wrong storing your file. PLease try again later." +msgstr "" + #: amt/site/templates/algorithms/details_base.html.j2:19 msgid "Delete algoritmic system" msgstr "" @@ -314,12 +348,14 @@ msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:39 #: amt/site/templates/algorithms/new.html.j2:153 +#: amt/site/templates/macros/form_macros.html.j2:168 #: amt/site/templates/organizations/members.html.j2:33 msgid "Yes" msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:44 #: amt/site/templates/algorithms/new.html.j2:163 +#: amt/site/templates/macros/form_macros.html.j2:173 #: amt/site/templates/organizations/members.html.j2:36 msgid "No" msgstr "" @@ -423,7 +459,7 @@ msgid "Failed to estimate WOZ value: " msgstr "" #: amt/site/templates/algorithms/details_info.html.j2:16 -#: amt/site/templates/algorithms/details_measure_modal.html.j2:19 +#: amt/site/templates/algorithms/details_measure_modal.html.j2:26 msgid "Description" msgstr "" @@ -453,45 +489,16 @@ msgstr "" msgid "References" msgstr "" -#: amt/site/templates/algorithms/details_measure_modal.html.j2:29 +#: amt/site/templates/algorithms/details_measure_modal.html.j2:36 msgid "Read more on the algoritmekader" msgstr "" -#: amt/site/templates/algorithms/details_measure_modal.html.j2:38 -msgid "Status" -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:45 #: amt/site/templates/algorithms/details_measure_modal.html.j2:47 -msgid "to do" -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:50 -#: amt/site/templates/algorithms/details_measure_modal.html.j2:52 -msgid "in progress" -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:55 -#: amt/site/templates/algorithms/details_measure_modal.html.j2:57 -msgid "done" -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:68 -msgid "Information on how this measure is implemented" -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:74 -msgid "" -"Describe how the measure has been implemented, including challenges and " -"solutions." -msgstr "" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:84 #: amt/site/templates/macros/editable.html.j2:82 msgid "Save" msgstr "" -#: amt/site/templates/algorithms/details_measure_modal.html.j2:88 +#: amt/site/templates/algorithms/details_measure_modal.html.j2:51 #: amt/site/templates/macros/editable.html.j2:87 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:26 msgid "Cancel" @@ -501,7 +508,7 @@ msgstr "" msgid "measures executed" msgstr "" -#: amt/site/templates/algorithms/details_requirements.html.j2:55 +#: amt/site/templates/algorithms/details_requirements.html.j2:59 #: amt/site/templates/macros/editable.html.j2:24 #: amt/site/templates/macros/editable.html.j2:27 msgid "Edit" @@ -633,22 +640,30 @@ msgstr "" msgid "Algorithmic Management Toolkit (AMT)" msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:52 +#: amt/site/templates/macros/form_macros.html.j2:58 msgid "Are you sure you want to remove " msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:52 +#: amt/site/templates/macros/form_macros.html.j2:58 msgid " from this organization? " msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:55 +#: amt/site/templates/macros/form_macros.html.j2:61 msgid "Delete" msgstr "" -#: amt/site/templates/macros/form_macros.html.j2:56 +#: amt/site/templates/macros/form_macros.html.j2:62 msgid "Delete member" msgstr "" +#: amt/site/templates/macros/form_macros.html.j2:150 +msgid "Delete file" +msgstr "" + +#: amt/site/templates/macros/form_macros.html.j2:157 +msgid "Are you sure you want to delete" +msgstr "" + #: amt/site/templates/macros/table_row.html.j2:19 msgid " ago" msgstr "" diff --git a/amt/locale/nl_NL/LC_MESSAGES/messages.mo b/amt/locale/nl_NL/LC_MESSAGES/messages.mo index 71123c3f..9be3eaba 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 0781ad7d..61462cac 100644 --- a/amt/locale/nl_NL/LC_MESSAGES/messages.po +++ b/amt/locale/nl_NL/LC_MESSAGES/messages.po @@ -201,6 +201,38 @@ msgstr "Selecteer organisatie" msgid "Organization" msgstr "Organisatie" +#: amt/api/forms/measure.py:17 +msgid "Status" +msgstr "Status" + +#: amt/api/forms/measure.py:32 +msgid "Information on how this measure is implemented" +msgstr "Informatie over hoe deze maatregel is geïmplementeerd" + +#: amt/api/forms/measure.py:39 +msgid "" +"Select one or more to upload. The files will be saved once you confirm " +"changes by pressing the save button." +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:44 +msgid "Add files" +msgstr "Bestanden toevoegen" + +#: amt/api/forms/measure.py:45 +msgid "No files selected." +msgstr "Geen bestanden geselecteerd." + +#: amt/api/forms/measure.py:49 +msgid "Add URI" +msgstr "Voeg URI toe" + +#: amt/api/forms/measure.py:52 +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:8 #: amt/site/templates/auth/profile.html.j2:34 @@ -213,32 +245,32 @@ msgstr "Naam" msgid "Name of the organization" msgstr "Naam van de organisatie" -#: amt/api/forms/organization.py:31 +#: amt/api/forms/organization.py:32 msgid "The slug is the web path, like /organizations/my-organization-name" msgstr "" "Een slug is het pad in het webadres, zoals /organizations/mijn-" "organisatie-naam" -#: amt/api/forms/organization.py:32 +#: amt/api/forms/organization.py:33 #: amt/site/templates/organizations/home.html.j2:16 msgid "Slug" msgstr "Slug" -#: amt/api/forms/organization.py:33 +#: amt/api/forms/organization.py:34 msgid "The slug for this organization" msgstr "Het web-pad (slug) voor deze organisatie" -#: amt/api/forms/organization.py:38 +#: amt/api/forms/organization.py:40 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:4 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:23 msgid "Add members" msgstr "Voeg personen toe" -#: amt/api/forms/organization.py:39 +#: amt/api/forms/organization.py:41 msgid "Search for a person..." msgstr "Zoek een persoon..." -#: amt/api/forms/organization.py:45 +#: amt/api/forms/organization.py:47 msgid "Add organization" msgstr "Organisatie toevoegen" @@ -310,6 +342,12 @@ msgstr "" "Er is iets fout gegaan tijdens de autorisatiestroom. Probeer het later " "opnieuw" +#: amt/core/exceptions.py:84 +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 " +"later opnieuw." + #: amt/site/templates/algorithms/details_base.html.j2:19 msgid "Delete algoritmic system" msgstr "Verwijder algoritme" @@ -328,12 +366,14 @@ msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:39 #: amt/site/templates/algorithms/new.html.j2:153 +#: amt/site/templates/macros/form_macros.html.j2:168 #: amt/site/templates/organizations/members.html.j2:33 msgid "Yes" msgstr "Ja" #: amt/site/templates/algorithms/details_base.html.j2:44 #: amt/site/templates/algorithms/new.html.j2:163 +#: amt/site/templates/macros/form_macros.html.j2:173 #: amt/site/templates/organizations/members.html.j2:36 msgid "No" msgstr "Nee" @@ -437,7 +477,7 @@ msgid "Failed to estimate WOZ value: " msgstr "Fout bij het schatten van de WOZ-waarde: " #: amt/site/templates/algorithms/details_info.html.j2:16 -#: amt/site/templates/algorithms/details_measure_modal.html.j2:19 +#: amt/site/templates/algorithms/details_measure_modal.html.j2:26 msgid "Description" msgstr "Omschrijving" @@ -467,47 +507,16 @@ msgstr "Labels" msgid "References" msgstr "Referenties" -#: amt/site/templates/algorithms/details_measure_modal.html.j2:29 +#: amt/site/templates/algorithms/details_measure_modal.html.j2:36 msgid "Read more on the algoritmekader" msgstr "Lees meer op het algoritmekader" -#: amt/site/templates/algorithms/details_measure_modal.html.j2:38 -msgid "Status" -msgstr "Status" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:45 #: amt/site/templates/algorithms/details_measure_modal.html.j2:47 -msgid "to do" -msgstr "Te doen" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:50 -#: amt/site/templates/algorithms/details_measure_modal.html.j2:52 -msgid "in progress" -msgstr "Onderhanden" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:55 -#: amt/site/templates/algorithms/details_measure_modal.html.j2:57 -msgid "done" -msgstr "Afgerond" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:68 -msgid "Information on how this measure is implemented" -msgstr "Informatie over hoe deze maatregel is geïmplementeerd" - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:74 -msgid "" -"Describe how the measure has been implemented, including challenges and " -"solutions." -msgstr "" -"Beschrijf hoe de maatregel is geimplementeerd, inclusief uitdagingen " -"enoplossingen." - -#: amt/site/templates/algorithms/details_measure_modal.html.j2:84 #: amt/site/templates/macros/editable.html.j2:82 msgid "Save" msgstr "Opslaan" -#: amt/site/templates/algorithms/details_measure_modal.html.j2:88 +#: amt/site/templates/algorithms/details_measure_modal.html.j2:51 #: amt/site/templates/macros/editable.html.j2:87 #: amt/site/templates/organizations/parts/add_members_modal.html.j2:26 msgid "Cancel" @@ -517,7 +526,7 @@ msgstr "Annuleren" msgid "measures executed" msgstr "maatregelen uitgevoerd" -#: amt/site/templates/algorithms/details_requirements.html.j2:55 +#: amt/site/templates/algorithms/details_requirements.html.j2:59 #: amt/site/templates/macros/editable.html.j2:24 #: amt/site/templates/macros/editable.html.j2:27 msgid "Edit" @@ -654,22 +663,30 @@ msgstr "Er is één fout:" msgid "Algorithmic Management Toolkit (AMT)" msgstr "Algoritme Management Toolkit" -#: amt/site/templates/macros/form_macros.html.j2:52 +#: amt/site/templates/macros/form_macros.html.j2:58 msgid "Are you sure you want to remove " msgstr "Weet u zeker dat u uw algoritmische systeem wilt verwijderen " -#: amt/site/templates/macros/form_macros.html.j2:52 +#: amt/site/templates/macros/form_macros.html.j2:58 msgid " from this organization? " msgstr "Het web-pad (slug) voor deze organisatie" -#: amt/site/templates/macros/form_macros.html.j2:55 +#: amt/site/templates/macros/form_macros.html.j2:61 msgid "Delete" msgstr "Verwijder" -#: amt/site/templates/macros/form_macros.html.j2:56 +#: amt/site/templates/macros/form_macros.html.j2:62 msgid "Delete member" msgstr "Voeg personen toe" +#: amt/site/templates/macros/form_macros.html.j2:150 +msgid "Delete file" +msgstr "Verwijder bestand" + +#: amt/site/templates/macros/form_macros.html.j2:157 +msgid "Are you sure you want to delete" +msgstr "Weet u zeker dat u het bestand wilt verwijderen" + #: amt/site/templates/macros/table_row.html.j2:19 msgid " ago" msgstr "geleden" diff --git a/amt/schema/measure.py b/amt/schema/measure.py index 2d7f396d..c55afd61 100644 --- a/amt/schema/measure.py +++ b/amt/schema/measure.py @@ -10,8 +10,28 @@ class MeasureBase(BaseModel): class MeasureTask(MeasureBase): state: str = Field(default="") value: str = Field(default="") + links: list[str] = Field(default=[]) + files: list[str] = Field(default=[]) version: str + def update( + self, + state: str | None, + value: str | None, + links: list[str] | None, + new_files: list[str] | None, + ) -> None: + if state: + self.state = state + + if value: + self.value = value + + self.links = [link for link in links if link] if links else [] + + if new_files: + self.files.extend(new_files) + class Measure(MeasureBase): name: str diff --git a/amt/schema/webform.py b/amt/schema/webform.py index 9c58ee5b..4645cce8 100644 --- a/amt/schema/webform.py +++ b/amt/schema/webform.py @@ -5,6 +5,8 @@ class WebFormFieldType(Enum): HIDDEN = "hidden" TEXT = "text" + TEXT_CLONEABLE = "text_cloneable" + FILE = "file" RADIO = "radio" SELECT = "select" TEXTAREA = "textarea" @@ -38,11 +40,12 @@ def __init__(self, type: WebFormFieldType, name: str, label: str, group: str | N class WebFormField(WebFormBaseField): placeholder: str | None - default_value: str | WebFormOption | None + default_value: str | list[str] | WebFormOption | list[tuple[str, str]] | None options: list[WebFormOption] | None validators: list[Any] description: str | None attributes: dict[str, str] | None + required: bool def __init__( self, @@ -50,11 +53,12 @@ def __init__( name: str, label: str, placeholder: str | None = None, - default_value: str | WebFormOption | None = None, + default_value: str | list[str] | list[tuple[str, str]] | WebFormOption | None = None, options: list[WebFormOption] | None = None, attributes: dict[str, str] | None = None, description: str | None = None, group: str | None = None, + required: bool = False, ) -> None: super().__init__(type=type, name=name, label=label, group=group) self.placeholder = placeholder @@ -62,6 +66,7 @@ def __init__( self.options = options self.attributes = attributes self.description = description + self.required = required class WebFormSearchField(WebFormField): @@ -75,7 +80,7 @@ def __init__( name: str, label: str, placeholder: str | None = None, - default_value: str | None | WebFormOption = None, + default_value: str | list[str] | list[tuple[str, str]] | WebFormOption | None = None, options: list[WebFormOption] | None = None, attributes: dict[str, str] | None = None, group: str | None = None, @@ -96,6 +101,35 @@ def __init__( self.query_var_name = query_var_name +class WebFormTextCloneableField(WebFormField): + clone_button_name: str + + def __init__( + self, + clone_button_name: str, + name: str, + label: str, + placeholder: str | None = None, + default_value: str | list[str] | list[tuple[str, str]] | None = None, + options: list[WebFormOption] | None = None, + attributes: dict[str, str] | None = None, + group: str | None = None, + description: str | None = None, + ) -> None: + super().__init__( + type=WebFormFieldType.TEXT_CLONEABLE, + name=name, + label=label, + placeholder=placeholder, + default_value=default_value, + options=options, + attributes=attributes, + group=group, + description=description, + ) + self.clone_button_name = clone_button_name + + class WebForm: id: str legend: str | None diff --git a/amt/services/object_storage.py b/amt/services/object_storage.py new file mode 100644 index 00000000..6234d6ca --- /dev/null +++ b/amt/services/object_storage.py @@ -0,0 +1,195 @@ +import logging +import os +from collections.abc import Iterable +from datetime import UTC, datetime + +from fastapi import UploadFile +from minio import Minio +from minio.datatypes import Object +from ulid import ULID +from urllib3 import BaseHTTPResponse + +from amt.core.config import get_settings +from amt.core.exceptions import AMTStorageError +from amt.schema.shared import BaseModel + +logger = logging.getLogger(__name__) + + +class ObjectMetadata(BaseModel): + """ + Metadata for files in the object storage. + """ + + algorithm_id: str + user_id: str + measure_urn: str + timestamp: str + filename: str + ext: str + + +class MinioMetadataExtractor: + @staticmethod + def from_file_upload(filename: str, algorithm_id: str, measure_urn: str, user_id: str) -> ObjectMetadata: + """ + Extract metadata for a file. + """ + filename, ext = os.path.splitext(filename) if filename else ("", "") + ext = ext.replace(".", "") + return ObjectMetadata( + algorithm_id=algorithm_id, + user_id=user_id, + measure_urn=measure_urn, + timestamp=datetime.now(UTC).isoformat(), + filename=filename, + ext=ext, + ) + + @staticmethod + def from_object(object: Object) -> ObjectMetadata: + """ + Extract metadata from a Minio object. + """ + return ObjectMetadata( + algorithm_id=object.metadata["X-Amz-Meta-Algorithm_id"] if object.metadata else "", + user_id=object.metadata["X-Amz-Meta-User_id"] if object.metadata else "", + measure_urn=object.metadata["X-Amz-Meta-Measure_urn"] if object.metadata else "", + timestamp=object.metadata["X-Amz-Meta-Timestamp"] if object.metadata else "", + filename=object.metadata["X-Amz-Meta-Filename"] if object.metadata else "", + ext=object.metadata["X-Amz-Meta-Ext"] if object.metadata else "", + ) + + +class ObjectStorageService: + def __init__( + self, + minio_client: Minio, + metadata_extractor: MinioMetadataExtractor, + bucket_name: str, + ) -> None: + self.client = minio_client + self.bucket_name = bucket_name + self._ensure_bucket_exists() + self.metadata_extractor = metadata_extractor + + def _ensure_bucket_exists(self) -> None: + """ + Validate bucket existence, raising an error if not found. + """ + if not self.client.bucket_exists(self.bucket_name): + logger.exception("Bucket in object storage does not exist.") + raise AMTStorageError() + + def _generate_destination_path( + self, organization_id: str | int, algorithm_id: str | int, ulid: ULID | None = None + ) -> str: + """ + Generate a unique destination path for file to upload if no ULID is passed. + Returns the destination path of a file with given ULID if a ULID is passed. + """ + ulid = ulid if ulid else ULID() + return f"uploads/org/{organization_id}/algorithm/{algorithm_id}/{ulid}" + + def get_file_metadata_from_object_name(self, object_name: str) -> ObjectMetadata: + """ + Gets the object metadata for a file stored under object_name. + """ + try: + stats = self.client.stat_object(self.bucket_name, object_name) + except Exception as err: + logger.exception("Could not retrieve file metadata from object store") + raise AMTStorageError() from err + + return self.metadata_extractor.from_object(stats) + + def get_file_metadata(self, organization_id: str | int, algorithm_id: str | int, ulid: ULID) -> ObjectMetadata: + """ + Gets the object metadata for a file with given organization_id, algorithm_id and ULID. + """ + path = self._generate_destination_path(organization_id, algorithm_id, ulid) + return self.get_file_metadata_from_object_name(path) + + def upload_file( + self, + organization_id: str | int, + algorithm_id: str | int, + measure_urn: str, + user_id: str, + file: UploadFile, + ) -> str: + """ + Uploads a single file to the object storage and returns the path (object name) + where the file is stored. + """ + if not file.size: + logger.exception("User provided upload file is empty.") + raise AMTStorageError() + + destination_path = self._generate_destination_path(organization_id, algorithm_id) + filename = file.filename if file.filename else "unnamed.unknown" + metadata = self.metadata_extractor.from_file_upload(filename, str(algorithm_id), measure_urn, user_id) + + try: + self.client.put_object( + self.bucket_name, destination_path, file.file, file.size, metadata=metadata.model_dump() + ) + except Exception as err: + logger.exception("Cannot upload file to object storage.") + raise AMTStorageError() from err + + return destination_path + + def upload_files( + self, + organization_id: str | int, + algorithm_id: str | int, + measure_urn: str, + user_id: str, + files: Iterable[UploadFile], + ) -> list[str]: + """ + Uploads multiple files to the object storage and returns the paths (object names) + where the files are stored. + """ + return [self.upload_file(organization_id, algorithm_id, measure_urn, user_id, file) for file in files] + + def get_file(self, organization_id: str | int, algorithm_id: str | int, ulid: ULID) -> BaseHTTPResponse: + """ + Gets a file from the object storage. + """ + path = self._generate_destination_path(organization_id, algorithm_id, ulid) + try: + file = self.client.get_object(self.bucket_name, path) + except Exception as err: + logger.exception("Cannot get file from object storage.") + raise AMTStorageError() from err + return file + + def delete_file(self, organization_id: str | int, algorithm_id: str | int, ulid: ULID) -> str: + """ + Deletes a file from the object storage. + """ + + path = self._generate_destination_path(organization_id, algorithm_id, ulid) + try: + self.client.remove_object(self.bucket_name, path) + except Exception as err: + logger.exception("Cannot delete file from object storage.") + raise AMTStorageError() from err + return path + + +def create_object_storage_service( + url: str = get_settings().OBJECT_STORE_URL, + username: str = get_settings().OBJECT_STORE_USER, + password: str = get_settings().OBJECT_STORE_PASSWORD, + bucket_name: str = get_settings().OBJECT_STORE_BUCKET_NAME, + secure: bool = False, +) -> ObjectStorageService: + """ + Creates an instance of the ObjectStorageService. + """ + metadata_extractor = MinioMetadataExtractor() + client = Minio(endpoint=url, access_key=username, secret_key=password, secure=secure) + return ObjectStorageService(client, metadata_extractor, bucket_name=bucket_name) diff --git a/amt/services/task_registry.py b/amt/services/task_registry.py index e751fb0f..71e16ee0 100644 --- a/amt/services/task_registry.py +++ b/amt/services/task_registry.py @@ -68,7 +68,9 @@ 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) - applicable_measures.append(MeasureTask(urn=measure_urn, version=measure[0].schema_version)) + applicable_measures.append( + MeasureTask(urn=measure_urn, state="to do", version=measure[0].schema_version) + ) measure_urns.add(measure_urn) return applicable_requirements, applicable_measures diff --git a/amt/site/static/scss/layout.scss b/amt/site/static/scss/layout.scss index 2356767c..dfca1d5b 100644 --- a/amt/site/static/scss/layout.scss +++ b/amt/site/static/scss/layout.scss @@ -424,8 +424,12 @@ main { z-index: 1; } -/* stylelint-enable */ - .amt-cursor-pointer { cursor: pointer; } + +.rvo-layout-gap--0 { + gap: 0 !important; +} + +/* stylelint-enable */ diff --git a/amt/site/static/ts/amt.ts b/amt/site/static/ts/amt.ts index 2354ec4c..429ed072 100644 --- a/amt/site/static/ts/amt.ts +++ b/amt/site/static/ts/amt.ts @@ -388,3 +388,41 @@ export function add_field_on_enter(id: string) { show_form_search_options(id); } } + +export function createLinkComponent(template_id: string, element_id: string) { + const template = document.getElementById(template_id) as HTMLTemplateElement; + const item = document.getElementById(element_id); + const link = template?.content.cloneNode(true); + if (link) { + item?.appendChild(link); + } +} + +export function getFiles(element: HTMLInputElement, target_id: string) { + if (element.files) { + const list = document.getElementById(target_id) as HTMLElement; + list.innerHTML = ""; + for (const file of element.files) { + const li = document.createElement("li"); + li.className = "rvo-item-list__item"; + + const container = document.createElement("div"); + container.className = "rvo-layout-row rvo-layout-gap--sm"; + + const icon_el = document.createElement("span"); + icon_el.className = + "utrecht-icon rvo-icon rvo-icon-document-met-lijnen rvo-icon--md rvo-icon--hemelblauw"; + icon_el.role = "img"; + icon_el.ariaLabel = "File"; + container.appendChild(icon_el); + + const text_el = document.createElement("span"); + text_el.textContent = file.name; + container.appendChild(text_el); + + li.appendChild(container); + + list.appendChild(li); + } + } +} diff --git a/amt/site/templates/algorithms/details_measure_modal.html.j2 b/amt/site/templates/algorithms/details_measure_modal.html.j2 index 9b93cd98..4f9a7673 100644 --- a/amt/site/templates/algorithms/details_measure_modal.html.j2 +++ b/amt/site/templates/algorithms/details_measure_modal.html.j2 @@ -1,21 +1,28 @@ +{% import "macros/form_macros.html.j2" as macros with context %}
diff --git a/amt/site/templates/algorithms/details_requirements.html.j2 b/amt/site/templates/algorithms/details_requirements.html.j2 index 1ca02273..485f90d9 100644 --- a/amt/site/templates/algorithms/details_requirements.html.j2 +++ b/amt/site/templates/algorithms/details_requirements.html.j2 @@ -31,11 +31,15 @@
+ {% trans %}Are you sure you want to delete{% endtrans %} + {{ file }}? +