Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ jobs:

- name: "Stand up compose stack"
run: |
IMAGE_URL=${{ fromJSON(steps.docker_metadata.outputs.json).tags[0] }} \
docker compose --file docker/compose.ci.yaml up --wait --wait-timeout 300 || COMPOSE_EXIT=$?
export IMAGE_URL=${{ fromJSON(steps.docker_metadata.outputs.json).tags[0] }}
docker compose --file docker/compose.ci.yaml up --wait --wait-timeout 750 || COMPOSE_EXIT=$?
if [ -n "$COMPOSE_EXIT" ]; then
docker compose --file docker/compose.ci.yaml ps
docker compose --file docker/compose.ci.yaml logs
Expand Down
4 changes: 2 additions & 2 deletions docker/compose.ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ services:
# OAuth2 provider/application) has actually been applied by the worker - unlike
# checking for the akadmin user, which exists long before the custom blueprint runs.
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://auth-webapp:9000/application/o/seis-lab-data/.well-known/openid-configuration\").raise_for_status()'"]
start_period: 60s
start_period: 90s
interval: 10s
retries: 30
retries: 60
timeout: 5s

message-broker:
Expand Down
7 changes: 5 additions & 2 deletions src/seis_lab_data/db/commands/surveyrelatedrecords.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,15 @@ async def bulk_update_manually_selected_records(
selected: list[identifiers.SurveyRelatedRecordId],
user_id: identifiers.UserId,
restrict_to_owned: bool = True,
survey_mission_id: identifiers.SurveyMissionId | None = None,
) -> int:
if restrict_to_owned:
ids_statement = record_queries.build_owned_survey_related_record_id_statement(
user_id, record_ids=selected
user_id, survey_mission_id=survey_mission_id, record_ids=selected
)
else:
ids_statement = record_queries.build_survey_related_record_id_statement(
record_ids=selected
survey_mission_id=survey_mission_id, record_ids=selected
)
matched_ids = (await session.exec(ids_statement)).all()
try:
Expand All @@ -219,13 +220,15 @@ async def bulk_update_filtered_records(
user_id: identifiers.UserId,
restrict_to_owned: bool = True,
excluded_record_ids: list[identifiers.SurveyRelatedRecordId] | None = None,
survey_mission_id: identifiers.SurveyMissionId | None = None,
en_name_filter: str | None = None,
pt_name_filter: str | None = None,
spatial_intersect: shapely.Polygon | None = None,
temporal_extent: filter_schemas.TemporalExtentFilterValue | None = None,
asset_path_fragment_filter: str | None = None,
) -> int:
filter_kwargs = dict(
survey_mission_id=survey_mission_id,
en_name_filter=en_name_filter,
pt_name_filter=pt_name_filter,
spatial_intersect=spatial_intersect,
Expand Down
11 changes: 11 additions & 0 deletions src/seis_lab_data/db/queries/surveyrelatedrecords.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,17 @@ def build_owned_survey_related_record_id_statement(
return statement


async def count_survey_related_records_matching(
session: AsyncSession, ids_statement
) -> int:
"""Count how many records an id-only statement matches.

Intended for the bulk-update id builders above, to show a user how many
records a pending bulk update would affect without materializing ids.
"""
return await _get_total_num_records(session, ids_statement)


async def list_survey_related_records(
session: AsyncSession,
survey_mission_id: identifiers.SurveyMissionId | None = None,
Expand Down
6 changes: 6 additions & 0 deletions src/seis_lab_data/operations/surveyrelatedrecords.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ async def bulk_update_survey_related_records(
event_dispatcher: dispatch.EventDispatcherProtocol,
selected: list[identifiers.SurveyRelatedRecordId] | None = None,
excluded_record_ids: list[identifiers.SurveyRelatedRecordId] | None = None,
survey_mission_id: identifiers.SurveyMissionId | None = None,
en_name_filter: str | None = None,
pt_name_filter: str | None = None,
spatial_intersect: shapely.Polygon | None = None,
Expand All @@ -471,6 +472,9 @@ async def bulk_update_survey_related_records(
exclusive ways of specifying which records to update, mirroring the two
selection modes offered by the UI: an explicit set of chosen records, or
"everything matching the current search, except what was excluded".

`survey_mission_id` is an optional additional scope - omit it to bulk-update
across all missions a user may access.
"""
is_admin = not {constants.ROLE_ADMIN, constants.ROLE_SYSTEM_ADMIN}.isdisjoint(
initiator.roles
Expand All @@ -487,6 +491,7 @@ async def bulk_update_survey_related_records(
selected,
identifiers.UserId(initiator.id),
restrict_to_owned=not is_admin,
survey_mission_id=survey_mission_id,
)
else:
updated_count = await record_commands.bulk_update_filtered_records(
Expand All @@ -495,6 +500,7 @@ async def bulk_update_survey_related_records(
identifiers.UserId(initiator.id),
restrict_to_owned=not is_admin,
excluded_record_ids=excluded_record_ids,
survey_mission_id=survey_mission_id,
en_name_filter=en_name_filter,
pt_name_filter=pt_name_filter,
spatial_intersect=spatial_intersect,
Expand Down
19 changes: 15 additions & 4 deletions src/seis_lab_data/schemas/surveyrelatedrecords.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,12 @@ class SurveyRelatedRecordCreate(pydantic.BaseModel):


class SurveyRelatedRecordBulkUpdate(pydantic.BaseModel):
name: LocalizableDraftName | None = None
description: LocalizableDraftDescription | None = None
dataset_category_id: DatasetCategoryId | None = None
workflow_stage_id: WorkflowStageId | None = None
bbox_4326: PossiblyInvalidPolygon | None = None
temporal_extent_begin: dt.date | None = None
temporal_extent_end: dt.date | None = None
links: list[LinkSchema] | None = None
related_records: list[RelatedRecordCreate] = []


Expand All @@ -142,10 +140,15 @@ class SurveyRelatedRecordBulkUpdateSelection(pydantic.BaseModel):
exclusive ways of specifying the target records, mirroring the two
selection modes offered by the UI - see
`operations.surveyrelatedrecords.bulk_update_survey_related_records`.

`survey_mission_id` is an optional additional scope, not a requirement -
callers that aren't scoped to a single mission (e.g. a future bulk-edit
entry point on the general record listing) can simply omit it.
"""

selected: list[SurveyRelatedRecordId] | None = None
excluded_record_ids: list[SurveyRelatedRecordId] | None = None
survey_mission_id: SurveyMissionId | None = None
en_name_filter: str | None = None
pt_name_filter: str | None = None
spatial_intersect: PossiblyInvalidPolygon | None = None
Expand Down Expand Up @@ -179,6 +182,8 @@ class SurveyRelatedRecordReadListItem(pydantic.BaseModel):
status: SurveyRelatedRecordStatus
validation_result: models.ValidationResult | None
survey_mission: SurveyMissionReadEmbedded
dataset_category: DatasetCategoryReadListItem
workflow_stage: WorkflowStageReadListItem
bbox_4326: PolygonOut | None
temporal_extent_begin: Annotated[
dt.date | None, pydantic.PlainSerializer(serialize_possibly_empty_date)
Expand All @@ -196,15 +201,21 @@ def from_db_instance(
survey_mission=SurveyMissionReadEmbedded.from_db_instance(
instance.survey_mission
),
dataset_category=DatasetCategoryReadListItem.model_validate(
instance.dataset_category, from_attributes=True
),
workflow_stage=WorkflowStageReadListItem.model_validate(
instance.workflow_stage, from_attributes=True
),
)


class SurveyRelatedRecordReadDetail(SurveyRelatedRecordReadListItem):
owner_id: UserId
links: list[LinkSchema] = []
survey_mission: SurveyMissionReadEmbedded
dataset_category: DatasetCategoryReadListItem
workflow_stage: WorkflowStageReadListItem
# dataset_category: DatasetCategoryReadListItem
# workflow_stage: WorkflowStageReadListItem
record_assets: list[RecordAssetReadDetailEmbedded]
related_to_records: list[
tuple[LocalizableDraftDescription, SurveyRelatedRecordReadEmbedded]
Expand Down
1 change: 1 addition & 0 deletions src/seis_lab_data/schemas/webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class UserPermissionDetails:
can_delete: bool
can_validate: bool = False
can_discover: bool = False
can_bulk_update: bool = False


ItemWithDetails = typing.TypeVar(
Expand Down
2 changes: 2 additions & 0 deletions src/seis_lab_data/webapp/forms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
SurveyMissionUpdateForm,
)
from .surveyrelatedrecords import (
SurveyRelatedRecordBulkUpdateForm,
SurveyRelatedRecordCreateForm,
SurveyRelatedRecordUpdateForm,
)
Expand All @@ -18,6 +19,7 @@
ProjectUpdateForm,
SurveyMissionCreateForm,
SurveyMissionUpdateForm,
SurveyRelatedRecordBulkUpdateForm,
SurveyRelatedRecordCreateForm,
SurveyRelatedRecordUpdateForm,
]
149 changes: 149 additions & 0 deletions src/seis_lab_data/webapp/forms/surveyrelatedrecords.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from starlette_babel import gettext_lazy as _
from starlette_wtf import StarletteForm
from wtforms import (
BooleanField,
FieldList,
Form,
FormField,
Expand Down Expand Up @@ -386,3 +387,151 @@ def validate_with_schema(self):
except pydantic.ValidationError as exc:
logger.error(f"pydantic errors {exc.errors()=}")
incorporate_schema_validation_errors_into_form(exc.errors(), self)


class SurveyRelatedRecordBulkUpdateForm(StarletteForm):
"""Form for bulk-updating survey-related records.

Deliberately not a subclass of `_SurveyRelatedRecordForm`, since it must
not drag in `name`/`description`/`assets`, none of which are bulk-editable.

Each `update_*` checkbox is the only thing that decides whether its field
group is included in the constructed `SurveyRelatedRecordBulkUpdate` -
this avoids the "is blank the same as unset?" ambiguity that would
otherwise exist per field (most notably for `related_records`, where an
empty list is a legitimate value meaning "clear all relationships").
"""

request_id = HiddenField()
selection = HiddenField()

update_dataset_category = BooleanField(_("update dataset category"))
dataset_category_id = SelectField(_("Dataset category"))

update_workflow_stage = BooleanField(_("update workflow stage"))
workflow_stage_id = SelectField(_("Workflow stage"))

update_bounding_box = BooleanField(_("update bounding box"))
bounding_box = FormField(BoundingBoxForm)

update_temporal_extent = BooleanField(_("update temporal extent"))
temporal_extent_begin = OptionalDateField()
temporal_extent_end = OptionalDateField()

update_related_records = BooleanField(_("update related records"))
related_records = FieldList(
FormField(RelatedRecordForm),
label=_("related records"),
min_entries=0,
max_entries=constants.SURVEY_RELATED_RECORD_MAX_RELATED,
)

def has_validation_errors(self) -> bool:
# see the equivalent workaround in _SurveyRelatedRecordForm
all_form_validation_errors = {**self.errors}
for related_record in self.related_records.entries:
all_form_validation_errors.update(**related_record.errors)
return bool(all_form_validation_errors)

@staticmethod
def parse_related_record_compound_name(name: str) -> str:
return name.rpartition(" - ")[-1]

def validate_with_schema(self) -> None:
kwargs = {}
if self.update_dataset_category.data:
kwargs["dataset_category_id"] = self.dataset_category_id.data
if self.update_workflow_stage.data:
kwargs["workflow_stage_id"] = self.workflow_stage_id.data
if self.update_bounding_box.data:
kwargs["bbox_4326"] = (
f"POLYGON(("
f"{self.bounding_box.min_lon.data} {self.bounding_box.min_lat.data}, "
f"{self.bounding_box.max_lon.data} {self.bounding_box.min_lat.data}, "
f"{self.bounding_box.max_lon.data} {self.bounding_box.max_lat.data}, "
f"{self.bounding_box.min_lon.data} {self.bounding_box.max_lat.data}, "
f"{self.bounding_box.min_lon.data} {self.bounding_box.min_lat.data}"
f"))"
)
if self.update_temporal_extent.data:
kwargs["temporal_extent_begin"] = self.temporal_extent_begin.data or None
kwargs["temporal_extent_end"] = self.temporal_extent_end.data or None
if self.update_related_records.data:
related_records = []
for relationship_sub_form in self.related_records.entries:
related_record_id = self.parse_related_record_compound_name(
relationship_sub_form.related_record.data
)
related_records.append(
{
"related_record_id": related_record_id,
"relationship": {
k: v
for k, v in relationship_sub_form.relationship.data.items()
if v
},
}
)
kwargs["related_records"] = related_records

self.built_bulk_update = None
if not kwargs:
# this error is attached to a visible field (rather than a hidden
# one) so it actually gets rendered - see render_form_field's
# special-casing of HiddenField, which never shows errors
self.update_dataset_category.errors.append(
_("Select at least one field to bulk-update")
)
return

try:
# stored on the instance so the caller can use the already-built
# schema object directly, rather than re-deriving it from form
# fields a second time after validation succeeds
self.built_bulk_update = record_schemas.SurveyRelatedRecordBulkUpdate(
**kwargs
)
except pydantic.ValidationError as exc:
logger.error(f"pydantic errors {exc.errors()=}")
incorporate_schema_validation_errors_into_form(exc.errors(), self)

@classmethod
async def from_request(cls, request, data: dict | None = None):
"""Creates a form instance from the request.

This method's main reason for existing is to ensure select fields are
populated dynamically, with choices from the database.
"""
if data is not None:
form_instance = cls(request, data=data)
else:
form_instance = await cls.from_formdata(request)
current_language = request.state.language
async with request.state.settings.get_db_session_maker()() as session:
form_instance.dataset_category_id.choices = [
(dc.id, dc.name.get(current_language, dc.name["en"]))
for dc in await category_queries.collect_all_dataset_categories(
session, order_by=current_language
)
]
form_instance.workflow_stage_id.choices = [
(ws.id, ws.name.get(current_language, ws.name["en"]))
for ws in await stage_queries.collect_all_workflow_stages(
session,
order_by=current_language,
)
]
return form_instance

@classmethod
async def get_validated_form_instance(cls, request: Request):
"""Performs full validation of the bulk-update form.

Unlike `_SurveyRelatedRecordForm.get_validated_form_instance`, there
is no English-name uniqueness check to perform, since `name` is not
a bulk-editable field.
"""
form_instance = await cls.from_request(request)
await form_instance.validate_on_submit()
form_instance.validate_with_schema()
return form_instance
Loading
Loading