Skip to content

Commit

Permalink
Add more editable fields
Browse files Browse the repository at this point in the history
  • Loading branch information
uittenbroekrobbert committed Jan 31, 2025
1 parent 0c5bf12 commit fa6fe03
Show file tree
Hide file tree
Showing 25 changed files with 514 additions and 374 deletions.
302 changes: 177 additions & 125 deletions amt/api/editable.py

Large diffs are not rendered by default.

123 changes: 123 additions & 0 deletions amt/api/editable_classes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import re
from enum import StrEnum
from typing import Any, Final

from amt.api.editable_converters import (
EditableConverter,
)
from amt.api.editable_enforcers import EditableEnforcer
from amt.api.editable_validators import EditableValidator
from amt.models.base import Base
from amt.schema.webform import WebFormFieldImplementationTypeFields, WebFormOption

type EditableType = Editable
type ResolvedEditableType = ResolvedEditable


class Editable:
"""
Editable contains all basic information for editing a field in a resources, like changing the name
of an algorithm.
It requires the 'full_resource_path' in a resolvable format, like algorithm/{algorithm_id}/system_card/name.
The implementation_type tells how this field can be edited using WebFormFieldImplementationType, like a 'plain'
TEXT field or SELECT_MY_ORGANIZATIONS.
The couples links fields together, if one is changed, so is the other.
The children field is for editing multiple fields at one (to be implemented).
The enforcer checks permissions and business rules.
The converter converts data between read and write when needed.
"""

full_resource_path: Final[str]

def __init__(
self,
full_resource_path: str,
implementation_type: WebFormFieldImplementationTypeFields,
couples: set[EditableType] | None = None,
children: list[EditableType] | None = None,
converter: EditableConverter | None = None,
enforcer: EditableEnforcer | None = None,
validator: EditableValidator | None = None,
# TODO: determine if relative resource path is really required for editable
relative_resource_path: str | None = None,
) -> None:
self.full_resource_path = full_resource_path
self.implementation_type = implementation_type
self.couples = set[EditableType]() if couples is None else couples
self.children = list[EditableType]() if children is None else children
self.converter = converter
self.enforcer = enforcer
self.validator = validator
self.relative_resource_path = relative_resource_path

def add_bidirectional_couple(self, target: EditableType) -> None:
"""
Changing an editable may require an update on another field as well, like when changing the name
of an algorithm; this is stored in two different places. Making it a couple ensures both values are
updated when one is changed.
:param target: the target editable type
:return: None
"""
self.couples.add(target)
target.couples.add(self)

def add_child(self, target: EditableType) -> None:
"""
An editable can be a container (parent) for other elements.
:param target: the target editable type
:return: None
"""
self.children.append(target)


class ResolvedEditable:
value: Any | None
# TODO: find out of holding resource_object in memory for many editables is wise / needed
resource_object: Any | None
relative_resource_path: str | None
form_options: list[WebFormOption] | None

def __init__(
self,
# fields copied from the Editable class
full_resource_path: str,
implementation_type: WebFormFieldImplementationTypeFields,
couples: set[ResolvedEditableType] | None = None,
children: list[ResolvedEditableType] | None = None,
converter: EditableConverter | None = None,
enforcer: EditableEnforcer | None = None,
validator: EditableValidator | None = None,
# resolved only fields
value: str | None = None,
resource_object: Base | None = None,
relative_resource_path: str | None = None,
) -> None:
self.full_resource_path = full_resource_path
self.implementation_type = implementation_type
self.couples = set[ResolvedEditableType]() if couples is None else couples
self.children = list[ResolvedEditableType]() if children is None else children
self.converter = converter
self.enforcer = enforcer
self.validator = validator
# resolved only fields
self.value = value
self.resource_object = resource_object
self.relative_resource_path = relative_resource_path

def last_path_item(self) -> str:
return self.full_resource_path.split("/")[-1]

def safe_html_path(self) -> str:
"""
The relative resource path with special characters replaced so it can be used in HTML/Javascript.
"""
if self.relative_resource_path is not None:
return re.sub(r"[\[\]/*]", "_", self.relative_resource_path) # pyright: ignore[reportUnknownVariableType, reportCallIssue]
raise ValueError("Can not convert path to save html path as it is None")


class EditModes(StrEnum):
EDIT = "EDIT"
VIEW = "VIEW"
SAVE = "SAVE"
13 changes: 9 additions & 4 deletions amt/api/editable_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@ def replace_digits_in_brackets(string: str) -> str:
return re.sub(r"\[(\d+)]", "[*]", string)


def replace_wildcard_with_digits_in_brackets(string: str, index: int) -> str:
return re.sub(r"\[\*]", "[" + str(index) + "]", string)
def replace_wildcard_with_digits_in_brackets(string: str, index: int | None) -> str:
if index is not None:
return re.sub(r"\[\*]", "[" + str(index) + "]", string)
else:
return string


def resolve_resource_list_path(full_resource_path_resolved: str, relative_resource_path: str) -> str:
"""
Given a full_resource_path_resolved that contains a list, like /algorithm/1/system_card/list[1], resolves
a relative_resource_path like /algorithm/1/system_card/list[*]/name so the result is /algorithm/1/system_card/list[1]/name.
a relative_resource_path like /algorithm/1/system_card/list[*]/name
so the result is /algorithm/1/system_card/list[1]/name.
Note this method assumes and only works with 1 list item, nested lists are not supported.
"""
full_resource_path, index = extract_number_and_string(full_resource_path_resolved)
_, index = extract_number_and_string(full_resource_path_resolved)
return replace_wildcard_with_digits_in_brackets(relative_resource_path, index)


