From 6dc314d029126610273436bab3623ad1f2afebcf Mon Sep 17 00:00:00 2001 From: Steven Gerrits Date: Fri, 31 Jan 2025 00:18:20 +0100 Subject: [PATCH 1/6] rc/2025-01 --- entrypoint.sh | 10 +- .../ObservationDetailsComponent.vue | 22 +-- src/stores/vespaStore.js | 2 + vespadb/observations/admin.py | 1 - .../0034_remove_observation_species.py | 17 +++ vespadb/observations/models.py | 22 ++- vespadb/observations/serializers.py | 33 ++-- vespadb/observations/tasks/export_utils.py | 48 +++--- vespadb/observations/tasks/generate_export.py | 4 +- .../observations/tasks/observation_mapper.py | 3 +- vespadb/observations/views.py | 142 +++++++++++++++--- 11 files changed, 204 insertions(+), 100 deletions(-) create mode 100644 vespadb/observations/migrations/0034_remove_observation_species.py diff --git a/entrypoint.sh b/entrypoint.sh index 63db785..de284ff 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -33,15 +33,19 @@ echo "Load waarnemingen observation data via: python manage.py load_waarnemingen # Start Gunicorn echo "Starting Gunicorn..." -gunicorn --workers 3 \ +gunicorn --workers 4 \ --worker-class gthread \ --threads 4 \ --worker-connections 1000 \ - --timeout 1800 \ + --timeout 3600 \ --graceful-timeout 300 \ --keep-alive 65 \ - --max-requests 1000 \ + --max-requests 500 \ --max-requests-jitter 50 \ + --worker-tmp-dir /dev/shm \ + --log-level info \ + --access-logfile /workspaces/vespadb/logs/gunicorn-access.log \ + --error-logfile /workspaces/vespadb/logs/gunicorn-error.log \ --bind 0.0.0.0:8000 \ vespadb.wsgi:application & diff --git a/src/components/ObservationDetailsComponent.vue b/src/components/ObservationDetailsComponent.vue index f860cb5..c3049f2 100644 --- a/src/components/ObservationDetailsComponent.vue +++ b/src/components/ObservationDetailsComponent.vue @@ -209,8 +209,7 @@

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

@@ -218,9 +217,7 @@

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

@@ -228,8 +225,7 @@

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

@@ -237,9 +233,7 @@

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

@@ -259,8 +253,7 @@

- {{ validationStatusEnum[selectedObservation.wn_validation_status] || "Geen" - }} + {{ validationStatusEnum[selectedObservation.wn_validation_status] || "Geen" }}

