diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 51f50e2..7e38ce9 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -35,11 +35,13 @@ jobs: ports: - 7687:7687 backend: - image: ghcr.io/robert-koch-institut/mex-backend:0.22.0 + image: ghcr.io/robert-koch-institut/mex-backend:0.28.0 env: MEX_BACKEND_API_USER_DATABASE: ${{ secrets.MEX_BACKEND_API_USER_DATABASE }} MEX_BACKEND_API_KEY_DATABASE: ${{ secrets.MEX_BACKEND_API_KEY_DATABASE }} + MEX_IDENTITY_PROVIDER: graph MEX_GRAPH_URL: neo4j://neo4j:7687 + MEX_DEBUG: True ports: - 8080:8080 @@ -81,6 +83,7 @@ jobs: env: MEX_BACKEND_API_USER_DATABASE: ${{ secrets.MEX_BACKEND_API_USER_DATABASE }} MEX_BACKEND_API_KEY: ${{ secrets.MEX_BACKEND_API_KEY }} + MEX_IDENTITY_PROVIDER: backend run: | pdm run editor run & sleep 30 && diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e15eff..c43680d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- add toggles for preventive and subtractive rules +- add functionality to edit component for submitting rules +- add utility function to escalate errors to all consoles +- temporarily add BackendIdentityProvider (stop-gap MX-1763) + ### Changes - bump cookiecutter template to 57e9b7 +- rename FixedX and EditableX classes to EditorX for consistency ### Deprecated ### Removed +- drop dev-dependency to mex-backend, use the flush endpoint instead +- temporarily removed localization of temporals entity output + ### Fixed ### Security diff --git a/mex/editor/components.py b/mex/editor/components.py index e26e3a4..59ea8df 100644 --- a/mex/editor/components.py +++ b/mex/editor/components.py @@ -1,10 +1,10 @@ import reflex as rx -from mex.editor.edit.models import FixedValue +from mex.editor.edit.models import EditorValue -def fixed_internal_link(value: FixedValue) -> rx.Component: - """Render a fixed value as a clickable internal link that reloads the edit page.""" +def render_internal_link(value: EditorValue) -> rx.Component: + """Render an editor value as a clickable internal link that loads the edit page.""" return rx.link( value.text, href=value.href, @@ -13,8 +13,8 @@ def fixed_internal_link(value: FixedValue) -> rx.Component: ) -def fixed_external_link(value: FixedValue) -> rx.Component: - """Render a fixed value as a clickable external link that opens in a new window.""" +def render_external_link(value: EditorValue) -> rx.Component: + """Render an editor value as a clickable external link that opens in a new tab.""" return rx.link( value.text, href=value.href, @@ -24,25 +24,25 @@ def fixed_external_link(value: FixedValue) -> rx.Component: ) -def fixed_link(value: FixedValue) -> rx.Component: - """Render a fixed value as a clickable link that reloads the edit page.""" +def render_link(value: EditorValue) -> rx.Component: + """Render an editor value as an internal or external link.""" return rx.cond( value.external, - fixed_external_link(value), - fixed_internal_link(value), + render_external_link(value), + render_internal_link(value), ) -def fixed_text(value: FixedValue) -> rx.Component: - """Render a fixed value as a text span.""" +def render_text(value: EditorValue) -> rx.Component: + """Render an editor value as a text span.""" return rx.text( value.text, as_="span", ) -def postfix_badge(value: FixedValue) -> rx.Component: - """Render a generic badge after the fixed value.""" +def postfix_badge(value: EditorValue) -> rx.Component: + """Render a generic badge after an editor value.""" return rx.badge( value.badge, radius="full", @@ -51,13 +51,13 @@ def postfix_badge(value: FixedValue) -> rx.Component: ) -def fixed_value(value: FixedValue) -> rx.Component: - """Return a single fixed value.""" +def render_value(value: EditorValue) -> rx.Component: + """Render a single editor value.""" return rx.hstack( rx.cond( value.href, - fixed_link(value), - fixed_text(value), + render_link(value), + render_text(value), ), rx.cond( value.badge, diff --git a/mex/editor/edit/main.py b/mex/editor/edit/main.py index 9619e1d..95af8c5 100644 --- a/mex/editor/edit/main.py +++ b/mex/editor/edit/main.py @@ -1,94 +1,185 @@ +from typing import cast + import reflex as rx -from mex.editor.components import fixed_value -from mex.editor.edit.models import EditableField, EditablePrimarySource, FixedValue +from mex.editor.components import render_value +from mex.editor.edit.models import EditorField, EditorPrimarySource, EditorValue from mex.editor.edit.state import EditState from mex.editor.layout import page -def fixed_value_card( - field_name: str, primary_source: str | None, index: int, value: FixedValue +def editor_value_switch( + field_name: str, + primary_source: str | None, + value: EditorValue, + index: int, +) -> rx.Component: + """Return a switch for toggling subtractive rules.""" + return rx.switch( + checked=value.enabled, + on_change=lambda enabled: cast(EditState, EditState).toggle_field_value( + field_name, + value, + enabled, + ), + custom_attrs={"data-testid": f"switch-{field_name}-{primary_source}-{index}"}, + ) + + +def editor_value_card( + field_name: str, + primary_source: str | None, + index: int, + value: EditorValue, ) -> rx.Component: - """Return a card containing a single fixed value.""" + """Return a card containing a single editor value.""" return rx.card( - fixed_value(value), + rx.hstack( + render_value(value), + rx.cond( + cast(rx.vars.ArrayVar, EditState.editor_fields).contains(field_name), + editor_value_switch(field_name, primary_source, value, index), + ), + ), style={"width": "30vw"}, - custom_attrs={"data-testid": f"value-{field_name}_{primary_source}_{index}"}, + custom_attrs={"data-testid": f"value-{field_name}-{primary_source}-{index}"}, + ) + + +def primary_source_switch( + field_name: str, + model: EditorPrimarySource, +) -> rx.Component: + """Return a switch for toggling preventive rules.""" + return rx.switch( + checked=model.enabled, + on_change=lambda enabled: cast(EditState, EditState).toggle_primary_source( + field_name, + cast(str, model.name.href), + enabled, + ), + custom_attrs={"data-testid": f"switch-{field_name}-{model.identifier}"}, ) -def editable_primary_source( - field_name: str, model: EditablePrimarySource +def primary_source_name( + field_name: str, + model: EditorPrimarySource, +) -> rx.Component: + """Return the name of a primary source as a card with a preventive rule toggle.""" + return rx.card( + rx.hstack( + render_value(model.name), + rx.cond( + cast(rx.vars.ArrayVar, EditState.editor_fields).contains(field_name), + primary_source_switch(field_name, model), + ), + ), + style={"width": "20vw"}, + custom_attrs={ + "data-testid": f"primary-source-{field_name}-{model.name.text}-name" + }, + ) + + +def editor_primary_source( + field_name: str, + model: EditorPrimarySource, ) -> rx.Component: """Return a horizontal grid of cards for editing one primary source.""" return rx.hstack( - rx.card( - fixed_value(model.name), - style={"width": "20vw"}, - custom_attrs={ - "data-testid": f"primary-source-{field_name}_{model.name.text}" - }, - ), + primary_source_name(field_name, model), rx.vstack( rx.foreach( model.editor_values, - lambda value, index: fixed_value_card( + lambda value, index: editor_value_card( field_name, model.name.text, index, value, ), - ) + ), ), + custom_attrs={ + "data-testid": f"primary-source-{field_name}-{model.name.text}", + }, ) -def editable_field(model: EditableField) -> rx.Component: +def editor_field(model: EditorField) -> rx.Component: """Return a horizontal grid of cards for editing one field.""" return rx.hstack( rx.card( rx.text(model.name), style={"width": "15vw"}, - custom_attrs={"data-testid": f"field-{model.name}"}, + custom_attrs={"data-testid": f"field-{model.name}-name"}, ), - rx.foreach( - model.primary_sources, - lambda primary_source: editable_primary_source( - model.name, - primary_source, - ), + rx.vstack( + rx.foreach( + model.primary_sources, + lambda primary_source: editor_primary_source( + model.name, + primary_source, + ), + ) ), + width="90vw", + custom_attrs={"data-testid": f"field-{model.name}"}, role="row", ) +def submit_button() -> rx.Component: + """Render a submit button to save the rule set.""" + return rx.button( + "Save", + color_scheme="jade", + size="3", + on_click=EditState.submit_rule_set, + style={"margin": "1em 0"}, + custom_attrs={"data-testid": "submit-button"}, + ) + + +def edit_heading() -> rx.Component: + """Return the heading for the edit page.""" + return rx.heading( + rx.hstack( + rx.foreach( + EditState.item_title, + render_value, + ), + ), + custom_attrs={"data-testid": "edit-heading"}, + style={ + "margin": "1em 0", + "whiteSpace": "nowrap", + "overflow": "hidden", + "textOverflow": "ellipsis", + "maxWidth": "80vw", + }, + ) + + def index() -> rx.Component: """Return the index for the edit component.""" return page( rx.box( - rx.heading( - rx.hstack( - rx.foreach( - EditState.item_title, - fixed_value, - ) - ), - custom_attrs={"data-testid": "edit-heading"}, - style={ - "margin": "1em 0", - "whiteSpace": "nowrap", - "overflow": "hidden", - "textOverflow": "ellipsis", - "maxWidth": "80%", - }, - ), + edit_heading(), rx.vstack( rx.foreach( EditState.fields, - editable_field, + editor_field, + ), + rx.cond( + EditState.fields, + submit_button(), ), ), - style={"width": "100%", "margin": "0 2em 1em"}, + style={ + "width": "100%", + "margin": "0 2em 1em", + }, custom_attrs={"data-testid": "edit-section"}, ), ) diff --git a/mex/editor/edit/models.py b/mex/editor/edit/models.py index 5dc61ef..20bdf11 100644 --- a/mex/editor/edit/models.py +++ b/mex/editor/edit/models.py @@ -1,17 +1,20 @@ import reflex as rx -from mex.editor.models import FixedValue +from mex.common.types import MergedPrimarySourceIdentifier +from mex.editor.models import EditorValue -class EditablePrimarySource(rx.Base): +class EditorPrimarySource(rx.Base): """Model for describing the editor state for one primary source.""" - name: FixedValue - editor_values: list[FixedValue] + name: EditorValue + identifier: MergedPrimarySourceIdentifier + editor_values: list[EditorValue] = [] + enabled: bool = True -class EditableField(rx.Base): +class EditorField(rx.Base): """Model for describing the editor state for a single field.""" name: str - primary_sources: list[EditablePrimarySource] + primary_sources: list[EditorPrimarySource] = [] diff --git a/mex/editor/edit/state.py b/mex/editor/edit/state.py index 6696d95..27897fc 100644 --- a/mex/editor/edit/state.py +++ b/mex/editor/edit/state.py @@ -1,29 +1,43 @@ +from collections.abc import Generator + import reflex as rx from reflex.event import EventSpec from requests import HTTPError +from starlette import status from mex.common.backend_api.connector import BackendApiConnector -from mex.common.logging import logger -from mex.editor.edit.models import EditableField -from mex.editor.edit.transform import transform_models_to_fields -from mex.editor.models import FixedValue +from mex.common.fields import MERGEABLE_FIELDS_BY_CLASS_NAME +from mex.common.models import RULE_SET_RESPONSE_CLASSES_BY_NAME +from mex.common.transform import ensure_postfix, ensure_prefix +from mex.editor.edit.models import EditorField, EditorPrimarySource +from mex.editor.edit.transform import ( + transform_fields_to_rule_set, + transform_models_to_fields, +) +from mex.editor.exceptions import escalate_error +from mex.editor.models import EditorValue from mex.editor.state import State -from mex.editor.transform import transform_models_to_title +from mex.editor.transform import ( + transform_models_to_stem_type, + transform_models_to_title, +) class EditState(State): """State for the edit component.""" - fields: list[EditableField] = [] - item_title: list[FixedValue] = [] + fields: list[EditorField] = [] + item_title: list[EditorValue] = [] + stem_type: str | None = None + editor_fields: list[str] = [] - def refresh(self) -> EventSpec | None: + def refresh(self) -> Generator[EventSpec | None, None, None]: """Refresh the edit page.""" self.reset() # TODO(ND): use the user auth for backend requests (stop-gap MX-1616) connector = BackendApiConnector.get() try: - response = connector.fetch_extracted_items( + extracted_items_response = connector.fetch_extracted_items( None, self.item_id, None, @@ -32,17 +46,90 @@ def refresh(self) -> EventSpec | None: ) except HTTPError as exc: self.reset() - logger.error( - "backend error fetching extracted items: %s", - exc.response.text, - exc_info=False, + yield from escalate_error( + "backend", "error fetching extracted items", exc.response.text ) - return rx.toast.error( - exc.response.text, - duration=5000, - close_button=True, - dismissible=True, + return + + self.item_title = transform_models_to_title(extracted_items_response.items) + self.stem_type = transform_models_to_stem_type(extracted_items_response.items) + self.editor_fields = ( + MERGEABLE_FIELDS_BY_CLASS_NAME[ensure_prefix(self.stem_type, "Merged")] + if self.stem_type + else [] + ) + try: + rule_set = connector.get_rule_set( + self.item_id, ) - self.item_title = transform_models_to_title(response.items) - self.fields = transform_models_to_fields(response.items) - return None + except HTTPError as exc: + if exc.response.status_code == status.HTTP_404_NOT_FOUND and self.stem_type: + rule_set_class = RULE_SET_RESPONSE_CLASSES_BY_NAME[ + ensure_postfix(self.stem_type, "RuleSetResponse") + ] + rule_set = rule_set_class(stableTargetId=self.item_id) + else: + self.reset() + yield from escalate_error( + "backend", "error fetching rule set", exc.response.text + ) + return + + self.fields = transform_models_to_fields( + *extracted_items_response.items, + # TODO(ND): add additive rule as a model here as well (MX-1741) + subtractive=rule_set.subtractive, + preventive=rule_set.preventive, + ) + + def submit_rule_set(self) -> Generator[EventSpec | None, None, None]: + """Convert the fields to a rule set and submit it to the backend.""" + if (stem_type := self.stem_type) is None: + self.reset() + return None + rule_set = transform_fields_to_rule_set(stem_type, self.fields) + connector = BackendApiConnector.get() + try: + # TODO(ND): use proper connector method when available (stop-gap MX-1762) + connector.request( + method="PUT", + endpoint=f"rule-set/{self.item_id}", + payload=rule_set, + ) + except HTTPError as exc: + self.reset() + yield from escalate_error( + "backend", "error submitting rule set", exc.response.text + ) + return + yield rx.toast.success( + title="Saved", + description=f"{self.stem_type} rule-set was saved successfully.", + class_name="editor-toast", + close_button=True, + dismissible=True, + duration=5000, + ) + yield from self.refresh() + + def _get_primary_sources_by_field_name( + self, field_name: str + ) -> list[EditorPrimarySource]: + for field in self.fields: + if field.name == field_name: + return field.primary_sources + msg = f"field not found: {field_name}" + raise ValueError(msg) + + def toggle_primary_source(self, field_name: str, href: str, enabled: bool) -> None: + """Toggle the `enabled` flag of a primary source.""" + for primary_source in self._get_primary_sources_by_field_name(field_name): + if primary_source.name.href == href: + primary_source.enabled = enabled + + def toggle_field_value(self, field_name: str, value: object, enabled: bool) -> None: + """Toggle the `enabled` flag of a field value.""" + for primary_source in self._get_primary_sources_by_field_name(field_name): + for editor_value in primary_source.editor_values: + if editor_value == value: + editor_value.enabled = enabled diff --git a/mex/editor/edit/transform.py b/mex/editor/edit/transform.py index 38a920e..14acae1 100644 --- a/mex/editor/edit/transform.py +++ b/mex/editor/edit/transform.py @@ -1,39 +1,202 @@ -from collections.abc import Iterable - -from mex.common.exceptions import MExError +from mex.common.fields import ( + LINK_FIELDS_BY_CLASS_NAME, + MERGEABLE_FIELDS_BY_CLASS_NAME, + TEMPORAL_FIELDS_BY_CLASS_NAME, + TEXT_FIELDS_BY_CLASS_NAME, + VOCABULARY_FIELDS_BY_CLASS_NAME, +) from mex.common.models import ( MEX_PRIMARY_SOURCE_STABLE_TARGET_ID, + RULE_SET_REQUEST_CLASSES_BY_NAME, + AnyAdditiveModel, AnyExtractedModel, + AnyMergedModel, + AnyPreventiveModel, AnyRuleModel, + AnyRuleSetRequest, + AnySubtractiveModel, ) -from mex.common.types import Identifier -from mex.editor.edit.models import EditableField, EditablePrimarySource -from mex.editor.transform import transform_value, transform_values +from mex.common.transform import ensure_postfix, ensure_prefix +from mex.common.types import ( + TEMPORAL_ENTITY_CLASSES_BY_PRECISION, + VOCABULARY_ENUMS_BY_NAME, + AnyNestedModel, + AnyPrimitiveType, + AnyTemporalEntity, + AnyVocabularyEnum, + Link, + MergedPrimarySourceIdentifier, + TemporalEntityPrecision, + Text, +) +from mex.editor.edit.models import EditorField, EditorPrimarySource +from mex.editor.models import EditorValue +from mex.editor.transform import ensure_list, transform_value + + +def _get_primary_source_id_from_model( + model: AnyExtractedModel | AnyMergedModel | AnyRuleModel, +) -> MergedPrimarySourceIdentifier: + """Given any model type, try to derive a sensible primary source identifier.""" + if isinstance(model, AnyExtractedModel): + return MergedPrimarySourceIdentifier(model.hadPrimarySource) + if isinstance(model, AnyMergedModel | AnyRuleModel): + return MEX_PRIMARY_SOURCE_STABLE_TARGET_ID + msg = ( + "Cannot get primary source ID for model. Expected ExtractedModel, " + f"MergedModel or RuleModel, got {type(model).__name__}." + ) + raise TypeError(msg) + + +def _transform_model_values_to_editor_values( + model: AnyExtractedModel | AnyMergedModel | AnyAdditiveModel, + field_name: str, + subtractive: AnySubtractiveModel, +) -> list[EditorValue]: + """Given a model, a field and a subtractive rule, create editor values.""" + model_values = ensure_list(getattr(model, field_name)) + editor_values = [] + for model_value in model_values: + editor_value = transform_value(model_value) + # we disable the value, when either: + editor_value.enabled = ( + # - the field is not supposed to be edited anyway, like type or id fields + field_name not in MERGEABLE_FIELDS_BY_CLASS_NAME[subtractive.entityType] + # - the value of the field in our model is subtracted by the given rule + or model_value not in getattr(subtractive, field_name) + ) + editor_values.append(editor_value) + return editor_values + + +def _transform_model_to_editor_primary_source( + fields_by_name: dict[str, EditorField], + model: AnyExtractedModel | AnyMergedModel | AnyAdditiveModel, + subtractive: AnySubtractiveModel, + preventive: AnyPreventiveModel, +) -> None: + """With a model and rules, attach an editor primary source to the field.""" + primary_source_id = _get_primary_source_id_from_model(model) + primary_source_name = transform_value(primary_source_id) + for field_name in model.model_fields: + if field_name in fields_by_name: + fields_by_name[field_name].primary_sources.append( + EditorPrimarySource( + name=primary_source_name, + identifier=primary_source_id, + editor_values=_transform_model_values_to_editor_values( + model, field_name, subtractive + ), + # we disable the primary source, when either: + enabled=( + # - the field is not supposed to be edited anyway + field_name + not in MERGEABLE_FIELDS_BY_CLASS_NAME[preventive.entityType] + # - the primary source was prevented by the given rule + or primary_source_id not in getattr(preventive, field_name) + ), + ) + ) def transform_models_to_fields( - models: Iterable[AnyExtractedModel | AnyRuleModel], -) -> list[EditableField]: - """Convert a list of extracted models into editable field models.""" - fields_by_name: dict[str, EditableField] = {} + *models: AnyExtractedModel | AnyMergedModel | AnyAdditiveModel, + subtractive: AnySubtractiveModel, + preventive: AnyPreventiveModel, +) -> list[EditorField]: + """Convert the given models and rules into editor field models. + + Args: + models: A series of extracted, merged or additive models + subtractive: A subtractive rule model + preventive: A preventive rule model + + Returns: + A list of editor field instances + """ + fields_by_name = { + field_name: EditorField(name=field_name, primary_sources=[]) + for field_name in { + f for m in models for f in MERGEABLE_FIELDS_BY_CLASS_NAME[m.entityType] + } + } for model in models: - if isinstance(model, AnyExtractedModel): - primary_source_name = transform_value(Identifier(model.hadPrimarySource)) - elif isinstance(model, AnyRuleModel): - primary_source_name = transform_value(MEX_PRIMARY_SOURCE_STABLE_TARGET_ID) - else: - msg = ( - "cannot transform model, expected extracted ExtractedData or " - f"RuleItem, got {type(model).__name__}" - ) - raise MExError(msg) - for field_name in model.model_fields: - editable_field = EditableField(name=field_name, primary_sources=[]) - fields_by_name.setdefault(field_name, editable_field) - if values := transform_values(getattr(model, field_name)): - editable_field.primary_sources.append( - EditablePrimarySource( - name=primary_source_name, editor_values=values - ) - ) + _transform_model_to_editor_primary_source( + fields_by_name, model, subtractive, preventive + ) return list(fields_by_name.values()) + + +def _transform_field_to_preventive( + field: EditorField, + preventive: AnyPreventiveModel, +) -> None: + """Transform an editor field back to a preventive rule field.""" + if field.name in MERGEABLE_FIELDS_BY_CLASS_NAME[preventive.entityType]: + prevented_sources = getattr(preventive, field.name) + for primary_source in field.primary_sources: + if not primary_source.enabled and ( + primary_source.identifier not in prevented_sources + ): + prevented_sources.append(primary_source.identifier) + + +def _transform_editor_value_to_model_value( + value: EditorValue, + field_name: str, + class_name: str, +) -> AnyNestedModel | AnyPrimitiveType | AnyTemporalEntity | AnyVocabularyEnum: + """Transform an editor value back to a value to be used in mex.common.models.""" + if field_name in LINK_FIELDS_BY_CLASS_NAME[class_name]: + return Link(url=value.href, language=value.badge, title=value.text) + if field_name in TEXT_FIELDS_BY_CLASS_NAME[class_name]: + return Text(language=value.badge, value=value.text) + if field_name in VOCABULARY_FIELDS_BY_CLASS_NAME[class_name]: + return VOCABULARY_ENUMS_BY_NAME[str(value.badge)][str(value.text)] + if field_name in TEMPORAL_FIELDS_BY_CLASS_NAME[class_name]: + precision = TemporalEntityPrecision(value.badge) + temporal_class = TEMPORAL_ENTITY_CLASSES_BY_PRECISION[precision] + return temporal_class(str(value.text), precision=precision) + return value.text + + +def _transform_field_to_subtractive( + field: EditorField, + subtractive: AnySubtractiveModel, +) -> None: + """Transform an editor field back to subtractive rule values.""" + if field.name in MERGEABLE_FIELDS_BY_CLASS_NAME[subtractive.entityType]: + subtracted_values = getattr(subtractive, field.name) + merged_class_name = ensure_prefix(subtractive.stemType, "Merged") + for primary_source in field.primary_sources: + for editor_value in primary_source.editor_values: + if not editor_value.enabled: + subtracted_value = _transform_editor_value_to_model_value( + editor_value, field.name, merged_class_name + ) + if subtracted_value not in subtracted_values: + subtracted_values.append(subtracted_value) + + +def transform_fields_to_rule_set( + stem_type: str, + fields: list[EditorField], +) -> AnyRuleSetRequest: + """Transform the given fields to a rule set of the given stem type. + + Args: + stem_type: The stemType the resulting rule set should have + fields: A list of editor fields to convert into rules + + Returns: + Any rule set request model + """ + rule_set_class = RULE_SET_REQUEST_CLASSES_BY_NAME[ + ensure_postfix(stem_type, "RuleSetRequest") + ] + rule_set = rule_set_class() + for field in fields: + _transform_field_to_preventive(field, rule_set.preventive) + _transform_field_to_subtractive(field, rule_set.subtractive) + return rule_set diff --git a/mex/editor/exceptions.py b/mex/editor/exceptions.py new file mode 100644 index 0000000..bdc1335 --- /dev/null +++ b/mex/editor/exceptions.py @@ -0,0 +1,30 @@ +from collections.abc import Generator + +import reflex as rx +from reflex.event import EventSpec + +from mex.common.logging import logger + + +def escalate_error( + namespace: str, summary: str, payload: object +) -> Generator[EventSpec, None, None]: + """Escalate an error by spreading it to the python and browser logs and the UI.""" + logger.error( + "{%s} - %s: %s", + namespace, + summary, + payload, + exc_info=False, + ) + yield rx.console_log( + f"[{namespace}] {summary}: {payload}", + ) + yield rx.toast.error( + title=f"{namespace} Error", + description=summary, + class_name="editor-toast", + close_button=True, + dismissible=True, + duration=5000, + ) diff --git a/mex/editor/identity.py b/mex/editor/identity.py new file mode 100644 index 0000000..388c2be --- /dev/null +++ b/mex/editor/identity.py @@ -0,0 +1,66 @@ +from functools import cache +from urllib.parse import urljoin + +from mex.common.connector.http import HTTPConnector +from mex.common.identity.base import BaseProvider +from mex.common.identity.models import Identity +from mex.common.types import Identifier, MergedPrimarySourceIdentifier +from mex.editor.settings import EditorSettings + +# TODO(ND): use mex-common version of BackendIdentityProvider (stop-gap MX-1763) + + +class BackendIdentityProvider(BaseProvider, HTTPConnector): + """Identity provider that communicates with the backend HTTP API.""" + + API_VERSION = "v0" + + def _check_availability(self) -> None: + """Send a GET request to verify the API is available.""" + self.request("GET", "_system/check") + + def _set_authentication(self) -> None: + """Set the backend API key to all session headers.""" + settings = EditorSettings.get() + self.session.headers["X-API-Key"] = settings.backend_api_key.get_secret_value() + + def _set_url(self) -> None: + """Set the backend api url with the version path.""" + settings = EditorSettings.get() + self.url = urljoin(str(settings.backend_api_url), self.API_VERSION) + + @cache # noqa: B019 safe to ignore because this class is a singleton + def assign( + self, + had_primary_source: MergedPrimarySourceIdentifier, + identifier_in_primary_source: str, + ) -> Identity: + """Find an Identity in a database or assign a new one.""" + payload = { + "hadPrimarySource": had_primary_source, + "identifierInPrimarySource": identifier_in_primary_source, + } + identity = self.request("POST", "identity", payload) + + return Identity.model_validate(identity) + + def fetch( + self, + *, + had_primary_source: Identifier | None = None, + identifier_in_primary_source: str | None = None, + stable_target_id: Identifier | None = None, + ) -> list[Identity]: + """Find Identity instances matching the given filters. + + Either provide `stableTargetId` or `hadPrimarySource` + and `identifierInPrimarySource` together to get a unique result. + """ + params = { + "hadPrimarySource": had_primary_source, + "identifierInPrimarySource": identifier_in_primary_source, + "stableTargetId": stable_target_id, + } + params_cleaned = {key: str(value) for key, value in params.items() if value} + results = self.request("GET", "identity", params=params_cleaned) + return [Identity.model_validate(i) for i in results["items"]] diff --git a/mex/editor/layout.py b/mex/editor/layout.py index 2817ff8..50d87e7 100644 --- a/mex/editor/layout.py +++ b/mex/editor/layout.py @@ -1,5 +1,8 @@ +from typing import cast + import reflex as rx +from mex.editor.models import User from mex.editor.state import NavItem, State @@ -7,7 +10,7 @@ def user_button() -> rx.Component: """Return a user button with an icon that indicates their access rights.""" return rx.button( rx.cond( - State.user.write_access, # type: ignore[union-attr] + cast(User, State.user).write_access, rx.icon(tag="user_round_cog"), rx.icon(tag="user_round"), ), @@ -22,7 +25,7 @@ def user_menu() -> rx.Component: rx.menu.trigger(user_button()), rx.menu.content( rx.menu.item( - State.user.name, # type: ignore[union-attr] + cast(User, State.user).name, disabled=True, style={"color": "var(--gray-12)"}, ), diff --git a/mex/editor/models.py b/mex/editor/models.py index 24c9701..540b5f1 100644 --- a/mex/editor/models.py +++ b/mex/editor/models.py @@ -7,14 +7,14 @@ from mex.common.models import BaseModel -class FixedValue(rx.Base): - """Model for describing fixed values that are not editable.""" - - text: str | None - badge: str | None - href: str | None - tooltip: str | None - external: bool +class EditorValue(rx.Base): + """Model for describing atomic values in the editor.""" + + text: str | None = None + badge: str | None = None + href: str | None = None + external: bool = False + enabled: bool = True class User(rx.Base): diff --git a/mex/editor/search/main.py b/mex/editor/search/main.py index 6164c63..bf2878a 100644 --- a/mex/editor/search/main.py +++ b/mex/editor/search/main.py @@ -1,7 +1,9 @@ +from typing import cast + import reflex as rx import reflex_chakra as rc -from mex.editor.components import fixed_value +from mex.editor.components import render_value from mex.editor.layout import page from mex.editor.search.models import SearchResult from mex.editor.search.state import SearchState @@ -11,34 +13,26 @@ def search_result(result: SearchResult) -> rx.Component: """Render a single merged item search result.""" return rx.card( rx.link( - rx.text( + rx.box( rx.hstack( rx.foreach( result.title, - fixed_value, + render_value, ) ), - weight="bold", - style={ - "whiteSpace": "nowrap", - "overflow": "hidden", - "textOverflow": "ellipsis", - "maxWidth": "100%", - }, + style={"fontWeight": "bold"}, ), - rx.text( + rx.box( rx.hstack( rx.foreach( result.preview, - fixed_value, + render_value, ) ), - weight="light", style={ - "whiteSpace": "nowrap", - "overflow": "hidden", - "textOverflow": "ellipsis", - "maxWidth": "100%", + "color": "var(--gray-12)", + "fontWeight": "light", + "textDecoration": "none", }, ), href=f"/item/{result.identifier}", @@ -80,7 +74,9 @@ def entity_type_filter() -> rx.Component: rc.checkbox( choice[0], checked=choice[1], - on_change=lambda val: SearchState.set_entity_type( # type: ignore[call-arg] + on_change=lambda val: cast( + SearchState, SearchState + ).set_entity_type( val, choice[0], ), @@ -120,7 +116,7 @@ def pagination() -> rx.Component: ), rx.select( SearchState.total_pages, - value=SearchState.current_page.to_string(), # type: ignore[attr-defined] + value=cast(rx.vars.NumberVar, SearchState.current_page).to_string(), on_change=SearchState.set_page, custom_attrs={"data-testid": "pagination-page-select"}, ), diff --git a/mex/editor/search/models.py b/mex/editor/search/models.py index 46fa5cd..1bb9bfe 100644 --- a/mex/editor/search/models.py +++ b/mex/editor/search/models.py @@ -1,11 +1,11 @@ import reflex as rx -from mex.editor.models import FixedValue +from mex.editor.models import EditorValue class SearchResult(rx.Base): """Search result preview.""" identifier: str - title: list[FixedValue] - preview: list[FixedValue] + title: list[EditorValue] + preview: list[EditorValue] diff --git a/mex/editor/search/state.py b/mex/editor/search/state.py index b069eec..b49f17e 100644 --- a/mex/editor/search/state.py +++ b/mex/editor/search/state.py @@ -6,8 +6,8 @@ from requests import HTTPError from mex.common.backend_api.connector import BackendApiConnector -from mex.common.logging import logger from mex.common.models import MERGED_MODEL_CLASSES_BY_NAME +from mex.editor.exceptions import escalate_error from mex.editor.search.models import SearchResult from mex.editor.search.transform import transform_models_to_results from mex.editor.state import State @@ -74,29 +74,22 @@ def search(self) -> Generator[EventSpec | None, None, None]: # TODO(ND): use the user auth for backend requests (stop-gap MX-1616) connector = BackendApiConnector.get() try: - response = connector.fetch_merged_items( - self.query_string, - [k for k, v in self.entity_types.items() if v], - self.limit * (self.current_page - 1), - self.limit, + response = connector.fetch_preview_items( + query_string=self.query_string, + entity_type=[k for k, v in self.entity_types.items() if v], + skip=self.limit * (self.current_page - 1), + limit=self.limit, ) except HTTPError as exc: self.reset() - logger.error( - "backend error fetching merged items: %s", - exc.response.text, - exc_info=False, + yield from escalate_error( + "backend", "error fetching merged items", exc.response.text ) - yield rx.toast.error( - exc.response.text, - duration=5000, - close_button=True, - dismissible=True, - ) - else: - yield rx.call_script("window.scrollTo({top: 0, behavior: 'smooth'});") - self.results = transform_models_to_results(response.items) - self.total = response.total + return + + yield rx.call_script("window.scrollTo({top: 0, behavior: 'smooth'});") + self.results = transform_models_to_results(response.items) + self.total = response.total def refresh(self) -> Generator[EventSpec | None, None, None]: """Refresh the search page.""" diff --git a/mex/editor/search/transform.py b/mex/editor/search/transform.py index 04d08a6..a60eb9a 100644 --- a/mex/editor/search/transform.py +++ b/mex/editor/search/transform.py @@ -1,12 +1,14 @@ from collections.abc import Iterable -from mex.common.models import AnyMergedModel +from mex.common.models import AnyPreviewModel from mex.editor.search.models import SearchResult from mex.editor.transform import transform_models_to_preview, transform_models_to_title -def transform_models_to_results(models: Iterable[AnyMergedModel]) -> list[SearchResult]: - """Convert a list of merged models into search result models.""" +def transform_models_to_results( + models: Iterable[AnyPreviewModel], +) -> list[SearchResult]: + """Convert a list of merged model previews into search result models.""" return [ SearchResult( identifier=model.identifier, diff --git a/mex/editor/settings.py b/mex/editor/settings.py index e1d15d1..657ba9b 100644 --- a/mex/editor/settings.py +++ b/mex/editor/settings.py @@ -1,7 +1,8 @@ from pydantic import Field from mex.common.settings import BaseSettings -from mex.editor.types import EditorUserDatabase +from mex.common.types import IdentityProvider +from mex.editor.types import EditorIdentityProvider, EditorUserDatabase class EditorSettings(BaseSettings): @@ -12,3 +13,8 @@ class EditorSettings(BaseSettings): description="Database of users.", validation_alias="MEX_BACKEND_API_USER_DATABASE", ) + identity_provider: IdentityProvider | EditorIdentityProvider = Field( + IdentityProvider.MEMORY, + description="Provider to assign stableTargetIds to new model instances.", + validation_alias="MEX_IDENTITY_PROVIDER", + ) # type: ignore[assignment] diff --git a/mex/editor/state.py b/mex/editor/state.py index e6e274c..5ee9d68 100644 --- a/mex/editor/state.py +++ b/mex/editor/state.py @@ -1,5 +1,3 @@ -from typing import Any - import reflex as rx from reflex.event import EventSpec @@ -18,7 +16,7 @@ class State(rx.State): NavItem(title="Merge", href_template=r"/merge/"), ] - def logout(self, _: Any) -> EventSpec: + def logout(self) -> EventSpec: """Log out a user.""" self.reset() return rx.redirect("/") diff --git a/mex/editor/transform.py b/mex/editor/transform.py index a163ef7..516fb81 100644 --- a/mex/editor/transform.py +++ b/mex/editor/transform.py @@ -1,125 +1,113 @@ from collections.abc import Sequence -import pytz -from babel.dates import format_datetime - from mex.common.exceptions import MExError -from mex.common.models import AnyExtractedModel, AnyMergedModel -from mex.common.types import ( - Identifier, - Link, - TemporalEntity, - TemporalEntityPrecision, - Text, - VocabularyEnum, +from mex.common.models import ( + AnyExtractedModel, + AnyMergedModel, + AnyPreviewModel, + AnyRuleModel, ) -from mex.editor.models import MODEL_CONFIG_BY_STEM_TYPE, FixedValue - -_DEFAULT_LOCALE = "de_DE" -_DEFAULT_TIMEZONE = pytz.timezone("Europe/Berlin") -_BABEL_FORMATS_BY_PRECISION = { - TemporalEntityPrecision.YEAR: "yyyy", - TemporalEntityPrecision.MONTH: "MMMM yyyy", - TemporalEntityPrecision.DAY: "d. MMMM yyyy", - TemporalEntityPrecision.HOUR: "d. MMMM yyyy k a", - TemporalEntityPrecision.MINUTE: "d. MMMM yyyy H:MM", - TemporalEntityPrecision.SECOND: "d. MMMM yyyy H:MM:ss", - TemporalEntityPrecision.MICROSECOND: "d. MMMM yyyy H:MM:ss:SS", -} +from mex.common.types import Identifier, Link, TemporalEntity, Text, VocabularyEnum +from mex.editor.models import MODEL_CONFIG_BY_STEM_TYPE, EditorValue -def transform_values(values: object) -> list[FixedValue]: - """Convert a single object or a list of objects into a list of fixed values.""" +def ensure_list(values: object) -> list[object]: + """Wrap single objects in lists, replace None with [] and return lists untouched.""" if values is None: return [] - if not isinstance(values, list): - values = [values] - return [transform_value(v) for v in values] + if isinstance(values, list): + return values + return [values] + +def transform_values(values: object, allow_link: bool = True) -> list[EditorValue]: + """Convert a single object or a list of objects into a list of editor values.""" + return [transform_value(v, allow_link) for v in ensure_list(values)] -def transform_value(value: object) -> FixedValue: - """Transform a single object into a fixed value ready for rendering.""" + +def transform_value(value: object, allow_link: bool = True) -> EditorValue: + """Transform a single object into an editor value ready for rendering.""" if isinstance(value, Text): - return FixedValue( + return EditorValue( text=value.value, badge=value.language, - href=None, - external=False, ) if isinstance(value, Link): - return FixedValue( + return EditorValue( text=value.title or value.url, - href=value.url, + href=value.url if allow_link else None, badge=value.language, external=True, ) if isinstance(value, Identifier): - return FixedValue( - text=value, - href=f"/item/{value}", - badge=None, - external=False, + return EditorValue( + text=str(value), + href=f"/item/{value}" if allow_link else None, ) if isinstance(value, VocabularyEnum): - return FixedValue( + return EditorValue( text=value.name, - href=None, badge=type(value).__name__, - external=False, ) if isinstance(value, TemporalEntity): - return FixedValue( - text=format_datetime( - _DEFAULT_TIMEZONE.localize(value.date_time), - format=_BABEL_FORMATS_BY_PRECISION[value.precision], - locale=_DEFAULT_LOCALE, - ), - href=None, - badge=None, - external=False, + return EditorValue( + text=str(value), + badge=value.precision.value, ) - if value is not None: - return FixedValue( + if isinstance(value, str): + return EditorValue( text=str(value), - href=None, - badge=None, - external=False, ) - msg = "cannot transform null value to renderable object" + msg = f"cannot transform {type(value).__name__} to editor value" raise MExError(msg) +def transform_models_to_stem_type( + models: Sequence[ + AnyRuleModel | AnyExtractedModel | AnyPreviewModel | AnyMergedModel + ], +) -> str | None: + """Get the stem type from a list of models.""" + if not models: + return None + return models[0].stemType + + def transform_models_to_title( - models: Sequence[AnyExtractedModel | AnyMergedModel], -) -> list[FixedValue]: - """Converts a list of models into fixed values based on the title config.""" + models: Sequence[ + AnyRuleModel | AnyExtractedModel | AnyPreviewModel | AnyMergedModel + ], +) -> list[EditorValue]: + """Convert a list of models into editor values based on the title config.""" if not models: return [] - titles: list[FixedValue] = [] + titles: list[EditorValue] = [] for model in models: config = MODEL_CONFIG_BY_STEM_TYPE[model.stemType] titles.extend( - transform_values(getattr(model, config.title)), + transform_values(getattr(model, config.title), allow_link=False), ) if titles: return titles - return transform_values(models[0].identifier) + return transform_values(transform_models_to_stem_type(models)) def transform_models_to_preview( - models: Sequence[AnyExtractedModel | AnyMergedModel], -) -> list[FixedValue]: - """Converts a list of models into fixed values based on the preview config.""" + models: Sequence[ + AnyRuleModel | AnyExtractedModel | AnyPreviewModel | AnyMergedModel + ], +) -> list[EditorValue]: + """Converts a list of models into editor values based on the preview config.""" if not models: return [] - previews: list[FixedValue] = [] + previews: list[EditorValue] = [] for model in models: config = MODEL_CONFIG_BY_STEM_TYPE[model.stemType] previews.extend( value for field in config.preview - for value in transform_values(getattr(model, field)) + for value in transform_values(getattr(model, field), allow_link=False) ) if previews: return previews - return transform_values(models[0].entityType) + return transform_values(transform_models_to_stem_type(models)) diff --git a/mex/editor/types.py b/mex/editor/types.py index 5268a1e..45c4e00 100644 --- a/mex/editor/types.py +++ b/mex/editor/types.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import cast from pydantic import SecretStr @@ -20,3 +21,9 @@ def __getitem__( ) -> dict[str, EditorUserPassword]: # stop-gap: MX-1596 """Return an attribute in indexing syntax.""" return cast(dict[str, EditorUserPassword], getattr(self, key)) + + +class EditorIdentityProvider(Enum): + """Identity providers implemented by the mex-editor.""" + + BACKEND = "backend" diff --git a/mex/mex.py b/mex/mex.py index 2481948..9e11c39 100644 --- a/mex/mex.py +++ b/mex/mex.py @@ -2,16 +2,21 @@ from reflex.components.radix import themes from reflex.utils.console import info as log_info +from mex.common.identity.registry import register_provider from mex.common.logging import logger from mex.editor.api.main import check_system_status from mex.editor.edit.main import index as edit_index from mex.editor.edit.state import EditState +from mex.editor.identity import BackendIdentityProvider from mex.editor.login.main import index as login_index from mex.editor.merge.main import index as merge_index from mex.editor.search.main import index as search_index from mex.editor.search.state import SearchState from mex.editor.settings import EditorSettings from mex.editor.state import State +from mex.editor.types import EditorIdentityProvider + +register_provider(EditorIdentityProvider.BACKEND, BackendIdentityProvider) app = rx.App( html_lang="en", diff --git a/pdm.lock b/pdm.lock index 253db8c..0936deb 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:e627351d5a4eb0799e51843f3d491117b2112049b560999ebab23fb99e03dd34" +content_hash = "sha256:98c96a8b4bdff1e90da8ee1bffd435883fda3fba8ab132cb81141ff20c33a690" [[metadata.targets]] requires_python = "==3.11.*" @@ -44,7 +44,7 @@ name = "annotated-types" version = "0.7.0" requires_python = ">=3.8" summary = "Reusable constraint types to use with typing.Annotated" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "typing-extensions>=4.0.0; python_version < \"3.9\"", ] @@ -58,7 +58,7 @@ name = "anyio" version = "4.8.0" requires_python = ">=3.9" summary = "High level compatibility layer for multiple asynchronous event loop implementations" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "exceptiongroup>=1.0.2; python_version < \"3.11\"", "idna>=2.8", @@ -113,7 +113,7 @@ name = "backoff" version = "2.2.1" requires_python = ">=3.7,<4.0" summary = "Function decoration for backoff and retry" -groups = ["default", "dev"] +groups = ["default"] files = [ {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, @@ -125,7 +125,7 @@ version = "1.2.0" requires_python = ">=3.8" summary = "Backport of CPython tarfile module" groups = ["default"] -marker = "python_version < \"3.12\"" +marker = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and python_version < \"3.12\"" files = [ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, @@ -177,7 +177,7 @@ version = "1.17.1" requires_python = ">=3.8" summary = "Foreign Function Interface for Python calling C code." groups = ["default"] -marker = "platform_python_implementation != \"PyPy\" and sys_platform == \"linux\"" +marker = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\"" dependencies = [ "pycparser", ] @@ -226,7 +226,7 @@ name = "click" version = "8.1.8" requires_python = ">=3.7" summary = "Composable command line interface toolkit" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "colorama; platform_system == \"Windows\"", "importlib-metadata; python_version < \"3.8\"", @@ -254,7 +254,7 @@ version = "44.0.0" requires_python = "!=3.9.0,!=3.9.1,>=3.7" summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." groups = ["default"] -marker = "sys_platform == \"linux\"" +marker = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"linux\"" dependencies = [ "cffi>=1.12; platform_python_implementation != \"PyPy\"", ] @@ -334,7 +334,7 @@ name = "fastapi" version = "0.115.6" requires_python = ">=3.8" summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4", "starlette<0.42.0,>=0.40.0", @@ -384,7 +384,7 @@ name = "h11" version = "0.14.0" requires_python = ">=3.7" summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "typing-extensions; python_version < \"3.8\"", ] @@ -398,7 +398,7 @@ name = "httpcore" version = "1.0.7" requires_python = ">=3.8" summary = "A minimal low-level HTTP client." -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "certifi", "h11<0.15,>=0.13", @@ -408,29 +408,12 @@ files = [ {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, ] -[[package]] -name = "httptools" -version = "0.6.4" -requires_python = ">=3.8.0" -summary = "A collection of framework independent HTTP protocol utils." -groups = ["dev"] -files = [ - {file = "httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069"}, - {file = "httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a"}, - {file = "httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975"}, - {file = "httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636"}, - {file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721"}, - {file = "httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988"}, - {file = "httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17"}, - {file = "httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c"}, -] - [[package]] name = "httpx" version = "0.28.1" requires_python = ">=3.8" summary = "The next generation HTTP client." -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "anyio", "certifi", @@ -470,6 +453,7 @@ version = "8.5.0" requires_python = ">=3.8" summary = "Read metadata from Python packages" groups = ["default"] +marker = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and python_version < \"3.12\"" dependencies = [ "typing-extensions>=3.6.4; python_version < \"3.8\"", "zipp>=3.20", @@ -552,6 +536,7 @@ version = "3.4.0" requires_python = ">=3.8" summary = "Utility functions for Python class constructs" groups = ["default"] +marker = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" dependencies = [ "more-itertools", ] @@ -566,6 +551,7 @@ version = "6.0.1" requires_python = ">=3.8" summary = "Useful decorators and context managers" groups = ["default"] +marker = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" dependencies = [ "backports-tarfile; python_version < \"3.12\"", ] @@ -580,6 +566,7 @@ version = "4.1.0" requires_python = ">=3.8" summary = "Functools like those found in stdlib" groups = ["default"] +marker = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" dependencies = [ "more-itertools", ] @@ -609,7 +596,7 @@ version = "0.8.0" requires_python = ">=3.7" summary = "Low-level, pure Python DBus protocol wrapper." groups = ["default"] -marker = "sys_platform == \"linux\"" +marker = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"linux\"" files = [ {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"}, {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"}, @@ -635,6 +622,7 @@ version = "25.6.0" requires_python = ">=3.9" summary = "Store and access your passwords safely." groups = ["default"] +marker = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" dependencies = [ "SecretStorage>=3.2; sys_platform == \"linux\"", "importlib-metadata>=4.11.4; python_version < \"3.12\"", @@ -654,7 +642,7 @@ files = [ name = "langdetect" version = "1.0.9" summary = "Language detection library ported from Google's language-detection." -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "six", ] @@ -681,7 +669,7 @@ files = [ name = "ldap3" version = "2.9.1" summary = "A strictly RFC 4510 conforming LDAP V3 pure Python client library" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "pyasn1>=0.4.6", ] @@ -764,59 +752,39 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] -[[package]] -name = "mex-backend" -version = "0.22.0" -requires_python = ">=3.11,<3.13" -git = "https://github.com/robert-koch-institut/mex-backend.git" -ref = "0.22.0" -revision = "be76c7c390efa13607280c925de07a1f2761a5a6" -summary = "Backend server for the RKI metadata exchange." -groups = ["dev"] -dependencies = [ - "fastapi<1,>=0.115", - "httpx<1,>=0.27", - "jinja2<4,>=3", - "mex-common @ git+https://github.com/robert-koch-institut/mex-common.git@0.40.0", - "neo4j<6,>=5", - "pydantic<3,>=2", - "starlette<1,>=0.41", - "uvicorn[standard]<1,>=0.30", -] - [[package]] name = "mex-common" -version = "0.40.0" +version = "0.46.0" requires_python = ">=3.11,<3.13" git = "https://github.com/robert-koch-institut/mex-common.git" -ref = "0.40.0" -revision = "402bfd513270659047bc8364179d7ed759685c93" +ref = "0.46.0" +revision = "ca1f62afdf09587d68fbb86c89bdbb2a1176377c" summary = "Common library for MEx python projects." -groups = ["default", "dev"] +groups = ["default"] dependencies = [ - "backoff<3,>=2.2.1", - "click<9,>=8.1.7", - "langdetect<2,>=1.0.9", - "ldap3<3,>=2.9.1", - "mex-model @ git+https://github.com/robert-koch-institut/mex-model.git@3.1.0", - "numpy<3,>=2.1.2", - "pandas<3,>=2.2.3", - "pyarrow<18,>=17.0.0", - "pydantic-settings<3,>=2.5.2", - "pydantic<3,>=2.9.2", - "pytz<2024.2,>=2024.1", - "requests<3,>=2.32.3", + "backoff<3,>=2", + "click<9,>=8", + "langdetect<2,>=1", + "ldap3<3,>=2", + "mex-model @ git+https://github.com/robert-koch-institut/mex-model.git@3.4.0", + "numpy<3,>=2", + "pandas<3,>=2", + "pyarrow<19,>=18", + "pydantic-settings<3,>=2", + "pydantic<2.10,>=2", + "pytz<2024.2,>=2024", + "requests<3,>=2", ] [[package]] name = "mex-model" -version = "3.1.0" -requires_python = "<3.13,>=3.11" +version = "3.4.0" +requires_python = ">=3.11,<3.13" git = "https://github.com/robert-koch-institut/mex-model.git" -ref = "3.1.0" -revision = "61283bbb386573e18db79ec86dbc03363b4348ce" +ref = "3.4.0" +revision = "08d161d40c659a69c52af76b654db41f49616a24" summary = "JSON schema files defining the MEx metadata model." -groups = ["default", "dev"] +groups = ["default"] [[package]] name = "more-itertools" @@ -824,6 +792,7 @@ version = "10.6.0" requires_python = ">=3.9" summary = "More routines for operating on iterables, beyond itertools" groups = ["default"] +marker = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ {file = "more-itertools-10.6.0.tar.gz", hash = "sha256:2cd7fad1009c31cc9fb6a035108509e6547547a7a738374f10bd49a09eb3ee3b"}, {file = "more_itertools-10.6.0-py3-none-any.whl", hash = "sha256:6eb054cb4b6db1473f6e15fcc676a08e4732548acd47c708f0e179c2c7c01e89"}, @@ -862,20 +831,6 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] -[[package]] -name = "neo4j" -version = "5.27.0" -requires_python = ">=3.7" -summary = "Neo4j Bolt driver for Python" -groups = ["dev"] -dependencies = [ - "pytz", -] -files = [ - {file = "neo4j-5.27.0-py3-none-any.whl", hash = "sha256:929c14b9e5341267324eca170b39d1798b032bffacc26a0529eacaf678ae483f"}, - {file = "neo4j-5.27.0.tar.gz", hash = "sha256:f82ee807cd15b178898d83f41a66372e11719a25dd487fd7bea48fd4b7323765"}, -] - [[package]] name = "nh3" version = "0.2.20" @@ -905,7 +860,7 @@ name = "numpy" version = "2.2.2" requires_python = ">=3.10" summary = "Fundamental package for array computing in Python" -groups = ["default", "dev"] +groups = ["default"] files = [ {file = "numpy-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:642199e98af1bd2b6aeb8ecf726972d238c9877b0f6e8221ee5ab945ec8a2189"}, {file = "numpy-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d9fc9d812c81e6168b6d405bf00b8d6739a7f72ef22a9214c4241e0dc70b323"}, @@ -936,7 +891,7 @@ name = "pandas" version = "2.2.3" requires_python = ">=3.9" summary = "Powerful data structures for data analysis, time series, and statistics" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "numpy>=1.22.4; python_version < \"3.11\"", "numpy>=1.23.2; python_version == \"3.11\"", @@ -1009,13 +964,13 @@ files = [ [[package]] name = "pkginfo" -version = "1.10.0" -requires_python = ">=3.6" +version = "1.12.0" +requires_python = ">=3.8" summary = "Query metadata from sdists / bdists / installed packages." groups = ["default"] files = [ - {file = "pkginfo-1.10.0-py3-none-any.whl", hash = "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097"}, - {file = "pkginfo-1.10.0.tar.gz", hash = "sha256:5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297"}, + {file = "pkginfo-1.12.0-py3-none-any.whl", hash = "sha256:dcd589c9be4da8973eceffa247733c144812759aa67eaf4bbf97016a02f39088"}, + {file = "pkginfo-1.12.0.tar.gz", hash = "sha256:8ad91a0445a036782b9366ef8b8c2c50291f83a553478ba8580c73d3215700cf"}, ] [[package]] @@ -1116,22 +1071,19 @@ files = [ [[package]] name = "pyarrow" -version = "17.0.0" -requires_python = ">=3.8" +version = "18.1.0" +requires_python = ">=3.9" summary = "Python library for Apache Arrow" -groups = ["default", "dev"] -dependencies = [ - "numpy>=1.16.6", -] +groups = ["default"] files = [ - {file = "pyarrow-17.0.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:1c8856e2ef09eb87ecf937104aacfa0708f22dfeb039c363ec99735190ffb977"}, - {file = "pyarrow-17.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e19f569567efcbbd42084e87f948778eb371d308e137a0f97afe19bb860ccb3"}, - {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b244dc8e08a23b3e352899a006a26ae7b4d0da7bb636872fa8f5884e70acf15"}, - {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b72e87fe3e1db343995562f7fff8aee354b55ee83d13afba65400c178ab2597"}, - {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:dc5c31c37409dfbc5d014047817cb4ccd8c1ea25d19576acf1a001fe07f5b420"}, - {file = "pyarrow-17.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:e3343cb1e88bc2ea605986d4b94948716edc7a8d14afd4e2c097232f729758b4"}, - {file = "pyarrow-17.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:a27532c38f3de9eb3e90ecab63dfda948a8ca859a66e3a47f5f42d1e403c4d03"}, - {file = "pyarrow-17.0.0.tar.gz", hash = "sha256:4beca9521ed2c0921c1023e68d097d0299b62c362639ea315572a58f3f50fd28"}, + {file = "pyarrow-18.1.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:eaeabf638408de2772ce3d7793b2668d4bb93807deed1725413b70e3156a7854"}, + {file = "pyarrow-18.1.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:3b2e2239339c538f3464308fd345113f886ad031ef8266c6f004d49769bb074c"}, + {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f39a2e0ed32a0970e4e46c262753417a60c43a3246972cfc2d3eb85aedd01b21"}, + {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e31e9417ba9c42627574bdbfeada7217ad8a4cbbe45b9d6bdd4b62abbca4c6f6"}, + {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:01c034b576ce0eef554f7c3d8c341714954be9b3f5d5bc7117006b85fcf302fe"}, + {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f266a2c0fc31995a06ebd30bcfdb7f615d7278035ec5b1cd71c48d56daaf30b0"}, + {file = "pyarrow-18.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:d4f13eee18433f99adefaeb7e01d83b59f73360c231d4782d9ddfaf1c3fbde0a"}, + {file = "pyarrow-18.1.0.tar.gz", hash = "sha256:9386d3ca9c145b5539a1cfc75df07757dff870168c959b473a0bccbc3abc8c73"}, ] [[package]] @@ -1139,7 +1091,7 @@ name = "pyasn1" version = "0.6.1" requires_python = ">=3.8" summary = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -groups = ["default", "dev"] +groups = ["default"] files = [ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, @@ -1151,7 +1103,7 @@ version = "2.22" requires_python = ">=3.8" summary = "C parser in Python" groups = ["default"] -marker = "platform_python_implementation != \"PyPy\" and sys_platform == \"linux\"" +marker = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -1159,45 +1111,44 @@ files = [ [[package]] name = "pydantic" -version = "2.10.5" +version = "2.9.2" requires_python = ">=3.8" summary = "Data validation using Python type hints" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "annotated-types>=0.6.0", - "pydantic-core==2.27.2", - "typing-extensions>=4.12.2", + "pydantic-core==2.23.4", + "typing-extensions>=4.12.2; python_version >= \"3.13\"", + "typing-extensions>=4.6.1; python_version < \"3.13\"", ] files = [ - {file = "pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53"}, - {file = "pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff"}, + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, ] [[package]] name = "pydantic-core" -version = "2.27.2" +version = "2.23.4" requires_python = ">=3.8" summary = "Core functionality for Pydantic validation and serialization" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "typing-extensions!=4.7.0,>=4.6.0", ] files = [ - {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, - {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, - {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, ] [[package]] @@ -1205,7 +1156,7 @@ name = "pydantic-settings" version = "2.7.1" requires_python = ">=3.8" summary = "Settings management using Pydantic" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "pydantic>=2.7.0", "python-dotenv>=0.21.0", @@ -1321,7 +1272,7 @@ name = "python-dateutil" version = "2.9.0.post0" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" summary = "Extensions to the standard Python datetime module" -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "six>=1.5", ] @@ -1335,7 +1286,7 @@ name = "python-dotenv" version = "1.0.1" requires_python = ">=3.8" summary = "Read key-value pairs from a .env file and set them as environment variables" -groups = ["default", "dev"] +groups = ["default"] files = [ {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, @@ -1399,7 +1350,7 @@ files = [ name = "pytz" version = "2024.1" summary = "World timezone definitions, modern and historical" -groups = ["default", "dev"] +groups = ["default"] files = [ {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, @@ -1411,7 +1362,7 @@ version = "0.2.3" requires_python = ">=3.6" summary = "A (partial) reimplementation of pywin32 using ctypes/cffi" groups = ["default"] -marker = "sys_platform == \"win32\"" +marker = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"win32\"" files = [ {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, @@ -1422,7 +1373,7 @@ name = "pyyaml" version = "6.0.2" requires_python = ">=3.8" summary = "YAML parser and emitter for Python" -groups = ["default", "dev"] +groups = ["default"] files = [ {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, @@ -1468,7 +1419,7 @@ files = [ [[package]] name = "reflex" -version = "0.6.2.post1" +version = "0.6.8" requires_python = "<4.0,>=3.9" summary = "Web apps in pure Python." groups = ["default"] @@ -1491,22 +1442,23 @@ dependencies = [ "python-socketio<6.0,>=5.7.0", "redis<6.0,>=4.3.5", "reflex-chakra>=0.6.0", - "reflex-hosting-cli<2.0,>=0.1.2", + "reflex-hosting-cli<2.0,>=0.1.29", "rich<14.0,>=13.0.0", - "setuptools<70.2,>=69.1.1", + "setuptools>=75.0", "sqlmodel<0.1,>=0.0.14", "starlette-admin<1.0,>=0.11.0", "tomlkit<1.0,>=0.12.4", - "twine<6.0,>=4.0.0", + "twine<7.0,>=4.0.0", "typer<1.0,>=0.4.2", + "typing-extensions>=4.6.0", "uvicorn>=0.20.0", "wheel<1.0,>=0.42.0", "wrapt<2.0,>=1.11.0; python_version < \"3.11\"", "wrapt<2.0,>=1.14.0; python_version >= \"3.11\"", ] files = [ - {file = "reflex-0.6.2.post1-py3-none-any.whl", hash = "sha256:9f04f58bec7157e4ee80d65de0142f7dff1bd8ab2cc9a13c940d1be56b26233d"}, - {file = "reflex-0.6.2.post1.tar.gz", hash = "sha256:3cecf7376f1b35f2f81016e0506ac7a66f9a58bca5eb321d82130d4b9a00b1ea"}, + {file = "reflex-0.6.8-py3-none-any.whl", hash = "sha256:958f4e81eff1eccd553200dc80b93980b5888dd712bbf51f436d4d245037acec"}, + {file = "reflex-0.6.8.tar.gz", hash = "sha256:cc6892e235902b6c86bc64ee6c0459d7c73f9ffedbc37f91d8b48de76a74a688"}, ] [[package]] @@ -1638,7 +1590,7 @@ version = "3.3.3" requires_python = ">=3.6" summary = "Python bindings to FreeDesktop.org Secret Service API" groups = ["default"] -marker = "sys_platform == \"linux\"" +marker = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and sys_platform == \"linux\"" dependencies = [ "cryptography>=2.0", "jeepney>=0.6", @@ -1650,13 +1602,13 @@ files = [ [[package]] name = "setuptools" -version = "70.1.1" -requires_python = ">=3.8" +version = "75.8.0" +requires_python = ">=3.9" summary = "Easily download, build, install, upgrade, and uninstall Python packages" groups = ["default"] files = [ - {file = "setuptools-70.1.1-py3-none-any.whl", hash = "sha256:a58a8fde0541dab0419750bcc521fbdf8585f6e5cb41909df3a472ef7b81ca95"}, - {file = "setuptools-70.1.1.tar.gz", hash = "sha256:937a48c7cdb7a21eb53cd7f9b59e525503aa8abaf3584c730dc5f7a5bec3a650"}, + {file = "setuptools-75.8.0-py3-none-any.whl", hash = "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3"}, + {file = "setuptools-75.8.0.tar.gz", hash = "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6"}, ] [[package]] @@ -1689,7 +1641,7 @@ name = "six" version = "1.17.0" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" summary = "Python 2 and 3 compatibility utilities" -groups = ["default", "dev"] +groups = ["default"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -1700,7 +1652,7 @@ name = "sniffio" version = "1.3.1" requires_python = ">=3.7" summary = "Sniff out which async library your code is running under" -groups = ["default", "dev"] +groups = ["default"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1872,7 +1824,7 @@ name = "starlette" version = "0.41.3" requires_python = ">=3.8" summary = "The little ASGI library that shines." -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "anyio<5,>=3.4.0", "typing-extensions>=3.10.0; python_version < \"3.10\"", @@ -1944,14 +1896,14 @@ files = [ [[package]] name = "twine" -version = "5.1.1" +version = "6.0.1" requires_python = ">=3.8" summary = "Collection of utilities for publishing packages on PyPI" groups = ["default"] dependencies = [ - "importlib-metadata>=3.6", - "keyring>=15.1", - "pkginfo<1.11", + "importlib-metadata>=3.6; python_version < \"3.10\"", + "keyring>=15.1; platform_machine != \"ppc64le\" and platform_machine != \"s390x\"", + "packaging", "pkginfo>=1.8.1", "readme-renderer>=35.0", "requests-toolbelt!=0.9.0,>=0.8.0", @@ -1961,8 +1913,8 @@ dependencies = [ "urllib3>=1.26.0", ] files = [ - {file = "twine-5.1.1-py3-none-any.whl", hash = "sha256:215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997"}, - {file = "twine-5.1.1.tar.gz", hash = "sha256:9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db"}, + {file = "twine-6.0.1-py3-none-any.whl", hash = "sha256:9c6025b203b51521d53e200f4a08b116dee7500a38591668c6a6033117bdc218"}, + {file = "twine-6.0.1.tar.gz", hash = "sha256:36158b09df5406e1c9c1fb8edb24fc2be387709443e7376689b938531582ee27"}, ] [[package]] @@ -2034,7 +1986,7 @@ name = "tzdata" version = "2024.2" requires_python = ">=2" summary = "Provider of IANA time zone data" -groups = ["default", "dev"] +groups = ["default"] files = [ {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, @@ -2056,7 +2008,7 @@ name = "uvicorn" version = "0.34.0" requires_python = ">=3.9" summary = "The lightning-fast ASGI server." -groups = ["default", "dev"] +groups = ["default"] dependencies = [ "click>=7.0", "h11>=0.8", @@ -2067,71 +2019,6 @@ files = [ {file = "uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9"}, ] -[[package]] -name = "uvicorn" -version = "0.34.0" -extras = ["standard"] -requires_python = ">=3.9" -summary = "The lightning-fast ASGI server." -groups = ["dev"] -dependencies = [ - "colorama>=0.4; sys_platform == \"win32\"", - "httptools>=0.6.3", - "python-dotenv>=0.13", - "pyyaml>=5.1", - "uvicorn==0.34.0", - "uvloop!=0.15.0,!=0.15.1,>=0.14.0; (sys_platform != \"cygwin\" and sys_platform != \"win32\") and platform_python_implementation != \"PyPy\"", - "watchfiles>=0.13", - "websockets>=10.4", -] -files = [ - {file = "uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4"}, - {file = "uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9"}, -] - -[[package]] -name = "uvloop" -version = "0.21.0" -requires_python = ">=3.8.0" -summary = "Fast implementation of asyncio event loop on top of libuv" -groups = ["dev"] -marker = "(sys_platform != \"cygwin\" and sys_platform != \"win32\") and platform_python_implementation != \"PyPy\"" -files = [ - {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8"}, - {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0"}, - {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e"}, - {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb"}, - {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6"}, - {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d"}, - {file = "uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3"}, -] - -[[package]] -name = "watchfiles" -version = "1.0.4" -requires_python = ">=3.9" -summary = "Simple, modern and high performance file watching and code reload in python." -groups = ["dev"] -dependencies = [ - "anyio>=3.0.0", -] -files = [ - {file = "watchfiles-1.0.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2a9f93f8439639dc244c4d2902abe35b0279102bca7bbcf119af964f51d53c19"}, - {file = "watchfiles-1.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9eea33ad8c418847dd296e61eb683cae1c63329b6d854aefcd412e12d94ee235"}, - {file = "watchfiles-1.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31f1a379c9dcbb3f09cf6be1b7e83b67c0e9faabed0471556d9438a4a4e14202"}, - {file = "watchfiles-1.0.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab594e75644421ae0a2484554832ca5895f8cab5ab62de30a1a57db460ce06c6"}, - {file = "watchfiles-1.0.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc2eb5d14a8e0d5df7b36288979176fbb39672d45184fc4b1c004d7c3ce29317"}, - {file = "watchfiles-1.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f68d8e9d5a321163ddacebe97091000955a1b74cd43724e346056030b0bacee"}, - {file = "watchfiles-1.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9ce064e81fe79faa925ff03b9f4c1a98b0bbb4a1b8c1b015afa93030cb21a49"}, - {file = "watchfiles-1.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b77d5622ac5cc91d21ae9c2b284b5d5c51085a0bdb7b518dba263d0af006132c"}, - {file = "watchfiles-1.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1941b4e39de9b38b868a69b911df5e89dc43767feeda667b40ae032522b9b5f1"}, - {file = "watchfiles-1.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f8c4998506241dedf59613082d1c18b836e26ef2a4caecad0ec41e2a15e4226"}, - {file = "watchfiles-1.0.4-cp311-cp311-win32.whl", hash = "sha256:4ebbeca9360c830766b9f0df3640b791be569d988f4be6c06d6fae41f187f105"}, - {file = "watchfiles-1.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:05d341c71f3d7098920f8551d4df47f7b57ac5b8dad56558064c3431bdfc0b74"}, - {file = "watchfiles-1.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:32b026a6ab64245b584acf4931fe21842374da82372d5c039cba6bf99ef722f3"}, - {file = "watchfiles-1.0.4.tar.gz", hash = "sha256:6ba473efd11062d73e4f00c2b730255f9c1bdd73cd5f9fe5b5da8dbd4a717205"}, -] - [[package]] name = "wcwidth" version = "0.2.13" @@ -2151,7 +2038,7 @@ name = "websockets" version = "14.2" requires_python = ">=3.9" summary = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -groups = ["default", "dev"] +groups = ["default"] files = [ {file = "websockets-14.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3bdc8c692c866ce5fefcaf07d2b55c91d6922ac397e031ef9b774e5b9ea42166"}, {file = "websockets-14.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c93215fac5dadc63e51bcc6dceca72e72267c11def401d6668622b47675b097f"}, @@ -2222,6 +2109,7 @@ version = "3.21.0" requires_python = ">=3.9" summary = "Backport of pathlib-compatible object wrapper for zip files" groups = ["default"] +marker = "(platform_machine != \"ppc64le\" and platform_machine != \"s390x\") and python_version < \"3.12\"" files = [ {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, diff --git a/pyproject.toml b/pyproject.toml index 80ff6d2..04bd74f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,19 +10,18 @@ requires-python = ">=3.11,<3.12" dependencies = [ "babel>=2,<3", "fastapi>=0.115,<1", - "mex-common@git+https://github.com/robert-koch-institut/mex-common.git@0.40.0", + "mex-common @ git+https://github.com/robert-koch-institut/mex-common.git@0.46.0", "pydantic>=2,<3", "pytz>=2024,<2025", "pyyaml>=6,<7", "reflex-chakra>=0.6,<1", - "reflex>=0.5,<0.6.3", + "reflex>=0.6,<1", "requests>=2,<3", "starlette>=0.41,<1", "uvicorn>=0.32,<1", ] optional-dependencies.dev = [ "ipdb>=0.13,<1", - "mex-backend@git+https://github.com/robert-koch-institut/mex-backend.git@0.22.0", "mypy>=1,<2", "pytest-playwright>=0.6,<1", "pytest-random-order>=1,<2", diff --git a/tests/conftest.py b/tests/conftest.py index d8a784d..627de46 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,11 @@ -from contextlib import closing - import pytest from fastapi.testclient import TestClient from playwright.sync_api import Page, expect from pydantic import SecretStr from pytest import MonkeyPatch -from mex.backend.graph.connector import GraphConnector -from mex.backend.graph.models import Result from mex.common.backend_api.connector import BackendApiConnector from mex.common.models import ( - EXTRACTED_MODEL_CLASSES, - MERGED_MODEL_CLASSES, MEX_PRIMARY_SOURCE_STABLE_TARGET_ID, AnyExtractedModel, ExtractedActivity, @@ -19,10 +13,9 @@ ExtractedOrganizationalUnit, ExtractedPrimarySource, ) -from mex.common.settings import SETTINGS_STORE from mex.common.types import ( Email, - Identifier, + IdentityProvider, Link, Text, TextLanguage, @@ -30,7 +23,11 @@ YearMonthDay, ) from mex.editor.settings import EditorSettings -from mex.editor.types import EditorUserDatabase, EditorUserPassword +from mex.editor.types import ( + EditorIdentityProvider, + EditorUserDatabase, + EditorUserPassword, +) from mex.mex import app pytest_plugins = ("mex.common.testing.plugin",) @@ -49,6 +46,20 @@ def settings() -> EditorSettings: return EditorSettings.get() +@pytest.fixture(autouse=True) +def set_identity_provider(is_integration_test: bool, monkeypatch: MonkeyPatch) -> None: + """Ensure the identifier provider is set correctly for unit and int tests.""" + # TODO(ND): clean this up after MX-1708 + settings = EditorSettings.get() + if is_integration_test: + monkeypatch.setitem(settings.model_config, "validate_assignment", False) + monkeypatch.setattr( + settings, "identity_provider", EditorIdentityProvider.BACKEND + ) + else: + monkeypatch.setattr(settings, "identity_provider", IdentityProvider.MEMORY) + + @pytest.fixture def frontend_url() -> str: """Return the URL of the current local frontend server for testing.""" @@ -117,39 +128,13 @@ def writer_user_page( return page -class ResettingGraphConnector(GraphConnector): - """Graph connector subclass for tests to resets constraints, indices and data.""" - - def _seed_constraints(self) -> list[Result]: - for row in self.commit("SHOW ALL CONSTRAINTS;").all(): - self.commit(f"DROP CONSTRAINT {row['name']};") - return super()._seed_constraints() - - def _seed_indices(self) -> Result: - for row in self.commit("SHOW ALL INDEXES WHERE type = 'FULLTEXT';").all(): - self.commit(f"DROP INDEX {row['name']};") - return super()._seed_indices() - - def _seed_data(self) -> list[Identifier]: - self.commit("MATCH (n) DETACH DELETE n;") - return super()._seed_data() - - @pytest.fixture(autouse=True) -def isolate_graph_database(settings: EditorSettings, is_integration_test: bool) -> None: - """Rebuild the graph in a sub-process, so the settings won't get angry with us.""" +def flush_graph_database(is_integration_test: bool) -> None: + """Flush the graph database before every integration test.""" if is_integration_test: - SETTINGS_STORE.reset() - with closing(ResettingGraphConnector()) as connector: - assert connector.commit( - "SHOW ALL CONSTRAINTS YIELD id RETURN COUNT(id) AS total;" - )["total"] == (len(EXTRACTED_MODEL_CLASSES) + len(MERGED_MODEL_CLASSES)) - assert ( - connector.commit("SHOW ALL INDEXES WHERE type = 'FULLTEXT';")["name"] - == "search_index" - ) - assert connector.commit("MATCH (n) RETURN COUNT(n) AS total;")["total"] == 2 - SETTINGS_STORE.push(settings) + connector = BackendApiConnector.get() + # TODO(ND): use proper connector method when available (stopgap mx-1762) + connector.request(method="DELETE", endpoint="/_system/graph") @pytest.fixture @@ -211,7 +196,10 @@ def dummy_data() -> list[AnyExtractedModel]: @pytest.fixture -def load_dummy_data(dummy_data: list[AnyExtractedModel]) -> list[AnyExtractedModel]: +def load_dummy_data( + dummy_data: list[AnyExtractedModel], + flush_graph_database: None, # noqa: ARG001 +) -> list[AnyExtractedModel]: """Ingest dummy data into the backend.""" connector = BackendApiConnector.get() connector.post_extracted_items(dummy_data) diff --git a/tests/edit/test_main.py b/tests/edit/test_main.py index bacf653..5f5ad92 100644 --- a/tests/edit/test_main.py +++ b/tests/edit/test_main.py @@ -4,6 +4,7 @@ import pytest from playwright.sync_api import Page, expect +from mex.common.fields import MERGEABLE_FIELDS_BY_CLASS_NAME from mex.common.models import AnyExtractedModel, ExtractedActivity @@ -24,76 +25,90 @@ def edit_page( @pytest.mark.integration -def test_edit_nav_bar(edit_page: Page) -> None: +def test_edit_page_updates_nav_bar(edit_page: Page) -> None: page = edit_page nav_bar = page.get_by_test_id("nav-bar") - page.screenshot(path="tests_edit_test_main-test_edit_nav_bar.png") + page.screenshot(path="tests_edit_test_main-test_edit_page_updates_nav_bar.png") expect(nav_bar).to_be_visible() nav_item = nav_bar.get_by_text("Edit", exact=True) expect(nav_item).to_have_class(re.compile("rt-underline-always")) @pytest.mark.integration -def test_edit_heading(edit_page: Page) -> None: +def test_edit_page_renders_heading(edit_page: Page) -> None: page = edit_page heading = page.get_by_test_id("edit-heading") - page.screenshot(path="tests_edit_test_main-test_edit_heading.png") + page.screenshot(path="tests_edit_test_main-test_edit_page_renders_heading.png") expect(heading).to_be_visible() assert re.match(r"Aktivität 1\s*de", heading.inner_text()) @pytest.mark.integration -def test_edit_fields(edit_page: Page, extracted_activity: ExtractedActivity) -> None: +def test_edit_page_renders_fields( + edit_page: Page, extracted_activity: ExtractedActivity +) -> None: page = edit_page - identifier_in_primary_source = page.get_by_test_id( - "field-identifierInPrimarySource" - ) - page.screenshot(path="tests_edit_test_main-test_edit_fields.png") - expect(identifier_in_primary_source).to_be_visible() + funding_program = page.get_by_test_id("field-fundingProgram-name") + page.screenshot(path="tests_edit_test_main-test_edit_page_renders_fields.png") + expect(funding_program).to_be_visible() all_fields = page.get_by_role("row").all() - assert len(all_fields) == len(extracted_activity.model_fields) + assert len(all_fields) == len( + MERGEABLE_FIELDS_BY_CLASS_NAME[extracted_activity.entityType] + ) @pytest.mark.integration -def test_edit_primary_sources( +def test_edit_page_renders_primary_sources( edit_page: Page, extracted_activity: ExtractedActivity ) -> None: page = edit_page - had_primary_source = page.get_by_test_id( - "primary-source-hadPrimarySource_gGdOIbDIHRt35He616Fv5q" + primary_source = page.get_by_test_id( + f"primary-source-title-{extracted_activity.hadPrimarySource}-name" + ) + page.screenshot( + path="tests_edit_test_main-test_edit_page_renders_primary_sources.png" + ) + expect(primary_source).to_be_visible() + expect(primary_source).to_contain_text(extracted_activity.hadPrimarySource) + link = primary_source.get_by_role("link") + expect(link).to_have_attribute( + "href", f"/item/{extracted_activity.hadPrimarySource}/" ) - page.screenshot(path="tests_edit_test_main-test_edit_primary_sources.png") - expect(had_primary_source).to_be_visible() - all_primary_sources = page.get_by_text(extracted_activity.hadPrimarySource).all() - set_values = extracted_activity.model_dump(exclude_none=True, exclude_defaults=True) - assert len(all_primary_sources) == len(set_values) @pytest.mark.integration -def test_edit_text(edit_page: Page) -> None: +def test_edit_page_renders_text( + edit_page: Page, extracted_activity: ExtractedActivity +) -> None: page = edit_page - text = page.get_by_test_id("value-title_gGdOIbDIHRt35He616Fv5q_0") - page.screenshot(path="tests_edit_test_main-test_edit_text.png") + text = page.get_by_test_id(f"value-title-{extracted_activity.hadPrimarySource}-0") + page.screenshot(path="tests_edit_test_main-test_edit_page_renders_text.png") expect(text).to_be_visible() expect(text).to_contain_text("Aktivität 1") # text value expect(text).to_contain_text("de") # language badge @pytest.mark.integration -def test_edit_vocab(edit_page: Page) -> None: +def test_edit_page_renders_vocab( + edit_page: Page, extracted_activity: ExtractedActivity +) -> None: page = edit_page - theme = page.get_by_test_id("value-theme_gGdOIbDIHRt35He616Fv5q_0") - page.screenshot(path="tests_edit_test_main-test_edit_vocab.png") + theme = page.get_by_test_id(f"value-theme-{extracted_activity.hadPrimarySource}-0") + page.screenshot(path="tests_edit_test_main-test_edit_page_renders_vocab.png") expect(theme).to_be_visible() expect(theme).to_contain_text("INFECTIOUS_DISEASES_AND_EPIDEMIOLOGY") # theme value expect(theme).to_contain_text("Theme") # vocabulary name @pytest.mark.integration -def test_edit_link(edit_page: Page) -> None: +def test_edit_page_renders_link( + edit_page: Page, extracted_activity: ExtractedActivity +) -> None: page = edit_page - website = page.get_by_test_id("value-website_gGdOIbDIHRt35He616Fv5q_0") - page.screenshot(path="tests_edit_test_main-test_edit_link.png") + website = page.get_by_test_id( + f"value-website-{extracted_activity.hadPrimarySource}-0" + ) + page.screenshot(path="tests_edit_test_main-test_edit_page_renders_link.png") expect(website).to_be_visible() link = website.get_by_role("link") expect(link).to_contain_text("Activity Homepage") # link title @@ -102,21 +117,102 @@ def test_edit_link(edit_page: Page) -> None: @pytest.mark.integration -def test_edit_temporal(edit_page: Page) -> None: +def test_edit_page_renders_temporal( + edit_page: Page, extracted_activity: ExtractedActivity +) -> None: page = edit_page - start = page.get_by_test_id("value-start_gGdOIbDIHRt35He616Fv5q_0") - page.screenshot(path="tests_edit_test_main-test_edit_temporal.png") + start = page.get_by_test_id(f"value-start-{extracted_activity.hadPrimarySource}-0") + page.screenshot(path="tests_edit_test_main-test_edit_page_renders_temporal.png") expect(start).to_be_visible() - expect(start).to_contain_text("24. Dezember 1999") # temporal localization + expect(start).to_contain_text("1999-12-24") # temporal localization @pytest.mark.integration -def test_edit_identifier(edit_page: Page) -> None: +def test_edit_page_renders_identifier( + edit_page: Page, extracted_activity: ExtractedActivity +) -> None: page = edit_page - contact = page.get_by_test_id("value-contact_gGdOIbDIHRt35He616Fv5q_2") - page.screenshot(path="tests_edit_test_main-test_edit_identifier.png") + contact = page.get_by_test_id( + f"value-contact-{extracted_activity.hadPrimarySource}-2" + ) + page.screenshot(path="tests_edit_test_main-test_edit_page_renders_identifier.png") expect(contact).to_be_visible() link = contact.get_by_role("link") - expect(link).to_contain_text("cWWm02l1c6cucKjIhkFqY4") # not resolved yet - expect(link).to_have_attribute("href", "/item/cWWm02l1c6cucKjIhkFqY4/") # link href + expect(link).to_contain_text(extracted_activity.contact[2]) # not resolved yet + expect(link).to_have_attribute( + "href", + f"/item/{extracted_activity.contact[2]}/", # link href + ) expect(link).not_to_have_attribute("target", "_blank") # internal link + + +@pytest.mark.parametrize( + ("switch_id"), + [ + (r"switch-abstract-{primary_source_id}"), + (r"switch-abstract-{primary_source_id}-1"), + ], + ids=["toggle primary source", "toggle value"], +) +@pytest.mark.integration +def test_edit_page_switch_roundtrip( + edit_page: Page, extracted_activity: ExtractedActivity, switch_id: str +) -> None: + switch_id = switch_id.format(primary_source_id=extracted_activity.hadPrimarySource) + test_id = f"tests_edit_test_main-test_edit_page_switch_roundtrip-{switch_id}" + page = edit_page + + # verify initial state: toggle is enabled + switch = page.get_by_test_id(switch_id) + page.screenshot(path=f"{test_id}-onload.png") + expect(switch).to_have_count(1) + expect(switch).to_be_visible() + expect(switch).to_have_attribute("data-state", "checked") + + # click on the toggle to disable the primary source / specific value + switch.click() + expect(switch).to_have_attribute("data-state", "unchecked") + page.screenshot(path=f"{test_id}-disabled.png") + + # click on the save button and verify the toast + submit = page.get_by_test_id("submit-button") + submit.scroll_into_view_if_needed() + submit.click() + toast = page.locator(".editor-toast").first + expect(toast).to_be_visible() + expect(toast).to_contain_text("Saved") + page.screenshot(path=f"{test_id}-toast_1.png") + + # force a page reload + page.reload() + + # verify the state after first saving: toggle is off + switch = page.get_by_test_id(switch_id) + page.screenshot(path=f"{test_id}-reload_1.png") + expect(switch).to_have_count(1) + expect(switch).to_be_visible() + expect(switch).to_have_attribute("data-state", "unchecked") + + # click on the toggle to re-enable the primary source / specific value + switch.click() + expect(switch).to_have_attribute("data-state", "checked") + page.screenshot(path=f"{test_id}-enabled.png") + + # click on the save button again and verify the toast again + submit = page.get_by_test_id("submit-button") + submit.scroll_into_view_if_needed() + submit.click() + toast = page.locator(".editor-toast").first + expect(toast).to_be_visible() + expect(toast).to_contain_text("Saved") + page.screenshot(path=f"{test_id}-toast_2.png") + + # force a page reload again + page.reload() + + # verify the state after second saving: toggle is on again + switch = page.get_by_test_id(switch_id) + page.screenshot(path=f"{test_id}-reload_2.png") + expect(switch).to_have_count(1) + expect(switch).to_be_visible() + expect(switch).to_have_attribute("data-state", "checked") diff --git a/tests/edit/test_transform.py b/tests/edit/test_transform.py new file mode 100644 index 0000000..52c8602 --- /dev/null +++ b/tests/edit/test_transform.py @@ -0,0 +1,523 @@ +import pytest + +from mex.common.fields import MERGEABLE_FIELDS_BY_CLASS_NAME +from mex.common.models import ( + MEX_PRIMARY_SOURCE_STABLE_TARGET_ID, + AdditiveActivity, + AdditiveContactPoint, + AdditivePerson, + AnyAdditiveModel, + AnyExtractedModel, + AnyMergedModel, + AnyPreventiveModel, + AnyRuleModel, + AnySubtractiveModel, + ExtractedContactPoint, + ExtractedPerson, + MergedConsent, + MergedContactPoint, + MergedPerson, + PreventivePerson, + SubtractiveActivity, + SubtractiveConsent, + SubtractivePerson, +) +from mex.common.types import ( + ConsentStatus, + Link, + LinkLanguage, + MergedActivityIdentifier, + MergedContactPointIdentifier, + MergedPersonIdentifier, + MergedPrimarySourceIdentifier, + Text, + TextLanguage, + Year, + YearMonthDayTime, +) +from mex.editor.edit.models import EditorField, EditorPrimarySource +from mex.editor.edit.transform import ( + _get_primary_source_id_from_model, + _transform_editor_value_to_model_value, + _transform_field_to_preventive, + _transform_field_to_subtractive, + _transform_model_to_editor_primary_source, + _transform_model_values_to_editor_values, + transform_fields_to_rule_set, + transform_models_to_fields, +) +from mex.editor.models import EditorValue + + +@pytest.mark.parametrize( + ("model", "expected"), + [ + ( + ExtractedContactPoint( + email="info@rki.de", + hadPrimarySource=MergedPrimarySourceIdentifier( + "gGdOIbDIHRt35He616Fv5q" + ), + identifierInPrimarySource="info", + ), + MergedPrimarySourceIdentifier("gGdOIbDIHRt35He616Fv5q"), + ), + ( + MergedContactPoint( + identifier=MergedContactPointIdentifier("t35He616Fv5qxGdOIbDiHR"), + email="info@rki.de", + ), + MEX_PRIMARY_SOURCE_STABLE_TARGET_ID, + ), + ( + AdditiveContactPoint( + email="example@rki.de", + ), + MEX_PRIMARY_SOURCE_STABLE_TARGET_ID, + ), + ], +) +def test_get_primary_source_id_from_model( + model: AnyExtractedModel | AnyMergedModel | AnyRuleModel, + expected: MergedPrimarySourceIdentifier, +) -> None: + primary_source_id = _get_primary_source_id_from_model(model) + assert primary_source_id == expected + + +@pytest.mark.parametrize( + ("model", "field_name", "subtractive", "expected"), + [ + ( + MergedConsent( + identifier=MergedContactPointIdentifier.generate(), + hasConsentStatus=ConsentStatus["VALID_FOR_PROCESSING"], + hasDataSubject=MergedPersonIdentifier.generate(), + isIndicatedAtTime=YearMonthDayTime("2022-09-30T20:48:35Z"), + ), + "hasConsentStatus", + SubtractiveConsent(), + [EditorValue(text="VALID_FOR_PROCESSING", badge="ConsentStatus")], + ), + ( + ExtractedPerson( + identifierInPrimarySource="example", + hadPrimarySource=MergedPrimarySourceIdentifier.generate(), + fullName=["Example, Name", "Dr. Example"], + ), + "fullName", + SubtractivePerson(), + [ + EditorValue(text="Example, Name"), + EditorValue(text="Dr. Example"), + ], + ), + ( + AdditiveActivity( + succeeds=[ + MergedActivityIdentifier("gGdOIbDIHRt35He616Fv5q"), + ] + ), + "succeeds", + SubtractiveActivity( + isPartOfActivity=[MergedActivityIdentifier("doesNotMatter000000000")] + ), + [ + EditorValue( + text="gGdOIbDIHRt35He616Fv5q", href="/item/gGdOIbDIHRt35He616Fv5q" + ), + ], + ), + ( + AdditiveActivity( + documentation=[ + Link( + url="http://example", + title="Example Homepage", + language=LinkLanguage.EN, + ), + Link(url="http://pavyzdys"), + ] + ), + "documentation", + SubtractiveActivity( + documentation=[ + Link( + url="http://example", + title="Example Homepage", + language=LinkLanguage.EN, + ), + ] + ), + [ + EditorValue( + text="Example Homepage", + badge="en", + href="http://example", + external=True, + enabled=False, + ), + EditorValue( + text="http://pavyzdys", + href="http://pavyzdys", + external=True, + ), + ], + ), + ], + ids=["single value", "list", "irrelevant subtractive", "subtractive applied"], +) +def test_transform_model_values_to_editor_values( + model: AnyExtractedModel | AnyMergedModel | AnyAdditiveModel, + field_name: str, + subtractive: AnySubtractiveModel, + expected: EditorValue, +) -> None: + editor_value = _transform_model_values_to_editor_values( + model, field_name, subtractive + ) + assert editor_value == expected + + +@pytest.mark.parametrize( + ( + "model", + "subtractive", + "preventive", + "expected_given_name", + "expected_family_name", + ), + [ + ( + ExtractedPerson( + identifierInPrimarySource="example", + hadPrimarySource=MergedPrimarySourceIdentifier("primarySourceId"), + givenName=["Example"], + ), + SubtractivePerson(), + PreventivePerson(), + [ + EditorPrimarySource( + name=EditorValue( + text="primarySourceId", href="/item/primarySourceId" + ), + identifier=MergedPrimarySourceIdentifier("primarySourceId"), + editor_values=[EditorValue(text="Example")], + ) + ], + [ + EditorPrimarySource( + name=EditorValue( + text="primarySourceId", href="/item/primarySourceId" + ), + identifier=MergedPrimarySourceIdentifier("primarySourceId"), + ) + ], + ), + ( + ExtractedPerson( + identifierInPrimarySource="given-family", + hadPrimarySource=MergedPrimarySourceIdentifier("primarySourceId"), + givenName=["Given", "Gegeben"], + familyName=["Family"], + ), + SubtractivePerson( + givenName=["Gegeben"], + ), + PreventivePerson( + familyName=[MergedPrimarySourceIdentifier("primarySourceId")] + ), + [ + EditorPrimarySource( + name=EditorValue( + text="primarySourceId", href="/item/primarySourceId" + ), + identifier=MergedPrimarySourceIdentifier("primarySourceId"), + editor_values=[ + EditorValue(text="Given"), + EditorValue(text="Gegeben", enabled=False), + ], + ) + ], + [ + EditorPrimarySource( + name=EditorValue( + text="primarySourceId", href="/item/primarySourceId" + ), + identifier=MergedPrimarySourceIdentifier("primarySourceId"), + editor_values=[EditorValue(text="Family")], + enabled=False, + ) + ], + ), + ], + ids=["without rules", "with rules"], +) +def test_transform_model_to_editor_primary_source( + model: AnyExtractedModel | AnyMergedModel | AnyAdditiveModel, + subtractive: AnySubtractiveModel, + preventive: AnyPreventiveModel, + expected_given_name: list[EditorPrimarySource], + expected_family_name: list[EditorPrimarySource], +) -> None: + given_name = EditorField(name="givenName", primary_sources=[]) + family_name = EditorField(name="familyName", primary_sources=[]) + fields_by_name = {"givenName": given_name, "familyName": family_name} + + _transform_model_to_editor_primary_source( + fields_by_name, model, subtractive, preventive + ) + + assert given_name.primary_sources == expected_given_name + assert family_name.primary_sources == expected_family_name + + +def test_transform_models_to_fields() -> None: + editor_fields = transform_models_to_fields( + MergedPerson( + identifier=MergedPersonIdentifier.generate(), email=["person@rki.de"] + ), + AdditivePerson(givenName=["Good"]), + subtractive=SubtractivePerson(givenName=["Bad"]), + preventive=PreventivePerson(memberOf=[MEX_PRIMARY_SOURCE_STABLE_TARGET_ID]), + ) + + assert len(editor_fields) == len(MERGEABLE_FIELDS_BY_CLASS_NAME["MergedPerson"]) + fields_by_name = {f.name: f for f in editor_fields} + assert fields_by_name["givenName"] == EditorField( + name="givenName", + primary_sources=[ + EditorPrimarySource( + name=EditorValue( + text="00000000000000", + href="/item/00000000000000", + ), + identifier=MergedPrimarySourceIdentifier("00000000000000"), + ), + EditorPrimarySource( + name=EditorValue( + text="00000000000000", + href="/item/00000000000000", + ), + identifier=MergedPrimarySourceIdentifier("00000000000000"), + editor_values=[EditorValue(text="Good")], + ), + ], + ) + assert fields_by_name["memberOf"] == EditorField( + name="memberOf", + primary_sources=[ + EditorPrimarySource( + name=EditorValue( + text="00000000000000", + href="/item/00000000000000", + ), + identifier=MergedPrimarySourceIdentifier("00000000000000"), + enabled=False, + ), + EditorPrimarySource( + name=EditorValue( + text="00000000000000", + href="/item/00000000000000", + ), + identifier=MergedPrimarySourceIdentifier("00000000000000"), + enabled=False, + ), + ], + ) + + +@pytest.mark.parametrize( + ("field", "expected"), + [ + ( + EditorField( + name="unknownField", + primary_sources=[ + EditorPrimarySource( + enabled=True, + name=EditorValue(text="Enabled Primary Source"), + identifier=MergedPrimarySourceIdentifier( + "enabledPrimarySourceId" + ), + ) + ], + ), + {}, + ), + ( + EditorField( + name="familyName", + primary_sources=[ + EditorPrimarySource( + enabled=True, + name=EditorValue(text="Enabled Primary Source"), + identifier=MergedPrimarySourceIdentifier( + "enabledPrimarySourceId" + ), + ), + EditorPrimarySource( + enabled=False, + name=EditorValue(text="Prevented Primary Source"), + identifier=MergedPrimarySourceIdentifier( + "preventedPrimarySourceId" + ), + ), + ], + ), + {"familyName": ["preventedPrimarySourceId"]}, + ), + ], +) +def test_transform_field_to_preventive( + field: EditorField, expected: dict[str, object] +) -> None: + preventive = PreventivePerson() + _transform_field_to_preventive(field, preventive) + assert preventive.model_dump(exclude_defaults=True) == expected + + +@pytest.mark.parametrize( + ("editor_value", "field_name", "class_name", "expected"), + [ + ( + EditorValue(text="Titel", badge="de", href="https://beispiel"), + "documentation", + "MergedResource", + Link(url="https://beispiel", language=LinkLanguage.DE, title="Titel"), + ), + ( + EditorValue(text="Beispiel Text", badge="de"), + "alternativeName", + "ExtractedOrganization", + Text(language=TextLanguage.DE, value="Beispiel Text"), + ), + ( + EditorValue(text="VALID_FOR_PROCESSING", badge="ConsentStatus"), + "hasConsentType", + "MergedConsent", + ConsentStatus["VALID_FOR_PROCESSING"], + ), + ( + EditorValue(text="2004", badge="year"), + "start", + "ExtractedActivity", + Year(2004), + ), + ( + EditorValue(text="Funds for Funding e.V."), + "fundingProgram", + "ExtractedActivity", + "Funds for Funding e.V.", + ), + ], + ids=["link", "text", "vocab", "temporal", "string"], +) +def test_transform_render_value_to_model_type( + editor_value: EditorValue, field_name: str, class_name: str, expected: object +) -> None: + model_value = _transform_editor_value_to_model_value( + editor_value, + field_name, + class_name, + ) + assert model_value == expected + + +@pytest.mark.parametrize( + ("field", "expected"), + [ + ( + EditorField( + name="unknownField", + primary_sources=[ + EditorPrimarySource( + name=EditorValue(text="Primary Source 1"), + identifier=MergedPrimarySourceIdentifier("PrimarySource001"), + ) + ], + ), + {}, + ), + ( + EditorField( + name="familyName", + primary_sources=[ + EditorPrimarySource( + name=EditorValue(text="Primary Source 1"), + identifier=MergedPrimarySourceIdentifier("PrimarySource001"), + editor_values=[ + EditorValue(text="active", enabled=True), + EditorValue(text="inactive", enabled=False), + ], + ), + EditorPrimarySource( + name=EditorValue(text="Primary Source 2"), + identifier=MergedPrimarySourceIdentifier("PrimarySource002"), + editor_values=[ + EditorValue(text="another inactive", enabled=False), + ], + ), + ], + ), + {"familyName": ["inactive", "another inactive"]}, + ), + ], +) +def test_transform_field_to_subtractive( + field: EditorField, expected: dict[str, object] +) -> None: + subtractive = SubtractivePerson() + _transform_field_to_subtractive(field, subtractive) + assert subtractive.model_dump(exclude_defaults=True) == expected + + +def test_transform_fields_to_rule_set() -> None: + rule_set_request = transform_fields_to_rule_set( + "Person", + [ + EditorField( + name="givenName", + primary_sources=[ + EditorPrimarySource( + name=EditorValue(text="Enabled Primary Source"), + identifier=MergedPrimarySourceIdentifier("PrimarySource001"), + ), + EditorPrimarySource( + name=EditorValue(text="Prevented Primary Source"), + identifier=MergedPrimarySourceIdentifier("PrimarySource002"), + enabled=False, + ), + ], + ), + EditorField( + name="familyName", + primary_sources=[ + EditorPrimarySource( + name=EditorValue(text="Primary Source 1"), + identifier=MergedPrimarySourceIdentifier("PrimarySource001"), + editor_values=[ + EditorValue(text="active", enabled=True), + EditorValue(text="inactive", enabled=False), + ], + ), + EditorPrimarySource( + name=EditorValue(text="Primary Source 2"), + identifier=MergedPrimarySourceIdentifier("PrimarySource002"), + editor_values=[ + EditorValue(text="another inactive", enabled=False), + ], + ), + ], + ), + ], + ) + assert rule_set_request.entityType == "PersonRuleSetRequest" + assert rule_set_request.model_dump(exclude_defaults=True) == { + "subtractive": { + "familyName": ["inactive", "another inactive"], + }, + "preventive": { + "givenName": ["PrimarySource002"], + }, + } diff --git a/tests/search/test_main.py b/tests/search/test_main.py index 54173ee..bd92d5a 100644 --- a/tests/search/test_main.py +++ b/tests/search/test_main.py @@ -19,14 +19,12 @@ def test_index(frontend_url: str, writer_user_page: Page) -> None: expect(page.get_by_text("showing 7 of total 7 items found")).to_be_visible() # check mex primary source is showing - primary_source = page.get_by_text( - re.compile(r"^00000000000000\s*MergedPrimarySource$") - ) - expect(primary_source).to_be_visible() + primary_source = page.get_by_text(re.compile(r"^PrimarySource$")) + expect(primary_source.first).to_be_visible() # check activity is showing activity = page.get_by_text( - re.compile(r"Aktivität 1\s*de\s*A1.*24\. Dezember 1999\s*1\. Januar 2023") + re.compile(r"Aktivität 1\s*de\s*A1.*1999-12-24\s*day\s*2023-01-01") ) activity.scroll_into_view_if_needed() expect(activity).to_be_visible() diff --git a/tests/test_state.py b/tests/test_state.py index fb8dd77..6bd357f 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -5,7 +5,7 @@ def test_state_logout() -> None: state = State(user=User(name="Test", authorization="Auth", write_access=True)) assert state.user - assert "/" in str(state.logout(None)) + assert "/" in str(state.logout()) assert state.user is None diff --git a/tests/test_transform.py b/tests/test_transform.py index 057c428..6bdc28c 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -2,10 +2,11 @@ from mex.common.exceptions import MExError from mex.common.models import AnyExtractedModel -from mex.common.types import APIType -from mex.editor.models import FixedValue +from mex.common.types import APIType, Identifier, Link, LinkLanguage, Text, TextLanguage +from mex.editor.models import EditorValue from mex.editor.transform import ( transform_models_to_preview, + transform_models_to_stem_type, transform_models_to_title, transform_value, transform_values, @@ -13,51 +14,66 @@ @pytest.mark.parametrize( - ("values", "expected"), + ("values", "allow_link", "expected"), [ - (None, []), + (None, True, []), ( "foo", + True, + [EditorValue(text="foo")], + ), + ( [ - FixedValue( - text="foo", - badge=None, - href=None, - tooltip=None, - external=False, - ) + "bar", + APIType["REST"], + Text(value="hi there", language=TextLanguage.EN), + Link(url="http://mex", title="homepage", language=LinkLanguage.EN), + ], + True, + [ + EditorValue(text="bar"), + EditorValue(text="REST", badge="APIType"), + EditorValue(text="hi there", badge="en"), + EditorValue( + text="homepage", badge="en", href="http://mex", external=True + ), ], ), ( - ["bar", APIType["REST"]], + Identifier("cWWm02l1c6cucKjIhkFqY4"), + True, [ - FixedValue( - text="bar", - badge=None, - href=None, - tooltip=None, - external=False, - ), - FixedValue( - text="REST", - badge="APIType", - href=None, - tooltip=None, - external=False, - ), + EditorValue( + text="cWWm02l1c6cucKjIhkFqY4", href="/item/cWWm02l1c6cucKjIhkFqY4" + ) ], ), + ( + Identifier("cWWm02l1c6cucKjIhkFqY4"), + False, + [EditorValue(text="cWWm02l1c6cucKjIhkFqY4")], + ), ], ) -def test_transform_values(values: object, expected: list[FixedValue]) -> None: - assert transform_values(values) == expected +def test_transform_values( + values: object, allow_link: bool, expected: list[EditorValue] +) -> None: + assert transform_values(values, allow_link=allow_link) == expected def test_transform_value_none_error() -> None: - with pytest.raises(MExError, match="cannot transform null"): + with pytest.raises(MExError, match="cannot transform NoneType to editor value"): transform_value(None) +def test_transform_models_to_stem_type_empty() -> None: + assert transform_models_to_stem_type([]) is None + + +def test_transform_models_to_stem_type(dummy_data: list[AnyExtractedModel]) -> None: + assert transform_models_to_stem_type(dummy_data[:2]) == "PrimarySource" + + def test_transform_models_to_title_empty() -> None: assert transform_models_to_title([]) == [] @@ -66,64 +82,28 @@ def test_transform_models_to_title(dummy_data: list[AnyExtractedModel]) -> None: dummy_titles = [transform_models_to_title([d]) for d in dummy_data] assert dummy_titles == [ [ - # mex primary source has no title, renders identifier instead - FixedValue( - text="sMgFvmdtJyegb9vkebq04", - badge=None, - href="/item/sMgFvmdtJyegb9vkebq04", - tooltip=None, - external=False, - ) + # mex primary source has no title, renders type instead + EditorValue(text="PrimarySource") ], [ # ps-2 primary source has no title either - FixedValue( - text="d0MGZryflsy7PbsBF3ZGXO", - badge=None, - href="/item/d0MGZryflsy7PbsBF3ZGXO", - tooltip=None, - external=False, - ) + EditorValue(text="PrimarySource") ], [ # contact-point renders email as text - FixedValue( - text="info@contact-point.one", - badge=None, - href=None, - tooltip=None, - external=False, - ) + EditorValue(text="info@contact-point.one") ], [ # contact-point renders email as text - FixedValue( - text="help@contact-point.two", - badge=None, - href=None, - tooltip=None, - external=False, - ) + EditorValue(text="help@contact-point.two") ], [ # unit renders shortName as text (no language badge) - FixedValue( - text="OU1", - badge=None, - href=None, - tooltip=None, - external=False, - ) + EditorValue(text="OU1") ], [ # activity renders title as text (with language badge) - FixedValue( - text="Aktivität 1", - badge="de", - href=None, - tooltip=None, - external=False, - ) + EditorValue(text="Aktivität 1", badge="de") ], ] @@ -135,100 +115,18 @@ def test_transform_models_to_preview_empty() -> None: def test_transform_models_to_preview(dummy_data: list[AnyExtractedModel]) -> None: dummy_previews = [transform_models_to_preview([d]) for d in dummy_data] assert dummy_previews == [ + [EditorValue(text="PrimarySource")], + [EditorValue(text="PrimarySource")], + [EditorValue(text="ContactPoint")], + [EditorValue(text="ContactPoint")], + [EditorValue(text="Unit 1", badge="en", enabled=True)], [ - FixedValue( - text="ExtractedPrimarySource", - badge=None, - href=None, - tooltip=None, - external=False, - ) - ], - [ - FixedValue( - text="ExtractedPrimarySource", - badge=None, - href=None, - tooltip=None, - external=False, - ) - ], - [ - FixedValue( - text="ExtractedContactPoint", - badge=None, - href=None, - tooltip=None, - external=False, - ) - ], - [ - FixedValue( - text="ExtractedContactPoint", - badge=None, - href=None, - tooltip=None, - external=False, - ) - ], - [ - FixedValue( - text="Unit 1", - badge="en", - href=None, - tooltip=None, - external=False, - ) - ], - [ - FixedValue( - text="A1", - badge=None, - href=None, - tooltip=None, - external=False, - ), - FixedValue( - text="wEvxYRPlmGVQCbZx9GAbn", - badge=None, - href="/item/wEvxYRPlmGVQCbZx9GAbn", - tooltip=None, - external=False, - ), - FixedValue( - text="g32qzYNVH1Ez7JTEk3fvLF", - badge=None, - href="/item/g32qzYNVH1Ez7JTEk3fvLF", - tooltip=None, - external=False, - ), - FixedValue( - text="cWWm02l1c6cucKjIhkFqY4", - badge=None, - href="/item/cWWm02l1c6cucKjIhkFqY4", - tooltip=None, - external=False, - ), - FixedValue( - text="cWWm02l1c6cucKjIhkFqY4", - badge=None, - href="/item/cWWm02l1c6cucKjIhkFqY4", - tooltip=None, - external=False, - ), - FixedValue( - text="24. Dezember 1999", - badge=None, - href=None, - tooltip=None, - external=False, - ), - FixedValue( - text="1. Januar 2023", - badge=None, - href=None, - tooltip=None, - external=False, - ), + EditorValue(text="A1", enabled=True), + EditorValue(text="wEvxYRPlmGVQCbZx9GAbn"), + EditorValue(text="g32qzYNVH1Ez7JTEk3fvLF"), + EditorValue(text="cWWm02l1c6cucKjIhkFqY4"), + EditorValue(text="cWWm02l1c6cucKjIhkFqY4"), + EditorValue(text="1999-12-24", badge="day"), + EditorValue(text="2023-01-01", badge="day"), ], ]