Expand Down
10 changes: 5 additions & 5 deletions amt/api/editable_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class EditableValidator(ABC):
"""

@abstractmethod
async def validate(self, in_value: Any, relative_resource_path: str) -> None: # noqa: ANN401
async def validate(self, in_value: Any, editable: "ResolvedEditable") -> None: # noqa: ANN401, F821 # pyright: ignore[reportUndefinedVariable, reportUnknownParameterType]
pass


Expand All @@ -25,21 +25,21 @@ class FieldValidator(BaseModel):

self.field_validator: type[FieldValidator] = FieldValidator

async def validate(self, in_value: str, relative_resource_path: str) -> None:
async def validate(self, in_value: str, editable: "ResolvedEditable") -> None: # noqa: F821 # pyright: ignore[reportUndefinedVariable, reportUnknownParameterType]
try:
self.field_validator(value=in_value)
except ValidationError as e:
errors = e.errors()
errors[0]["loc"] = (relative_resource_path.replace("/", "_"),)
errors[0]["loc"] = (editable.safe_html_path(),) # pyright: ignore[reportUnknownMemberType]
raise RequestValidationError(errors) from e


class EditableValidatorSlug(EditableValidator):
async def validate(self, in_value: str, relative_resource_path: str) -> None:
async def validate(self, in_value: str, editable: "ResolvedEditable") -> None: # noqa: F821 # pyright: ignore[reportUndefinedVariable, reportUnknownParameterType]
try:
organization_slug: OrganizationSlug = OrganizationSlug(slug=in_value)
OrganizationSlug.model_validate(organization_slug)
except ValidationError as e:
errors = e.errors()
errors[0]["loc"] = (relative_resource_path.replace("/", "_"),)
errors[0]["loc"] = (editable.safe_html_path(),) # pyright: ignore[reportUnknownMemberType]
raise RequestValidationError(errors) from e
11 changes: 3 additions & 8 deletions amt/api/routes/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@
from amt.api.decorators import permission
from amt.api.deps import templates
from amt.api.editable import (
EditModes,
ResolvedEditable,
get_enriched_resolved_editable,
get_resolved_editables,
save_editable,
)
from amt.api.editable_classes import EditModes, ResolvedEditable
from amt.api.forms.measure import MeasureStatusOptions, get_measure_form
from amt.api.lifecycles import Lifecycles, get_localized_lifecycles
from amt.api.navigation import (
Expand Down Expand Up @@ -425,9 +424,7 @@ async def get_algorithm_cancel(
editable.value = await editable.converter.view(editable.value, **editable_context)

context = {
"relative_resource_path": editable.relative_resource_path.replace("/", ".")
if editable.relative_resource_path
else "",
"relative_resource_path": editable.relative_resource_path if editable.relative_resource_path else "",
"base_href": f"/algorithm/{ algorithm_id }",
"resource_object": editable.resource_object,
"full_resource_path": full_resource_path,
Expand Down Expand Up @@ -477,9 +474,7 @@ async def get_algorithm_update(
editable.value = await editable.converter.view(editable.value, **editable_context)

context = {
"relative_resource_path": editable.relative_resource_path.replace("/", ".")
if editable.relative_resource_path
else "",
"relative_resource_path": editable.relative_resource_path if editable.relative_resource_path else "",
"base_href": f"/algorithm/{ algorithm_id }",
"resource_object": editable.resource_object,
"full_resource_path": full_resource_path,
Expand Down
10 changes: 4 additions & 6 deletions amt/api/routes/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@
from amt.api.deps import templates
from amt.api.editable import (
Editables,
EditModes,
ResolvedEditable,
get_enriched_resolved_editable,
save_editable,
)
from amt.api.editable_classes import EditModes, ResolvedEditable
from amt.api.forms.organization import get_organization_form
from amt.api.group_by_category import get_localized_group_by_categories
from amt.api.lifecycles import get_localized_lifecycles
Expand All @@ -28,7 +27,7 @@
from amt.api.risk_group import get_localized_risk_groups
from amt.api.routes.algorithm import get_user_id_or_error
from amt.api.routes.algorithms import get_algorithms
from amt.api.routes.shared import UpdateFieldModel, get_filters_and_sort_by
from amt.api.routes.shared import get_filters_and_sort_by
from amt.api.utils import SafeDict
from amt.core.authorization import AuthorizationResource, AuthorizationVerb, get_user
from amt.core.exceptions import AMTAuthorizationError, AMTNotFound, AMTRepositoryError
Expand Down Expand Up @@ -278,11 +277,11 @@ async def get_organization_cancel(
async def get_organization_update(
request: Request,
organizations_service: Annotated[OrganizationsService, Depends(OrganizationsService)],
update_data: UpdateFieldModel,
organization_slug: str,
full_resource_path: str,
) -> HTMLResponse:
organization = await get_organization_or_error(organizations_service, request, organization_slug)
new_values = await request.json()

user_id = get_user_id_or_error(request)

Expand All @@ -297,13 +296,12 @@ async def get_organization_update(

editable_context = {
"user_id": user_id,
"new_value": update_data.value,
"new_values": new_values,
"organizations_service": organizations_service,
}

editable = await save_editable(
editable,
update_data=update_data,
editable_context=editable_context,
organizations_service=organizations_service,
do_save=True,
Expand Down
2 changes: 2 additions & 0 deletions amt/api/routes/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ def nested_value(obj: Any, attr_path: str) -> Any: # noqa: ANN401
obj = get_nested(obj, attr_path)
if isinstance(obj, Enum):
return obj.value
if obj is None:
return ""
return obj


Expand Down
Loading

0 comments on commit fa6fe03

Please sign in to comment.