From ab7a639707f53b4eee427b8d9ef209e0759adc40 Mon Sep 17 00:00:00 2001 From: steve <82342664+stevegerrits@users.noreply.github.com> Date: Tue, 17 Dec 2024 18:31:15 +0100 Subject: [PATCH] dec release 2 (#275) --- .../ObservationDetailsComponent.vue | 54 ++--- .../observations/tasks/observation_mapper.py | 35 ++- .../observations/tasks/observation_sync.py | 2 +- vespadb/observations/views.py | 214 ++++++++++-------- 4 files changed, 169 insertions(+), 136 deletions(-) diff --git a/src/components/ObservationDetailsComponent.vue b/src/components/ObservationDetailsComponent.vue index 8321eb1..f860cb5 100644 --- a/src/components/ObservationDetailsComponent.vue +++ b/src/components/ObservationDetailsComponent.vue @@ -205,53 +205,42 @@ Volgende -
- +

+ {{ selectedObservation.nest_type ? nestTypeEnum[selectedObservation.nest_type] : + 'Geen' }} +

- +

+ {{ selectedObservation.nest_location ? + nestLocationEnum[selectedObservation.nest_location] : + 'Geen' }} +

- +

+ {{ selectedObservation.nest_size ? nestSizeEnum[selectedObservation.nest_size] : + 'Geen' }} +

- +

+ {{ selectedObservation.nest_height ? + nestHeightEnum[selectedObservation.nest_height] : + 'Geen' }} +

@@ -322,7 +311,8 @@
-

{{ selectedObservation.observer_phone_number }}

+

{{ selectedObservation.observer_phone_number }} +

@@ -498,10 +488,6 @@ export default { }; const editableFields = [ - "nest_height", - "nest_size", - "nest_location", - "nest_type", "observation_datetime", "eradication_date", "admin_notes", diff --git a/vespadb/observations/tasks/observation_mapper.py b/vespadb/observations/tasks/observation_mapper.py index d815c6f..6121c04 100644 --- a/vespadb/observations/tasks/observation_mapper.py +++ b/vespadb/observations/tasks/observation_mapper.py @@ -2,7 +2,6 @@ import logging from datetime import datetime -from difflib import get_close_matches from typing import Any, cast import pytz @@ -28,8 +27,10 @@ mapping_dict: dict[int, dict[str, str]] = { 329: { + "hoger_dan_4_meter": "hoger_dan_4_meter", "Hoger dan 4 meter": "hoger_dan_4_meter", "Higher than 4 meters": "hoger_dan_4_meter", + "lager_dan_4_meter": "lager_dan_4_meter", "Lager dan 4 meter": "lager_dan_4_meter", "Lower than 4 meters": "lager_dan_4_meter", }, @@ -39,7 +40,7 @@ "Larger than 25cm": "groter_dan_25_cm", "Smaller than 25cm": "kleiner_dan_25_cm", }, - 331 : { + 331: { "Binnen, in gebouw of constructie": "binnen_in_gebouw_of_constructie", "Buiten, maar overdekt door constructie": "buiten_maar_overdekt_door_constructie", "Buiten, natuurlijk overdekt": "buiten_natuurlijk_overdekt", @@ -50,7 +51,28 @@ "Outside, natural cover": "buiten_natuurlijk_overdekt", "Outside, uncovered in a tree or bush": "buiten_onbedekt_in_boom_of_struik", "Outside, uncovered on building": "buiten_onbedekt_op_gebouw", - } + }, + 368: { + "Actief embryonaal nest": "actief_embryonaal_nest", + "Actief embryonaal nest (van maart tot eind juni, nest met enkel koningin)": "actief_embryonaal_nest", + "Actief primair nest": "actief_primair_nest", + "Actief primair nest (van juni tot eind november, nest met werksters op lage hoogte (tot 6 meter))": "actief_primair_nest", + "Actief secundair nest": "actief_secundair_nest", + "Actief secundair nest (van augustus tot eind november, nest met werksters op grote hoogte (tot 30m))": "actief_secundair_nest", + "Active embryonic nest": "actief_embryonaal_nest", + "Active embryonic nest (from march to the end of june, nest with queen only)": "actief_embryonaal_nest", + "Active primary nest": "actief_primair_nest", + "Active primary nest (from june to the end of november, nest with workers at low altitude (up to 6m))": "actief_primair_nest", + "Active secondary nest": "actief_secundair_nest", + "Active secondary nest (from aug. to the end of nov., nest with workers at high altitude (up to 30m))": "actief_secundair_nest", + "Inactief/leeg nest (typisch tijdens wintermaanden, een leeg nest hoog in een boom)": "inactief_leeg_nest", + "Inactief/leeg nest (typisch tijdens wintermaanden, een leeg netst oog in een boom)": "inactief_leeg_nest", + "Inactive/empty nest": "inactief_leeg_nest", + "Inactive/empty nest (typically during the winter months, an empty nest high in a tree)": "inactief_leeg_nest", + "Potential nest": None, + "Potentieel nest": None, + "Potentieel nest (onzeker van de soort)": None, + }, } ENUMS_MAPPING: dict[str, type[TextChoices]] = { @@ -67,6 +89,7 @@ 329: "nest_height", 330: "nest_size", 331: "nest_location", + 368: "nest_type", } # Literal mapping functions def map_nest_height_attribute_to_enum(value: str) -> Any | None: @@ -81,6 +104,10 @@ def map_nest_location_attribute_to_enum(value: str) -> str | None: """Maps Nest location values to enums based on literal mapping.""" return mapping_dict[331].get(value.strip()) +def map_nest_type_attribute_to_enum(value: str) -> str | None: + """Maps Nest location values to enums based on literal mapping.""" + return mapping_dict[368].get(value.strip()) + def map_attribute_to_enum(attribute_id: int, value: str) -> str | None: """ Maps a single attribute value to an enum using literal mapping functions. @@ -91,6 +118,8 @@ def map_attribute_to_enum(attribute_id: int, value: str) -> str | None: return map_nest_size_attribute_to_enum(value) elif attribute_id == 331: return map_nest_location_attribute_to_enum(value) + elif attribute_id == 368: + return map_nest_type_attribute_to_enum(value) else: return None diff --git a/vespadb/observations/tasks/observation_sync.py b/vespadb/observations/tasks/observation_sync.py index e234ea2..332e632 100644 --- a/vespadb/observations/tasks/observation_sync.py +++ b/vespadb/observations/tasks/observation_sync.py @@ -325,4 +325,4 @@ def fetch_and_update_observations(self: Task, since_week: int | None = None, dat logger.info("Finished processing observations") manage_observations_visibility(token) - logger.info("Finished managing observations visibility") + logger.info("Finished managing observations visibility") \ No newline at end of file diff --git a/vespadb/observations/views.py b/vespadb/observations/views.py index 41b95d6..7246067 100644 --- a/vespadb/observations/views.py +++ b/vespadb/observations/views.py @@ -9,6 +9,9 @@ import csv import json from typing import TYPE_CHECKING, Any, Generator, Any, Union +from django.http import FileResponse +import os +import tempfile from django.contrib.gis.db.models.functions import Transform from django.contrib.gis.geos import GEOSGeometry @@ -62,13 +65,14 @@ BBOX_LENGTH = 4 GEOJSON_REDIS_CACHE_EXPIRATION = 900 # 15 minutes GET_REDIS_CACHE_EXPIRATION = 86400 # 1 day -BATCH_SIZE = 150 CSV_HEADERS = [ "id", "created_datetime", "modified_datetime", "latitude", "longitude", "source", "source_id", "nest_height", "nest_size", "nest_location", "nest_type", "observation_datetime", "province", "eradication_date", "municipality", "images", "anb_domain", "notes", "eradication_result", "wn_id", "wn_validation_status", "nest_status" ] +BATCH_SIZE = 1000 + class ObservationsViewSet(ModelViewSet): # noqa: PLR0904 """ViewSet for the Observation model.""" @@ -642,127 +646,141 @@ def save_observations(self, valid_data: list[dict[str, Any]]) -> Response: ) @method_decorator(ratelimit(key="ip", rate="60/m", method="GET", block=True)) @action(detail=False, methods=["get"], permission_classes=[AllowAny]) - def export(self, request: HttpRequest) -> Union[StreamingHttpResponse, JsonResponse]: + def export(self, request: HttpRequest) -> FileResponse: """ - Export observations as CSV with dynamically controlled fields based on user permissions. - - Observations from municipalities the user has access to will display full details; - others will show limited fields as per public access. + Export observations as CSV using batch processing. """ - if request.query_params.get("export_format", "csv").lower() != "csv": - return JsonResponse({"error": "Only CSV export is supported"}, status=400) - - # Determine user permissions - if request.user.is_authenticated: - user_municipality_ids = set(request.user.municipalities.values_list("id", flat=True)) - is_admin = request.user.is_superuser - else: - user_municipality_ids = set() - is_admin = False - - # Set CSV headers directly from CSV_HEADERS as a base - dynamic_csv_headers = CSV_HEADERS - - # Prepare response - queryset = self.filter_queryset(self.get_queryset()) - response = StreamingHttpResponse( - self.generate_csv_rows(queryset, dynamic_csv_headers, user_municipality_ids, is_admin), - content_type="text/csv" - ) - response["Content-Disposition"] = 'attachment; filename="observations_export.csv"' - return response - - def generate_csv_rows( - self, queryset: QuerySet, headers: list[str], user_municipality_ids: set, is_admin: bool - ) -> Generator[bytes, None, None]: - """Generate CSV rows with headers and filtered data according to user permissions.""" - # Yield headers - yield self._csv_line(headers) - - for observation in queryset.iterator(chunk_size=500): - # Determine fields to include based on user permissions for each observation - if is_admin or (observation.municipality_id in user_municipality_ids): - # Full access for admins and assigned municipalities - allowed_fields = user_read_fields + try: + # Validate export format + if request.query_params.get("export_format", "csv").lower() != "csv": + return JsonResponse({"error": "Only CSV export is supported"}, status=400) + + # Get user permissions + if request.user.is_authenticated: + user_municipality_ids = set(request.user.municipalities.values_list("id", flat=True)) + is_admin = request.user.is_superuser else: - # Restricted access for other municipalities - allowed_fields = public_read_fields - - # Add essential fields for export - allowed_fields.extend(["source_id", "latitude", "longitude", "anb_domain", "nest_status"]) + user_municipality_ids = set() + is_admin = False - # Serialize the observation with restricted fields as needed - row = self.serialize_observation(observation, headers, allowed_fields) - yield self._csv_line(row) + # Get filtered queryset + queryset = self.filter_queryset(self.get_queryset()) - def parse_location(self, srid_str: str) -> tuple[float, float]: + # Create temporary file + with tempfile.NamedTemporaryFile(mode='w+', newline='', delete=False, suffix='.csv') as temp_file: + writer = csv.writer(temp_file) + + # Write headers + writer.writerow(CSV_HEADERS) + + # Process in batches + total_processed = 0 + while True: + # Get batch of observations + batch = queryset[total_processed:total_processed + BATCH_SIZE] + if not batch: + break + + # Process each observation in the batch + for observation in batch: + row_data = self._prepare_row_data( + observation, + is_admin, + user_municipality_ids + ) + writer.writerow(row_data) + + total_processed += len(batch) + logger.info(f"Processed {total_processed} observations") + + # Create response with the temporary file + response = FileResponse( + open(temp_file.name, 'rb'), + content_type='text/csv', + as_attachment=True, + filename=f"observations_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + ) + + # Delete the temporary file after it's sent + response.close = lambda: os.unlink(temp_file.name) + + return response + + except Exception as e: + logger.exception("Export failed") + return JsonResponse( + {"error": "Export failed. Please try again or contact support."}, + status=500 + ) + def _prepare_row_data( + self, + observation: Observation, + is_admin: bool, + user_municipality_ids: set[str] + ) -> list[str]: """ - Parse SRID string to extract latitude and longitude. + Prepare a single row of data for the CSV export. """ - # Convert the SRID location string to GEOSGeometry - geom = GEOSGeometry(srid_str) + # Determine allowed fields based on permissions + if is_admin or (observation.municipality_id in user_municipality_ids): + allowed_fields = user_read_fields + else: + allowed_fields = public_read_fields + + # Add essential fields + allowed_fields.extend(["source_id", "latitude", "longitude", "anb_domain", "nest_status"]) - # Extract latitude and longitude - longitude = geom.x - latitude = geom.y - return latitude, longitude - - def serialize_observation(self, obj: Observation, headers: list[str], allowed_fields: list[str]) -> list[str]: - """Serialize an observation for CSV export with specified fields.""" - data = [] - for field in headers: + row_data = [] + for field in CSV_HEADERS: if field not in allowed_fields: - data.append("") # Add empty string for restricted fields + row_data.append("") continue - # Handle custom formatting for certain fields - if field == "latitude" or field == "longitude": - if obj.location: - srid_location_str = f"SRID=4326;POINT ({obj.location.x} {obj.location.y})" - latitude, longitude = self.parse_location(srid_location_str) - logger.info('Latitude: %s, Longitude: %s', latitude, longitude) - if field == "latitude": - data.append(str(latitude)) - elif field == "longitude": - data.append(str(longitude)) - else: - data.append("") + # Handle special fields + if field == "latitude": + row_data.append(str(observation.location.y) if observation.location else "") + elif field == "longitude": + row_data.append(str(observation.location.x) if observation.location else "") elif field in ["created_datetime", "modified_datetime", "observation_datetime"]: - datetime_val = getattr(obj, field, None) + datetime_val = getattr(observation, field, None) if datetime_val: - # Remove milliseconds and ensure ISO format with 'Z' datetime_val = datetime_val.replace(microsecond=0) - # Convert to ISO format and replace +00:00 with Z if present - iso_datetime = datetime_val.isoformat() - if iso_datetime.endswith('+00:00'): - iso_datetime = iso_datetime[:-6] + 'Z' - elif not iso_datetime.endswith('Z'): - iso_datetime += 'Z' - data.append(iso_datetime) + row_data.append(datetime_val.isoformat() + "Z") else: - data.append("") + row_data.append("") elif field == "province": - data.append(obj.province.name if obj.province else "") + row_data.append(observation.province.name if observation.province else "") elif field == "municipality": - data.append(obj.municipality.name if obj.municipality else "") + row_data.append(observation.municipality.name if observation.municipality else "") elif field == "anb_domain": - data.append(str(obj.anb)) - elif field == "eradication_result": - data.append(obj.eradication_result if obj.eradication_result else "") + row_data.append(str(observation.anb)) elif field == "nest_status": - logger.info("Getting status for observation %s", obj.eradication_result) - data.append(self.get_status(obj)) + row_data.append(self.get_status(observation)) elif field == "source_id": - data.append(str(obj.source_id) if obj.source_id is not None else "") + row_data.append(str(observation.source_id) if observation.source_id is not None else "") else: - value = getattr(obj, field, "") - data.append(str(value) if value is not None else "") - return data + value = getattr(observation, field, "") + row_data.append(str(value) if value is not None else "") + + return row_data + + + def parse_location(self, srid_str: str) -> tuple[float, float]: + """ + Parse SRID string to extract latitude and longitude. + """ + # Convert the SRID location string to GEOSGeometry + geom = GEOSGeometry(srid_str) + + # Extract latitude and longitude + longitude = geom.x + latitude = geom.y + return latitude, longitude def get_status(self, observation: Observation) -> str: """Determine observation status based on eradication data.""" - logger.info("Getting status for observation %s", observation.eradication_result) - if observation.eradication_result == EradicationResultEnum.SUCCESSFUL: + logger.debug("Getting status for observation %s", observation.eradication_result) + if observation.eradication_result: return "eradicated" if observation.reserved_by: return "reserved"