@@ -277,8 +270,7 @@ - + @@ -476,7 +468,7 @@ export default { const eradicationAfterCareEnum = { "nest_volledig_verwijderd": "Nest volledig verwijderd", - "nest_gedeeltelijk verwijderd": "Nest gedeeltelijk verwijderd", + "nest_gedeeltelijk_verwijderd": "Nest gedeeltelijk verwijderd", "nest_laten_hangen": "Nest laten hangen" }; diff --git a/src/stores/vespaStore.js b/src/stores/vespaStore.js index a97be28..9615537 100644 --- a/src/stores/vespaStore.js +++ b/src/stores/vespaStore.js @@ -505,6 +505,8 @@ export const useVespaStore = defineStore('vespaStore', { return '#198754'; } else if (status === 'reserved') { return '#ea792a'; + } else if (status === 'untreatable') { + return '#198754'; } return '#212529'; }, diff --git a/vespadb/observations/admin.py b/vespadb/observations/admin.py index 02e75b0..59e71a0 100644 --- a/vespadb/observations/admin.py +++ b/vespadb/observations/admin.py @@ -157,7 +157,6 @@ class ObservationAdmin(gis_admin.GISModelAdmin): "created_by", "wn_modified_datetime", "wn_created_datetime", - "species", "wn_cluster_id", "modified_by", "modified_datetime", diff --git a/vespadb/observations/migrations/0034_remove_observation_species.py b/vespadb/observations/migrations/0034_remove_observation_species.py new file mode 100644 index 0000000..33e81e0 --- /dev/null +++ b/vespadb/observations/migrations/0034_remove_observation_species.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.4 on 2025-01-30 19:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0033_export'), + ] + + operations = [ + migrations.RemoveField( + model_name='observation', + name='species', + ), + ] diff --git a/vespadb/observations/models.py b/vespadb/observations/models.py index 55ab414..ba97446 100644 --- a/vespadb/observations/models.py +++ b/vespadb/observations/models.py @@ -233,7 +233,6 @@ class Observation(models.Model): help_text="Validation status of the observation", ) - species = models.IntegerField(help_text="Species of the observed nest", blank=True, null=True) nest_height = models.CharField( max_length=50, choices=NestHeightEnum.choices, blank=True, null=True, help_text="Height of the nest" ) @@ -374,19 +373,26 @@ def save(self, *args: Any, **kwargs: Any) -> None: :param args: Variable length argument list. :param kwargs: Arbitrary keyword arguments. """ - # Only compute the municipality if the location is set and the municipality is not - if self.location: - # Ensure self.location is a Point instance + logger.info(f"Save method called for observation {self.id if self.id else 'new'}") + + # Issue #290 - Automatically determine municipality, province and anb + if self.location and not (self.municipality and self.province and self.anb is not None): if not isinstance(self.location, Point): self.location = Point(self.location) long = self.location.x lat = self.location.y - self.anb = check_if_point_in_anb_area(long, lat) - municipality = get_municipality_from_coordinates(long, lat) - self.municipality = municipality - self.province = municipality.province if municipality else None + if self.anb is None: + self.anb = check_if_point_in_anb_area(long, lat) + + if not self.municipality: + municipality = get_municipality_from_coordinates(long, lat) + self.municipality = municipality + if municipality and not self.province: + self.province = municipality.province + + logger.info(f"Save method for observation {self.id if self.id else 'new'}: Setting municipality={self.municipality}, province={self.province}, anb={self.anb}") super().save(*args, **kwargs) diff --git a/vespadb/observations/serializers.py b/vespadb/observations/serializers.py index 71332aa..86d3235 100644 --- a/vespadb/observations/serializers.py +++ b/vespadb/observations/serializers.py @@ -7,7 +7,7 @@ from django.conf import settings from django.contrib.gis.geos import GEOSGeometry, Point -from django.core.exceptions import ValidationError +from django.core.exceptions import PermissionDenied, ValidationError from pytz import timezone from rest_framework import serializers from rest_framework.request import Request @@ -307,6 +307,17 @@ def validate_reserved_by(self, value: VespaUser) -> VespaUser: def update(self, instance: Observation, validated_data: dict[Any, Any]) -> Observation: # noqa: C901 """Update method to handle observation reservations.""" user = self.context["request"].user + + # Check if someone is trying to update a nest reserved by another user + if instance.reserved_by and instance.reserved_by != user and not user.is_superuser: + raise serializers.ValidationError("You cannot edit an observation reserved by another user.") + + # Only proceed if user has appropriate permissions + if not user.is_superuser: + user_municipality_ids = user.municipalities.values_list("id", flat=True) + if instance.municipality and instance.municipality.id not in user_municipality_ids: + raise PermissionDenied("You do not have permission to update nests in this municipality.") + allowed_admin_fields = [ "location", "nest_height", @@ -377,9 +388,6 @@ def update(self, instance: Observation, validated_data: dict[Any, Any]) -> Obser raise serializers.ValidationError(f"Field(s) {field}' can not be updated by non-admin users.") error_fields = [] - # Check if 'species' is in validated_data and if it has changed - if "species" in validated_data and validated_data["species"] != instance.species: - error_fields.append("species") # Check if 'wn_cluster_id' is in validated_data and if it has changed if "wn_cluster_id" in validated_data and validated_data["wn_cluster_id"] != instance.wn_cluster_id: @@ -390,21 +398,18 @@ def update(self, instance: Observation, validated_data: dict[Any, Any]) -> Obser error_message = f"Following field(s) cannot be updated by any user: {', '.join(error_fields)}" raise serializers.ValidationError(error_message) - # Conditionally set `reserved_by` and `reserved_datetime` for all users - if "reserved_by" in validated_data and instance.reserved_by is None: - validated_data["reserved_datetime"] = ( - datetime.now(timezone("EST")) if validated_data["reserved_by"] else None - ) - instance.reserved_by = user - - # Prevent non-admin users from updating observations reserved by others - if not user.is_superuser and instance.reserved_by and instance.reserved_by != user: - raise serializers.ValidationError("You cannot edit an observation reserved by another user.") + # Conditionally set `reserved_by` and `reserved_datetime` + if "reserved_by" in validated_data: + if validated_data["reserved_by"] is not None: + validated_data["reserved_datetime"] = datetime.now(timezone("EST")) + else: + validated_data["reserved_datetime"] = None for field in set(validated_data) - set(allowed_admin_fields): validated_data.pop(field) instance = super().update(instance, validated_data) return instance + def to_internal_value(self, data: dict[str, Any]) -> dict[str, Any]: """Convert the incoming data to a Python native representation.""" logger.info("Raw input data: %s", data) diff --git a/vespadb/observations/tasks/export_utils.py b/vespadb/observations/tasks/export_utils.py index 0725595..d4fbf55 100644 --- a/vespadb/observations/tasks/export_utils.py +++ b/vespadb/observations/tasks/export_utils.py @@ -10,12 +10,11 @@ class WriterProtocol(Protocol): def writerow(self, row: List[str]) -> Any: ... -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" +PUBLIC_FIELDS = [ + "id", "created_datetime", "latitude", "longitude", "source", + "nest_height", "nest_size", "nest_location", "nest_type", + "observation_datetime", "province", "municipality", "nest_status", + "source_id", "anb_domain" ] def get_status(observation: Observation) -> str: @@ -35,16 +34,10 @@ def prepare_row_data( Prepare a single row of data for the CSV export with error handling. """ try: - # Determine allowed fields based on permissions - if is_admin or (observation.municipality_id in user_municipality_ids): - allowed_fields = CSV_HEADERS - else: - allowed_fields = ["id", "created_datetime", "latitude", "longitude", "source", - "nest_height", "nest_type", "observation_datetime", "province", - "municipality", "nest_status", "source_id", "anb_domain"] + allowed_fields = PUBLIC_FIELDS row_data: List[str] = [] - for field in CSV_HEADERS: + for field in PUBLIC_FIELDS: try: if field not in allowed_fields: row_data.append("") @@ -57,46 +50,39 @@ def prepare_row_data( elif field in ["created_datetime", "modified_datetime", "observation_datetime"]: datetime_val = getattr(observation, field, None) if datetime_val: - datetime_val = datetime_val.replace(microsecond=0) - row_data.append(datetime_val.isoformat() + "Z") + datetime_val = datetime_val.replace(microsecond=0, tzinfo=None) + row_data.append(datetime_val.strftime("%Y-%m-%dT%H:%M:%SZ")) else: row_data.append("") elif field == "province": row_data.append(observation.province.name if observation.province else "") elif field == "municipality": row_data.append(observation.municipality.name if observation.municipality else "") - elif field == "anb_domain": - row_data.append(str(observation.anb)) elif field == "nest_status": row_data.append(get_status(observation)) - elif field == "source_id": - row_data.append(str(observation.source_id) if observation.source_id is not None else "") else: value = getattr(observation, field, "") row_data.append(str(value) if value is not None else "") except Exception as e: - logger.warning(f"Error processing field {field} for observation {observation.id}: {str(e)}") + logger.warning(f"Error processing field {field}: {str(e)}") row_data.append("") return row_data except Exception as e: - logger.error(f"Error preparing row data for observation {observation.id}: {str(e)}") - return [""] * len(CSV_HEADERS) + logger.error(f"Error preparing row data: {str(e)}") + return [""] * len(PUBLIC_FIELDS) def generate_rows( queryset: QuerySet[Model], writer: WriterProtocol, is_admin: bool, - user_municipality_ids: Set[str] + user_municipality_ids: Set[str], + batch_size: int = 200 ) -> Iterator[Any]: - """ - Generate CSV rows for streaming. - """ - # First yield the headers - yield writer.writerow(CSV_HEADERS) + """Generate CSV rows for streaming with memory optimization.""" + yield writer.writerow(PUBLIC_FIELDS) - # Then yield the data rows - for observation in queryset.iterator(chunk_size=2000): + for observation in queryset.iterator(chunk_size=batch_size): try: row = prepare_row_data( observation, diff --git a/vespadb/observations/tasks/generate_export.py b/vespadb/observations/tasks/generate_export.py index 1e40805..5a9f2c7 100644 --- a/vespadb/observations/tasks/generate_export.py +++ b/vespadb/observations/tasks/generate_export.py @@ -7,7 +7,7 @@ from typing import Dict, Any, Optional, Set from vespadb.users.models import VespaUser as User from vespadb.observations.models import Export -from .export_utils import CSV_HEADERS, prepare_row_data +from .export_utils import PUBLIC_FIELDS, prepare_row_data logger = logging.getLogger(__name__) @@ -43,7 +43,7 @@ def generate_export(export_id: int, filters: Dict[str, Any], user_id: Optional[i # Process in batches batch_size = 1000 processed = 0 - rows = [CSV_HEADERS] # Start with headers + rows = [PUBLIC_FIELDS] # Start with headers # Get user permissions is_admin = False diff --git a/vespadb/observations/tasks/observation_mapper.py b/vespadb/observations/tasks/observation_mapper.py index a56f09d..7605eeb 100644 --- a/vespadb/observations/tasks/observation_mapper.py +++ b/vespadb/observations/tasks/observation_mapper.py @@ -206,7 +206,7 @@ def map_external_data_to_observation_model(external_data: dict[str, Any]) -> dic :param external_data: A dictionary of external API data. :return: A dictionary suitable for creating or updating an Observation model instance, or None if an error occurs. """ - required_fields = ["id", "date", "point", "created", "modified", "species"] + required_fields = ["id", "date", "point", "created", "modified"] for field in required_fields: if field not in external_data or external_data[field] is None: logger.error( @@ -250,7 +250,6 @@ def map_external_data_to_observation_model(external_data: dict[str, Any]) -> dic mapped_data = { "wn_id": external_data["id"], "location": location, - "species": external_data.get("species"), "observation_datetime": observation_datetime_utc, "wn_created_datetime": created_datetime, "wn_modified_datetime": modified_datetime, diff --git a/vespadb/observations/views.py b/vespadb/observations/views.py index 111a110..b531153 100644 --- a/vespadb/observations/views.py +++ b/vespadb/observations/views.py @@ -16,6 +16,7 @@ from django.db.models import Model from csv import writer as _writer from django.db.models.query import QuerySet +from django.contrib.gis.geos import Point from django.views.decorators.csrf import csrf_exempt from django.contrib.gis.db.models.functions import Transform @@ -55,9 +56,10 @@ from vespadb.observations.helpers import parse_and_convert_to_utc from vespadb.observations.models import Municipality, Observation, Province, Export from vespadb.observations.models import Export +from vespadb.observations.tasks.export_utils import generate_rows from vespadb.observations.tasks.generate_export import generate_export from vespadb.observations.serializers import ObservationSerializer, MunicipalitySerializer, ProvinceSerializer - +from vespadb.observations.utils import check_if_point_in_anb_area, get_municipality_from_coordinates from django.utils.decorators import method_decorator from django_ratelimit.decorators import ratelimit from rest_framework.decorators import action @@ -81,16 +83,14 @@ def write(self, value: Any) -> Any: 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" -] class ObservationsViewSet(ModelViewSet): # noqa: PLR0904 """ViewSet for the Observation model.""" - queryset = Observation.objects.all() + queryset = ( + Observation.objects.select_related( + "reserved_by", "modified_by", "created_by", "municipality", "province" + ) + ) serializer_class = ObservationSerializer filter_backends = [ DjangoFilterBackend, @@ -580,10 +580,81 @@ def validate_location(self, location: str) -> GEOSGeometry: def process_data(self, data: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """Process and validate the incoming data.""" + logger.info("Starting to process import data") + valid_observations = [] errors = [] + for data_item in data: try: + logger.info(f"Processing data item: {data_item}") + + # Issue #294 - Only allow specific fields in import + allowed_fields = { + 'id', 'source_id', 'observation_datetime', 'eradication_problems', + 'source', 'eradication_notes', 'images', 'created_datetime', + 'longitude', 'latitude', 'eradication_persons', 'nest_size', + 'visible', 'nest_location', 'eradication_date', 'eradication_product', + 'nest_type', 'eradicator_name', 'eradication_method', + 'eradication_aftercare', 'public_domain', 'eradication_duration', + 'nest_height', 'eradication_result', 'notes', 'admin_notes' + } + + # Filter out non-allowed fields + data_item = {k: v for k, v in data_item.items() if k in allowed_fields} + + # Issue #297 - Handle record updates vs inserts + observation_id = data_item.pop('id', None) # Remove id from data_item if it exists + + if observation_id is not None: # Explicitly check for None to allow id=0 + try: + existing_obj = Observation.objects.get(id=observation_id) + logger.info(f"Updating existing observation with id {observation_id}") + # Update only provided fields + for key, value in data_item.items(): + if key != 'id': # Don't update the ID + setattr(existing_obj, key, value) + existing_obj.save() + valid_observations.append(existing_obj) + continue + except Observation.DoesNotExist: + logger.error(f"Observation with id {observation_id} not found") + errors.append({"error": f"Observation with id {observation_id} not found"}) + continue + + # Issue #290 - Auto-determine province, municipality and anb + # Handle coordinates for new records or updates + if 'longitude' in data_item and 'latitude' in data_item: + try: + long = float(data_item.pop('longitude')) + lat = float(data_item.pop('latitude')) + data_item['location'] = Point(long, lat, srid=4326) + logger.info(f"Created point from coordinates: {long}, {lat}") + + # Determine municipality, province and anb status + municipality = get_municipality_from_coordinates(long, lat) + if municipality: + data_item['municipality'] = municipality.id + if municipality.province: + data_item['province'] = municipality.province.id + data_item['anb'] = check_if_point_in_anb_area(long, lat) + + logger.info(f"Municipality ID: {data_item.get('municipality')}, Province ID: {data_item.get('province')}, ANB: {data_item['anb']}") + except (ValueError, TypeError) as e: + logger.error(f"Invalid coordinates: {e}") + errors.append({"error": f"Invalid coordinates: {str(e)}"}) + continue + + # Issue #292 - Fix timezone handling for eradication_date + if 'eradication_date' in data_item: + date_str = data_item['eradication_date'] + if isinstance(date_str, str): + try: + data_item['eradication_date'] = datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError: + errors.append({"error": f"Invalid date format for eradication_date: {date_str}"}) + continue + cleaned_item = self.clean_data(data_item) serializer = ObservationSerializer(data=cleaned_item) if serializer.is_valid(): @@ -593,6 +664,7 @@ def process_data(self, data: list[dict[str, Any]]) -> tuple[list[dict[str, Any]] except Exception as e: logger.exception(f"Error processing data item: {data_item} - {e}") errors.append({"error": str(e)}) + return valid_observations, errors def clean_data(self, data_dict: dict[str, Any]) -> dict[str, Any]: @@ -634,15 +706,23 @@ def clean_data(self, data_dict: dict[str, Any]) -> dict[str, Any]: def save_observations(self, valid_data: list[dict[str, Any]]) -> Response: """Save the valid observations to the database.""" try: + logger.info(f"Saving {len(valid_data)} valid observations") with transaction.atomic(): - Observation.objects.bulk_create([Observation(**data) for data in valid_data]) + created_count = 0 + for data in valid_data: + obs = Observation(**data) + obs.save() # This ensures the save() method in the model is called + created_count += 1 + invalidate_geojson_cache() return Response( - {"message": f"Successfully imported {len(valid_data)} observations."}, status=status.HTTP_201_CREATED + {"message": f"Successfully imported {created_count} observations."}, + status=status.HTTP_201_CREATED ) except IntegrityError as e: logger.exception("Error during bulk import") return Response( - {"error": f"An error occurred during bulk import: {e!s}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + {"error": f"An error occurred during bulk import: {e!s}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @method_decorator(ratelimit(key="ip", rate="60/m", method="GET", block=True)) @@ -780,19 +860,29 @@ def download_export(self, request: HttpRequest) -> Union[StreamingHttpResponse, except Exception as e: logger.error(f"Error streaming export: {str(e)}") return HttpResponseServerError("Error generating export") - + @method_decorator(csrf_exempt) @action(detail=False, methods=['get'], permission_classes=[AllowAny]) def export_direct(self, request: HttpRequest) -> Union[StreamingHttpResponse, JsonResponse]: - """Stream observations directly as CSV without using Celery.""" + """Stream observations directly as CSV with optimized memory usage.""" try: - # Initialize the filterset with request parameters + # Initialize the filterset with an optimized queryset + queryset = self.get_queryset().select_related( + 'province', + 'municipality', + 'reserved_by' + ).only( + 'id', 'created_datetime', 'modified_datetime', 'location', + 'source', 'source_id', 'nest_height', 'nest_size', 'nest_location', + 'nest_type', 'observation_datetime', 'eradication_date', + 'images', 'anb_domain', 'notes', 'eradication_result', + 'wn_id', 'wn_validation_status', 'province__name', + 'municipality__name' + ) + filterset = self.filterset_class( data=request.GET, - queryset=self.get_queryset().select_related( - 'province', - 'municipality' - ) + queryset=queryset ) if not filterset.is_valid(): @@ -801,7 +891,7 @@ def export_direct(self, request: HttpRequest) -> Union[StreamingHttpResponse, Js # Get filtered queryset queryset = filterset.qs - # Check count + # Check count with a more efficient query total_count = queryset.count() if total_count > 10000: return JsonResponse({ @@ -816,14 +906,18 @@ def export_direct(self, request: HttpRequest) -> Union[StreamingHttpResponse, Js request.user.municipalities.values_list('id', flat=True) ) - # Create the streaming response with data from the task module - from .tasks.export_utils import generate_rows + # Create the streaming response pseudo_buffer = Echo() writer = csv.writer(pseudo_buffer) - # Stream response with appropriate headers response = StreamingHttpResponse( - generate_rows(queryset, writer, is_admin, user_municipality_ids), + streaming_content=generate_rows( + queryset=queryset, + writer=writer, + is_admin=is_admin, + user_municipality_ids=user_municipality_ids, + batch_size=200 # Smaller batch size for memory efficiency + ), content_type='text/csv' ) @@ -842,7 +936,7 @@ def export_direct(self, request: HttpRequest) -> Union[StreamingHttpResponse, Js except Exception as e: logger.exception("Export failed") return JsonResponse({"error": str(e)}, status=500) - + @require_GET def search_address(request: Request) -> JsonResponse: """ From 7ee622a9bb5fc99eb92fee7fc5a9be176ac1debd Mon Sep 17 00:00:00 2001 From: Steven Gerrits Date: Fri, 31 Jan 2025 00:59:49 +0100 Subject: [PATCH 2/6] rc/2025-01 --- .../ObservationDetailsComponent.vue | 24 ++- vespadb/observations/serializers.py | 65 +------- vespadb/observations/views.py | 140 ++++++++++++------ 3 files changed, 116 insertions(+), 113 deletions(-) diff --git a/src/components/ObservationDetailsComponent.vue b/src/components/ObservationDetailsComponent.vue index c3049f2..e984680 100644 --- a/src/components/ObservationDetailsComponent.vue +++ b/src/components/ObservationDetailsComponent.vue @@ -6,10 +6,11 @@
Melding {{ selectedObservation.id }} -