diff --git a/amt/api/deps.py b/amt/api/deps.py index 7cc6f388..02076c84 100644 --- a/amt/api/deps.py +++ b/amt/api/deps.py @@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from jinja2 import Environment, StrictUndefined, Undefined +from jinja2_base64_filters import jinja2_base64_filters # pyright: ignore #noqa from starlette.background import BackgroundTask from starlette.templating import _TemplateResponse # pyright: ignore [reportPrivateUsage] @@ -30,6 +31,7 @@ ) from amt.schema.localized_value_item import LocalizedValueItem from amt.schema.shared import IterMixin +from amt.schema.webform import WebFormFieldType T = TypeVar("T", bound=Enum | LocalizableEnum) @@ -38,7 +40,7 @@ def custom_context_processor( request: Request, -) -> dict[str, str | None | list[str] | dict[str, str] | list[NavigationItem]]: +) -> dict[str, str | None | list[str] | dict[str, str] | list[NavigationItem] | type[WebFormFieldType]]: lang = get_requested_language(request) translations = get_current_translation(request) return { @@ -48,6 +50,7 @@ def custom_context_processor( "translations": get_dynamic_field_translations(lang), "main_menu_items": get_main_menu(request, translations), "user": get_user(request), + "WebFormFieldType": WebFormFieldType, } @@ -163,3 +166,4 @@ def instance(obj: Class, type_string: str) -> bool: templates.env.globals.update(nested_enum=nested_enum) # pyright: ignore [reportUnknownMemberType] templates.env.globals.update(nested_enum_value=nested_enum_value) # pyright: ignore [reportUnknownMemberType] templates.env.globals.update(isinstance=instance) # pyright: ignore [reportUnknownMemberType] +templates.env.add_extension("jinja2_base64_filters.Base64Filters") # pyright: ignore [reportUnknownMemberType] diff --git a/amt/api/forms/algorithm.py b/amt/api/forms/algorithm.py new file mode 100644 index 00000000..dc08d8b1 --- /dev/null +++ b/amt/api/forms/algorithm.py @@ -0,0 +1,36 @@ +from gettext import NullTranslations +from uuid import UUID + +from amt.schema.webform import WebForm, WebFormField, WebFormFieldType, WebFormOption +from amt.services.organizations import OrganizationsService + + +async def get_algorithm_form( + id: str, translations: NullTranslations, organizations_service: OrganizationsService, user_id: str | UUID | None +) -> WebForm: + _ = translations.gettext + + algorithm_form: WebForm = WebForm(id=id, post_url="") + + user_id = UUID(user_id) if isinstance(user_id, str) else user_id + + my_organizations = await organizations_service.get_organizations_for_user(user_id=user_id) + + select_organization: WebFormOption = WebFormOption(value="", display_value=_("Select organization")) + + algorithm_form.fields = [ + WebFormField( + type=WebFormFieldType.SELECT, + name="organization_id", + label=_("Organization"), + options=[select_organization] + + [ + WebFormOption(value=str(organization.id), display_value=organization.name) + for organization in my_organizations + ], + default_value="", + group="1", + ), + ] + + return algorithm_form diff --git a/amt/api/forms/organization.py b/amt/api/forms/organization.py new file mode 100644 index 00000000..2bdbc7c6 --- /dev/null +++ b/amt/api/forms/organization.py @@ -0,0 +1,38 @@ +from gettext import NullTranslations + +from amt.schema.webform import WebForm, WebFormField, WebFormFieldType, WebFormSearchField, WebFormSubmitButton + + +def get_organization_form(id: str, translations: NullTranslations) -> WebForm: + _ = translations.gettext + + organization_form: WebForm = WebForm(id=id, post_url="/organizations/new") + + organization_form.fields = [ + WebFormField( + type=WebFormFieldType.TEXT, + name="name", + label=_("Name"), + placeholder=_("Name of the organization"), + attributes={"onkeyup": "amt.generate_slug('" + id + "name', '" + id + "slug')"}, + group="1", + ), + WebFormField( + type=WebFormFieldType.TEXT, + name="slug", + description=_("The slug is the web path, like /organizations/my-organization-name"), + label=_("Slug"), + placeholder=_("The slug for this organization"), + group="1", + ), + WebFormSearchField( + name="user_ids", + label=_("Add users"), + placeholder=_("Search for a user"), + search_url="/organizations/users?returnType=search_select_field", + query_var_name="query", + group="1", + ), + WebFormSubmitButton(label=_("Add organization"), group="1", name="submit"), + ] + return organization_form diff --git a/amt/api/main.py b/amt/api/main.py index f381b234..2d8c8d44 100644 --- a/amt/api/main.py +++ b/amt/api/main.py @@ -1,11 +1,12 @@ from fastapi import APIRouter -from amt.api.routes import algorithm, algorithms, auth, health, pages, root +from amt.api.routes import algorithm, algorithms, auth, health, organizations, pages, root -api_router = APIRouter() +api_router: APIRouter = APIRouter() api_router.include_router(root.router) api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(pages.router, prefix="/pages", tags=["pages"]) api_router.include_router(algorithms.router, prefix="/algorithms", tags=["algorithms"]) api_router.include_router(algorithm.router, prefix="/algorithm", tags=["algorithm"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(organizations.router, prefix="/organizations", tags=["organizations"]) diff --git a/amt/api/navigation.py b/amt/api/navigation.py index d1516168..11ae3524 100644 --- a/amt/api/navigation.py +++ b/amt/api/navigation.py @@ -30,6 +30,8 @@ class DisplayText(Enum): ASSESSMENTCARD = "assessmentcard" MODELCARD = "modelcard" DETAILS = "details" + ORGANIZATIONS = "organizations" + PEOPLE = "people" def get_translation(key: DisplayText, translations: NullTranslations) -> str: @@ -57,6 +59,9 @@ def get_translation(key: DisplayText, translations: NullTranslations) -> str: DisplayText.DATA: _("Data"), DisplayText.MODEL: _("Model"), DisplayText.INSTRUMENTS: _("Instruments"), + DisplayText.ORGANIZATIONS: _("Organizations"), + DisplayText.ALGORITHMS: _("Algorithms"), + DisplayText.PEOPLE: _("People"), } return keys[key] @@ -128,6 +133,18 @@ class Navigation: ALGORITHM_INSTRUMENTS = BaseNavigationItem( display_text=DisplayText.INSTRUMENTS, url="/algorithm/{algorithm_id}/details/system_card/instruments" ) + ORGANIZATIONS_ROOT = BaseNavigationItem( + display_text=DisplayText.ORGANIZATIONS, url="/organizations", icon="rvo-icon-man-torso-voor-hoogbouw" + ) + ORGANIZATIONS_NEW = BaseNavigationItem(display_text=DisplayText.NEW, url="/organizations/new") + ORGANIZATIONS_OVERVIEW = BaseNavigationItem(display_text=DisplayText.OVERVIEW, url="/organizations/") + ORGANIZATIONS_INFO = BaseNavigationItem(display_text=DisplayText.INFO, url="/organizations/{organization_slug}") + ORGANIZATIONS_ALGORITHMS = BaseNavigationItem( + display_text=DisplayText.ALGORITHMS, url="/organizations/{organization_slug}/algorithms" + ) + ORGANIZATIONS_PEOPLE = BaseNavigationItem( + display_text=DisplayText.PEOPLE, url="/organizations/{organization_slug}/people" + ) class NavigationItem: @@ -242,6 +259,7 @@ def get_main_menu(request: Request, translations: NullTranslations) -> list[Navi # main menu items are the same for all pages main_menu_items = [ NavigationItem(Navigation.ALGORITHMS_ROOT, translations=translations), + NavigationItem(Navigation.ORGANIZATIONS_ROOT, translations=translations), ] return _mark_active_navigation_item(main_menu_items, request.url.path) diff --git a/amt/api/organization_filter_options.py b/amt/api/organization_filter_options.py new file mode 100644 index 00000000..82648b43 --- /dev/null +++ b/amt/api/organization_filter_options.py @@ -0,0 +1,27 @@ +from collections.abc import Callable + +from fastapi import Request + +from ..schema.localized_value_item import LocalizedValueItem +from .localizable import LocalizableEnum, get_localized_enum, get_localized_enums + + +class OrganizationFilterOptions(LocalizableEnum): + ALL = "ALL" + MY_ORGANIZATIONS = "MY_ORGANIZATIONS" + + @classmethod + def get_display_values( + cls: type["OrganizationFilterOptions"], _: Callable[[str], str] + ) -> dict["OrganizationFilterOptions", str]: + return {cls.ALL: _("All organizations"), cls.MY_ORGANIZATIONS: _("My organizations")} + + +def get_localized_organization_filter( + key: OrganizationFilterOptions | None, request: Request +) -> LocalizedValueItem | None: + return get_localized_enum(key, request) + + +def get_localized_organization_filters(request: Request) -> list[LocalizedValueItem | None]: + return get_localized_enums(OrganizationFilterOptions, request) diff --git a/amt/api/routes/algorithm.py b/amt/api/routes/algorithm.py index b482820c..42e6c7b8 100644 --- a/amt/api/routes/algorithm.py +++ b/amt/api/routes/algorithm.py @@ -15,17 +15,21 @@ resolve_base_navigation_items, resolve_navigation_items, ) +from amt.core.authorization import get_user from amt.core.exceptions import AMTNotFound, AMTRepositoryError from amt.enums.status import Status from amt.models import Algorithm from amt.models.task import Task +from amt.repositories.organizations import OrganizationsRepository from amt.schema.measure import ExtendedMeasureTask, MeasureTask from amt.schema.requirement import RequirementTask -from amt.schema.system_card import SystemCard +from amt.schema.system_card import Owner, SystemCard from amt.schema.task import MovedTask +from amt.schema.webform import WebFormOption from amt.services.algorithms import AlgorithmsService from amt.services.instruments_and_requirements_state import InstrumentStateService, RequirementsStateService from amt.services.measures import MeasuresService, create_measures_service +from amt.services.organizations import OrganizationsService from amt.services.requirements import RequirementsService, create_requirements_service from amt.services.tasks import TasksService @@ -201,6 +205,7 @@ async def get_algorithm_details( ) context["breadcrumbs"] = breadcrumbs + context["base_href"] = f"/algorithm/{ algorithm_id }" return templates.TemplateResponse(request, "algorithms/details_info.html.j2", context) @@ -210,10 +215,30 @@ async def get_algorithm_edit( request: Request, algorithm_id: int, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], + organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], path: str, + edit_type: str = "systemcard", ) -> HTMLResponse: - _, context = await get_algorithm_context(algorithm_id, algorithms_service, request) - context["path"] = path.replace("/", ".") + algorithm, context = await get_algorithm_context(algorithm_id, algorithms_service, request) + context.update( + { + "path": path.replace("/", "."), + "edit_type": edit_type, + "object": algorithm, + "base_href": f"/algorithm/{ algorithm_id }", + } + ) + + if edit_type == "select_my_organizations": + user = get_user(request) + + my_organizations = await organizations_service.get_organizations_for_user(user_id=user["sub"] if user else None) + + context["select_options"] = [ + WebFormOption(value=str(organization.id), display_value=organization.name) + for organization in my_organizations + ] + return templates.TemplateResponse(request, "parts/edit_cell.html.j2", context) @@ -223,9 +248,17 @@ async def get_algorithm_cancel( algorithm_id: int, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], path: str, + edit_type: str = "systemcard", ) -> HTMLResponse: - _, context = await get_algorithm_context(algorithm_id, algorithms_service, request) - context["path"] = path.replace("/", ".") + algorithm, context = await get_algorithm_context(algorithm_id, algorithms_service, request) + context.update( + { + "path": path.replace("/", "."), + "edit_type": edit_type, + "base_href": f"/algorithm/{ algorithm_id }", + "object": algorithm, + } + ) return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) @@ -261,13 +294,28 @@ async def get_algorithm_update( request: Request, algorithm_id: int, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], + organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], update_data: UpdateFieldModel, path: str, + edit_type: str = "systemcard", ) -> HTMLResponse: algorithm, context = await get_algorithm_context(algorithm_id, algorithms_service, request) - set_path(algorithm, path, update_data.value) - await algorithms_service.update(algorithm) - context["path"] = path.replace("/", ".") + context.update( + {"path": path.replace("/", "."), "edit_type": edit_type, "base_href": f"/algorithm/{ algorithm_id }"} + ) + + if edit_type == "select_my_organizations": + organization = await organizations_repository.find_by_id(int(update_data.value)) + algorithm.organization = organization + # TODO: we need to know which organization to update and what to remove + if not algorithm.system_card.owners: + algorithm.system_card.owners = [Owner(organization=organization.name, oin=str(organization.id))] + algorithm.system_card.owners[0].organization = organization.name + else: + set_path(algorithm, path, update_data.value) + + algorithm = await algorithms_service.update(algorithm) + context.update({"object": algorithm}) return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) diff --git a/amt/api/routes/algorithms.py b/amt/api/routes/algorithms.py index 5161bb2b..1bd20f3b 100644 --- a/amt/api/routes/algorithms.py +++ b/amt/api/routes/algorithms.py @@ -6,39 +6,28 @@ from amt.api.ai_act_profile import get_ai_act_profile_selector 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 from amt.api.lifecycles import Lifecycles, get_localized_lifecycle, get_localized_lifecycles from amt.api.navigation import Navigation, resolve_base_navigation_items, resolve_navigation_items from amt.api.publication_category import ( - PublicationCategories, get_localized_publication_categories, - get_localized_publication_category, ) +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.internationalization import get_current_translation from amt.models import Algorithm from amt.schema.algorithm import AlgorithmNew -from amt.schema.localized_value_item import LocalizedValueItem +from amt.schema.webform import WebForm from amt.services.algorithms import AlgorithmsService, get_template_files from amt.services.instruments import InstrumentsService, create_instrument_service +from amt.services.organizations import OrganizationsService router = APIRouter() logger = logging.getLogger(__name__) -def get_localized_value(key: str, value: str, request: Request) -> LocalizedValueItem: - match key: - case "lifecycle": - localized = get_localized_lifecycle(Lifecycles[value], request) - case "publication-category": - localized = get_localized_publication_category(PublicationCategories[value], request) - case _: - localized = None - - if localized: - return localized - - return LocalizedValueItem(value=value, display_value="Unknown filter option") - - @router.get("/") async def get_root( request: Request, @@ -48,22 +37,7 @@ async def get_root( search: str = Query(""), display_type: str = Query(""), ) -> HTMLResponse: - active_filters = { - k.removeprefix("active-filter-"): v - for k, v in request.query_params.items() - if k.startswith("active-filter") and v != "" - } - add_filters = { - k.removeprefix("add-filter-"): v - for k, v in request.query_params.items() - if k.startswith("add-filter") and v != "" - } - drop_filters = [v for k, v in request.query_params.items() if k.startswith("drop-filter") and v != ""] - filters = {k: v for k, v in (active_filters | add_filters).items() if k not in drop_filters} - localized_filters = {k: get_localized_value(k, v, request) for k, v in filters.items()} - sort_by = { - k.removeprefix("sort-by-"): v for k, v in request.query_params.items() if k.startswith("sort-by-") and v != "" - } + filters, drop_filters, localized_filters, sort_by = get_filters_and_sort_by(request) amount_algorithm_systems: int = 0 if display_type == "LIFECYCLE": @@ -126,12 +100,22 @@ async def get_root( async def get_new( request: Request, instrument_service: Annotated[InstrumentsService, Depends(create_instrument_service)], + organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], ) -> HTMLResponse: sub_menu_items = resolve_navigation_items([Navigation.ALGORITHMS_OVERVIEW], request) # pyright: ignore [reportUnusedVariable] # noqa breadcrumbs = resolve_base_navigation_items([Navigation.ALGORITHMS_ROOT, Navigation.ALGORITHM_NEW], request) ai_act_profile = get_ai_act_profile_selector(request) + user = get_user(request) + + algorithm_form: WebForm = await get_algorithm_form( + id="algorithm", + translations=get_current_translation(request), + organizations_service=organizations_service, + user_id=user["sub"] if user else None, + ) + template_files = get_template_files() instruments = await instrument_service.fetch_instruments() @@ -143,6 +127,7 @@ async def get_new( "sub_menu_items": {}, # sub_menu_items disabled for now, "lifecycles": get_localized_lifecycles(request), "template_files": template_files, + "form": algorithm_form, } response = templates.TemplateResponse(request, "algorithms/new.html.j2", context) @@ -155,6 +140,10 @@ async def post_new( algorithm_new: AlgorithmNew, algorithms_service: Annotated[AlgorithmsService, Depends(AlgorithmsService)], ) -> HTMLResponse: - algorithm = await algorithms_service.create(algorithm_new) - response = templates.Redirect(request, f"/algorithm/{algorithm.id}/details/tasks") - return response + 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/tasks") + return response + raise AMTAuthorizationError diff --git a/amt/api/routes/auth.py b/amt/api/routes/auth.py index 9e7d4ee5..1a62d885 100644 --- a/amt/api/routes/auth.py +++ b/amt/api/routes/auth.py @@ -66,8 +66,15 @@ async def auth_callback( user["name_encoded"] = quote_plus(name) if "sub" in user and isinstance(user["sub"], str): - new_user = User(id=UUID(user["sub"]), name=user["name"]) # type: ignore - new_user = await users_service.create_or_update(new_user) + new_user = User( + id=UUID(user["sub"]), # type: ignore + name=user["name"], + email=user["email"], + name_encoded=user["name_encoded"], + email_hash=user["email_hash"], + ) + # update the user in the database with (potential) new information + await users_service.create_or_update(new_user) if user: request.session["user"] = dict(user) # type: ignore diff --git a/amt/api/routes/organizations.py b/amt/api/routes/organizations.py new file mode 100644 index 00000000..48dd1ca7 --- /dev/null +++ b/amt/api/routes/organizations.py @@ -0,0 +1,234 @@ +from collections.abc import Sequence +from typing import Annotated, Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import HTMLResponse, JSONResponse, Response +from pydantic_core._pydantic_core import ValidationError # pyright: ignore + +from amt.api.deps import templates +from amt.api.forms.organization import get_organization_form +from amt.api.navigation import Navigation, NavigationItem, resolve_base_navigation_items, resolve_navigation_items +from amt.api.organization_filter_options import get_localized_organization_filters +from amt.api.routes.algorithm import UpdateFieldModel, set_path +from amt.api.routes.shared import get_filters_and_sort_by +from amt.core.authorization import get_user +from amt.core.exceptions import AMTNotFound, AMTRepositoryError +from amt.core.internationalization import get_current_translation +from amt.models import Organization +from amt.repositories.organizations import OrganizationsRepository +from amt.repositories.users import UsersRepository +from amt.schema.organization import OrganizationBase, OrganizationNew, OrganizationSlug +from amt.services.organizations import OrganizationsService + +router = APIRouter() + + +def get_organization_tabs(request: Request, organization_slug: str) -> list[NavigationItem]: + request.state.path_variables = {"organization_slug": organization_slug} + + return resolve_navigation_items( + [Navigation.ORGANIZATIONS_INFO, Navigation.ORGANIZATIONS_ALGORITHMS, Navigation.ORGANIZATIONS_PEOPLE], + request, + ) + + +# TODO (Robbert): maybe this should become its own endpoint +@router.get("/users") +async def get_users( + request: Request, + users_repository: Annotated[UsersRepository, Depends(UsersRepository)], +) -> Response: + query = request.query_params.get("query", None) + return_type = request.query_params.get("returnType", "json") + search_results = [] + if query and len(query) >= 2: + search_results: list[dict[str, str | UUID]] = [ + {"value": str(user.id), "display_value": user.name} + for user in await users_repository.find_all(search=query, limit=5) + ] + match return_type: + case "search_select_field": + context = {"search_results": search_results} + return templates.TemplateResponse(request, "organizations/parts/search_select_field.html.j2", context) + case _: + return JSONResponse(content=search_results) + + +@router.get("/new") +async def get_new( + request: Request, +) -> HTMLResponse: + breadcrumbs = resolve_base_navigation_items([Navigation.ORGANIZATIONS_ROOT, Navigation.ORGANIZATIONS_NEW], request) + + form = get_organization_form(id="organization", translations=get_current_translation(request)) + + context: dict[str, Any] = {"form": form, "breadcrumbs": breadcrumbs} + + return templates.TemplateResponse(request, "organizations/new.html.j2", context) + + +@router.get("/") +async def root( + request: Request, + organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], + 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) + + user = get_user(request) + + breadcrumbs = resolve_base_navigation_items( + [Navigation.ORGANIZATIONS_ROOT, Navigation.ORGANIZATIONS_OVERVIEW], request + ) + organizations: Sequence[Organization] = await organizations_repository.find_by( + search=search, sort=sort_by, filters=filters, user_id=user["sub"] if user else None + ) + + context: dict[str, Any] = { + "breadcrumbs": breadcrumbs, + "organizations": organizations, + "next": next, + "limit": limit, + "start": skip, + "search": search, + "sort_by": sort_by, + "organizations_length": len(organizations), + "filters": localized_filters, + "include_filters": False, + "organization_filters": get_localized_organization_filters(request), + } + + if request.state.htmx: + if drop_filters: + context.update({"include_filters": True}) + return templates.TemplateResponse(request, "organizations/parts/overview_results.html.j2", context) + else: + context.update({"include_filters": True}) + return templates.TemplateResponse(request, "organizations/index.html.j2", context) + + +@router.post("/new", response_class=HTMLResponse) +async def post_new( + request: Request, + organization_new: OrganizationNew, + organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)], +) -> HTMLResponse: + session_user = get_user(request) + + await organizations_service.save( + name=organization_new.name, + slug=organization_new.slug, + user_ids=organization_new.user_ids, # pyright: ignore[reportArgumentType] + created_by_user_id=(session_user["sub"] if session_user else None), # pyright: ignore[reportUnknownArgumentType] + ) + + response = templates.Redirect(request, f"/organizations/{organization_new.slug}") + return response + + +@router.get("/{slug}") +async def get_by_slug( + request: Request, + slug: str, + organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], +) -> HTMLResponse: + try: + organization = await organizations_repository.find_by_slug(slug) + tab_items = get_organization_tabs(request, organization_slug=slug) + context = {"base_href": f"/organizations/{ slug }", "organization": organization, "tab_items": tab_items} + return templates.TemplateResponse(request, "organizations/home.html.j2", context) + except AMTRepositoryError as e: + raise AMTNotFound from e + + +@router.get("/{slug}/edit/{path:path}") +async def get_organization_edit( + request: Request, + organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], + path: str, + slug: str, + edit_type: str, +) -> HTMLResponse: + context: dict[str, Any] = {"base_href": f"/organizations/{ slug }"} + organization = await organizations_repository.find_by_slug(slug) + context.update({"path": path.replace("/", "."), "edit_type": edit_type, "object": organization}) + return templates.TemplateResponse(request, "parts/edit_cell.html.j2", context) + + +@router.get("/{slug}/cancel/{path:path}") +async def get_organization_cancel( + organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], + request: Request, + path: str, + edit_type: str, + slug: str, +) -> HTMLResponse: + context: dict[str, Any] = { + "base_href": f"/organizations/{ slug }", + "path": path.replace("/", "."), + "edit_type": edit_type, + } + organization = await organizations_repository.find_by_slug(slug) + context.update({"object": organization}) + return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) + + +@router.put("/{slug}/update/{path:path}") +async def get_organization_update( + request: Request, + organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], + update_data: UpdateFieldModel, + path: str, + edit_type: str, + slug: str, +) -> HTMLResponse: + context: dict[str, Any] = { + "base_href": f"/organizations/{ slug }", + "path": path.replace("/", "."), + "edit_type": edit_type, + } + organization = await organizations_repository.find_by_slug(slug) + context.update({"object": organization}) + + redirect_to: str | None = None + # TODO (Robbert) it would be nice to check this on the object.field type (instead of strings) + if path == "slug": + try: + organization_new1: OrganizationSlug = OrganizationSlug(slug=update_data.value) + OrganizationSlug.model_validate(organization_new1) + redirect_to = organization_new1.slug + except ValidationError as e: + raise RequestValidationError(e.errors()) from e + elif path == "name": + try: + organization_new: OrganizationBase = OrganizationBase(name=update_data.value) + OrganizationBase.model_validate(organization_new) + except ValidationError as e: + raise RequestValidationError(e.errors()) from e + + set_path(organization, path, update_data.value) + + await organizations_repository.save(organization) + + if redirect_to: + return templates.Redirect(request, f"/organizations/{redirect_to}") + else: + return templates.TemplateResponse(request, "parts/view_cell.html.j2", context) + + +@router.get("/{slug}/algorithms") +async def get_algorithms( + request: Request, +) -> HTMLResponse: + return templates.TemplateResponse(request, "pages/under_construction.html.j2", {}) + + +@router.get("/{slug}/people") +async def get_people( + request: Request, +) -> HTMLResponse: + return templates.TemplateResponse(request, "pages/under_construction.html.j2", {}) diff --git a/amt/api/routes/shared.py b/amt/api/routes/shared.py new file mode 100644 index 00000000..bc8c378a --- /dev/null +++ b/amt/api/routes/shared.py @@ -0,0 +1,50 @@ +from starlette.requests import Request + +from amt.api.lifecycles import Lifecycles, get_localized_lifecycle +from amt.api.organization_filter_options import OrganizationFilterOptions, get_localized_organization_filter +from amt.api.publication_category import PublicationCategories, get_localized_publication_category +from amt.schema.localized_value_item import LocalizedValueItem + + +def get_filters_and_sort_by( + request: Request, +) -> tuple[dict[str, str], 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() + if k.startswith("active-filter") and v != "" + } + add_filters: dict[str, str] = { + k.removeprefix("add-filter-"): v + for k, v in request.query_params.items() + if k.startswith("add-filter") and v != "" + } + # 'all organizations' is not really a filter type, so we remove it when it is added + 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() + } + 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 != "" + } + return filters, drop_filters, localized_filters, sort_by + + +def get_localized_value(key: str, value: str, request: Request) -> LocalizedValueItem: + match key: + case "lifecycle": + localized = get_localized_lifecycle(Lifecycles(value), request) + case "publication-category": + localized = get_localized_publication_category(PublicationCategories[value], request) + case "organization-type": + localized = get_localized_organization_filter(OrganizationFilterOptions(value), request) + case _: + localized = None + + if localized: + return localized + + return LocalizedValueItem(value=value, display_value="Unknown filter option") diff --git a/amt/clients/clients.py b/amt/clients/clients.py index 0b0a8c71..5522d57f 100644 --- a/amt/clients/clients.py +++ b/amt/clients/clients.py @@ -3,6 +3,7 @@ from typing import Any import httpx +from amt.core.config import get_settings from amt.core.exceptions import AMTInstrumentError, AMTNotFound from amt.schema.github import RepositoryContent from async_lru import alru_cache @@ -39,9 +40,7 @@ class TaskRegistryAPIClient(APIClient): """ def __init__(self, max_retries: int = 3, timeout: int = 5) -> None: - super().__init__( - base_url="https://task-registry.apps.digilab.network", max_retries=max_retries, timeout=timeout - ) + super().__init__(base_url=get_settings().TASK_REGISTRY_URL, max_retries=max_retries, timeout=timeout) async def get_list_of_task(self, task: TaskType = TaskType.INSTRUMENTS) -> RepositoryContent: response_data = await self._make_request(f"{task.value}/") diff --git a/amt/core/authorization.py b/amt/core/authorization.py index b882c9e9..15956894 100644 --- a/amt/core/authorization.py +++ b/amt/core/authorization.py @@ -1,14 +1,16 @@ from collections.abc import Iterable +from typing import Any from starlette.requests import Request from amt.core.internationalization import get_requested_language -def get_user(request: Request) -> dict[str, str] | None: +def get_user(request: Request) -> dict[str, Any] | None: user = None if isinstance(request.scope, Iterable) and "session" in request.scope: user = request.session.get("user", None) if user: user["locale"] = get_requested_language(request) + return user diff --git a/amt/core/config.py b/amt/core/config.py index 00c72a16..28f662df 100644 --- a/amt/core/config.py +++ b/amt/core/config.py @@ -35,7 +35,6 @@ class Settings(BaseSettings): DEBUG: bool = False AUTO_CREATE_SCHEMA: bool = False - ISSUER: str = "https://keycloak.apps.digilab.network/realms/algoritmes" OIDC_CLIENT_ID: str | None = None OIDC_CLIENT_SECRET: str | None = None OIDC_DISCOVERY_URL: str = "https://keycloak.apps.digilab.network/realms/algoritmes/.well-known/openid-configuration" @@ -62,6 +61,8 @@ class Settings(BaseSettings): CSRF_TOKEN_KEY: str = "csrf-token" CSRF_COOKIE_SAMESITE: str = "strict" + TASK_REGISTRY_URL: str = "https://task-registry.apps.digilab.network" + @computed_field def SQLALCHEMY_ECHO(self) -> bool: return self.DEBUG diff --git a/amt/core/exception_handlers.py b/amt/core/exception_handlers.py index e8eac482..3a06f261 100644 --- a/amt/core/exception_handlers.py +++ b/amt/core/exception_handlers.py @@ -9,7 +9,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from amt.api.deps import templates -from amt.core.exceptions import AMTHTTPException, AMTNotFound, AMTRepositoryError +from amt.core.exceptions import AMTCSRFProtectError, AMTHTTPException, AMTNotFound, AMTRepositoryError from amt.core.internationalization import ( get_current_translation, ) @@ -19,6 +19,8 @@ CUSTOM_MESSAGES = { "string_too_short": _("String should have at least {min_length} characters"), "missing": _("Field required"), + "value_error": _("Field required"), + "string_pattern_mismatch": _("String should match pattern '{pattern}'"), } @@ -32,20 +34,24 @@ def translate_pydantic_exception(err: dict[str, Any], translations: NullTranslat return err["msg"] -async def general_exception_handler(request: Request, exc: Exception) -> HTMLResponse: +async def general_exception_handler(request: Request, exc: Exception) -> HTMLResponse: # noqa exception_name = exc.__class__.__name__ logger.debug(f"general_exception_handler {exception_name}: {exc}") translations = get_current_translation(request) + response_headers: dict[str, str] = {} message = None - if isinstance(exc, AMTRepositoryError | AMTHTTPException): + if isinstance(exc, AMTCSRFProtectError): # AMTCSRFProtectError is AMTHTTPException + message = exc.getmessage(translations) + response_headers["HX-Retarget"] = "#general-error-container" + elif isinstance(exc, AMTRepositoryError | AMTHTTPException): message = exc.getmessage(translations) elif isinstance(exc, StarletteHTTPException): message = AMTNotFound().getmessage(translations) if exc.status_code == status.HTTP_404_NOT_FOUND else exc.detail elif isinstance(exc, RequestValidationError): - # i assume only pydantic errors get into this section + # I assume only pydantic errors get into this section message = exc.errors() for err in message: err["msg"] = translate_pydantic_exception(err, translations) @@ -69,10 +75,7 @@ async def general_exception_handler(request: Request, exc: Exception) -> HTMLRes try: response = templates.TemplateResponse( - request, - template_name, - {"message": message}, - status_code=status_code, + request, template_name, {"message": message}, status_code=status_code, headers=response_headers ) except Exception: response = templates.TemplateResponse( diff --git a/amt/locale/base.pot b/amt/locale/base.pot index c7e52cc3..c9be4afd 100644 --- a/amt/locale/base.pot +++ b/amt/locale/base.pot @@ -42,10 +42,10 @@ msgid "Role" msgstr "" #: amt/api/group_by_category.py:15 -#: amt/site/templates/algorithms/details_info.html.j2:24 -#: amt/site/templates/algorithms/new.html.j2:40 +#: amt/site/templates/algorithms/details_info.html.j2:28 +#: 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:94 +#: amt/site/templates/parts/filter_list.html.j2:71 msgid "Lifecycle" msgstr "" @@ -85,66 +85,85 @@ msgstr "" msgid "Phasing Out" msgstr "" -#: amt/api/navigation.py:45 +#: amt/api/navigation.py:47 msgid "Home" msgstr "" -#: amt/api/navigation.py:46 amt/site/templates/parts/algorithm_search.html.j2:9 +#: amt/api/navigation.py:48 amt/api/navigation.py:63 +#: amt/site/templates/parts/algorithm_search.html.j2:9 msgid "Algorithms" msgstr "" -#: amt/api/navigation.py:47 amt/site/templates/auth/profile.html.j2:10 +#: amt/api/navigation.py:49 amt/site/templates/auth/profile.html.j2:10 msgid "Overview" msgstr "" -#: amt/api/navigation.py:48 +#: amt/api/navigation.py:50 msgid "Tasks" msgstr "" -#: amt/api/navigation.py:49 +#: amt/api/navigation.py:51 msgid "New" msgstr "" -#: amt/api/navigation.py:50 +#: amt/api/navigation.py:52 msgid "System card" msgstr "" -#: amt/api/navigation.py:51 +#: amt/api/navigation.py:53 msgid "Assessment card" msgstr "" -#: amt/api/navigation.py:52 +#: amt/api/navigation.py:54 msgid "Model card" msgstr "" -#: amt/api/navigation.py:53 +#: amt/api/navigation.py:55 msgid "Details" msgstr "" -#: amt/api/navigation.py:54 +#: amt/api/navigation.py:56 msgid "Info" msgstr "" -#: amt/api/navigation.py:55 +#: amt/api/navigation.py:57 msgid "Algorithm details" msgstr "" -#: amt/api/navigation.py:56 +#: amt/api/navigation.py:58 msgid "Requirements" msgstr "" -#: amt/api/navigation.py:57 +#: amt/api/navigation.py:59 msgid "Data" msgstr "" -#: amt/api/navigation.py:58 +#: amt/api/navigation.py:60 msgid "Model" msgstr "" -#: amt/api/navigation.py:59 amt/site/templates/algorithms/new.html.j2:149 +#: amt/api/navigation.py:61 amt/site/templates/algorithms/new.html.j2:151 msgid "Instruments" msgstr "" +#: amt/api/navigation.py:62 +#: amt/site/templates/organizations/parts/overview_results.html.j2:6 +msgid "Organizations" +msgstr "" + +#: amt/api/navigation.py:64 amt/site/templates/organizations/home.html.j2:45 +#: amt/site/templates/organizations/parts/overview_results.html.j2:130 +msgid "People" +msgstr "" + +#: amt/api/organization_filter_options.py:17 +msgid "All organizations" +msgstr "" + +#: amt/api/organization_filter_options.py:17 +msgid "My organizations" +msgstr "" + #: amt/api/publication_category.py:22 msgid "Impactful algorithm" msgstr "" @@ -169,14 +188,63 @@ msgstr "" msgid "Exception of application" msgstr "" +#: amt/api/forms/algorithm.py:19 +msgid "Select organization" +msgstr "" + +#: amt/api/forms/algorithm.py:25 +#: amt/site/templates/algorithms/details_info.html.j2:12 +msgid "Organization" +msgstr "" + +#: amt/api/forms/organization.py:15 +#: amt/site/templates/algorithms/details_info.html.j2:8 +#: amt/site/templates/auth/profile.html.j2:34 +#: amt/site/templates/organizations/home.html.j2:25 +msgid "Name" +msgstr "" + +#: amt/api/forms/organization.py:16 +msgid "Name of the organization" +msgstr "" + +#: amt/api/forms/organization.py:23 +msgid "The slug is the web path, like /organizations/my-organization-name" +msgstr "" + +#: amt/api/forms/organization.py:24 +#: amt/site/templates/organizations/home.html.j2:29 +msgid "Slug" +msgstr "" + +#: amt/api/forms/organization.py:25 +msgid "The slug for this organization" +msgstr "" + +#: amt/api/forms/organization.py:30 +msgid "Add users" +msgstr "" + +#: amt/api/forms/organization.py:31 +msgid "Search for a user" +msgstr "" + +#: amt/api/forms/organization.py:36 +msgid "Add organization" +msgstr "" + #: amt/core/exception_handlers.py:20 msgid "String should have at least {min_length} characters" msgstr "" -#: amt/core/exception_handlers.py:21 +#: amt/core/exception_handlers.py:21 amt/core/exception_handlers.py:22 msgid "Field required" msgstr "" +#: amt/core/exception_handlers.py:23 +msgid "String should match pattern '{pattern}'" +msgstr "" + #: amt/core/exceptions.py:20 msgid "" "An error occurred while configuring the options for '{field}'. Please " @@ -240,12 +308,12 @@ msgid "" msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:39 -#: amt/site/templates/algorithms/new.html.j2:132 +#: amt/site/templates/algorithms/new.html.j2:134 msgid "Yes" msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:44 -#: amt/site/templates/algorithms/new.html.j2:142 +#: amt/site/templates/algorithms/new.html.j2:144 msgid "No" msgstr "" @@ -347,38 +415,34 @@ msgstr "" msgid "Failed to estimate WOZ value: " msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:8 -#: amt/site/templates/auth/profile.html.j2:34 -msgid "Name" -msgstr "" - -#: amt/site/templates/algorithms/details_info.html.j2:12 +#: amt/site/templates/algorithms/details_info.html.j2:16 #: amt/site/templates/algorithms/details_measure_modal.html.j2:19 msgid "Description" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:16 +#: amt/site/templates/algorithms/details_info.html.j2:20 msgid "Repository" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:20 +#: amt/site/templates/algorithms/details_info.html.j2:24 msgid "Algorithm code" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:28 +#: amt/site/templates/algorithms/details_info.html.j2:32 +#: amt/site/templates/organizations/parts/overview_results.html.j2:132 #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:6 #: amt/site/templates/pages/system_card.html.j2:4 -#: amt/site/templates/parts/filter_list.html.j2:98 -#: amt/site/templates/parts/filter_list.html.j2:120 +#: amt/site/templates/parts/filter_list.html.j2:75 +#: amt/site/templates/parts/filter_list.html.j2:97 msgid "Last updated" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:32 +#: amt/site/templates/algorithms/details_info.html.j2:36 msgid "Labels" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:40 +#: amt/site/templates/algorithms/details_info.html.j2:44 msgid "References" msgstr "" @@ -416,12 +480,12 @@ msgid "" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:84 -#: amt/site/templates/macros/editable.html.j2:63 +#: amt/site/templates/macros/editable.html.j2:82 msgid "Save" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:88 -#: amt/site/templates/macros/editable.html.j2:68 +#: amt/site/templates/macros/editable.html.j2:87 msgid "Cancel" msgstr "" @@ -435,37 +499,37 @@ msgstr "" msgid "Edit" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:7 +#: amt/site/templates/algorithms/new.html.j2:8 msgid "Create a Algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:25 -#: amt/site/templates/parts/filter_list.html.j2:90 -#: amt/site/templates/parts/filter_list.html.j2:116 +#: amt/site/templates/algorithms/new.html.j2:26 +#: amt/site/templates/parts/filter_list.html.j2:67 +#: amt/site/templates/parts/filter_list.html.j2:93 msgid "Algorithm name" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:29 +#: amt/site/templates/algorithms/new.html.j2:30 msgid "Name of the algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:43 +#: amt/site/templates/algorithms/new.html.j2:44 msgid "Select the lifecycle your algorithm is currently in." msgstr "" -#: amt/site/templates/algorithms/new.html.j2:44 +#: amt/site/templates/algorithms/new.html.j2:45 msgid "For more information on lifecycle, read the" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:47 +#: amt/site/templates/algorithms/new.html.j2:48 msgid "Algorithm Framework" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:65 +#: amt/site/templates/algorithms/new.html.j2:67 msgid "AI Act Profile" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:67 +#: amt/site/templates/algorithms/new.html.j2:69 msgid "" "The AI Act profile provides insight into, among other things, the type of" " AI system and the associated obligations from the European AI Act. If " @@ -474,25 +538,25 @@ msgid "" "tree." msgstr "" -#: amt/site/templates/algorithms/new.html.j2:74 +#: amt/site/templates/algorithms/new.html.j2:76 msgid "Find your AI Act profile" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:151 +#: amt/site/templates/algorithms/new.html.j2:153 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:159 +#: amt/site/templates/algorithms/new.html.j2:161 msgid "Choose one or more instruments" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:183 +#: amt/site/templates/algorithms/new.html.j2:185 msgid "Create Algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:200 +#: amt/site/templates/algorithms/new.html.j2:202 msgid "Copy results and close" msgstr "" @@ -533,7 +597,7 @@ msgstr "" #: amt/site/templates/errors/AMTAuthorizationError_401.html.j2:11 #: amt/site/templates/pages/landingpage.html.j2:17 #: amt/site/templates/parts/header.html.j2:86 -#: amt/site/templates/parts/header.html.j2:144 +#: amt/site/templates/parts/header.html.j2:145 msgid "Login" msgstr "" @@ -541,6 +605,7 @@ msgstr "" msgid "An error occurred" 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" msgstr "" @@ -553,7 +618,7 @@ msgstr "" msgid "There is one error:" msgstr "" -#: amt/site/templates/layouts/base.html.j2:11 +#: amt/site/templates/layouts/base.html.j2:1 msgid "Algorithmic Management Toolkit (AMT)" msgstr "" @@ -577,6 +642,59 @@ msgstr "" msgid "Unknown" msgstr "" +#: amt/site/templates/organizations/home.html.j2:33 +msgid "Created at" +msgstr "" + +#: amt/site/templates/organizations/home.html.j2:37 +msgid "Created by" +msgstr "" + +#: amt/site/templates/organizations/home.html.j2:41 +msgid "Modified at" +msgstr "" + +#: amt/site/templates/organizations/new.html.j2:8 +msgid "Create an organization" +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:11 +msgid "New organization" +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:27 +#: amt/site/templates/parts/algorithm_search.html.j2:24 +msgid "Search" +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:34 +msgid "Find organizations..." +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:51 +msgid "Organisation Type" +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:79 +#, python-format +msgid "" +"%(organizations_length)s result\n" +" " +msgid_plural "" +"\n" +" %(organizations_length)s results" +msgstr[0] "" +msgstr[1] "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:84 +#: amt/site/templates/parts/filter_list.html.j2:16 +msgid "for" +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:127 +msgid "Organization name" +msgstr "" + #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:7 #: amt/site/templates/pages/system_card.html.j2:5 @@ -606,6 +724,7 @@ msgid "Answer" msgstr "" #: amt/site/templates/pages/index.html.j2:3 +#: amt/site/templates/pages/under_construction.html.j2:3 msgid "AMT Placeholder informatie pagina's" msgstr "" @@ -656,12 +775,16 @@ msgstr "" msgid "Model cards" msgstr "" -#: amt/site/templates/parts/algorithm_search.html.j2:14 -msgid "New algorithm" +#: amt/site/templates/pages/under_construction.html.j2:8 +msgid "Under construction" msgstr "" -#: amt/site/templates/parts/algorithm_search.html.j2:24 -msgid "Search" +#: amt/site/templates/pages/under_construction.html.j2:9 +msgid "This page is yet to be build." +msgstr "" + +#: amt/site/templates/parts/algorithm_search.html.j2:14 +msgid "New algorithm" msgstr "" #: amt/site/templates/parts/algorithm_search.html.j2:31 @@ -688,7 +811,7 @@ msgstr "" msgid "Select group by" msgstr "" -#: amt/site/templates/parts/filter_list.html.j2:32 +#: amt/site/templates/parts/filter_list.html.j2:9 #, python-format msgid "" "\n" @@ -701,11 +824,7 @@ msgid_plural "" msgstr[0] "" msgstr[1] "" -#: amt/site/templates/parts/filter_list.html.j2:39 -msgid "for" -msgstr "" - -#: amt/site/templates/parts/filter_list.html.j2:70 +#: amt/site/templates/parts/filter_list.html.j2:47 msgid "" "No Algorithm match your selected filters. Try adjusting your filters or " "clearing them to\n" diff --git a/amt/locale/en_US/LC_MESSAGES/messages.mo b/amt/locale/en_US/LC_MESSAGES/messages.mo index 019252d2..83f4ad01 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 14276271..553948d7 100644 --- a/amt/locale/en_US/LC_MESSAGES/messages.po +++ b/amt/locale/en_US/LC_MESSAGES/messages.po @@ -43,10 +43,10 @@ msgid "Role" msgstr "" #: amt/api/group_by_category.py:15 -#: amt/site/templates/algorithms/details_info.html.j2:24 -#: amt/site/templates/algorithms/new.html.j2:40 +#: amt/site/templates/algorithms/details_info.html.j2:28 +#: 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:94 +#: amt/site/templates/parts/filter_list.html.j2:71 msgid "Lifecycle" msgstr "" @@ -86,66 +86,85 @@ msgstr "" msgid "Phasing Out" msgstr "" -#: amt/api/navigation.py:45 +#: amt/api/navigation.py:47 msgid "Home" msgstr "" -#: amt/api/navigation.py:46 amt/site/templates/parts/algorithm_search.html.j2:9 +#: amt/api/navigation.py:48 amt/api/navigation.py:63 +#: amt/site/templates/parts/algorithm_search.html.j2:9 msgid "Algorithms" msgstr "" -#: amt/api/navigation.py:47 amt/site/templates/auth/profile.html.j2:10 +#: amt/api/navigation.py:49 amt/site/templates/auth/profile.html.j2:10 msgid "Overview" msgstr "" -#: amt/api/navigation.py:48 +#: amt/api/navigation.py:50 msgid "Tasks" msgstr "" -#: amt/api/navigation.py:49 +#: amt/api/navigation.py:51 msgid "New" msgstr "" -#: amt/api/navigation.py:50 +#: amt/api/navigation.py:52 msgid "System card" msgstr "" -#: amt/api/navigation.py:51 +#: amt/api/navigation.py:53 msgid "Assessment card" msgstr "" -#: amt/api/navigation.py:52 +#: amt/api/navigation.py:54 msgid "Model card" msgstr "" -#: amt/api/navigation.py:53 +#: amt/api/navigation.py:55 msgid "Details" msgstr "" -#: amt/api/navigation.py:54 +#: amt/api/navigation.py:56 msgid "Info" msgstr "" -#: amt/api/navigation.py:55 +#: amt/api/navigation.py:57 msgid "Algorithm details" msgstr "" -#: amt/api/navigation.py:56 +#: amt/api/navigation.py:58 msgid "Requirements" msgstr "" -#: amt/api/navigation.py:57 +#: amt/api/navigation.py:59 msgid "Data" msgstr "" -#: amt/api/navigation.py:58 +#: amt/api/navigation.py:60 msgid "Model" msgstr "" -#: amt/api/navigation.py:59 amt/site/templates/algorithms/new.html.j2:149 +#: amt/api/navigation.py:61 amt/site/templates/algorithms/new.html.j2:151 msgid "Instruments" msgstr "" +#: amt/api/navigation.py:62 +#: amt/site/templates/organizations/parts/overview_results.html.j2:6 +msgid "Organizations" +msgstr "" + +#: amt/api/navigation.py:64 amt/site/templates/organizations/home.html.j2:45 +#: amt/site/templates/organizations/parts/overview_results.html.j2:130 +msgid "People" +msgstr "" + +#: amt/api/organization_filter_options.py:17 +msgid "All organizations" +msgstr "" + +#: amt/api/organization_filter_options.py:17 +msgid "My organizations" +msgstr "" + #: amt/api/publication_category.py:22 msgid "Impactful algorithm" msgstr "" @@ -170,14 +189,63 @@ msgstr "" msgid "Exception of application" msgstr "" +#: amt/api/forms/algorithm.py:19 +msgid "Select organization" +msgstr "" + +#: amt/api/forms/algorithm.py:25 +#: amt/site/templates/algorithms/details_info.html.j2:12 +msgid "Organization" +msgstr "" + +#: amt/api/forms/organization.py:15 +#: amt/site/templates/algorithms/details_info.html.j2:8 +#: amt/site/templates/auth/profile.html.j2:34 +#: amt/site/templates/organizations/home.html.j2:25 +msgid "Name" +msgstr "" + +#: amt/api/forms/organization.py:16 +msgid "Name of the organization" +msgstr "" + +#: amt/api/forms/organization.py:23 +msgid "The slug is the web path, like /organizations/my-organization-name" +msgstr "" + +#: amt/api/forms/organization.py:24 +#: amt/site/templates/organizations/home.html.j2:29 +msgid "Slug" +msgstr "" + +#: amt/api/forms/organization.py:25 +msgid "The slug for this organization" +msgstr "" + +#: amt/api/forms/organization.py:30 +msgid "Add users" +msgstr "" + +#: amt/api/forms/organization.py:31 +msgid "Search for a user" +msgstr "" + +#: amt/api/forms/organization.py:36 +msgid "Add organization" +msgstr "" + #: amt/core/exception_handlers.py:20 msgid "String should have at least {min_length} characters" msgstr "" -#: amt/core/exception_handlers.py:21 +#: amt/core/exception_handlers.py:21 amt/core/exception_handlers.py:22 msgid "Field required" msgstr "" +#: amt/core/exception_handlers.py:23 +msgid "String should match pattern '{pattern}'" +msgstr "" + #: amt/core/exceptions.py:20 msgid "" "An error occurred while configuring the options for '{field}'. Please " @@ -241,12 +309,12 @@ msgid "" msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:39 -#: amt/site/templates/algorithms/new.html.j2:132 +#: amt/site/templates/algorithms/new.html.j2:134 msgid "Yes" msgstr "" #: amt/site/templates/algorithms/details_base.html.j2:44 -#: amt/site/templates/algorithms/new.html.j2:142 +#: amt/site/templates/algorithms/new.html.j2:144 msgid "No" msgstr "" @@ -348,38 +416,34 @@ msgstr "" msgid "Failed to estimate WOZ value: " msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:8 -#: amt/site/templates/auth/profile.html.j2:34 -msgid "Name" -msgstr "" - -#: amt/site/templates/algorithms/details_info.html.j2:12 +#: amt/site/templates/algorithms/details_info.html.j2:16 #: amt/site/templates/algorithms/details_measure_modal.html.j2:19 msgid "Description" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:16 +#: amt/site/templates/algorithms/details_info.html.j2:20 msgid "Repository" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:20 +#: amt/site/templates/algorithms/details_info.html.j2:24 msgid "Algorithm code" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:28 +#: amt/site/templates/algorithms/details_info.html.j2:32 +#: amt/site/templates/organizations/parts/overview_results.html.j2:132 #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:6 #: amt/site/templates/pages/system_card.html.j2:4 -#: amt/site/templates/parts/filter_list.html.j2:98 -#: amt/site/templates/parts/filter_list.html.j2:120 +#: amt/site/templates/parts/filter_list.html.j2:75 +#: amt/site/templates/parts/filter_list.html.j2:97 msgid "Last updated" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:32 +#: amt/site/templates/algorithms/details_info.html.j2:36 msgid "Labels" msgstr "" -#: amt/site/templates/algorithms/details_info.html.j2:40 +#: amt/site/templates/algorithms/details_info.html.j2:44 msgid "References" msgstr "" @@ -417,12 +481,12 @@ msgid "" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:84 -#: amt/site/templates/macros/editable.html.j2:70 +#: amt/site/templates/macros/editable.html.j2:82 msgid "Save" msgstr "" #: amt/site/templates/algorithms/details_measure_modal.html.j2:88 -#: amt/site/templates/macros/editable.html.j2:75 +#: amt/site/templates/macros/editable.html.j2:87 msgid "Cancel" msgstr "" @@ -436,37 +500,37 @@ msgstr "" msgid "Edit" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:7 +#: amt/site/templates/algorithms/new.html.j2:8 msgid "Create a Algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:25 -#: amt/site/templates/parts/filter_list.html.j2:90 -#: amt/site/templates/parts/filter_list.html.j2:116 +#: amt/site/templates/algorithms/new.html.j2:26 +#: amt/site/templates/parts/filter_list.html.j2:67 +#: amt/site/templates/parts/filter_list.html.j2:93 msgid "Algorithm name" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:29 +#: amt/site/templates/algorithms/new.html.j2:30 msgid "Name of the algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:43 +#: amt/site/templates/algorithms/new.html.j2:44 msgid "Select the lifecycle your algorithm is currently in." msgstr "" -#: amt/site/templates/algorithms/new.html.j2:44 +#: amt/site/templates/algorithms/new.html.j2:45 msgid "For more information on lifecycle, read the" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:47 +#: amt/site/templates/algorithms/new.html.j2:48 msgid "Algorithm Framework" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:65 +#: amt/site/templates/algorithms/new.html.j2:67 msgid "AI Act Profile" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:67 +#: amt/site/templates/algorithms/new.html.j2:69 msgid "" "The AI Act profile provides insight into, among other things, the type of" " AI system and the associated obligations from the European AI Act. If " @@ -475,25 +539,25 @@ msgid "" "tree." msgstr "" -#: amt/site/templates/algorithms/new.html.j2:74 +#: amt/site/templates/algorithms/new.html.j2:76 msgid "Find your AI Act profile" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:151 +#: amt/site/templates/algorithms/new.html.j2:153 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:159 +#: amt/site/templates/algorithms/new.html.j2:161 msgid "Choose one or more instruments" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:183 +#: amt/site/templates/algorithms/new.html.j2:185 msgid "Create Algorithm" msgstr "" -#: amt/site/templates/algorithms/new.html.j2:200 +#: amt/site/templates/algorithms/new.html.j2:202 msgid "Copy results and close" msgstr "" @@ -534,7 +598,7 @@ msgstr "" #: amt/site/templates/errors/AMTAuthorizationError_401.html.j2:11 #: amt/site/templates/pages/landingpage.html.j2:17 #: amt/site/templates/parts/header.html.j2:86 -#: amt/site/templates/parts/header.html.j2:144 +#: amt/site/templates/parts/header.html.j2:145 msgid "Login" msgstr "" @@ -542,6 +606,7 @@ msgstr "" msgid "An error occurred" 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" msgstr "" @@ -554,7 +619,7 @@ msgstr "" msgid "There is one error:" msgstr "" -#: amt/site/templates/layouts/base.html.j2:11 +#: amt/site/templates/layouts/base.html.j2:1 msgid "Algorithmic Management Toolkit (AMT)" msgstr "" @@ -578,6 +643,59 @@ msgstr "" msgid "Unknown" msgstr "" +#: amt/site/templates/organizations/home.html.j2:33 +msgid "Created at" +msgstr "" + +#: amt/site/templates/organizations/home.html.j2:37 +msgid "Created by" +msgstr "" + +#: amt/site/templates/organizations/home.html.j2:41 +msgid "Modified at" +msgstr "" + +#: amt/site/templates/organizations/new.html.j2:8 +msgid "Create an organization" +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:11 +msgid "New organization" +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:27 +#: amt/site/templates/parts/algorithm_search.html.j2:24 +msgid "Search" +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:34 +msgid "Find organizations..." +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:51 +msgid "Organisation Type" +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:79 +#, fuzzy, python-format +msgid "" +"%(organizations_length)s result\n" +" " +msgid_plural "" +"\n" +" %(organizations_length)s results" +msgstr[0] "" +msgstr[1] "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:84 +#: amt/site/templates/parts/filter_list.html.j2:16 +msgid "for" +msgstr "" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:127 +msgid "Organization name" +msgstr "" + #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:7 #: amt/site/templates/pages/system_card.html.j2:5 @@ -607,6 +725,7 @@ msgid "Answer" msgstr "" #: amt/site/templates/pages/index.html.j2:3 +#: amt/site/templates/pages/under_construction.html.j2:3 msgid "AMT Placeholder informatie pagina's" msgstr "" @@ -657,12 +776,16 @@ msgstr "" msgid "Model cards" msgstr "" -#: amt/site/templates/parts/algorithm_search.html.j2:14 -msgid "New algorithm" +#: amt/site/templates/pages/under_construction.html.j2:8 +msgid "Under construction" msgstr "" -#: amt/site/templates/parts/algorithm_search.html.j2:24 -msgid "Search" +#: amt/site/templates/pages/under_construction.html.j2:9 +msgid "This page is yet to be build." +msgstr "" + +#: amt/site/templates/parts/algorithm_search.html.j2:14 +msgid "New algorithm" msgstr "" #: amt/site/templates/parts/algorithm_search.html.j2:31 @@ -689,7 +812,7 @@ msgstr "" msgid "Select group by" msgstr "" -#: amt/site/templates/parts/filter_list.html.j2:32 +#: amt/site/templates/parts/filter_list.html.j2:9 #, python-format msgid "" "\n" @@ -702,11 +825,7 @@ msgid_plural "" msgstr[0] "" msgstr[1] "" -#: amt/site/templates/parts/filter_list.html.j2:39 -msgid "for" -msgstr "" - -#: amt/site/templates/parts/filter_list.html.j2:70 +#: amt/site/templates/parts/filter_list.html.j2:47 msgid "" "No Algorithm match your selected filters. Try adjusting your filters or " "clearing them to\n" diff --git a/amt/locale/nl_NL/LC_MESSAGES/messages.mo b/amt/locale/nl_NL/LC_MESSAGES/messages.mo index b7463b38..db919d81 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 dfb23c30..43033455 100644 --- a/amt/locale/nl_NL/LC_MESSAGES/messages.po +++ b/amt/locale/nl_NL/LC_MESSAGES/messages.po @@ -43,10 +43,10 @@ msgid "Role" msgstr "Rol" #: amt/api/group_by_category.py:15 -#: amt/site/templates/algorithms/details_info.html.j2:24 -#: amt/site/templates/algorithms/new.html.j2:40 +#: amt/site/templates/algorithms/details_info.html.j2:28 +#: 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:94 +#: amt/site/templates/parts/filter_list.html.j2:71 msgid "Lifecycle" msgstr "Levenscyclus" @@ -86,66 +86,85 @@ msgstr "Monitoring en beheer" msgid "Phasing Out" msgstr "Uitfaseren" -#: amt/api/navigation.py:45 +#: amt/api/navigation.py:47 msgid "Home" msgstr "Start" -#: amt/api/navigation.py:46 amt/site/templates/parts/algorithm_search.html.j2:9 +#: amt/api/navigation.py:48 amt/api/navigation.py:63 +#: amt/site/templates/parts/algorithm_search.html.j2:9 msgid "Algorithms" msgstr "Algoritmes" -#: amt/api/navigation.py:47 amt/site/templates/auth/profile.html.j2:10 +#: amt/api/navigation.py:49 amt/site/templates/auth/profile.html.j2:10 msgid "Overview" msgstr "Overzicht" -#: amt/api/navigation.py:48 +#: amt/api/navigation.py:50 msgid "Tasks" msgstr "Taken" -#: amt/api/navigation.py:49 +#: amt/api/navigation.py:51 msgid "New" msgstr "Nieuw" -#: amt/api/navigation.py:50 +#: amt/api/navigation.py:52 msgid "System card" msgstr "Systeemkaart" -#: amt/api/navigation.py:51 +#: amt/api/navigation.py:53 msgid "Assessment card" msgstr "Beoordelingskaart" -#: amt/api/navigation.py:52 +#: amt/api/navigation.py:54 msgid "Model card" msgstr "Model kaart" -#: amt/api/navigation.py:53 +#: amt/api/navigation.py:55 msgid "Details" msgstr "Details" -#: amt/api/navigation.py:54 +#: amt/api/navigation.py:56 msgid "Info" msgstr "Info" -#: amt/api/navigation.py:55 +#: amt/api/navigation.py:57 msgid "Algorithm details" msgstr "Algoritme details" -#: amt/api/navigation.py:56 +#: amt/api/navigation.py:58 msgid "Requirements" msgstr "Vereisten" -#: amt/api/navigation.py:57 +#: amt/api/navigation.py:59 msgid "Data" msgstr "Data" -#: amt/api/navigation.py:58 +#: amt/api/navigation.py:60 msgid "Model" msgstr "Model" -#: amt/api/navigation.py:59 amt/site/templates/algorithms/new.html.j2:149 +#: amt/api/navigation.py:61 amt/site/templates/algorithms/new.html.j2:151 msgid "Instruments" msgstr "Instrumenten" +#: amt/api/navigation.py:62 +#: amt/site/templates/organizations/parts/overview_results.html.j2:6 +msgid "Organizations" +msgstr "Organisaties" + +#: amt/api/navigation.py:64 amt/site/templates/organizations/home.html.j2:45 +#: amt/site/templates/organizations/parts/overview_results.html.j2:130 +msgid "People" +msgstr "Rol" + +#: amt/api/organization_filter_options.py:17 +msgid "All organizations" +msgstr "Alle organisaties" + +#: amt/api/organization_filter_options.py:17 +msgid "My organizations" +msgstr "Mijn organisaties" + #: amt/api/publication_category.py:22 msgid "Impactful algorithm" msgstr "Impactvol algoritme" @@ -170,14 +189,65 @@ msgstr "Verboden AI" msgid "Exception of application" msgstr "Uitzondering van toepassing" +#: amt/api/forms/algorithm.py:19 +msgid "Select organization" +msgstr "Selecteer organisatie" + +#: amt/api/forms/algorithm.py:25 +#: amt/site/templates/algorithms/details_info.html.j2:12 +msgid "Organization" +msgstr "Organisatie" + +#: amt/api/forms/organization.py:15 +#: amt/site/templates/algorithms/details_info.html.j2:8 +#: amt/site/templates/auth/profile.html.j2:34 +#: amt/site/templates/organizations/home.html.j2:25 +msgid "Name" +msgstr "Naam" + +#: amt/api/forms/organization.py:16 +msgid "Name of the organization" +msgstr "Naam van de organisatie" + +#: amt/api/forms/organization.py:23 +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:24 +#: amt/site/templates/organizations/home.html.j2:29 +msgid "Slug" +msgstr "Slug" + +#: amt/api/forms/organization.py:25 +msgid "The slug for this organization" +msgstr "Het web-pad (slug) voor deze organisatie" + +#: amt/api/forms/organization.py:30 +msgid "Add users" +msgstr "Voeg personen toe" + +#: amt/api/forms/organization.py:31 +msgid "Search for a user" +msgstr "Zoek een persoon" + +#: amt/api/forms/organization.py:36 +msgid "Add organization" +msgstr "Organisatie toevoegen" + #: amt/core/exception_handlers.py:20 msgid "String should have at least {min_length} characters" msgstr "Tekst moet minimaal {min_length} tekens bevatten" -#: amt/core/exception_handlers.py:21 +#: amt/core/exception_handlers.py:21 amt/core/exception_handlers.py:22 msgid "Field required" msgstr "Veld verplicht" +#: amt/core/exception_handlers.py:23 +msgid "String should match pattern '{pattern}'" +msgstr "De waarde moet voldoen aan het patroon '{pattern}'" + #: amt/core/exceptions.py:20 msgid "" "An error occurred while configuring the options for '{field}'. Please " @@ -251,12 +321,12 @@ msgstr "" " verwijderd." #: amt/site/templates/algorithms/details_base.html.j2:39 -#: amt/site/templates/algorithms/new.html.j2:132 +#: amt/site/templates/algorithms/new.html.j2:134 msgid "Yes" msgstr "Ja" #: amt/site/templates/algorithms/details_base.html.j2:44 -#: amt/site/templates/algorithms/new.html.j2:142 +#: amt/site/templates/algorithms/new.html.j2:144 msgid "No" msgstr "Nee" @@ -358,38 +428,34 @@ msgstr "Ongedefinieerd" msgid "Failed to estimate WOZ value: " msgstr "Fout bij het schatten van de WOZ-waarde: " -#: amt/site/templates/algorithms/details_info.html.j2:8 -#: amt/site/templates/auth/profile.html.j2:34 -msgid "Name" -msgstr "Naam" - -#: amt/site/templates/algorithms/details_info.html.j2:12 +#: amt/site/templates/algorithms/details_info.html.j2:16 #: amt/site/templates/algorithms/details_measure_modal.html.j2:19 msgid "Description" msgstr "Omschrijving" -#: amt/site/templates/algorithms/details_info.html.j2:16 +#: amt/site/templates/algorithms/details_info.html.j2:20 msgid "Repository" msgstr "Repository" -#: amt/site/templates/algorithms/details_info.html.j2:20 +#: amt/site/templates/algorithms/details_info.html.j2:24 msgid "Algorithm code" msgstr "algoritme code" -#: amt/site/templates/algorithms/details_info.html.j2:28 +#: amt/site/templates/algorithms/details_info.html.j2:32 +#: amt/site/templates/organizations/parts/overview_results.html.j2:132 #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:6 #: amt/site/templates/pages/system_card.html.j2:4 -#: amt/site/templates/parts/filter_list.html.j2:98 -#: amt/site/templates/parts/filter_list.html.j2:120 +#: amt/site/templates/parts/filter_list.html.j2:75 +#: amt/site/templates/parts/filter_list.html.j2:97 msgid "Last updated" msgstr "Laatst bijgewerkt" -#: amt/site/templates/algorithms/details_info.html.j2:32 +#: amt/site/templates/algorithms/details_info.html.j2:36 msgid "Labels" msgstr "Labels" -#: amt/site/templates/algorithms/details_info.html.j2:40 +#: amt/site/templates/algorithms/details_info.html.j2:44 msgid "References" msgstr "Referenties" @@ -429,12 +495,12 @@ msgstr "" "enoplossingen." #: amt/site/templates/algorithms/details_measure_modal.html.j2:84 -#: amt/site/templates/macros/editable.html.j2:70 +#: 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/macros/editable.html.j2:75 +#: amt/site/templates/macros/editable.html.j2:87 msgid "Cancel" msgstr "Annuleren" @@ -448,37 +514,37 @@ msgstr "maatregelen uitgevoerd" msgid "Edit" msgstr "Bewerk" -#: amt/site/templates/algorithms/new.html.j2:7 +#: amt/site/templates/algorithms/new.html.j2:8 msgid "Create a Algorithm" msgstr "Creëer een algoritme" -#: amt/site/templates/algorithms/new.html.j2:25 -#: amt/site/templates/parts/filter_list.html.j2:90 -#: amt/site/templates/parts/filter_list.html.j2:116 +#: amt/site/templates/algorithms/new.html.j2:26 +#: amt/site/templates/parts/filter_list.html.j2:67 +#: amt/site/templates/parts/filter_list.html.j2:93 msgid "Algorithm name" msgstr "algoritme naam" -#: amt/site/templates/algorithms/new.html.j2:29 +#: amt/site/templates/algorithms/new.html.j2:30 msgid "Name of the algorithm" msgstr "Nieuw algoritme" -#: amt/site/templates/algorithms/new.html.j2:43 +#: amt/site/templates/algorithms/new.html.j2:44 msgid "Select the lifecycle your algorithm is currently in." msgstr "Selecteer de levenscyclus waarin uw algoritme zich momenteel bevindt." -#: amt/site/templates/algorithms/new.html.j2:44 +#: amt/site/templates/algorithms/new.html.j2:45 msgid "For more information on lifecycle, read the" msgstr "Lees voor meer meer informatie over levenscyclus het" -#: amt/site/templates/algorithms/new.html.j2:47 +#: amt/site/templates/algorithms/new.html.j2:48 msgid "Algorithm Framework" msgstr "Algoritmekader" -#: amt/site/templates/algorithms/new.html.j2:65 +#: amt/site/templates/algorithms/new.html.j2:67 msgid "AI Act Profile" msgstr "AI Verordening Profiel" -#: amt/site/templates/algorithms/new.html.j2:67 +#: amt/site/templates/algorithms/new.html.j2:69 msgid "" "The AI Act profile provides insight into, among other things, the type of" " AI system and the associated obligations from the European AI Act. If " @@ -490,11 +556,11 @@ msgstr "" "onder andere het type AI systeem, de regelgeving die van toepassing is en" " de risicocategorie." -#: amt/site/templates/algorithms/new.html.j2:74 +#: amt/site/templates/algorithms/new.html.j2:76 msgid "Find your AI Act profile" msgstr "Vind uw AI Act profiel" -#: amt/site/templates/algorithms/new.html.j2:151 +#: amt/site/templates/algorithms/new.html.j2:153 msgid "" "Overview of instruments for the responsible development, deployment, " "assessment and monitoring of algorithms and AI-systems." @@ -502,15 +568,15 @@ 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:159 +#: amt/site/templates/algorithms/new.html.j2:161 msgid "Choose one or more instruments" msgstr "Kies één of meerdere instrumenten" -#: amt/site/templates/algorithms/new.html.j2:183 +#: amt/site/templates/algorithms/new.html.j2:185 msgid "Create Algorithm" msgstr "Creëer algoritme" -#: amt/site/templates/algorithms/new.html.j2:200 +#: amt/site/templates/algorithms/new.html.j2:202 msgid "Copy results and close" msgstr "Resultaten overnemen en sluiten" @@ -551,7 +617,7 @@ msgstr "Meld u aan om deze pagina te bekijken" #: amt/site/templates/errors/AMTAuthorizationError_401.html.j2:11 #: amt/site/templates/pages/landingpage.html.j2:17 #: amt/site/templates/parts/header.html.j2:86 -#: amt/site/templates/parts/header.html.j2:144 +#: amt/site/templates/parts/header.html.j2:145 msgid "Login" msgstr "Inloggen" @@ -559,6 +625,7 @@ msgstr "Inloggen" msgid "An error occurred" msgstr "Er is een fout opgetreden" +#: 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" msgstr "Er is een fout opgetreden. Probeer het later opnieuw." @@ -571,7 +638,7 @@ msgstr "Er zijn enkele fouten" msgid "There is one error:" msgstr "Er is één fout:" -#: amt/site/templates/layouts/base.html.j2:11 +#: amt/site/templates/layouts/base.html.j2:1 msgid "Algorithmic Management Toolkit (AMT)" msgstr "Algoritme Management Toolkit (AMT)" @@ -595,6 +662,59 @@ msgstr "Beoordelen" msgid "Unknown" msgstr "Onbekend" +#: amt/site/templates/organizations/home.html.j2:33 +msgid "Created at" +msgstr "Aangemaakt op" + +#: amt/site/templates/organizations/home.html.j2:37 +msgid "Created by" +msgstr "Aangemaakt door" + +#: amt/site/templates/organizations/home.html.j2:41 +msgid "Modified at" +msgstr "Bijgewerkt op" + +#: amt/site/templates/organizations/new.html.j2:8 +msgid "Create an organization" +msgstr "Organisatie aanmaken" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:11 +msgid "New organization" +msgstr "Nieuwe organisatie" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:27 +#: amt/site/templates/parts/algorithm_search.html.j2:24 +msgid "Search" +msgstr "Zoek" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:34 +msgid "Find organizations..." +msgstr "Zoek een organisatie..." + +#: amt/site/templates/organizations/parts/overview_results.html.j2:51 +msgid "Organisation Type" +msgstr "Organisatie Type" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:79 +#, python-format +msgid "" +"%(organizations_length)s result\n" +" " +msgid_plural "" +"\n" +" %(organizations_length)s results" +msgstr[0] "%(organizations_length)s resultaat" +msgstr[1] "%(organizations_length)s resultaten" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:84 +#: amt/site/templates/parts/filter_list.html.j2:16 +msgid "for" +msgstr "voor" + +#: amt/site/templates/organizations/parts/overview_results.html.j2:127 +msgid "Organization name" +msgstr "Organisatie naam" + #: amt/site/templates/pages/assessment_card.html.j2:7 #: amt/site/templates/pages/model_card.html.j2:7 #: amt/site/templates/pages/system_card.html.j2:5 @@ -624,6 +744,7 @@ msgid "Answer" msgstr "Antwoord" #: amt/site/templates/pages/index.html.j2:3 +#: amt/site/templates/pages/under_construction.html.j2:3 msgid "AMT Placeholder informatie pagina's" msgstr "AMT Placeholder informatie pagina's" @@ -681,14 +802,18 @@ msgstr "Assessmentkaart" msgid "Model cards" msgstr "Modelkaart" +#: amt/site/templates/pages/under_construction.html.j2:8 +msgid "Under construction" +msgstr "In ontwikkeling" + +#: amt/site/templates/pages/under_construction.html.j2:9 +msgid "This page is yet to be build." +msgstr "Deze pagina is nog niet klaar." + #: amt/site/templates/parts/algorithm_search.html.j2:14 msgid "New algorithm" msgstr "Nieuw algoritme" -#: amt/site/templates/parts/algorithm_search.html.j2:24 -msgid "Search" -msgstr "Zoek" - #: amt/site/templates/parts/algorithm_search.html.j2:31 msgid "Find algorithm..." msgstr "Vind algoritme..." @@ -713,7 +838,7 @@ msgstr "Groeperen op" msgid "Select group by" msgstr "Selecteer groepering" -#: amt/site/templates/parts/filter_list.html.j2:32 +#: amt/site/templates/parts/filter_list.html.j2:9 #, python-format msgid "" "\n" @@ -732,11 +857,7 @@ msgstr[1] "" " %(amount_algorithm_systems)s resultaten\n" " " -#: amt/site/templates/parts/filter_list.html.j2:39 -msgid "for" -msgstr "voor" - -#: amt/site/templates/parts/filter_list.html.j2:70 +#: amt/site/templates/parts/filter_list.html.j2:47 msgid "" "No Algorithm match your selected filters. Try adjusting your filters or " "clearing them to\n" diff --git a/amt/middleware/csrf.py b/amt/middleware/csrf.py index 28ec2c79..748a032c 100644 --- a/amt/middleware/csrf.py +++ b/amt/middleware/csrf.py @@ -9,6 +9,7 @@ from starlette.types import ASGIApp from amt.core.csrf import get_csrf_config # type: ignore # noqa +from amt.core.exception_handlers import general_exception_handler from amt.core.exceptions import AMTCSRFProtectError RequestResponseEndpoint = typing.Callable[[Request], typing.Awaitable[Response]] @@ -55,9 +56,11 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - response = await call_next(request) - if self._include_request(request) and request.method in self.safe_methods: - self.csrf_protect.set_csrf_cookie(signed_token, response) - logger.debug(f"set csrf_cookie: signed_token={signed_token}") + if self._include_request(request) and request.method in self.safe_methods: # noqa + # TODO FIXME (Robbert) we always set the cookie, this causes CSRF problems + if request.url.path != "/organizations/users": + self.csrf_protect.set_csrf_cookie(signed_token, response) + logger.debug(f"set csrf_cookie: signed_token={signed_token}") return response @@ -73,6 +76,7 @@ def __init__(self, app: ASGIApp) -> None: async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: try: response = await call_next(request) - except CsrfProtectError as e: - raise AMTCSRFProtectError() from e + except CsrfProtectError: + # middleware exceptions are not handled by the fastapi error handlers, so we call the function ourselves + return await general_exception_handler(request, AMTCSRFProtectError()) return response diff --git a/amt/migrations/env.py b/amt/migrations/env.py index 53309e78..288cf9dc 100644 --- a/amt/migrations/env.py +++ b/amt/migrations/env.py @@ -6,7 +6,6 @@ from amt.models import * # noqa from sqlalchemy import Connection, pool from sqlalchemy.ext.asyncio import async_engine_from_config -from sqlalchemy.schema import MetaData from amt.models.base import Base diff --git a/amt/migrations/versions/5de977ad946f_add_organization.py b/amt/migrations/versions/5de977ad946f_add_organization.py new file mode 100644 index 00000000..1d8d06e6 --- /dev/null +++ b/amt/migrations/versions/5de977ad946f_add_organization.py @@ -0,0 +1,97 @@ +"""Add organization + +Revision ID: 5de977ad946f +Revises: 69243fd24222 +Create Date: 2024-11-19 14:26:41.815333 + +""" + +from collections.abc import Sequence +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy import text +from alembic import op +from sqlalchemy.orm.session import Session + +from amt.models import User + +# revision identifiers, used by Alembic. +revision: str = "5de977ad946f" +down_revision: str | None = "f6da4d6dd867" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +def upgrade() -> None: + organization = op.create_table( + "organization", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("slug", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.Column("modified_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column("created_by_id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(["created_by_id"], ["user.id"], name=op.f("fk_organization_created_by_id_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organization")), + sa.UniqueConstraint("slug", name=op.f("uq_organization_slug")), + ) + + users_and_organizations = op.create_table( + "users_and_organizations", + sa.Column("organization_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organization.id"], + name=op.f("fk_users_and_organizations_organization_id_organization"), + ), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_users_and_organizations_user_id_user")), + sa.PrimaryKeyConstraint("organization_id", "user_id", name=op.f("pk_users_and_organizations")), + ) + op.add_column("algorithm", sa.Column("organization_id", sa.Integer(), nullable=True)) + + with op.batch_alter_table("algorithm", schema=None) as batch_op: + batch_op.create_foreign_key(None, "organization", ["organization_id"], ["id"]) + + op.add_column("user", sa.Column("email_hash", sa.String(), nullable=True)) + op.add_column("user", sa.Column("name_encoded", sa.String(), nullable=True)) + op.add_column("user", sa.Column("email", sa.String(), nullable=True)) + + session = Session(bind=op.get_bind()) + + first_user = session.query(User).first() + if not first_user: + first_user = User(id=UUID("1738b1e151dc46219556a5662b26517c"), name="AMT Demo User", email="amt@amt.nl", email_hash="hash123", name_encoded="amt+demo+user") + session.add(first_user) + session.commit() + session.refresh(first_user) + + # add demo organization + op.bulk_insert(organization,[{"name": "Demo AMT", "slug": "demo-amt", "created_by_id": first_user.id}]) + + # add all current users to the demo organization + op.bulk_insert( + users_and_organizations, + [{"organization_id": 1, "user_id": user.id} for user in session.query(User).all()] + ) + + # add all current algorithms to the demo organization + op.execute("UPDATE algorithm SET organization_id = 1") + + # make organization_id required for all algorithms + with op.batch_alter_table("algorithm", schema=None) as batch_op: + batch_op.alter_column("organization_id", nullable=False) + + +def downgrade() -> None: + op.drop_column("user", "email") + op.drop_column("user", "name_encoded") + op.drop_column("user", "email_hash") + + with op.batch_alter_table("algorithm", schema=None) as batch_op: + batch_op.drop_constraint("fk_algorithm_organization_id_organization", type_="foreignkey") + + op.drop_column("algorithm", "organization_id") + op.drop_table("users_and_organizations") + op.drop_table("organization") diff --git a/amt/models/__init__.py b/amt/models/__init__.py index 9a921ea7..cb4cac57 100644 --- a/amt/models/__init__.py +++ b/amt/models/__init__.py @@ -1,5 +1,6 @@ from .algorithm import Algorithm +from .organization import Organization from .task import Task from .user import User -__all__ = ["Task", "User", "Algorithm"] +__all__ = ["Task", "User", "Algorithm", "Organization"] diff --git a/amt/models/algorithm.py b/amt/models/algorithm.py index 8a0b16db..a28f97b6 100644 --- a/amt/models/algorithm.py +++ b/amt/models/algorithm.py @@ -3,9 +3,9 @@ from enum import Enum from typing import Any, TypeVar -from sqlalchemy import String, func +from sqlalchemy import ForeignKey, String, func from sqlalchemy.dialects.postgresql import ENUM -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.types import JSON from amt.api.lifecycles import Lifecycles @@ -60,6 +60,8 @@ class Algorithm(Base): system_card_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) last_edited: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now(), nullable=False) deleted_at: Mapped[datetime | None] = mapped_column(server_default=None, nullable=True) + organization_id: Mapped[int] = mapped_column(ForeignKey("organization.id")) + organization: Mapped["Organization"] = relationship(back_populates="algorithms", lazy="selectin") # pyright: ignore [reportUndefinedVariable, reportUnknownVariableType] #noqa def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 system_card: SystemCard | None = kwargs.pop("system_card", None) diff --git a/amt/models/organization.py b/amt/models/organization.py new file mode 100644 index 00000000..4c3c96ca --- /dev/null +++ b/amt/models/organization.py @@ -0,0 +1,25 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy import ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from amt.models.base import Base +from amt.models.relationships import users_and_organizations + + +class Organization(Base): + __tablename__ = "organization" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + slug: Mapped[str] = mapped_column(unique=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False) + modified_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now(), nullable=False) + deleted_at: Mapped[datetime] = mapped_column(nullable=True) + users: Mapped[list["User"]] = relationship( # pyright: ignore[reportUnknownVariableType, reportUndefinedVariable] #noqa + secondary=users_and_organizations, back_populates="organizations", lazy="selectin" + ) + algorithms: Mapped[list["Algorithm"]] = relationship(back_populates="organization", lazy="selectin") # pyright: ignore[reportUnknownVariableType, reportUndefinedVariable] #noqa + created_by_id: Mapped[UUID] = mapped_column(ForeignKey("user.id")) + created_by: Mapped["User"] = relationship(back_populates="organizations_created", lazy="selectin") # pyright: ignore [reportUndefinedVariable, reportUnknownVariableType] #noqa diff --git a/amt/models/relationships.py b/amt/models/relationships.py new file mode 100644 index 00000000..4af941fe --- /dev/null +++ b/amt/models/relationships.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, ForeignKey, Table + +from amt.models.base import Base + +users_and_organizations = Table( + "users_and_organizations", + Base.metadata, + Column("organization_id", ForeignKey("organization.id"), primary_key=True), # pyright: ignore[reportUnknownArgumentType] + Column("user_id", ForeignKey("user.id"), primary_key=True), # pyright: ignore[reportUnknownArgumentType] +) diff --git a/amt/models/user.py b/amt/models/user.py index 5f31d27d..51612419 100644 --- a/amt/models/user.py +++ b/amt/models/user.py @@ -1,8 +1,9 @@ from uuid import UUID from sqlalchemy import UUID as SQLAlchemyUUID -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship +from amt.models import Organization from amt.models.base import Base @@ -10,4 +11,11 @@ class User(Base): __tablename__ = "user" id: Mapped[UUID] = mapped_column(SQLAlchemyUUID(as_uuid=True), primary_key=True) - name: Mapped[str] + name: Mapped[str] = mapped_column(default=None) + email_hash: Mapped[str] = mapped_column(default=None) + name_encoded: Mapped[str] = mapped_column(default=None) + email: Mapped[str] = mapped_column(default=None) + organizations: Mapped[list["Organization"]] = relationship( + "Organization", secondary="users_and_organizations", back_populates="users", lazy="selectin" + ) + organizations_created: Mapped[list["Organization"]] = relationship(back_populates="created_by", lazy="selectin") diff --git a/amt/repositories/organizations.py b/amt/repositories/organizations.py new file mode 100644 index 00000000..ce3c1061 --- /dev/null +++ b/amt/repositories/organizations.py @@ -0,0 +1,137 @@ +import logging +from collections.abc import Sequence +from typing import Annotated, Any +from uuid import UUID + +from fastapi import Depends +from sqlalchemy import Select, func, select +from sqlalchemy.exc import NoResultFound, SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import lazyload +from sqlalchemy_utils import escape_like # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType] + +from amt.api.organization_filter_options import OrganizationFilterOptions +from amt.core.exceptions import AMTRepositoryError +from amt.models import Organization, User +from amt.repositories.deps import get_session + +logger = logging.getLogger(__name__) + + +class OrganizationsRepository: + def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]) -> None: + self.session = session + + def _as_count_query(self, statement: Select[Any]) -> Select[Any]: + statement = statement.with_only_columns(func.count()).order_by(None) + statement = statement.options(lazyload("*")) + return statement + + def _find_by( # noqa + self, + sort: dict[str, str] | None = None, + filters: dict[str, str] | None = None, + user_id: str | UUID | None = None, + search: str | None = None, + skip: int | None = None, + limit: int | None = None, + ) -> Select[Any]: + user_id = UUID(user_id) if isinstance(user_id, str) else user_id + statement = select(Organization) + if search: + statement = statement.filter(Organization.name.ilike(f"%{escape_like(search)}%")) + if ( + filters + and user_id + and "organization-type" in filters + and filters["organization-type"] == OrganizationFilterOptions.MY_ORGANIZATIONS.value + ): + statement = statement.join( + Organization.users # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] + ).where(User.id == user_id) + if sort: + if "name" in sort and sort["name"] == "ascending": + statement = statement.order_by(func.lower(Organization.name).asc()) + elif "name" in sort and sort["name"] == "descending": + statement = statement.order_by(func.lower(Organization.name).desc()) + elif "last_update" in sort and sort["last_update"] == "ascending": + statement = statement.order_by(Organization.modified_at.asc()) + elif "last_update" in sort and sort["last_update"] == "descending": + statement = statement.order_by(Organization.modified_at.desc()) + statement = statement.where(Organization.deleted_at.is_(None)) + if skip: + statement = statement.offset(skip) + if limit: + statement = statement.limit(limit) + # to force lazy-loading, use line below + # statement = statement.options(lazyload('*')) # noqa + return statement + + async def find_by( + self, + sort: dict[str, str] | None = None, + filters: dict[str, str] | None = None, + user_id: str | UUID | None = None, + search: str | None = None, + skip: int | None = None, + limit: int | None = None, + ) -> Sequence[Organization]: + statement = self._find_by(sort=sort, filters=filters, user_id=user_id, search=search, skip=skip, limit=limit) + return (await self.session.execute(statement)).scalars().all() + + async def find_by_as_count( + self, + sort: dict[str, str] | None = None, + filters: dict[str, str] | None = None, + user_id: str | UUID | None = None, + search: str | None = None, + skip: int | None = None, + limit: int | None = None, + ) -> Any: # noqa + statement = self._find_by(sort=sort, filters=filters, user_id=user_id, search=search, skip=skip, limit=limit) + statement = self._as_count_query(statement) + return (await self.session.execute(statement)).scalars().first() + + async def save(self, organization: Organization) -> Organization: + try: + self.session.add(organization) + await self.session.commit() + await self.session.refresh(organization) + except SQLAlchemyError as e: + logger.exception("Error saving organization") + await self.session.rollback() + raise AMTRepositoryError from e + return organization + + async def find_by_slug(self, slug: str) -> Organization: + try: + statement = select(Organization).where(Organization.slug == slug).where(Organization.deleted_at.is_(None)) + return (await self.session.execute(statement)).scalars().one() + except NoResultFound as e: + logger.exception("Organization not found") + raise AMTRepositoryError from e + + async def find_by_id(self, organization_id: int) -> Organization: + try: + statement = ( + select(Organization).where(Organization.id == organization_id).where(Organization.deleted_at.is_(None)) + ) + return (await self.session.execute(statement)).scalars().one() + except NoResultFound as e: + logger.exception("Organization not found") + raise AMTRepositoryError from e + + async def find_by_id_and_user_id(self, organization_id: int, user_id: str | UUID) -> Organization: + user_id = UUID(user_id) if isinstance(user_id, str) else user_id + # usage: to make sure a user is actually part of an organization + try: + statement = ( + select(Organization) + .where(Organization.users.any(User.id == user_id)) # pyright: ignore[reportUnknownMemberType] + .where(Organization.id == organization_id) + .where(Organization.deleted_at.is_(None)) + ) + return (await self.session.execute(statement)).scalars().one() + except NoResultFound as e: + logger.exception("Organization not found") + raise AMTRepositoryError from e diff --git a/amt/repositories/users.py b/amt/repositories/users.py index f1a001f4..f9a37eab 100644 --- a/amt/repositories/users.py +++ b/amt/repositories/users.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Sequence from typing import Annotated from uuid import UUID @@ -6,9 +7,11 @@ from sqlalchemy import select from sqlalchemy.exc import NoResultFound, SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import lazyload +from sqlalchemy_utils import escape_like # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType] from amt.core.exceptions import AMTRepositoryError -from amt.models.user import User +from amt.models import User from amt.repositories.deps import get_session logger = logging.getLogger(__name__) @@ -22,6 +25,17 @@ class UsersRepository: def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]) -> None: self.session = session + async def find_all(self, search: str | None = None, limit: int | None = None) -> Sequence[User]: + statement = select(User) + if search: + statement = statement.filter(User.name.ilike(f"%{escape_like(search)}%")) + # https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html#lazy-loading + statement = statement.options(lazyload(User.organizations)) + if limit: + statement = statement.limit(limit) + + return (await self.session.execute(statement)).scalars().all() + async def find_by_id(self, id: UUID) -> User | None: """ Returns the user with the given id. @@ -29,6 +43,8 @@ async def find_by_id(self, id: UUID) -> User | None: :return: the user with the given id or an exception if no user was found """ statement = select(User).where(User.id == id) + # https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html#lazy-loading + statement = statement.options(lazyload(User.organizations)) try: return (await self.session.execute(statement)).scalars().one() except NoResultFound: @@ -44,6 +60,10 @@ async def upsert(self, user: User) -> User: existing_user = await self.find_by_id(user.id) if existing_user: existing_user.name = user.name + existing_user.email = user.email + existing_user.email_hash = user.email_hash + existing_user.name_encoded = user.name_encoded + self.session.add(existing_user) else: self.session.add(user) await self.session.commit() diff --git a/amt/schema/algorithm.py b/amt/schema/algorithm.py index 09303124..de4ac26b 100644 --- a/amt/schema/algorithm.py +++ b/amt/schema/algorithm.py @@ -16,7 +16,19 @@ class AlgorithmNew(AlgorithmBase): transparency_obligations: str = Field(default=None) role: list[str] | str = [] template_id: str = Field(default=None) + organization_id: int = Field() + + @field_validator("organization_id", mode="before") + @classmethod + def ensure_required(cls, v: int | str) -> int: # noqa + if isinstance(v, str) and v == "": # this is always a string + # TODO (Robbert): the error message from pydantic becomes 'Value error, + # missing' which is why a custom message will be applied + raise ValueError("missing") + else: + return int(v) @field_validator("instruments", "role") - def ensure_list(cls, v: list[str] | str) -> list[str]: + @classmethod + def ensure_list(cls, v: list[str] | str) -> list[str]: # noqa return v if isinstance(v, list) else [v] diff --git a/amt/schema/organization.py b/amt/schema/organization.py new file mode 100644 index 00000000..9f1e6a5e --- /dev/null +++ b/amt/schema/organization.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, Field +from pydantic.functional_validators import field_validator + + +class OrganizationBase(BaseModel): + name: str = Field(min_length=3, max_length=64) + + +class OrganizationSlug(BaseModel): + slug: str = Field(min_length=3, max_length=64, pattern=r"^[a-z0-9-_]*{3,64}$") + + +class OrganizationNew(OrganizationBase, OrganizationSlug): + user_ids: list[str] | str + + @field_validator("user_ids") + def ensure_list(cls, v: list[str] | str) -> list[str]: + return v if isinstance(v, list) else [v] diff --git a/amt/schema/system_card.py b/amt/schema/system_card.py index 1a68b35e..e588e763 100644 --- a/amt/schema/system_card.py +++ b/amt/schema/system_card.py @@ -16,6 +16,16 @@ class Reference(BaseModel): link: str = Field(default=None) +# TODO: consider reusing classes, Owner is now also defined for Models +class Owner(BaseModel): + organization: str = Field(default=None) + oin: str | None = Field(default=None) + + def __init__(self, organization: str, oin: str | None = None, **data) -> None: # pyright: ignore # noqa + super().__init__(**data) + self.organization = organization + + class SystemCard(BaseModel): schema_version: str = Field(default="0.1a10") name: str = Field(default=None) @@ -30,3 +40,4 @@ class SystemCard(BaseModel): assessments: list[AssessmentCard] = Field(default=[]) references: list[Reference] = Field(default=[]) models: list[ModelCardSchema] = Field(default=[]) + owners: list[Owner] = Field(default=[]) diff --git a/amt/schema/webform.py b/amt/schema/webform.py new file mode 100644 index 00000000..a0d8925c --- /dev/null +++ b/amt/schema/webform.py @@ -0,0 +1,113 @@ +from enum import Enum +from typing import Any + + +class WebFormFieldType(Enum): + HIDDEN = "hidden" + TEXT = "text" + RADIO = "radio" + SELECT = "select" + TEXTAREA = "textarea" + DISABLED = "disabled" + SEARCH_SELECT = "search_select" + SUBMIT = "submit" + + +class WebFormOption: + value: str + display_value: str + + def __init__(self, value: str, display_value: str) -> None: + self.value = value + self.display_value = display_value + + +class WebFormBaseField: + id: str + type: WebFormFieldType + name: str + label: str + group: str | None + + def __init__(self, type: WebFormFieldType, name: str, label: str, group: str | None = None) -> None: + self.type = type + self.label = label + self.name = name + self.group = group + + +class WebFormField(WebFormBaseField): + placeholder: str | None + default_value: str | None + options: list[WebFormOption] | None + validators: list[Any] + description: str | None + attributes: dict[str, str] | None + + def __init__( + self, + type: WebFormFieldType, + name: str, + label: str, + placeholder: str | None = None, + default_value: str | None = None, + options: list[WebFormOption] | None = None, + attributes: dict[str, str] | None = None, + description: str | None = None, + group: str | None = None, + ) -> None: + super().__init__(type=type, name=name, label=label, group=group) + self.placeholder = placeholder + self.default_value = default_value + self.options = options + self.attributes = attributes + self.description = description + + +class WebFormSearchField(WebFormField): + search_url: str + query_var_name: str + + def __init__( + self, + query_var_name: str, + search_url: str, + name: str, + label: str, + placeholder: str | None = None, + default_value: 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.SEARCH_SELECT, + name=name, + label=label, + placeholder=placeholder, + default_value=default_value, + options=options, + attributes=attributes, + group=group, + description=description, + ) + self.search_url = search_url + self.query_var_name = query_var_name + + +class WebForm: + id: str + legend: str | None + post_url: str + fields: list[WebFormBaseField] + + def __init__(self, id: str, post_url: str, legend: str | None = None) -> None: + self.id = id + self.legend = legend + self.post_url = post_url + + +class WebFormSubmitButton(WebFormBaseField): + def __init__(self, label: str, name: str, group: str | None = None) -> None: + super().__init__(type=WebFormFieldType.SUBMIT, name=name, label=label, group=group) diff --git a/amt/services/algorithms.py b/amt/services/algorithms.py index 88769cd6..6dbb107f 100644 --- a/amt/services/algorithms.py +++ b/amt/services/algorithms.py @@ -6,12 +6,14 @@ from os.path import isfile, join from pathlib import Path from typing import Annotated +from uuid import UUID from fastapi import Depends from amt.core.exceptions import AMTNotFound from amt.models import Algorithm from amt.repositories.algorithms import AlgorithmsRepository +from amt.repositories.organizations import OrganizationsRepository from amt.schema.algorithm import AlgorithmNew from amt.schema.instrument import InstrumentBase from amt.schema.system_card import AiActProfile, SystemCard @@ -28,9 +30,11 @@ def __init__( self, repository: Annotated[AlgorithmsRepository, Depends(AlgorithmsRepository)], instrument_service: Annotated[InstrumentsService, Depends(create_instrument_service)], + organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], ) -> None: self.repository = repository self.instrument_service = instrument_service + self.organizations_repository = organizations_repository async def get(self, algorithm_id: int) -> Algorithm: algorithm = await self.repository.find_by_id(algorithm_id) @@ -44,7 +48,7 @@ async def delete(self, algorithm_id: int) -> Algorithm: algorithm = await self.repository.save(algorithm) return algorithm - async def create(self, algorithm_new: AlgorithmNew) -> Algorithm: + async def create(self, algorithm_new: AlgorithmNew, user_id: UUID | str) -> Algorithm: system_card_from_template = None if algorithm_new.template_id: template_files = get_template_files() @@ -98,6 +102,10 @@ async def create(self, algorithm_new: AlgorithmNew) -> Algorithm: system_card = SystemCard.model_validate(system_card_merged) algorithm = Algorithm(name=algorithm_new.name, lifecycle=algorithm_new.lifecycle, system_card=system_card) + algorithm.organization = await self.organizations_repository.find_by_id_and_user_id( + algorithm_new.organization_id, user_id + ) + algorithm = await self.update(algorithm) return algorithm diff --git a/amt/services/organizations.py b/amt/services/organizations.py new file mode 100644 index 00000000..59f21c96 --- /dev/null +++ b/amt/services/organizations.py @@ -0,0 +1,49 @@ +import logging +from collections.abc import Sequence +from typing import Annotated +from uuid import UUID + +from fastapi import Depends + +from amt.api.organization_filter_options import OrganizationFilterOptions +from amt.core.exceptions import AMTAuthorizationError +from amt.models import Organization +from amt.models.user import User +from amt.repositories.organizations import OrganizationsRepository +from amt.services.users import UsersService + +logger = logging.getLogger(__name__) + + +class OrganizationsService: + def __init__( + self, + organizations_repository: Annotated[OrganizationsRepository, Depends(OrganizationsRepository)], + users_service: Annotated[UsersService, Depends(UsersService)], + ) -> None: + self.organizations_repository: OrganizationsRepository = organizations_repository + self.users_service: UsersService = users_service + + async def save(self, name: str, slug: str, user_ids: list[str], created_by_user_id: str | None) -> Organization: + 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] + organization.users = users + created_by_user = (await self.users_service.get(created_by_user_id)) if created_by_user_id else None + organization.created_by = created_by_user + return await self.organizations_repository.save(organization) + + async def get_organizations_for_user(self, user_id: str | UUID | None) -> Sequence[Organization]: + # TODO (Robbert): this is not the right place to throw permission denied, + # a different error should be raised here + if not user_id: + raise AMTAuthorizationError() + + user_id = UUID(user_id) if isinstance(user_id, str) else user_id + + return await self.organizations_repository.find_by( + sort={"name": "ascending"}, + filters={"organization-type": OrganizationFilterOptions.MY_ORGANIZATIONS.value}, + user_id=user_id, + ) diff --git a/amt/site/static/scss/layout.scss b/amt/site/static/scss/layout.scss index a44ddd63..17f0733d 100644 --- a/amt/site/static/scss/layout.scss +++ b/amt/site/static/scss/layout.scss @@ -304,6 +304,32 @@ main { font-weight: var(--rvo-form-feedback-error-font-weight); } +/* stylelint-disable selector-class-pattern */ + +.amt-item-list__as_select { + border-width: var(--utrecht-form-control-border-width); + border-color: var(--utrecht-form-control-focus-border-color); + border-radius: var(--utrecht-form-control-border-radius, 0); + border-style: solid; + position: absolute; + background-color: var(--rvo-color-wit); + width: 100%; + top: 45px; + z-index: 100; +} + +.amt-item-list__item_as_select { + padding-left: 1em; + + &:hover { + background-color: var(--rvo-color-logoblauw-150); + } +} + +.amt-position-relative { + position: relative; +} + .amt-flex-container { display: flex; justify-content: space-between; @@ -314,9 +340,54 @@ main { } /* we override the default ROOS style because we want to display as column, not rows */ -/* stylelint-disable selector-class-pattern */ .amt-theme .rvo-accordion__item > .rvo-accordion__item-summary { align-items: initial; flex-direction: column; } + +.amt-avatar-list { + display: inline-flex; +} + +.amt-avatar-list__item { + img { + border: 1px solid var(--rvo-color-hemelblauw-750); + border-radius: 50%; + } + + .amt-avatar-list__more { + display: inline-block; + min-width: 24px; + font-size: var(--rvo-size-sm); + height: 24px; + border: 1px solid var(--rvo-color-hemelblauw-750); + border-radius: 50%; + text-align: center; + background-color: var(--rvo-color-grijs-300); + } +} + +.amt-avatar-list__item:not(:first-child) { + margin-left: -10px; +} + +.amt-tooltip { + position: relative; +} + +.amt-tooltip .amt-tooltip__text { + visibility: hidden; + background-color: var(--rvo-color-hemelblauw); + color: #fff; + border-radius: 5px; + padding: 5px; + position: absolute; + z-index: 1; + top: -2em; + text-align: center; +} + +.amt-tooltip:hover .amt-tooltip__text { + visibility: visible; +} /* stylelint-enable */ diff --git a/amt/site/static/ts/amt.ts b/amt/site/static/ts/amt.ts index e316dd32..3589907c 100644 --- a/amt/site/static/ts/amt.ts +++ b/amt/site/static/ts/amt.ts @@ -4,6 +4,7 @@ import _hyperscript from "hyperscript.org"; import "../scss/tabs.scss"; import "../scss/layout.scss"; +/* eslint-disable @typescript-eslint/no-non-null-assertion */ _hyperscript.browserInit(); @@ -153,3 +154,93 @@ export function closeModalSave(id: string) { }); } } + +export function reset_errorfield(id: string): void { + const el = document.getElementById(id); + if (el) { + el.innerHTML = ""; + } +} + +export function prevent_submit_on_enter(): void { + // @ts-expect-error false positive + if (event.key === "Enter") { + // @ts-expect-error false positive + event.preventDefault(); + } +} + +export function generate_slug(sourceId: string, targetId: string) { + if ( + document.getElementById(sourceId) != null && + document.getElementById(targetId) != null + ) { + const value = (document.getElementById(sourceId) as HTMLInputElement)! + .value; + (document.getElementById(targetId) as HTMLInputElement)!.value = value + .toLowerCase() + .replace(/[^a-z0-9-_ ]/g, "") + .replace(/\s/g, "-"); + } +} + +export function add_field(field: HTMLElement) { + const value = field.getAttribute("data-value"); + const targetId = (field.parentNode as HTMLElement)!.getAttribute( + "data-target-id", + ); + const parentId = (field?.parentNode as HTMLElement).id; + + if (targetId != null) { + const existing = document + .getElementById(targetId)! + .querySelectorAll(`[data-value="` + value + `"]`); + if (existing.length === 1) { + return; + } + // the new element is in the attribute field of the search result as base64 encoded string + const newElement = atob(field.getAttribute("data-list-result") as string); + document + .getElementById(targetId)! + .insertAdjacentHTML("beforeend", newElement); + document.getElementById(parentId)!.style.display = "none"; + document.getElementById(parentId)!.innerHTML = ""; + (document.getElementById(parentId)! + .previousElementSibling as HTMLFormElement)!.value = ""; + } +} + +export function show_form_search_options(id: string) { + document.getElementById(id)!.style.display = "block"; +} + +export function hide_form_search_options(id: string) { + window.setTimeout(function () { + document.getElementById(id)!.style.display = "none"; + }, 250); +} + +export function add_field_on_enter(id: string) { + if (!event) { + return; + } + if ( + (event as KeyboardEvent).key == "ArrowUp" || + (event as KeyboardEvent).key == "ArrowDown" + ) { + // const searchOptions = document.getElementById(id)!.querySelectorAll("li"); + event.preventDefault(); + // nice to have: use arrow keys to select options + // searchOptions[0].style.border = "1px solid red" + } + if ((event as KeyboardEvent).key === "Enter") { + event.preventDefault(); + const searchOptions = document.getElementById(id)!.querySelectorAll("li"); + if (searchOptions.length === 1) { + searchOptions[0].click(); + } + } else { + // make sure search results are visible when we type any key + show_form_search_options(id); + } +} diff --git a/amt/site/templates/algorithms/details_info.html.j2 b/amt/site/templates/algorithms/details_info.html.j2 index 494fab0c..340d0ee5 100644 --- a/amt/site/templates/algorithms/details_info.html.j2 +++ b/amt/site/templates/algorithms/details_info.html.j2 @@ -6,15 +6,19 @@ {% trans %}Name{% endtrans %} - {{ editable(algorithm, "name") }} + {{ editable(algorithm, "name", "systemcard") }} + + + {% trans %}Organization{% endtrans %} + {{ editable(algorithm, "organization.name", "select_my_organizations") }} {% trans %}Description{% endtrans %} - {{ editable(algorithm, "system_card.description") }} + {{ editable(algorithm, "system_card.description", "systemcard") }} {% trans %}Repository{% endtrans %} - {{ editable(algorithm, "system_card.provenance.uri") }} + {{ editable(algorithm, "system_card.provenance.uri", "systemcard") }} {% trans %}Algorithm code{% endtrans %} @@ -22,7 +26,7 @@ {% trans %}Lifecycle{% endtrans %} - {{ editable(algorithm, "lifecycle") }} + {{ editable(algorithm, "lifecycle", "systemcard") }} {% trans %}Last updated{% endtrans %} diff --git a/amt/site/templates/algorithms/details_requirements.html.j2 b/amt/site/templates/algorithms/details_requirements.html.j2 index 15a1fc34..1ca02273 100644 --- a/amt/site/templates/algorithms/details_requirements.html.j2 +++ b/amt/site/templates/algorithms/details_requirements.html.j2 @@ -13,7 +13,7 @@ aria-label="Delta omhoog"> {{ requirement.name }} - +
{{ requirement.description }}
{% if completed_measures_count == 0 %} @@ -25,7 +25,7 @@ {% endif %} {{ completed_measures_count }} / {{ measures | length }} {% trans %}measures executed{% endtrans %}
- +
{% for measure in measures %}
diff --git a/amt/site/templates/algorithms/new.html.j2 b/amt/site/templates/algorithms/new.html.j2 index 8793bf46..f38871b8 100644 --- a/amt/site/templates/algorithms/new.html.j2 +++ b/amt/site/templates/algorithms/new.html.j2 @@ -1,3 +1,4 @@ +{% import "macros/form_macros.html.j2" as macros with context %} {% extends "layouts/base.html.j2" %} {% block content %}
@@ -60,6 +61,7 @@
{% endfor %}
+ {{ macros.form_field(form.id, form.fields[0]) }}

