diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..19e1875c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Open a bug report +title: "[bug] " +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of the bug or unexpected behavior. + +**Steps To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**System Informatioon:** + - OS: [e.g. Ubuntu 24.04 LTS] + - Python Version: [e.g. Python 3.11.2] + - Django Version: [e.g. Django 4.2.5] + - Browser and Browser Version (if applicable): [e.g. Chromium v126.0.6478.126] diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..523386b9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[feature] " +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 00000000..d7f10536 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,10 @@ +--- +name: Question +about: Please use the Discussion Forum to ask questions +title: "[question] " +labels: question +assignees: '' + +--- + +Please use the [Discussion Forum](https://github.com/openwisp/django-rest-framework-gis/discussions) to ask questions. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..db958279 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "monthly" + commit-message: + prefix: "[deps] " diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..84cd5be6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Checklist + +- [ ] I have read the [OpenWISP Contributing Guidelines](http://openwisp.io/docs/developer/contributing.html). +- [ ] I have manually tested the changes proposed in this pull request. +- [ ] I have written new test cases for new code and/or updated existing tests for changes to existing code. +- [ ] I have updated the documentation. + +## Reference to Existing Issue + +Closes #. + +Please [open a new issue](https://github.com/openwisp/django-rest-framework-gis/issues/new/choose) if there isn't an existing issue yet. + +## Description of Changes + +Please describe these changes. + +## Screenshot + +Please include any relevant screenshots. diff --git a/README.rst b/README.rst index 311d8372..902a0016 100644 --- a/README.rst +++ b/README.rst @@ -251,6 +251,9 @@ to be serialized as the "geometry". For example: # as with a ModelSerializer. fields = ('id', 'address', 'city', 'state') +If your model is geometry-less, you can set ``geo_field`` to ``None`` +and a null geometry will be produced. + Using GeometrySerializerMethodField as "geo_field" ################################################## @@ -755,8 +758,8 @@ Contributing 8. Document your changes 9. Send pull request -.. |Build Status| image:: https://github.com/openwisp/django-rest-framework-gis/workflows/Django%20Rest%20Framework%20Gis%20CI%20Build/badge.svg?branch=master - :target: https://github.com/openwisp/django-rest-framework-gis/actions?query=workflow%3A%22Django+Rest+Framework+Gis+CI+Build%22 +.. |Build Status| image:: https://github.com/openwisp/django-rest-framework-gis/actions/workflows/ci.yml/badge.svg + :target: https://github.com/openwisp/django-rest-framework-gis/actions/workflows/ci.yml .. |Coverage Status| image:: https://coveralls.io/repos/openwisp/django-rest-framework-gis/badge.svg :target: https://coveralls.io/r/openwisp/django-rest-framework-gis .. |Requirements Status| image:: https://img.shields.io/librariesio/release/github/openwisp/django-rest-framework-gis diff --git a/requirements-test.txt b/requirements-test.txt index 00ee6b4a..b48c2ce2 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,5 +2,5 @@ psycopg2 django-filter>=2.0 contexttimer # QA checks -openwisp-utils[qa]~=1.0.0 +openwisp-utils[qa]~=1.0.5 packaging~=20.4 diff --git a/rest_framework_gis/filters.py b/rest_framework_gis/filters.py index 07d264f8..5c175837 100644 --- a/rest_framework_gis/filters.py +++ b/rest_framework_gis/filters.py @@ -292,8 +292,8 @@ def filter_queryset(self, request, queryset, view): return queryset.order_by(GeometryDistance(filter_field, point)) def get_schema_operation_parameters(self, view): - params = super().get_schema_operation_parameters(view) - params.append( + return [ + *super().get_schema_operation_parameters(view), { "name": self.order_param, "required": False, @@ -306,5 +306,5 @@ def get_schema_operation_parameters(self, view): }, "style": "form", "explode": False, - } - ) + }, + ] diff --git a/rest_framework_gis/serializers.py b/rest_framework_gis/serializers.py index 60ac9384..1b808e44 100644 --- a/rest_framework_gis/serializers.py +++ b/rest_framework_gis/serializers.py @@ -72,8 +72,11 @@ def __init__(self, *args, **kwargs): default_id_field = primary_key meta.id_field = getattr(meta, 'id_field', default_id_field) - if not hasattr(meta, 'geo_field') or not meta.geo_field: - raise ImproperlyConfigured("You must define a 'geo_field'.") + if not hasattr(meta, 'geo_field'): + raise ImproperlyConfigured( + "You must define a 'geo_field'. " + "Set it to None if there is no geometry." + ) def check_excludes(field_name, field_role): """make sure the field is not excluded""" @@ -93,7 +96,9 @@ def add_to_fields(field_name): meta.fields += additional_fields check_excludes(meta.geo_field, 'geo_field') - add_to_fields(meta.geo_field) + + if meta.geo_field is not None: + add_to_fields(meta.geo_field) meta.bbox_geo_field = getattr(meta, 'bbox_geo_field', None) if meta.bbox_geo_field: @@ -128,12 +133,15 @@ def to_representation(self, instance): # must be "Feature" according to GeoJSON spec feature["type"] = "Feature" - # required geometry attribute - # MUST be present in output according to GeoJSON spec - field = self.fields[self.Meta.geo_field] - geo_value = field.get_attribute(instance) - feature["geometry"] = field.to_representation(geo_value) - processed_fields.add(self.Meta.geo_field) + # geometry attribute + # must be present in output according to GeoJSON spec + if self.Meta.geo_field: + field = self.fields[self.Meta.geo_field] + geo_value = field.get_attribute(instance) + feature["geometry"] = field.to_representation(geo_value) + processed_fields.add(self.Meta.geo_field) + else: + feature["geometry"] = None # Bounding Box # if auto_bbox feature is enabled @@ -211,7 +219,7 @@ def unformat_geojson(self, feature): """ attrs = feature["properties"] - if 'geometry' in feature: + if 'geometry' in feature and self.Meta.geo_field: attrs[self.Meta.geo_field] = feature['geometry'] if self.Meta.id_field and 'id' in feature: diff --git a/tests/django_restframework_gis_tests/migrations/0004_auto_20240228_2357.py b/tests/django_restframework_gis_tests/migrations/0004_auto_20240228_2357.py new file mode 100644 index 00000000..ff0da97a --- /dev/null +++ b/tests/django_restframework_gis_tests/migrations/0004_auto_20240228_2357.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.24 on 2024-02-28 22:57 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_restframework_gis_tests', '0003_schema_models'), + ] + + operations = [ + migrations.AlterField( + model_name='boxedlocation', + name='geometry', + field=django.contrib.gis.db.models.fields.GeometryField(srid=4326), + ), + migrations.AlterField( + model_name='locatedfile', + name='geometry', + field=django.contrib.gis.db.models.fields.GeometryField(srid=4326), + ), + migrations.AlterField( + model_name='location', + name='geometry', + field=django.contrib.gis.db.models.fields.GeometryField(srid=4326), + ), + ] diff --git a/tests/django_restframework_gis_tests/models.py b/tests/django_restframework_gis_tests/models.py index 3c8713d0..e9c044af 100644 --- a/tests/django_restframework_gis_tests/models.py +++ b/tests/django_restframework_gis_tests/models.py @@ -20,7 +20,6 @@ class BaseModel(models.Model): name = models.CharField(max_length=32) slug = models.SlugField(max_length=128, unique=True, blank=True) timestamp = models.DateTimeField(null=True, blank=True) - geometry = models.GeometryField() class Meta: abstract = True @@ -44,15 +43,22 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) -class Location(BaseModel): +class BaseModelGeometry(BaseModel): + class Meta: + abstract = True + + geometry = models.GeometryField() + + +class Location(BaseModelGeometry): pass -class LocatedFile(BaseModel): +class LocatedFile(BaseModelGeometry): file = models.FileField(upload_to='located_files', blank=True, null=True) -class BoxedLocation(BaseModel): +class BoxedLocation(BaseModelGeometry): bbox_geometry = models.PolygonField() diff --git a/tests/django_restframework_gis_tests/serializers.py b/tests/django_restframework_gis_tests/serializers.py index 95ef3371..6d4f7874 100644 --- a/tests/django_restframework_gis_tests/serializers.py +++ b/tests/django_restframework_gis_tests/serializers.py @@ -12,6 +12,7 @@ MultiLineStringModel, MultiPointModel, MultiPolygonModel, + Nullable, PointModel, PolygonModel, ) @@ -185,6 +186,13 @@ class Meta: fields = ['name', 'slug', 'id'] +class NoGeoFeatureMethodSerializer(gis_serializers.GeoFeatureModelSerializer): + class Meta: + model = Nullable + geo_field = None + fields = ['name', 'slug', 'id'] + + class PointSerializer(gis_serializers.GeoFeatureModelSerializer): class Meta: model = PointModel diff --git a/tests/django_restframework_gis_tests/tests.py b/tests/django_restframework_gis_tests/tests.py index d4305800..948a168c 100644 --- a/tests/django_restframework_gis_tests/tests.py +++ b/tests/django_restframework_gis_tests/tests.py @@ -634,6 +634,16 @@ def test_geometry_serializer_method_field_none(self): self.assertEqual(response.data['properties']['name'], 'None value') self.assertEqual(response.data['geometry'], None) + def test_geometry_serializer_method_field_nogeo(self): + nullable = Nullable.objects.create(name='No geometry value') + nullable_loaded = Nullable.objects.get(pk=nullable.id) + self.assertEqual(nullable_loaded.name, "No geometry value") + url = reverse('api_geojson_nullable_details_nogeo', args=[nullable.id]) + response = self.client.generic('GET', url, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['properties']['name'], 'No geometry value') + self.assertEqual(response.data['geometry'], None) + def test_nullable_empty_geometry(self): empty = Nullable(name='empty', geometry='POINT EMPTY') empty.full_clean() diff --git a/tests/django_restframework_gis_tests/urls.py b/tests/django_restframework_gis_tests/urls.py index 8199e773..17c61eba 100644 --- a/tests/django_restframework_gis_tests/urls.py +++ b/tests/django_restframework_gis_tests/urls.py @@ -22,6 +22,11 @@ views.geojson_nullable_details, name='api_geojson_nullable_details', ), + path( + 'geojson_nogeo//', + views.geojson_nullable_details_nogeo, + name='api_geojson_nullable_details_nogeo', + ), path( 'geojson_hidden//', views.geojson_location_details_hidden, diff --git a/tests/django_restframework_gis_tests/views.py b/tests/django_restframework_gis_tests/views.py index 7d0107e6..cc96337b 100644 --- a/tests/django_restframework_gis_tests/views.py +++ b/tests/django_restframework_gis_tests/views.py @@ -23,6 +23,7 @@ LocationGeoFeatureSlugSerializer, LocationGeoFeatureWritableIdSerializer, LocationGeoSerializer, + NoGeoFeatureMethodSerializer, NoneGeoFeatureMethodSerializer, PaginatedLocationGeoSerializer, PolygonModelSerializer, @@ -167,6 +168,15 @@ class GeojsonLocationDetailsNone(generics.RetrieveUpdateDestroyAPIView): geojson_location_details_none = GeojsonLocationDetailsNone.as_view() +class GeojsonNullableDetailsNoGeo(generics.RetrieveUpdateDestroyAPIView): + model = Nullable + serializer_class = NoGeoFeatureMethodSerializer + queryset = Nullable.objects.all() + + +geojson_nullable_details_nogeo = GeojsonNullableDetailsNoGeo.as_view() + + class GeojsonLocationSlugDetails(generics.RetrieveUpdateDestroyAPIView): model = Location lookup_field = 'slug'