diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 07513b0..7fe2012 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/docker/compose.ci.yaml b/docker/compose.ci.yaml index 31787dc..3596f95 100644 --- a/docker/compose.ci.yaml +++ b/docker/compose.ci.yaml @@ -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: diff --git a/src/seis_lab_data/db/commands/surveyrelatedrecords.py b/src/seis_lab_data/db/commands/surveyrelatedrecords.py index fafe256..6142cfa 100644 --- a/src/seis_lab_data/db/commands/surveyrelatedrecords.py +++ b/src/seis_lab_data/db/commands/surveyrelatedrecords.py @@ -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: @@ -219,6 +220,7 @@ 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, @@ -226,6 +228,7 @@ async def bulk_update_filtered_records( 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, diff --git a/src/seis_lab_data/db/queries/surveyrelatedrecords.py b/src/seis_lab_data/db/queries/surveyrelatedrecords.py index 81cab83..249ac0e 100644 --- a/src/seis_lab_data/db/queries/surveyrelatedrecords.py +++ b/src/seis_lab_data/db/queries/surveyrelatedrecords.py @@ -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, diff --git a/src/seis_lab_data/operations/surveyrelatedrecords.py b/src/seis_lab_data/operations/surveyrelatedrecords.py index 3e39fe8..0e473b6 100644 --- a/src/seis_lab_data/operations/surveyrelatedrecords.py +++ b/src/seis_lab_data/operations/surveyrelatedrecords.py @@ -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, @@ -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 @@ -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( @@ -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, diff --git a/src/seis_lab_data/schemas/surveyrelatedrecords.py b/src/seis_lab_data/schemas/surveyrelatedrecords.py index da4c653..ede8b09 100644 --- a/src/seis_lab_data/schemas/surveyrelatedrecords.py +++ b/src/seis_lab_data/schemas/surveyrelatedrecords.py @@ -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] = [] @@ -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 @@ -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) @@ -196,6 +201,12 @@ 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 + ), ) @@ -203,8 +214,8 @@ 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] diff --git a/src/seis_lab_data/schemas/webui.py b/src/seis_lab_data/schemas/webui.py index b76da55..335ea74 100644 --- a/src/seis_lab_data/schemas/webui.py +++ b/src/seis_lab_data/schemas/webui.py @@ -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( diff --git a/src/seis_lab_data/webapp/forms/__init__.py b/src/seis_lab_data/webapp/forms/__init__.py index 799dc3c..38c7547 100644 --- a/src/seis_lab_data/webapp/forms/__init__.py +++ b/src/seis_lab_data/webapp/forms/__init__.py @@ -8,6 +8,7 @@ SurveyMissionUpdateForm, ) from .surveyrelatedrecords import ( + SurveyRelatedRecordBulkUpdateForm, SurveyRelatedRecordCreateForm, SurveyRelatedRecordUpdateForm, ) @@ -18,6 +19,7 @@ ProjectUpdateForm, SurveyMissionCreateForm, SurveyMissionUpdateForm, + SurveyRelatedRecordBulkUpdateForm, SurveyRelatedRecordCreateForm, SurveyRelatedRecordUpdateForm, ] diff --git a/src/seis_lab_data/webapp/forms/surveyrelatedrecords.py b/src/seis_lab_data/webapp/forms/surveyrelatedrecords.py index 58b095a..07cc58a 100644 --- a/src/seis_lab_data/webapp/forms/surveyrelatedrecords.py +++ b/src/seis_lab_data/webapp/forms/surveyrelatedrecords.py @@ -7,6 +7,7 @@ from starlette_babel import gettext_lazy as _ from starlette_wtf import StarletteForm from wtforms import ( + BooleanField, FieldList, Form, FormField, @@ -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 diff --git a/src/seis_lab_data/webapp/routes/surveymissions.py b/src/seis_lab_data/webapp/routes/surveymissions.py index b7e32fa..d2850c5 100644 --- a/src/seis_lab_data/webapp/routes/surveymissions.py +++ b/src/seis_lab_data/webapp/routes/surveymissions.py @@ -24,6 +24,7 @@ geojson, subscribers, ) +from ...db.queries import surveyrelatedrecords as record_queries from ...operations import ( projects as project_ops, surveymissions as survey_mission_ops, @@ -151,6 +152,11 @@ async def _get_survey_mission_details( ) if user else False, + can_bulk_update=( + record_permissions.can_bulk_update_survey_related_records(user) + if user + else False + ), ), breadcrumbs=[ webui_schemas.BreadcrumbItem( @@ -376,6 +382,16 @@ async def get_mission_records_list_component(request: Request): serialized_items = [ webui_schemas.SurveyRelatedRecordReadListItem.from_db_instance(i) for i in items ] + bulk_update_base_url = ( + str( + request.url_for( + "survey_missions:get_bulk_update_form", + survey_mission_id=survey_mission_id, + ) + ) + if user and record_permissions.can_bulk_update_survey_related_records(user) + else None + ) template_processor = request.state.templates template = template_processor.get_template( "survey-related-records/list-component.html" @@ -385,6 +401,7 @@ async def get_mission_records_list_component(request: Request): items=serialized_items, update_current_url_with=filter_query_string, pagination=pagination_info, + bulk_update_base_url=bulk_update_base_url, ) async def event_streamer(): @@ -1103,6 +1120,304 @@ async def event_streamer(): return DatastarResponse(event_streamer()) +def _parse_bulk_update_selection( + raw_selection: str | None, + current_language: str, + survey_mission_id: identifiers.SurveyMissionId, +) -> record_schemas.SurveyRelatedRecordBulkUpdateSelection: + """Resolve the `selection` query param into a bulk-update selection. + + The param carries the same JSON-blob-of-signals shape Datastar's `@get` + auto-appends elsewhere in this app (see `get_mission_records_list_component`), + just arriving under a different name via a plain link instead - so the + same filter-parsing machinery applies. + """ + params = json.loads(raw_selection) if raw_selection else {} + list_filters = filters.SurveyRelatedRecordListFilters.from_params( + params, current_language + ) + valid_selection_fields = set( + record_schemas.SurveyRelatedRecordBulkUpdateSelection.model_fields + ) + filter_kwargs = { + k: v + for k, v in list_filters.as_kwargs().items() + if k in valid_selection_fields and k != "survey_mission_id" + } + if params.get("selectAllMatching"): + excluded_ids = [ + identifiers.SurveyRelatedRecordId(uuid.UUID(k)) + for k, v in (params.get("excludedIds") or {}).items() + if v + ] + return record_schemas.SurveyRelatedRecordBulkUpdateSelection( + survey_mission_id=survey_mission_id, + excluded_record_ids=excluded_ids or None, + **filter_kwargs, + ) + selected_ids = [ + identifiers.SurveyRelatedRecordId(uuid.UUID(k)) + for k, v in (params.get("selectedIds") or {}).items() + if v + ] + return record_schemas.SurveyRelatedRecordBulkUpdateSelection( + survey_mission_id=survey_mission_id, + selected=selected_ids, + ) + + +async def _count_bulk_update_matches( + session, user, selection: record_schemas.SurveyRelatedRecordBulkUpdateSelection +) -> int: + is_admin = not {constants.ROLE_ADMIN, constants.ROLE_SYSTEM_ADMIN}.isdisjoint( + user.roles + ) + kwargs = dict( + survey_mission_id=selection.survey_mission_id, + en_name_filter=selection.en_name_filter, + pt_name_filter=selection.pt_name_filter, + spatial_intersect=selection.spatial_intersect, + temporal_extent=selection.temporal_extent, + asset_path_fragment_filter=selection.asset_path_fragment_filter, + record_ids=selection.selected, + excluded_record_ids=selection.excluded_record_ids, + ) + if is_admin: + statement = record_queries.build_survey_related_record_id_statement(**kwargs) + else: + statement = record_queries.build_owned_survey_related_record_id_statement( + user.id, **kwargs + ) + return await record_queries.count_survey_related_records_matching( + session, statement + ) + + +async def _get_initial_related_records(session, user) -> list[tuple[str, str]]: + items, _ = await survey_related_record_ops.list_survey_related_records( + session, initiator=user + ) + return [(i.id, i.name["en"]) for i in items] + + +@requires_auth +async def get_bulk_update_form(request: Request): + """Render the bulk-update form for a survey mission's records. + + Reached via a plain link (not a Datastar action) whose href is computed + client-side from the current selection/filter signals - see + `list-component.html`. Everything needed is already in the query string, + so this is a single ordinary GET, no redirect hop involved. + """ + survey_mission_id = get_id_from_request_path( + request, "survey_mission_id", identifiers.SurveyMissionId + ) + user = request.user + selection = _parse_bulk_update_selection( + request.query_params.get("selection"), + request.state.language, + survey_mission_id, + ) + async with request.state.settings.get_db_session_maker()() as session: + survey_mission = await survey_mission_ops.get_survey_mission( + survey_mission_id, user, session + ) + if survey_mission is None: + raise HTTPException( + status_code=404, + detail=f"Survey mission {survey_mission_id!r} not found.", + ) + matched_count = await _count_bulk_update_matches(session, user, selection) + initial_related_records = await _get_initial_related_records(session, user) + + form_instance = await forms.SurveyRelatedRecordBulkUpdateForm.from_request( + request, data={} + ) + form_instance.request_id.data = uuid.uuid4() + form_instance.selection.data = selection.model_dump_json() + + template_processor: Jinja2Templates = request.state.templates + return template_processor.TemplateResponse( + request, + "survey-missions/bulk-update-form-page.html", + context={ + "survey_mission": survey_mission, + "survey_mission_id": survey_mission_id, + "form": form_instance, + "matched_count": matched_count, + "initial_related_records": initial_related_records, + }, + ) + + +@csrf_protect +@requires_auth +async def post_bulk_update(request: Request): + survey_mission_id = get_id_from_request_path( + request, "survey_mission_id", identifiers.SurveyMissionId + ) + user = request.user + form_instance = ( + await forms.SurveyRelatedRecordBulkUpdateForm.get_validated_form_instance( + request + ) + ) + + if form_instance.has_validation_errors(): + selection = ( + record_schemas.SurveyRelatedRecordBulkUpdateSelection.model_validate_json( + form_instance.selection.data + ) + ) + async with request.state.settings.get_db_session_maker()() as session: + matched_count = await _count_bulk_update_matches(session, user, selection) + initial_related_records = await _get_initial_related_records(session, user) + template_processor: Jinja2Templates = request.state.templates + template = template_processor.get_template( + "survey-missions/bulk-update-form.html" + ) + rendered = template.render( + request=request, + form=form_instance, + survey_mission_id=survey_mission_id, + matched_count=matched_count, + initial_related_records=initial_related_records, + ) + + async def stream_validation_failed_events(): + yield ServerSentEventGenerator.patch_elements( + rendered, + selector=webui_schemas.selector_info.main_content_selector, + mode=ElementPatchMode.INNER, + ) + yield ServerSentEventGenerator.execute_script( + "document.querySelector('.is-invalid')?.scrollIntoView({behavior: 'smooth', block: 'center'})" + ) + + # Datastar only processes SSE streams from 2xx responses; non-2xx are treated as errors + return DatastarResponse(stream_validation_failed_events(), status_code=200) + + selection = ( + record_schemas.SurveyRelatedRecordBulkUpdateSelection.model_validate_json( + form_instance.selection.data + ) + ) + record_tasks.bulk_update_survey_related_records.send( + raw_request_id=str(form_instance.request_id.data), + raw_to_update=form_instance.built_bulk_update.model_dump_json( + exclude_unset=True + ), + raw_selection=selection.model_dump_json(exclude_unset=True), + raw_initiator=json.dumps(dataclasses.asdict(user)), + ) + return Response(status_code=200) + + +@requires_auth +async def stream_to_bulk_update_page(request: Request): + survey_mission_id = get_id_from_request_path( + request, "survey_mission_id", identifiers.SurveyMissionId + ) + try: + request_id = identifiers.RequestId(uuid.UUID(request.path_params["request_id"])) + except ValueError as err: + raise HTTPException(status_code=400, detail="Invalid request id") from err + + subscription = subscribers.subscribe_to_topic( + request.state.redis_client, + [constants.NEW_TOPIC_SURVEY_RELATED_RECORDS], + subscribers.HandlerContext( + resource_id=str(survey_mission_id), + request_id=request_id, + user=request.user, + url_resolver=request.url_for, + ), + { + "bulk_resource_modified": common_handlers.handle_bulk_resource_modification, + }, + ) + + async def event_streamer(): + async for sse_event in subscription: + yield sse_event + + return DatastarResponse(event_streamer()) + + +@csrf_protect +async def add_bulk_update_form_related_to_record(request: Request): + survey_mission_id = get_id_from_request_path( + request, "survey_mission_id", identifiers.SurveyMissionId + ) + form_instance = await forms.SurveyRelatedRecordBulkUpdateForm.from_request(request) + if len(form_instance.related_records.entries) < ( + constants.SURVEY_RELATED_RECORD_MAX_RELATED + ): + form_instance.related_records.append_entry() + user = request.user if request.user.is_authenticated else None + selection = ( + record_schemas.SurveyRelatedRecordBulkUpdateSelection.model_validate_json( + form_instance.selection.data + ) + ) + async with request.state.settings.get_db_session_maker()() as session: + initial_related_records = await _get_initial_related_records(session, user) + matched_count = await _count_bulk_update_matches(session, user, selection) + template_processor: Jinja2Templates = request.state.templates + template = template_processor.get_template("survey-missions/bulk-update-form.html") + rendered = template.render( + request=request, + form=form_instance, + survey_mission_id=survey_mission_id, + matched_count=matched_count, + initial_related_records=initial_related_records, + ) + + async def event_streamer(): + yield ServerSentEventGenerator.patch_elements( + rendered, + selector=webui_schemas.selector_info.main_content_selector, + mode=ElementPatchMode.INNER, + ) + + return DatastarResponse(event_streamer()) + + +@csrf_protect +async def remove_bulk_update_form_related_to_record(request: Request): + survey_mission_id = get_id_from_request_path( + request, "survey_mission_id", identifiers.SurveyMissionId + ) + form_instance = await forms.SurveyRelatedRecordBulkUpdateForm.from_request(request) + index = int(request.query_params.get("index", 0)) + form_instance.related_records.entries.pop(index) + user = request.user if request.user.is_authenticated else None + selection = ( + record_schemas.SurveyRelatedRecordBulkUpdateSelection.model_validate_json( + form_instance.selection.data + ) + ) + async with request.state.settings.get_db_session_maker()() as session: + matched_count = await _count_bulk_update_matches(session, user, selection) + template_processor: Jinja2Templates = request.state.templates + template = template_processor.get_template("survey-missions/bulk-update-form.html") + rendered = template.render( + request=request, + form=form_instance, + survey_mission_id=survey_mission_id, + matched_count=matched_count, + ) + + async def event_streamer(): + yield ServerSentEventGenerator.patch_elements( + rendered, + selector=webui_schemas.selector_info.main_content_selector, + mode=ElementPatchMode.INNER, + ) + + return DatastarResponse(event_streamer()) + + routes = [ Route( "/", @@ -1194,6 +1509,36 @@ async def event_streamer(): methods=["POST"], name="trigger_discovery", ), + Route( + "/{survey_mission_id}/records/bulk-update", + get_bulk_update_form, + methods=["GET"], + name="get_bulk_update_form", + ), + Route( + "/{survey_mission_id}/records/bulk-update", + post_bulk_update, + methods=["POST"], + name="post_bulk_update", + ), + Route( + "/{survey_mission_id}/records/bulk-update/stream/{request_id}", + stream_to_bulk_update_page, + methods=["GET"], + name="bulk_update_stream", + ), + Route( + "/{survey_mission_id}/records/bulk-update/add-related-record-form", + add_bulk_update_form_related_to_record, + methods=["POST"], + name="add_bulk_update_form_related_to_record", + ), + Route( + "/{survey_mission_id}/records/bulk-update/remove-related-record-form", + remove_bulk_update_form_related_to_record, + methods=["POST"], + name="remove_bulk_update_form_related_to_record", + ), Route( "/{survey_mission_id}", SurveyMissionDetailEndpoint, diff --git a/src/seis_lab_data/webapp/streamhandlers/common.py b/src/seis_lab_data/webapp/streamhandlers/common.py index c0fe371..c2d005d 100644 --- a/src/seis_lab_data/webapp/streamhandlers/common.py +++ b/src/seis_lab_data/webapp/streamhandlers/common.py @@ -153,6 +153,41 @@ async def handle_resource_modification_list_page( ) +async def handle_bulk_resource_modification( + message: message_schemas.SldPubSubMessage, + context: subscribers.HandlerContext, + done: asyncio.Event | None = None, +) -> AsyncGenerator[DatastarEvent, None]: + """Flash the outcome of a bulk update and return to the mission detail page. + + There's no single `resource_id` to match on for a bulk action, so this + matches on `request_id` instead - `context.resource_id` is repurposed to + carry the survey mission to redirect back to. + """ + if message.request_id != context.request_id: + return + + if message.succeeded: + notification = webui_schemas.Notification( + message=f"Bulk update succeeded: {message.affected_count} record(s) updated.", + category="success", + ) + else: + notification = webui_schemas.Notification( + message=f"Bulk update failed: {message.details}", + category="error", + ) + async for event in flash_ui_message_after_redirect(notification): + yield event + yield ServerSentEventGenerator.redirect( + str( + context.url_resolver( + "survey_missions:detail", survey_mission_id=context.resource_id + ) + ) + ) + + async def handle_resource_modification_edit_page( message: message_schemas.SldPubSubMessage, context: subscribers.HandlerContext, diff --git a/src/seis_lab_data/webapp/templates/macros/checkbox.html b/src/seis_lab_data/webapp/templates/macros/checkbox.html new file mode 100644 index 0000000..7e92f94 --- /dev/null +++ b/src/seis_lab_data/webapp/templates/macros/checkbox.html @@ -0,0 +1,30 @@ +{% macro render_checkbox(field, name_prefix="", test_id=None) %} + {% set form_element_classes = "form-check-input is-invalid" if field.errors else "form-check-input" %} + {% set form_element_described_by = name_prefix ~ field.name ~ "-help backend-validation-" ~ name_prefix ~ field.name ~ "-feedback" if field.errors else name_prefix ~ field.name ~ "-help" %} + {% set stable_test_id = test_id or ("field-" ~ name_prefix ~ field.name) %} +
{{ error }}
+ {% endfor %} +