{% trans %}AI Act Profile{% endtrans %}

@@ -200,7 +202,7 @@ {% trans %}Copy results and close{% endtrans %}
- + {# #}

diff --git a/amt/site/templates/errors/_AMTCSRFProtectError_401.html.j2 b/amt/site/templates/errors/_AMTCSRFProtectError_401.html.j2 new file mode 100644 index 00000000..e4c404bd --- /dev/null +++ b/amt/site/templates/errors/_AMTCSRFProtectError_401.html.j2 @@ -0,0 +1,32 @@ +
+ +
+
+ {% if message is defined and message is not none %} + {{ message }} + {% else %} + {% trans %}An error occurred. Please try again later{% endtrans %} + {% endif %} +
+
+ + +
diff --git a/amt/site/templates/macros/editable.html.j2 b/amt/site/templates/macros/editable.html.j2 index bfcc5640..b5f231c4 100644 --- a/amt/site/templates/macros/editable.html.j2 +++ b/amt/site/templates/macros/editable.html.j2 @@ -1,4 +1,4 @@ -{% macro editable(obj, field_path) %} +{% macro editable(obj, field_path, type) %} {% set value = nested_value(obj, field_path) %} {% if value is string and value.startswith('http') %} @@ -15,7 +15,7 @@ {% endif %} {% endif %} @@ -27,17 +27,35 @@ color: rgb(0, 123, 199) !important">{% trans %}Edit{% endtrans %} {% endmacro %} - {% macro edit(obj, field_path) %} + {% macro edit(obj, field_path, edit_type) %} -
+
- {% set value = nested_value(obj, field_path) %} - {% if is_nested_enum(obj, field_path) %} + {% if edit_type == "select_my_organizations" %} + {% set value = algorithm.organization.id %} +
+ +
+ {% elif is_nested_enum(obj, field_path) %} + {% set value = nested_value(obj, field_path) %}
{% trans %}Save{% endtrans %} -
+ {% endmacro %} diff --git a/amt/site/templates/macros/form_macros.html.j2 b/amt/site/templates/macros/form_macros.html.j2 new file mode 100644 index 00000000..6c3aaf73 --- /dev/null +++ b/amt/site/templates/macros/form_macros.html.j2 @@ -0,0 +1,195 @@ +{% macro user_avatars(users) %} +
+ {# this solution is not very elegant, a limit and count query would be better #} + {% for user in users[0:5] %} + + User icon {{ user.name }} + {{ user.name }} + + {% endfor %} + {% if users|length > 5 %} + + +{{ users|length - 5 }} + + {% endif %} +
+{% endmacro %} +{% macro form_field(form, field) %} + {% if field.type == WebFormFieldType.TEXT %} + {{ form_field_text(form, field) }} + {% elif field.type == WebFormFieldType.SELECT %} + {{ form_field_select(form, field) }} + {% elif field.type == WebFormFieldType.SEARCH_SELECT %} + {{ form_field_search(form, field) }} + {% elif field.type == WebFormFieldType.SUBMIT %} + {{ form_field_submit(form, field) }} + {% else %} + Unknown field {{ field.type }} + {% endif %} +{% endmacro %} +{% macro overview_table_row(loop, organization) -%} + + + {{ organization.name }} + + {{ user_avatars(organization.users) }} + {{ organization.modified_at | time_ago(language) }} + +{% endmacro %} +{% macro render_form_field_search_result(value, display_value) -%} + {% set list_result = render_form_field_list_result("user_ids", value, display_value) %} +
  • + {{ display_value }} +
  • +{% endmacro %} +{% macro render_form_field_list_result(name, value, display_value) -%} +
  • + + {{ display_value }} + +
  • +{% endmacro %} +{% macro form_field_select(prefix, field) %} +
    +
    + + {% if field.description %} +
    {{ field.description }}
    + {% endif %} +
    +
    +
    + +
    +
    +{% endmacro %} +{% macro form_field_text(prefix, field) %} +
    +
    + + {% if field.description %}
    {{ field.description }}
    {% endif %} +
    +
    + +
    +{% endmacro %} +{% macro form_field_search(prefix, field) %} +
    +
    + + {% if field.description %}
    {{ field.description }}
    {% endif %} +
    +
    +
    + + +
    +
      + {% if user %}{{ render_form_field_list_result("user_ids", user.sub, user.name) }}{% endif %} +
    +
    +{% endmacro %} +{% macro form_field_submit(form, field) %} +
    +

    + +

    +{% endmacro %} +{% macro add_form(form) %} +
    + {% set vars = {'last_group': '', 'fieldset_closed': False} %} + {% for field in form.fields %} + {% if vars.last_group != "" and vars.last_group != field.group %} + {% if vars.update({'fieldset_closed': True}) %}{% endif %} + +
    + {% endif %} + {% if vars.last_group == "" or vars.last_group != field.group %} + {% if vars.update({'fieldset_closed': False}) %}{% endif %} +
    +
    + {% if form.legend %} + {{ form.legend }} + {% endif %} + {% endif %} + {{ form_field(form.id, field) }} + {% if vars.update({'last_group': field.group}) %}{% endif %} + {% endfor %} + {% if not vars.fieldset_closed %} +
    +
    +{% endif %} + +{% endmacro %} diff --git a/amt/site/templates/macros/table_row.html.j2 b/amt/site/templates/macros/table_row.html.j2 index 8c2ca2c4..eeca4f19 100644 --- a/amt/site/templates/macros/table_row.html.j2 +++ b/amt/site/templates/macros/table_row.html.j2 @@ -20,3 +20,26 @@ {% endmacro %} + {% macro sort_button(field_name, sort_by, base_url) %} + {% if field_name in sort_by and sort_by[field_name] == "ascending" or (not field_name in sort_by) %} + + {% elif field_name in sort_by and sort_by[field_name] == "descending" %} + + {% endif %} + {% endmacro %} diff --git a/amt/site/templates/organizations/home.html.j2 b/amt/site/templates/organizations/home.html.j2 new file mode 100644 index 00000000..579b61a4 --- /dev/null +++ b/amt/site/templates/organizations/home.html.j2 @@ -0,0 +1,52 @@ +{% extends 'layouts/base.html.j2' %} +{% from "macros/editable.html.j2" import editable with context %} +{% import "macros/form_macros.html.j2" as macros with context %} +{% block content %} +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {% trans %}Name{% endtrans %}{{ editable(organization, "name", "systemcard") }}
    {% trans %}Slug{% endtrans %}{{ editable(organization, "slug", "systemcard") }}
    {% trans %}Created at{% endtrans %}{{ organization.created_at|format_datetime(language) }}
    {% trans %}Created by{% endtrans %}{{ organization.created_by.name }}
    {% trans %}Modified at{% endtrans %}{{ organization.modified_at|format_datetime(language) }}
    {% trans %}People{% endtrans %}{{ macros.user_avatars(organization.users) }}
    +
    +
    +{% endblock %} diff --git a/amt/site/templates/organizations/index.html.j2 b/amt/site/templates/organizations/index.html.j2 new file mode 100644 index 00000000..ab5b3198 --- /dev/null +++ b/amt/site/templates/organizations/index.html.j2 @@ -0,0 +1,8 @@ +{% extends 'layouts/base.html.j2' %} +{% block content %} +
    +
    + {% include 'organizations/parts/overview_results.html.j2' %} +
    +
    +{% endblock %} diff --git a/amt/site/templates/organizations/new.html.j2 b/amt/site/templates/organizations/new.html.j2 new file mode 100644 index 00000000..dcb679a1 --- /dev/null +++ b/amt/site/templates/organizations/new.html.j2 @@ -0,0 +1,14 @@ +{% import "macros/form_macros.html.j2" as macros with context %} +{% extends "layouts/base.html.j2" %} +{% block content %} +
    +
    +
    +
    +

    {% trans %}Create an organization{% endtrans %}

    +
    + {{ macros.add_form(form) }} +
    +
    +
    +{% endblock content %} diff --git a/amt/site/templates/organizations/parts/overview_results.html.j2 b/amt/site/templates/organizations/parts/overview_results.html.j2 new file mode 100644 index 00000000..243ab762 --- /dev/null +++ b/amt/site/templates/organizations/parts/overview_results.html.j2 @@ -0,0 +1,141 @@ +{% import "macros/table_row.html.j2" as table_row with context %} +{% import "macros/form_macros.html.j2" as macros with context %} +{% if include_filters %} +
    +
    +

    {% trans %}Organizations{% endtrans %}

    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    {% trans %}Search{% endtrans %}
    +
    + + +
    +
    +
    +
    {% trans %}Organisation Type{% endtrans %}
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +{% endif %} +{% if start > 0 %} + {% for organization in organizations %}{{ macros.overview_table_row(loop, organization) }}{% endfor %} +{% else %} +
    +
    +
    + + {% trans organizations_length %}{{ organizations_length }} result + {% pluralize %} + {{ organizations_length }} results{% endtrans %} + + {% if search or filters %} + {% trans %}for{% endtrans %} + {% if search %}'{{ search }}'{% endif %} + {% endif %} +
    + + {% for key, localized_value in filters.items() %} + + + {{ localized_value.display_value }} + + + {% endfor %} +
    +
    +
    +
    + + {% for key, localized_value in filters.items() %} + + {% endfor %} + + + + + + + + + {% for organization in organizations %}{{ macros.overview_table_row(loop, organization) }}{% endfor %} +
    + {% trans %}Organization name{% endtrans %} + {{ table_row.sort_button('name', sort_by, "/organizations/") }} + {% trans %}People{% endtrans %} + {% trans %}Last updated{% endtrans %} + {{ table_row.sort_button('last_update', sort_by, "/organizations/") }} +
    +
    +
    +{% endif %} diff --git a/amt/site/templates/organizations/parts/search_select_field.html.j2 b/amt/site/templates/organizations/parts/search_select_field.html.j2 new file mode 100644 index 00000000..760ce6ee --- /dev/null +++ b/amt/site/templates/organizations/parts/search_select_field.html.j2 @@ -0,0 +1,4 @@ +{% import "macros/form_macros.html.j2" as macros with context %} +{% for result in search_results %} + {{ macros.render_form_field_search_result(result.value, result.display_value) }} +{% endfor %} diff --git a/amt/site/templates/pages/under_construction.html.j2 b/amt/site/templates/pages/under_construction.html.j2 new file mode 100644 index 00000000..eed18da9 --- /dev/null +++ b/amt/site/templates/pages/under_construction.html.j2 @@ -0,0 +1,12 @@ +{% extends 'layouts/base.html.j2' %} +{% block title %} + {% trans %}AMT Placeholder informatie pagina's{% endtrans %} +{% endblock %} +{% block content %} +
    +
    +

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

    +

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

    +
    +
    +{% endblock %} diff --git a/amt/site/templates/parts/edit_cell.html.j2 b/amt/site/templates/parts/edit_cell.html.j2 index 1b2fc938..418d6ffa 100644 --- a/amt/site/templates/parts/edit_cell.html.j2 +++ b/amt/site/templates/parts/edit_cell.html.j2 @@ -1,2 +1,2 @@ {% from 'macros/editable.html.j2' import edit with context %} -{{ edit(algorithm, path) }} +{{ edit(object, path, edit_type) }} diff --git a/amt/site/templates/parts/filter_list.html.j2 b/amt/site/templates/parts/filter_list.html.j2 index dad02b90..51756a39 100644 --- a/amt/site/templates/parts/filter_list.html.j2 +++ b/amt/site/templates/parts/filter_list.html.j2 @@ -1,27 +1,4 @@ {% import 'macros/table_row.html.j2' as table_row with context %} -{% macro sort_button(field_name, sort_by) %} - {% if field_name in sort_by and sort_by[field_name] == "ascending" or (not field_name in sort_by) %} - - {% elif field_name in sort_by and sort_by[field_name] == "descending" %} - - {% endif %} -{% endmacro %} {% import 'macros/algorithm_systems_grid.html.j2' as render with context %} {% if start > 0 %} {% for algorithm in algorithms %}{{ table_row.item(loop, algorithm, true) }}{% endfor %} @@ -88,15 +65,15 @@ {% trans %}Algorithm name{% endtrans %} - {{ sort_button('name', sort_by) }} + {{ table_row.sort_button('name', sort_by, "/algorithms/") }} {% trans %}Lifecycle{% endtrans %} - {{ sort_button('lifecycle', sort_by) }} + {{ table_row.sort_button('lifecycle', sort_by, "/algorithms/") }} {% trans %}Last updated{% endtrans %} - {{ sort_button('last_update', sort_by) }} + {{ table_row.sort_button('last_update', sort_by, "/algorithms/") }} diff --git a/amt/site/templates/parts/header.html.j2 b/amt/site/templates/parts/header.html.j2 index 57aba26f..7f91938c 100644 --- a/amt/site/templates/parts/header.html.j2 +++ b/amt/site/templates/parts/header.html.j2 @@ -136,7 +136,8 @@ {% endif %} {% endfor %} -
    {% endif %} +
    {% if breadcrumbs is defined %}
    diff --git a/amt/site/templates/parts/view_cell.html.j2 b/amt/site/templates/parts/view_cell.html.j2 index 7df59405..8755663b 100644 --- a/amt/site/templates/parts/view_cell.html.j2 +++ b/amt/site/templates/parts/view_cell.html.j2 @@ -1,2 +1,2 @@ {% from 'macros/editable.html.j2' import editable with context %} -{{ editable(algorithm, path) }} +{{ editable(object, path, edit_type) }} diff --git a/poetry.lock b/poetry.lock index 3885a2e5..41123842 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiosqlite" @@ -986,6 +986,20 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jinja2-base64-filters" +version = "0.1.4" +description = "Tiny jinja2 extension to add b64encode and b64decode filters." +optional = false +python-versions = "*" +files = [ + {file = "jinja2_base64_filters-0.1.4-py2-none-any.whl", hash = "sha256:d007d543a9ce1e66a7a65645eef9100bc21a5d060a60b193fa4d4c4239bb3a86"}, + {file = "jinja2_base64_filters-0.1.4.tar.gz", hash = "sha256:f5f5d3e0476c4918ab3266093e8757918aed7cc47dab12338f9bda048cdbacd9"}, +] + +[package.dependencies] +jinja2 = "*" + [[package]] name = "jsbeautifier" version = "1.15.1" @@ -2861,4 +2875,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "675217fb2235def0b59242674fa90af40d5342574b87cb40a667a49a6b4b47a3" +content-hash = "1ca5edd8d5216cbd1a01877d8f90769a8f3542146048813b0c373cae5c7c2aa3" diff --git a/pyproject.toml b/pyproject.toml index 2b3cc7fe..8cefeab5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ authlib = "^1.3.2" aiosqlite = "^0.20.0" asyncpg = "^0.30.0" async-lru = "^2.0.4" +jinja2-base64-filters = "^0.1.4" [tool.poetry.group.test.dependencies] diff --git a/tests/api/routes/test_algorithm.py b/tests/api/routes/test_algorithm.py index c83344b4..6589fa2a 100644 --- a/tests/api/routes/test_algorithm.py +++ b/tests/api/routes/test_algorithm.py @@ -22,6 +22,7 @@ default_algorithm, default_algorithm_with_system_card, default_task, + default_user, ) from tests.database_test_utils import DatabaseTestUtils @@ -40,7 +41,7 @@ async def test_get_unknown_algorithm(client: AsyncClient) -> None: @pytest.mark.asyncio async def test_get_algorithm_tasks(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) + await db.given([default_user(), default_algorithm("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) # when response = await client.get("/algorithm/1/details/tasks") @@ -54,7 +55,7 @@ async def test_get_algorithm_tasks(client: AsyncClient, db: DatabaseTestUtils) - @pytest.mark.asyncio async def test_get_algorithm_inference(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) + await db.given([default_user(), default_algorithm("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) # when response = await client.get("/algorithm/1/details/model/inference") @@ -70,6 +71,7 @@ async def test_move_task(client: AsyncClient, db: DatabaseTestUtils, mocker: Moc # given await db.given( [ + default_user(), default_algorithm("testalgorithm1"), default_task(algorithm_id=1, status_id=1), default_task(algorithm_id=1, status_id=1), @@ -114,7 +116,7 @@ async def test_get_algorithm_context(client: AsyncClient, db: DatabaseTestUtils, @pytest.mark.asyncio async def test_get_algorithm_non_existing_algorithm(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) + await db.given([default_user(), default_algorithm("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) # when response = await client.get("/algorithm/99/details/tasks") @@ -151,7 +153,7 @@ async def test_get_algorithm_or_error(client: AsyncClient, db: DatabaseTestUtils @pytest.mark.asyncio async def test_get_system_card(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1")]) + await db.given([default_user(), default_algorithm("testalgorithm1")]) # when response = await client.get("/algorithm/1/details/system_card") @@ -180,7 +182,7 @@ async def test_get_system_card_unknown_algorithm(client: AsyncClient) -> None: @vcr.use_cassette("tests/fixtures/vcr_cassettes/test_get_assessment_card.yml") # type: ignore async def test_get_assessment_card(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm_with_system_card("testalgorithm1")]) + await db.given([default_user(), default_algorithm_with_system_card("testalgorithm1")]) # when response = await client.get("/algorithm/1/details/system_card/assessments/iama") @@ -194,7 +196,7 @@ async def test_get_assessment_card(client: AsyncClient, db: DatabaseTestUtils) - @pytest.mark.asyncio async def test_get_assessment_card_unknown_algorithm(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1")]) + await db.given([default_user(), default_algorithm("testalgorithm1")]) # when response = await client.get("/algorithm/1/details/system_card/assessments/iama") @@ -208,7 +210,7 @@ async def test_get_assessment_card_unknown_algorithm(client: AsyncClient, db: Da @pytest.mark.asyncio async def test_get_assessment_card_unknown_assessment(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1")]) + await db.given([default_user(), default_algorithm("testalgorithm1")]) # when response = await client.get("/algorithm/1/details/system_card/assessments/nonexistent") @@ -223,7 +225,7 @@ async def test_get_assessment_card_unknown_assessment(client: AsyncClient, db: D @vcr.use_cassette("tests/fixtures/vcr_cassettes/test_get_model_card.yml") # type: ignore async def test_get_model_card(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm_with_system_card("testalgorithm1")]) + await db.given([default_user(), default_algorithm_with_system_card("testalgorithm1")]) # when response = await client.get("/algorithm/1/details/system_card/models/logres_iris") @@ -247,7 +249,7 @@ async def test_get_model_card_unknown_algorithm(client: AsyncClient) -> None: @pytest.mark.asyncio async def test_get_assessment_card_unknown_model_card(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1")]) + await db.given([default_user(), default_algorithm("testalgorithm1")]) # when response = await client.get("/algorithm/1/details/system_card/models/nonexistent") @@ -261,7 +263,7 @@ async def test_get_assessment_card_unknown_model_card(client: AsyncClient, db: D @pytest.mark.asyncio async def test_get_algorithm_details(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) + await db.given([default_user(), default_algorithm("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) # when response = await client.get("/algorithm/1/details") @@ -276,7 +278,13 @@ async def test_get_algorithm_details(client: AsyncClient, db: DatabaseTestUtils) @vcr.use_cassette("tests/fixtures/vcr_cassettes/test_get_system_card_requirements.yml") # type: ignore async def test_get_system_card_requirements(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm_with_system_card("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) + await db.given( + [ + default_user(), + default_algorithm_with_system_card("testalgorithm1"), + default_task(algorithm_id=1, status_id=1), + ] + ) # when response = await client.get("/algorithm/1/details/system_card/requirements") @@ -291,7 +299,13 @@ async def test_get_system_card_requirements(client: AsyncClient, db: DatabaseTes @vcr.use_cassette("tests/fixtures/vcr_cassettes/test_get_system_card_data_page.yml") # type: ignore async def test_get_system_card_data_page(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm_with_system_card("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) + await db.given( + [ + default_user(), + default_algorithm_with_system_card("testalgorithm1"), + default_task(algorithm_id=1, status_id=1), + ] + ) # when response = await client.get("/algorithm/1/details/system_card/data") @@ -306,7 +320,13 @@ async def test_get_system_card_data_page(client: AsyncClient, db: DatabaseTestUt @vcr.use_cassette("tests/fixtures/vcr_cassettes/test_get_system_card_instruments.yml") # type: ignore async def test_get_system_card_instruments(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm_with_system_card("testalgorithm1"), default_task(algorithm_id=1, status_id=1)]) + await db.given( + [ + default_user(), + default_algorithm_with_system_card("testalgorithm1"), + default_task(algorithm_id=1, status_id=1), + ] + ) # when response = await client.get("/algorithm/1/details/system_card/instruments") @@ -320,7 +340,7 @@ async def test_get_system_card_instruments(client: AsyncClient, db: DatabaseTest @pytest.mark.asyncio async def test_get_algorithm_edit(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1")]) + await db.given([default_user(), default_algorithm("testalgorithm1")]) # when response = await client.get("/algorithm/1/edit/system_card/lifecycle") @@ -335,7 +355,7 @@ async def test_get_algorithm_edit(client: AsyncClient, db: DatabaseTestUtils) -> @pytest.mark.asyncio async def test_delete_algorithm(client: AsyncClient, db: DatabaseTestUtils, mocker: MockFixture) -> None: # given - await db.given([default_algorithm("testalgorithm1")]) + await db.given([default_user(), default_algorithm("testalgorithm1")]) mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) client.cookies["fastapi-csrf-token"] = "1" @@ -352,7 +372,7 @@ async def test_delete_algorithm(client: AsyncClient, db: DatabaseTestUtils, mock @pytest.mark.asyncio async def test_delete_algorithm_and_check_list(client: AsyncClient, db: DatabaseTestUtils, mocker: MockFixture) -> None: # given - await db.given([default_algorithm("testalgorithm1"), default_algorithm("testalgorithm2")]) + await db.given([default_user(), default_algorithm("testalgorithm1"), default_algorithm("testalgorithm2")]) mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) client.cookies["fastapi-csrf-token"] = "1" @@ -376,7 +396,7 @@ async def test_delete_algorithm_and_check_algorithm( client: AsyncClient, db: DatabaseTestUtils, mocker: MockFixture ) -> None: # given - await db.given([default_algorithm("testalgorithm1"), default_algorithm("testalgorithm2")]) + await db.given([default_user(), default_algorithm("testalgorithm1"), default_algorithm("testalgorithm2")]) mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) client.cookies["fastapi-csrf-token"] = "1" @@ -395,7 +415,7 @@ async def test_delete_algorithm_and_check_algorithm( @pytest.mark.asyncio async def test_get_algorithm_cancel(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1")]) + await db.given([default_user(), default_algorithm("testalgorithm1")]) # when response = await client.get("/algorithm/1/cancel/system_card/lifecycle") @@ -408,7 +428,7 @@ async def test_get_algorithm_cancel(client: AsyncClient, db: DatabaseTestUtils) @pytest.mark.asyncio async def test_get_algorithm_update(client: AsyncClient, mocker: MockFixture, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm("testalgorithm1")]) + await db.given([default_user(), default_algorithm("testalgorithm1")]) client.cookies["fastapi-csrf-token"] = "1" mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) @@ -529,7 +549,7 @@ async def test_find_requirement_tasks_by_measure_urn() -> None: @pytest.mark.asyncio async def test_get_measure(client: AsyncClient, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm_with_system_card("testalgorithm1")]) + await db.given([default_user(), default_algorithm_with_system_card("testalgorithm1")]) # when response = await client.get("/algorithm/1/measure/urn:nl:ak:mtr:dat-01") @@ -543,7 +563,7 @@ async def test_get_measure(client: AsyncClient, db: DatabaseTestUtils) -> None: @pytest.mark.asyncio async def test_update_measure_value(client: AsyncClient, mocker: MockFixture, db: DatabaseTestUtils) -> None: # given - await db.given([default_algorithm_with_system_card("testalgorithm1")]) + await db.given([default_user(), default_algorithm_with_system_card("testalgorithm1")]) client.cookies["fastapi-csrf-token"] = "1" mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) diff --git a/tests/api/routes/test_algorithms.py b/tests/api/routes/test_algorithms.py index 3a16a330..32eace79 100644 --- a/tests/api/routes/test_algorithms.py +++ b/tests/api/routes/test_algorithms.py @@ -3,7 +3,7 @@ from typing import Any, cast import pytest -from amt.api.routes.algorithms import get_localized_value +from amt.api.routes.shared import get_localized_value from amt.models import Algorithm from amt.models.base import Base from amt.schema.ai_act_profile import AiActProfile @@ -15,7 +15,7 @@ from pytest_mock import MockFixture from starlette.datastructures import URL -from tests.constants import default_algorithm, default_instrument +from tests.constants import default_algorithm, default_auth_user, default_instrument, default_user from tests.database_test_utils import DatabaseTestUtils @@ -76,12 +76,15 @@ async def test_algorithms_get_root_htmx_with_algorithms_mock(client: AsyncClient @pytest.mark.asyncio -async def test_get_new_algorithms(client: AsyncClient, mocker: MockFixture) -> None: +async def test_get_new_algorithms(client: AsyncClient, mocker: MockFixture, db: DatabaseTestUtils) -> None: # given + await db.given([default_user()]) + mocker.patch( "amt.services.instruments.InstrumentsService.fetch_instruments", return_value=[default_instrument(urn="urn1", name="name1"), default_instrument(urn="urn2", name="name2")], ) + mocker.patch("amt.api.routes.algorithms.get_user", return_value=default_auth_user()) # when response = await client.get("/algorithms/new") @@ -103,6 +106,7 @@ async def test_get_new_algorithms(client: AsyncClient, mocker: MockFixture) -> N async def test_post_new_algorithms_bad_request(client: AsyncClient, mocker: MockFixture) -> None: # given mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) + mocker.patch("amt.core.authorization.get_user", return_value=default_auth_user()) # when client.cookies["fastapi-csrf-token"] = "1" @@ -115,7 +119,9 @@ async def test_post_new_algorithms_bad_request(client: AsyncClient, mocker: Mock @pytest.mark.asyncio -async def test_post_new_algorithms(client: AsyncClient, mocker: MockFixture) -> None: +async def test_post_new_algorithms(client: AsyncClient, mocker: MockFixture, db: DatabaseTestUtils) -> None: + await db.given([default_user()]) + client.cookies["fastapi-csrf-token"] = "1" new_algorithm = AlgorithmNew( name="default algorithm", @@ -127,6 +133,7 @@ async def test_post_new_algorithms(client: AsyncClient, mocker: MockFixture) -> transparency_obligations="geen transparantieverplichtingen", role="gebruiksverantwoordelijke", template_id="0", + organization_id=1, ) # given mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) @@ -134,6 +141,7 @@ async def test_post_new_algorithms(client: AsyncClient, mocker: MockFixture) -> "amt.services.instruments.InstrumentsService.fetch_instruments", return_value=[default_instrument(urn="urn1", name="name1"), default_instrument(urn="urn2", name="name2")], ) + mocker.patch("amt.api.routes.algorithms.get_user", return_value=default_auth_user()) # when response = await client.post("/algorithms/new", json=new_algorithm.model_dump(), headers={"X-CSRF-Token": "1"}) @@ -157,6 +165,9 @@ async def test_post_new_algorithms_write_system_card( "amt.services.instruments.InstrumentsService.fetch_instruments", return_value=[default_instrument(urn="urn1", name="name1"), default_instrument(urn="urn2", name="name2")], ) + mocker.patch("amt.api.routes.algorithms.get_user", return_value=default_auth_user()) + + await db.given([default_user()]) name = "name1" algorithm_new = AlgorithmNew( @@ -169,6 +180,7 @@ async def test_post_new_algorithms_write_system_card( transparency_obligations="geen transparantieverplichtingen", role="gebruiksverantwoordelijke", template_id="", + organization_id=1, ) ai_act_profile = AiActProfile( diff --git a/tests/api/routes/test_deps.py b/tests/api/routes/test_deps.py index 45010b33..eb714abe 100644 --- a/tests/api/routes/test_deps.py +++ b/tests/api/routes/test_deps.py @@ -30,6 +30,7 @@ def test_custom_context_processor(mocker: MockerFixture): "translations", "main_menu_items", "user", + "WebFormFieldType", ] assert result["version"] == VERSION assert result["available_translations"] == list(supported_translations) diff --git a/tests/api/routes/test_organizations.py b/tests/api/routes/test_organizations.py new file mode 100644 index 00000000..ee372521 --- /dev/null +++ b/tests/api/routes/test_organizations.py @@ -0,0 +1,186 @@ +import pytest +from amt.schema.organization import OrganizationNew +from httpx import AsyncClient +from pytest_mock import MockFixture + +from tests.constants import default_auth_user, default_user +from tests.database_test_utils import DatabaseTestUtils + + +@pytest.mark.asyncio +async def test_organizations_get_root(client: AsyncClient) -> None: + response = await client.get("/organizations/") + + assert response.status_code == 200 + assert b'
    None: + response = await client.get("/organizations", follow_redirects=True) + + assert response.status_code == 200 + assert b'
    None: + response = await client.get("/organizations/", headers={"HX-Request": "true"}) + + assert response.status_code == 200 + assert b'' not in response.content + + +@pytest.mark.asyncio +async def test_organizations_get_root_htmx_with_group_by(client: AsyncClient) -> None: + response = await client.get( + "/organizations/?skip=0&search=&add-filter-organization-type=MY_ORGANIZATIONS", headers={"HX-Request": "true"} + ) + + assert response.status_code == 200 + assert b"/organizations/?skip=0&drop-filter-=organization-type" in response.content + + +@pytest.mark.asyncio +async def test_organizations_get_search(client: AsyncClient) -> None: + response = await client.get("/organizations/?skip=0&search=test", headers={"HX-Request": "true"}) + + assert response.status_code == 200 + assert b'' in response.content + + +@pytest.mark.asyncio +async def test_get_new_organizations(client: AsyncClient, mocker: MockFixture, db: DatabaseTestUtils) -> None: + # given + await db.given([default_user()]) + + mocker.patch("amt.api.routes.organizations.get_user", return_value=default_auth_user()) + + # when + response = await client.get("/organizations/new") + assert response.status_code == 200 + assert response.headers["content-type"] == "text/html; charset=utf-8" + + assert b'id="form-organization">' in response.content + + +@pytest.mark.asyncio +async def test_post_new_organizations_bad_request(client: AsyncClient, mocker: MockFixture) -> None: + # given + mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) + mocker.patch("amt.api.routes.organizations.get_user", return_value=default_auth_user()) + + # when + client.cookies["fastapi-csrf-token"] = "1" + response = await client.post("/organizations/new", json={}, headers={"X-CSRF-Token": "1"}) + + # then + assert response.status_code == 400 + assert response.headers["content-type"] == "text/html; charset=utf-8" + assert b"Field required" in response.content + + +@pytest.mark.asyncio +async def test_post_new_organizations(client: AsyncClient, mocker: MockFixture, db: DatabaseTestUtils) -> None: + await db.given([default_user()]) + + client.cookies["fastapi-csrf-token"] = "1" + new_organization = OrganizationNew(name="test-name", slug="test-slug", user_ids=[default_auth_user()["sub"]]) + # given + mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) + mocker.patch("amt.api.routes.organizations.get_user", return_value=default_auth_user()) + + # when + response = await client.post( + "/organizations/new", json=new_organization.model_dump(), headers={"X-CSRF-Token": "1"} + ) + + # then + assert response.status_code == 200 + assert response.headers["content-type"] == "text/html; charset=utf-8" + assert response.headers["HX-Redirect"] == "/organizations/test-slug" + + +@pytest.mark.asyncio +async def test_edit_organization_inline(client: AsyncClient, mocker: MockFixture, db: DatabaseTestUtils) -> None: + # given + await db.given([default_user()]) + + client.cookies["fastapi-csrf-token"] = "1" + + mocker.patch("amt.api.routes.organizations.get_user", return_value=default_auth_user()) + + # when + response = await client.get("/organizations/default-organization/edit/name?edit_type=systemcard") + + # then + assert response.status_code == 200 + assert response.headers["content-type"] == "text/html; charset=utf-8" + assert b"/organizations/default-organization/update/name?edit_type=systemcard" in response.content + + +@pytest.mark.asyncio +async def test_organization_slug(client: AsyncClient, mocker: MockFixture, db: DatabaseTestUtils) -> None: + # given + await db.given([default_user()]) + + client.cookies["fastapi-csrf-token"] = "1" + + mocker.patch("amt.api.routes.organizations.get_user", return_value=default_auth_user()) + + # when + response = await client.get("/organizations/default-organization") + + # then + assert response.status_code == 200 + assert response.headers["content-type"] == "text/html; charset=utf-8" + + +@pytest.mark.asyncio +async def test_update_organization_inline(client: AsyncClient, mocker: MockFixture, db: DatabaseTestUtils) -> None: + # given + await db.given([default_user()]) + client.cookies["fastapi-csrf-token"] = "1" + + mocker.patch("amt.api.routes.organizations.get_user", return_value=default_auth_user()) + mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) + + client.cookies["fastapi-csrf-token"] = "1" + response = await client.put( + "/organizations/default-organization/update/name?edit_type=systemcard", + json={"value": "New name"}, + headers={"X-CSRF-Token": "1"}, + ) + + assert b"New name" in response.content + + +@pytest.mark.asyncio +async def test_get_users(client: AsyncClient, mocker: MockFixture, db: DatabaseTestUtils) -> None: + # given + await db.given([default_user()]) + client.cookies["fastapi-csrf-token"] = "1" + + mocker.patch("amt.api.routes.organizations.get_user", return_value=default_auth_user()) + mocker.patch("fastapi_csrf_protect.CsrfProtect.validate_csrf", new_callable=mocker.AsyncMock) + + # when + response = await client.get("/organizations/users?query=Default") + # then + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert b'{"value":"92714be3-f798-4461-ba83-55d6cfd889a6","display_value":"Default User"}' in response.content + + # when + response = await client.get("/organizations/users?query=Default&returnType=search_select_field") + # then + assert response.status_code == 200 + assert response.headers["content-type"] == "text/html; charset=utf-8" + assert b'Default User' in response.content + + # when + response = await client.get("/organizations/users?query=D") + # then + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert b"[]" in response.content diff --git a/tests/api/test_navigation.py b/tests/api/test_navigation.py index 84533af3..45c9497d 100644 --- a/tests/api/test_navigation.py +++ b/tests/api/test_navigation.py @@ -112,7 +112,7 @@ def test_get_main_menu(): main_menu = get_main_menu(default_fastapi_request(url="/algorithm/"), internationalization.get_translation("en")) # assert right item is active - assert len(main_menu) == 1 + assert len(main_menu) == 2 assert main_menu[0].active is True # assert display text is correct diff --git a/tests/conftest.py b/tests/conftest.py index ae1c1631..7537376e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,14 +8,14 @@ from typing import Any import httpx -import nest_asyncio # type: ignore [(reportMissingTypeStubs)] +import nest_asyncio # pyright: ignore [(reportMissingTypeStubs)] import pytest import pytest_asyncio import uvicorn from amt.models.base import Base from amt.server import create_app from httpx import ASGITransport, AsyncClient -from playwright.sync_api import Browser +from playwright.sync_api import Browser, Page from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio.session import async_sessionmaker @@ -28,11 +28,17 @@ # Dubious choice here: allow nested event loops. nest_asyncio.apply() # type: ignore [(reportUnknownMemberType)] +logging.getLogger("vcr").setLevel(logging.WARNING) + def run_server_uvicorn(database_file: Path, host: str = "127.0.0.1", port: int = 3462) -> None: os.environ["APP_DATABASE_FILE"] = "/" + str(database_file) os.environ["AUTO_CREATE_SCHEMA"] = "true" os.environ["DISABLE_AUTH"] = "true" + os.environ["OIDC_CLIENT_ID"] = "AMT" + os.environ["OIDC_DISCOVERY_URL"] = ( + "https://keycloak.apps.digilab.network/realms/algoritmes-test/.well-known/openid-configuration" + ) logger.info(os.environ["APP_DATABASE_FILE"]) app = create_app() uvicorn.run(app, host=host, port=port) @@ -218,3 +224,11 @@ async def db( async_session = async_sessionmaker(engine, expire_on_commit=False) async with async_session() as session: yield DatabaseTestUtils(session, database_file) + + +def do_e2e_login(page: Page): + page.goto("/") + page.locator("#header-link-login").click() + page.fill("#username", "default") + page.fill("#password", "default") + page.locator("#kc-login").click() diff --git a/tests/constants.py b/tests/constants.py index efb20ca5..1025a9c7 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -1,15 +1,44 @@ import json +from datetime import datetime +from typing import Any +from urllib.parse import quote_plus from uuid import UUID from amt.api.lifecycles import Lifecycles from amt.api.navigation import BaseNavigationItem, DisplayText -from amt.models import Algorithm, Task, User +from amt.models import Algorithm, Organization, Task, User from amt.schema.instrument import Instrument, InstrumentTask, Owner from amt.schema.system_card import SystemCard from fastapi import Request from starlette.datastructures import URL +def default_auth_user() -> dict[str, Any]: + return { + "exp": 1732621076, + "iat": 1732620776, + "auth_time": 1732620601, + "jti": "ad011860-aecb-4378-ba46-98284a7818f3", + "iss": "https://keycloak.apps.digilab.network/realms/algoritmes-test", + "aud": "AMT", + "sub": "92714be3-f798-4461-ba83-55d6cfd889a6", + "typ": "ID", + "azp": "AMT", + "nonce": "qZ9KGvZs6acg4nYENou5", + "sid": "dd57ee84-8920-437e-bd86-35f7d306074f", + "at_hash": "NI7WGdovQASx96dOg_wDlg", + "acr": "0", + "email_verified": True, + "name": "Default User", + "preferred_username": "default", + "given_name": "Default", + "family_name": "User", + "email": "default@amt.nl", + "email_hash": "a329108d9aabe362bc2fe4994989f6090b1445fd90ebe3520ab052f1836fa1a1", + "name_encoded": "default+user", + } + + def default_base_navigation_item( url: str = "/default/", custom_display_text: str = "Default item", @@ -19,13 +48,31 @@ def default_base_navigation_item( return BaseNavigationItem(display_text=display_text, url=url, custom_display_text=custom_display_text, icon=icon) -def default_algorithm(name: str = "default algorithm") -> Algorithm: - return Algorithm(name=name) +def default_algorithm(name: str = "default algorithm", organization_id: int = 1) -> Algorithm: + return Algorithm(name=name, organization_id=organization_id) + +def default_organization(name: str = "default organization") -> Organization: + return Organization(name=name, slug="default-organization", created_by_id=UUID(default_auth_user()["sub"])) -def default_user(id: str | UUID = "00494b4d-bcdf-425a-8140-bea0f3cbd3c2", name: str = "John Smith") -> User: - id = UUID(id) if isinstance(id, str) else id - return User(id=id, name=name) + +def default_user( + id: str | UUID | None = None, + name: str | None = None, + organizations: list[Organization] | None = None, +) -> User: + user_name = name if name else default_auth_user()["name"] + organizations = [default_organization()] if organizations is None else organizations + user_id = UUID(default_auth_user()["sub"]) if id is None else UUID(id) if isinstance(id, str) else id + + return User( + id=user_id, + name=user_name, + email=default_auth_user()["email"], + name_encoded=quote_plus(user_name.strip().lower()), + email_hash=default_auth_user()["email_hash"], + organizations=organizations, + ) def default_algorithm_with_system_card(name: str = "default algorithm") -> Algorithm: @@ -33,13 +80,15 @@ def default_algorithm_with_system_card(name: str = "default algorithm") -> Algor system_card_from_template = json.load(f) system_card_from_template["name"] = name system_card = SystemCard.model_validate(system_card_from_template) - return Algorithm(name=name, lifecycle=Lifecycles.DEVELOPMENT, system_card=system_card) + return Algorithm(name=name, lifecycle=Lifecycles.DEVELOPMENT, system_card=system_card, organization_id=1) def default_algorithm_with_lifecycle( - name: str = "default algorithm", lifecycle: Lifecycles = Lifecycles.DESIGN + name: str = "default algorithm", lifecycle: Lifecycles = Lifecycles.DESIGN, last_edited: datetime | None = None ) -> Algorithm: - return Algorithm(name=name, lifecycle=lifecycle) + if last_edited: + return Algorithm(name=name, lifecycle=lifecycle, organization_id=1, last_edited=last_edited) + return Algorithm(name=name, lifecycle=lifecycle, organization_id=1) def default_fastapi_request(url: str = "/") -> Request: diff --git a/tests/core/test_exception_handlers.py b/tests/core/test_exception_handlers.py index 59091a25..c7841a85 100644 --- a/tests/core/test_exception_handlers.py +++ b/tests/core/test_exception_handlers.py @@ -1,6 +1,5 @@ import pytest from amt.core.exception_handlers import translate_pydantic_exception -from amt.core.exceptions import AMTCSRFProtectError from amt.schema.algorithm import AlgorithmNew from babel.support import NullTranslations from fastapi import status @@ -26,14 +25,16 @@ async def test_request_validation_exception_handler(client: AsyncClient): @pytest.mark.asyncio async def test_request_csrf_protect_exception_handler_invalid_token_in_header(client: AsyncClient): data = await client.get("/algorithms/new") - new_algorithm = AlgorithmNew(name="default algorithm", lifecycle="DATA_EXPLORATION_AND_PREPARATION") - with pytest.raises(AMTCSRFProtectError): - _response = await client.post( - "/algorithms/new", - json=new_algorithm.model_dump(), - headers={"X-CSRF-Token": "1"}, - cookies=data.cookies, - ) + new_algorithm = AlgorithmNew( + name="default algorithm", lifecycle="DATA_EXPLORATION_AND_PREPARATION", organization_id=1 + ) + response = await client.post( + "/algorithms/new", + json=new_algorithm.model_dump(), + headers={"X-CSRF-Token": "1"}, + cookies=data.cookies, + ) + assert response.status_code == 401 @pytest.mark.asyncio @@ -55,14 +56,16 @@ async def test_request_validation_exception_handler_htmx(client: AsyncClient): @pytest.mark.asyncio async def test_request_csrf_protect_exception_handler_invalid_token(client: AsyncClient): data = await client.get("/algorithms/new") - new_algorithm = AlgorithmNew(name="default algorithm", lifecycle="DATA_EXPLORATION_AND_PREPARATION") - with pytest.raises(AMTCSRFProtectError): - _response = await client.post( - "/algorithms/new", - json=new_algorithm.model_dump(), - headers={"HX-Request": "true", "X-CSRF-Token": "1"}, - cookies=data.cookies, - ) + new_algorithm = AlgorithmNew( + name="default algorithm", lifecycle="DATA_EXPLORATION_AND_PREPARATION", organization_id=1 + ) + response = await client.post( + "/algorithms/new", + json=new_algorithm.model_dump(), + headers={"HX-Request": "true", "X-CSRF-Token": "1"}, + cookies=data.cookies, + ) + assert response.status_code == 401 @pytest.mark.asyncio diff --git a/tests/database_e2e_setup.py b/tests/database_e2e_setup.py index ee8f0e7c..42619556 100644 --- a/tests/database_e2e_setup.py +++ b/tests/database_e2e_setup.py @@ -11,6 +11,7 @@ async def setup_database_e2e(session: AsyncSession) -> None: db_e2e = DatabaseTestUtils(session) await db_e2e.given([default_user()]) + await db_e2e.given([default_user(id="4738b1e151dc46219556a5662b26517c", name="Test User", organizations=[])]) algorithms: list[Algorithm] = [] for idx in range(120): diff --git a/tests/database_test_utils.py b/tests/database_test_utils.py index e36577a6..769c1f52 100644 --- a/tests/database_test_utils.py +++ b/tests/database_test_utils.py @@ -15,9 +15,11 @@ def __init__(self, session: AsyncSession, database_file: Path | None = None) -> async def given(self, models: list[Base]) -> None: session = self.get_session() - session.add_all(models) - - await session.commit() + # we add each model separately, because they may have relationships, + # that doesn't work with session.add_all + for model in models: + session.add(model) + await session.commit() for model in models: await session.refresh(model) # inefficient, but needed to create correlations between models diff --git a/tests/e2e/test_create_algorithm.py b/tests/e2e/test_create_algorithm.py index 7931b18b..bd93690f 100644 --- a/tests/e2e/test_create_algorithm.py +++ b/tests/e2e/test_create_algorithm.py @@ -1,9 +1,13 @@ import pytest from playwright.sync_api import Page, expect +from tests.conftest import do_e2e_login + @pytest.mark.slow -def test_e2e_create_algorithm(page: Page): +def test_e2e_create_algorithm(page: Page) -> None: + do_e2e_login(page) + page.goto("/algorithms/new") page.fill("#name", "My new algorithm") @@ -11,6 +15,8 @@ def test_e2e_create_algorithm(page: Page): button = page.locator("#lifecycle-DATA_EXPLORATION_AND_PREPARATION") button.click() + page.locator("#algorithmorganization_id").select_option("default organization") + impact_assessment = page.get_by_label("AI Impact Assessment (AIIA)") expect(impact_assessment).not_to_be_checked() @@ -35,6 +41,8 @@ def test_e2e_create_algorithm(page: Page): @pytest.mark.slow def test_e2e_create_algorithm_invalid(page: Page): + do_e2e_login(page) + page.goto("/algorithms/new") button = page.locator("#transparency_obligations-transparantieverplichtingen") diff --git a/tests/e2e/test_create_organization.py b/tests/e2e/test_create_organization.py new file mode 100644 index 00000000..68f46430 --- /dev/null +++ b/tests/e2e/test_create_organization.py @@ -0,0 +1,33 @@ +import pytest +from playwright.sync_api import Page, expect + +from tests.conftest import do_e2e_login + + +@pytest.mark.slow +def test_e2e_create_organization(page: Page) -> None: + do_e2e_login(page) + + page.goto("/organizations/new") + + page.get_by_placeholder("Name of the organization").click() + page.get_by_placeholder("Name of the organization").fill("new test organization") + page.keyboard.press("ArrowLeft") # we need a key-up event for the slug to fill + page.get_by_placeholder("Search for a user").click() + page.get_by_placeholder("Search for a user").fill("test") + page.locator("li").filter(has_text="Test User").click() + page.get_by_role("button", name="Add organization").click() + + expect(page.get_by_text("new test organization").first).to_be_visible() + + +@pytest.mark.slow +def test_e2e_create_organization_error(page: Page) -> None: + do_e2e_login(page) + + page.goto("/organizations/new") + + page.get_by_placeholder("Name of the organization").click() + page.get_by_role("button", name="Add organization").click() + + expect(page.get_by_text("String should have at least 3 characters").first).to_be_visible() diff --git a/tests/repositories/test_algorithms.py b/tests/repositories/test_algorithms.py index 029f03c0..58bbce95 100644 --- a/tests/repositories/test_algorithms.py +++ b/tests/repositories/test_algorithms.py @@ -1,16 +1,23 @@ +from datetime import datetime + import pytest from amt.api.lifecycles import Lifecycles from amt.api.publication_category import PublicationCategories from amt.core.exceptions import AMTRepositoryError from amt.models import Algorithm from amt.repositories.algorithms import AlgorithmsRepository, sort_by_lifecycle, sort_by_lifecycle_reversed -from tests.constants import default_algorithm, default_algorithm_with_lifecycle, default_algorithm_with_system_card +from tests.constants import ( + default_algorithm, + default_algorithm_with_lifecycle, + default_algorithm_with_system_card, + default_user, +) from tests.database_test_utils import DatabaseTestUtils @pytest.mark.asyncio async def test_find_all(db: DatabaseTestUtils): - await db.given([default_algorithm(), default_algorithm()]) + await db.given([default_user(), default_algorithm(), default_algorithm()]) algorithm_repository = AlgorithmsRepository(db.get_session()) results = await algorithm_repository.find_all() assert results[0].id == 1 @@ -27,6 +34,8 @@ async def test_find_all_no_results(db: DatabaseTestUtils): @pytest.mark.asyncio async def test_save(db: DatabaseTestUtils): + await db.given([default_user()]) + algorithm_repository = AlgorithmsRepository(db.get_session()) algorithm = default_algorithm() await algorithm_repository.save(algorithm) @@ -41,6 +50,7 @@ async def test_save(db: DatabaseTestUtils): @pytest.mark.asyncio async def test_delete(db: DatabaseTestUtils): + await db.given([default_user()]) algorithm_repository = AlgorithmsRepository(db.get_session()) algorithm = default_algorithm() await algorithm_repository.save(algorithm) @@ -53,6 +63,7 @@ async def test_delete(db: DatabaseTestUtils): @pytest.mark.asyncio async def test_save_failed(db: DatabaseTestUtils): + await db.given([default_user()]) algorithm_repository = AlgorithmsRepository(db.get_session()) algorithm = default_algorithm() algorithm.id = 1 @@ -69,6 +80,7 @@ async def test_save_failed(db: DatabaseTestUtils): @pytest.mark.asyncio async def test_delete_failed(db: DatabaseTestUtils): + await db.given([default_user()]) algorithm_repository = AlgorithmsRepository(db.get_session()) algorithm = default_algorithm() @@ -78,7 +90,7 @@ async def test_delete_failed(db: DatabaseTestUtils): @pytest.mark.asyncio async def test_find_by_id(db: DatabaseTestUtils): - await db.given([default_algorithm()]) + await db.given([default_user(), default_algorithm()]) algorithm_repository = AlgorithmsRepository(db.get_session()) result = await algorithm_repository.find_by_id(1) assert result.id == 1 @@ -94,7 +106,7 @@ async def test_find_by_id_failed(db: DatabaseTestUtils): @pytest.mark.asyncio async def test_paginate(db: DatabaseTestUtils): - await db.given([default_algorithm()]) + await db.given([default_user(), default_algorithm()]) algorithm_repository = AlgorithmsRepository(db.get_session()) result: list[Algorithm] = await algorithm_repository.paginate(skip=0, limit=3, search="", filters={}, sort={}) @@ -104,7 +116,7 @@ async def test_paginate(db: DatabaseTestUtils): @pytest.mark.asyncio async def test_paginate_more(db: DatabaseTestUtils): - await db.given([default_algorithm(), default_algorithm(), default_algorithm(), default_algorithm()]) + await db.given([default_user(), default_algorithm(), default_algorithm(), default_algorithm(), default_algorithm()]) algorithm_repository = AlgorithmsRepository(db.get_session()) result: list[Algorithm] = await algorithm_repository.paginate(skip=0, limit=3, search="", filters={}, sort={}) @@ -116,6 +128,7 @@ async def test_paginate_more(db: DatabaseTestUtils): async def test_paginate_capitalize(db: DatabaseTestUtils): await db.given( [ + default_user(), default_algorithm(name="Algorithm1"), default_algorithm(name="bbb"), default_algorithm(name="Aaa"), @@ -137,6 +150,7 @@ async def test_paginate_capitalize(db: DatabaseTestUtils): async def test_search(db: DatabaseTestUtils): await db.given( [ + default_user(), default_algorithm(name="Algorithm1"), default_algorithm(name="bbb"), default_algorithm(name="Aaa"), @@ -155,6 +169,7 @@ async def test_search(db: DatabaseTestUtils): async def test_search_multiple(db: DatabaseTestUtils): await db.given( [ + default_user(), default_algorithm(name="Algorithm1"), default_algorithm(name="bbb"), default_algorithm(name="Aaa"), @@ -180,7 +195,7 @@ async def test_search_no_results(db: DatabaseTestUtils): @pytest.mark.asyncio async def test_raises_exception(db: DatabaseTestUtils): - await db.given([default_algorithm()]) + await db.given([default_user(), default_algorithm()]) algorithm_repository = AlgorithmsRepository(db.get_session()) with pytest.raises(AMTRepositoryError): @@ -191,6 +206,7 @@ async def test_raises_exception(db: DatabaseTestUtils): async def test_sort_by_lifecyle(db: DatabaseTestUtils): await db.given( [ + default_user(), default_algorithm_with_lifecycle(name="Algorithm1", lifecycle=Lifecycles.DESIGN), default_algorithm(name="Algorithm2"), ] @@ -209,6 +225,7 @@ async def test_sort_by_lifecyle(db: DatabaseTestUtils): async def test_sort_by_lifecycle_reversed(db: DatabaseTestUtils): await db.given( [ + default_user(), default_algorithm_with_lifecycle(name="Algorithm1", lifecycle=Lifecycles.DESIGN), default_algorithm(name="Algorithm2"), ] @@ -227,6 +244,7 @@ async def test_sort_by_lifecycle_reversed(db: DatabaseTestUtils): async def test_with_lifecycle_filter(db: DatabaseTestUtils): await db.given( [ + default_user(), default_algorithm_with_lifecycle(name="Algorithm1"), default_algorithm(name="Algorithm2"), default_algorithm(name="Algorithm3"), @@ -247,6 +265,7 @@ async def test_with_lifecycle_filter(db: DatabaseTestUtils): async def test_with_publication_category_filter(db: DatabaseTestUtils): await db.given( [ + default_user(), default_algorithm_with_system_card(name="Algorithm1"), default_algorithm(name="Algorithm2"), default_algorithm(name="Algorithm3"), @@ -267,8 +286,13 @@ async def test_with_publication_category_filter(db: DatabaseTestUtils): async def test_with_sorting(db: DatabaseTestUtils): await db.given( [ + default_user(), default_algorithm_with_lifecycle(name="Algorithm1", lifecycle=Lifecycles.DESIGN), - default_algorithm_with_lifecycle(name="Algorithm2", lifecycle=Lifecycles.PHASING_OUT), + default_algorithm_with_lifecycle( + name="Algorithm2", + last_edited=datetime(2099, 1, 1, 1, 1, 1, 0), # noqa: DTZ001 + lifecycle=Lifecycles.PHASING_OUT, + ), ] ) algorithm_repository = AlgorithmsRepository(db.get_session()) @@ -295,7 +319,7 @@ async def test_with_sorting(db: DatabaseTestUtils): result: list[Algorithm] = await algorithm_repository.paginate( skip=0, limit=4, search="", filters={}, sort={"last_update": "descending"} ) - assert result[0].name == "Algorithm1" + assert result[0].name == "Algorithm2" # Sort lifecycle regular result: list[Algorithm] = await algorithm_repository.paginate( diff --git a/tests/repositories/test_tasks.py b/tests/repositories/test_tasks.py index deb6dd63..8c8cfd80 100644 --- a/tests/repositories/test_tasks.py +++ b/tests/repositories/test_tasks.py @@ -3,7 +3,7 @@ from amt.enums.status import Status from amt.models import Task from amt.repositories.tasks import TasksRepository -from tests.constants import default_algorithm, default_task +from tests.constants import default_algorithm, default_task, default_user from tests.database_test_utils import DatabaseTestUtils @@ -139,7 +139,7 @@ async def test_find_by_status_id(db: DatabaseTestUtils): @pytest.mark.asyncio async def test_find_by_algorithm_id_and_status_id(db: DatabaseTestUtils): - await db.given([default_algorithm()]) + await db.given([default_user(), default_algorithm()]) task = default_task(status_id=Status.TODO, algorithm_id=1) await db.given([task, default_task()]) diff --git a/tests/schema/test_schema_algorithm.py b/tests/schema/test_schema_algorithm.py index ef16b9f3..98d15c59 100644 --- a/tests/schema/test_schema_algorithm.py +++ b/tests/schema/test_schema_algorithm.py @@ -12,6 +12,7 @@ def test_algorithm_schema_create_new(): systemic_risk="systeemrisico", transparency_obligations="transparantieverplichtingen", role=["aanbieder", "gebruiksverantwoordelijke"], + organization_id=1, ) assert algorithm_new.name == "Algorithm Name" assert algorithm_new.instruments == ["urn:instrument:1", "urn:instrument:2"] @@ -21,6 +22,7 @@ def test_algorithm_schema_create_new(): assert algorithm_new.systemic_risk == "systeemrisico" assert algorithm_new.transparency_obligations == "transparantieverplichtingen" assert algorithm_new.role == ["aanbieder", "gebruiksverantwoordelijke"] + assert algorithm_new.organization_id == 1 def test_algorithm_schema_create_new_one_instrument(): @@ -34,6 +36,7 @@ def test_algorithm_schema_create_new_one_instrument(): systemic_risk="systeemrisico", transparency_obligations="transparantieverplichtingen", role="aanbieder", + organization_id=1, ) assert algorithm_new.name == "Algorithm Name" assert algorithm_new.instruments == ["urn:instrument:1"] @@ -43,3 +46,4 @@ def test_algorithm_schema_create_new_one_instrument(): assert algorithm_new.systemic_risk == "systeemrisico" assert algorithm_new.transparency_obligations == "transparantieverplichtingen" assert algorithm_new.role == ["aanbieder"] + assert algorithm_new.organization_id == 1 diff --git a/tests/schema/test_schema_system_cards.py b/tests/schema/test_schema_system_cards.py index e7d44726..3312944b 100644 --- a/tests/schema/test_schema_system_cards.py +++ b/tests/schema/test_schema_system_cards.py @@ -26,6 +26,7 @@ def test_get_system_card(setup: SystemCard) -> None: "measures": [], "references": [], "models": [], + "owners": [], } assert system_card.model_dump() == expected @@ -44,6 +45,7 @@ def test_system_card_update(setup: SystemCard) -> None: "measures": [], "references": [], "models": [], + "owners": [], } system_card.name = "IAMA 1.1" assert system_card.model_dump(exclude_none=True) == expected diff --git a/tests/services/test_algorithms_service.py b/tests/services/test_algorithms_service.py index 5af693ff..883a384b 100644 --- a/tests/services/test_algorithms_service.py +++ b/tests/services/test_algorithms_service.py @@ -3,12 +3,13 @@ from amt.core.exceptions import AMTNotFound from amt.models.algorithm import Algorithm from amt.repositories.algorithms import AlgorithmsRepository +from amt.repositories.organizations import OrganizationsRepository from amt.schema.algorithm import AlgorithmNew from amt.schema.system_card import SystemCard from amt.services.algorithms import AlgorithmsService from amt.services.instruments import InstrumentsService from pytest_mock import MockFixture -from tests.constants import default_instrument +from tests.constants import default_instrument, default_organization, default_user @pytest.mark.asyncio @@ -20,6 +21,7 @@ async def test_get_algorithm(mocker: MockFixture): algorithms_service = AlgorithmsService( repository=mocker.AsyncMock(spec=AlgorithmsRepository), instrument_service=mocker.AsyncMock(spec=InstrumentsService), + organizations_repository=mocker.AsyncMock(spec=OrganizationsRepository), ) algorithms_service.repository.find_by_id.return_value = Algorithm( # type: ignore id=algorithm_id, name=algorithm_name, lifecycle=algorithm_lifecycle @@ -42,15 +44,20 @@ async def test_create_algorithm(mocker: MockFixture): algorithm_lifecycle = "development" system_card = SystemCard(name=algorithm_name) + organizations_repository = mocker.AsyncMock(spec=OrganizationsRepository) + algorithms_service = AlgorithmsService( repository=mocker.AsyncMock(spec=AlgorithmsRepository), instrument_service=mocker.AsyncMock(spec=InstrumentsService), + organizations_repository=organizations_repository, ) algorithms_service.repository.save.return_value = Algorithm( # type: ignore id=algorithm_id, name=algorithm_name, lifecycle=algorithm_lifecycle, system_card=system_card ) algorithms_service.instrument_service.fetch_instruments.return_value = [default_instrument()] # type: ignore + organizations_repository.find_by_id_and_user_id.return_value = default_organization() + # When algorithm_new = AlgorithmNew( name=algorithm_name, @@ -62,8 +69,9 @@ async def test_create_algorithm(mocker: MockFixture): systemic_risk="algorithm_systemic_risk", transparency_obligations="algorithm_transparency_obligations", role="algorithm_role", + organization_id=1, ) - algorithm = await algorithms_service.create(algorithm_new) + algorithm = await algorithms_service.create(algorithm_new, default_user().id) # Then assert algorithm.id == algorithm_id @@ -86,12 +94,16 @@ async def test_create_algorithm_unknown_template_id(mocker: MockFixture): systemic_risk="algorithm_systemic_risk", transparency_obligations="algorithm_transparency_obligations", role="algorithm_role", + organization_id=1, ) + organizations_repository = mocker.AsyncMock(spec=OrganizationsRepository) + algorithms_service = AlgorithmsService( repository=mocker.AsyncMock(spec=AlgorithmsRepository), instrument_service=mocker.AsyncMock(spec=InstrumentsService), + organizations_repository=organizations_repository, ) with pytest.raises(AMTNotFound): - await algorithms_service.create(algorithm_new) + await algorithms_service.create(algorithm_new, default_user().id) diff --git a/tests/site/static/templates/test_template_new_algorithm.py b/tests/site/static/templates/test_template_new_algorithm.py index 34dc79b3..b2872208 100644 --- a/tests/site/static/templates/test_template_new_algorithm.py +++ b/tests/site/static/templates/test_template_new_algorithm.py @@ -1,11 +1,25 @@ +import pickle + from amt.api.deps import templates +from pytest_mock import MockFixture from tests.constants import default_fastapi_request -def test_tempate_algorithms_new(): +def test_tempate_algorithms_new(mocker: MockFixture): # given request = default_fastapi_request() - context = {"algorithm": "", "instruments": "", "ai_act_profile": {"category": ["option"]}} + + # TODO (Robbert): templates get to complex to test, this is a workaround for a form object + algorithm_form = pickle.loads( # noqa: S301 + b"\x80\x04\x95x\x01\x00\x00\x00\x00\x00\x00\x8c\x12amt.schema.webform\x94\x8c\x07WebForm\x94\x93\x94)\x81\x94}\x94(\x8c\x02id\x94\x8c\talgorithm\x94\x8c\x06legend\x94N\x8c\x08post_url\x94\x8c\x00\x94\x8c\x06fields\x94]\x94h\x00\x8c\x0cWebFormField\x94\x93\x94)\x81\x94}\x94(\x8c\x04type\x94h\x00\x8c\x10WebFormFieldType\x94\x93\x94\x8c\x06select\x94\x85\x94R\x94\x8c\x05label\x94\x8c\x0cOrganization\x94\x8c\x04name\x94\x8c\x0forganization_id\x94\x8c\x05group\x94\x8c\x011\x94\x8c\x0bplaceholder\x94N\x8c\rdefault_value\x94h\t\x8c\x07options\x94]\x94h\x00\x8c\rWebFormOption\x94\x93\x94)\x81\x94}\x94(\x8c\x05value\x94h\t\x8c\rdisplay_value\x94\x8c\x13Select organization\x94uba\x8c\nattributes\x94N\x8c\x0bdescription\x94Nubaub." # noqa: E501 + ) + + context = { + "algorithm": "", + "instruments": "", + "ai_act_profile": {"category": ["option"]}, + "form": algorithm_form, + } # when response = templates.TemplateResponse(request, "algorithms/new.html.j2", context) diff --git a/webpack.config.js b/webpack.config.js index e912c52f..e62c2bdc 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -95,7 +95,7 @@ module.exports = { dir: 'amt/site/static/dist/@nl-rvo/assets/icons/', files: ['index.css'], rules: [{ - search: /url\("/ig, + search: /url\("(?!\/)/ig, replace: 'url("/static/dist/@nl-rvo/assets/icons/' }] }])