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(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) }} | +
+ {% 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/") }} + | +
---|
{% trans %}This page is yet to be build.{% endtrans %}
+