diff --git a/services/api.py b/services/api.py index 0b3406e60..6c4d84beb 100644 --- a/services/api.py +++ b/services/api.py @@ -15,6 +15,11 @@ from django.utils import timezone, translation from django.utils.module_loading import import_string from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import ( + extend_schema, + extend_schema_field, + extend_schema_serializer, +) from modeltranslation.translator import NotRegistered, translator from mptt.utils import drilldown_tree_for_node from munigeo import api as munigeo_api @@ -41,6 +46,32 @@ UnitServiceDetails, ) from services.models.unit import ORGANIZER_TYPES, PROVIDER_TYPES +from services.open_api_parameters import ( + ACCESSIBILITY_DESCRIPTION_PARAMETER, + ANCESTOR_ID_PARAMETER, + BBOX_PARAMETER, + BUILDING_NUMBER_PARAMETER, + CITY_AS_DEPARTMENT_PARAMETER, + DATE_PARAMETER, + DISTANCE_PARAMETER, + DIVISION_TYPE_PARAMETER, + GEOMETRY_PARAMETER, + ID_PARAMETER, + INPUT_PARAMETER, + LATITUDE_PARAMETER, + LEVEL_PARAMETER, + LONGITUDE_PARAMETER, + MUNICIPALITY_PARAMETER, + OCD_ID_PARAMETER, + OCD_MUNICIPALITY_PARAMETER, + ORGANIZATION_PARAMETER, + ORIGIN_ID_PARAMETER, + PROVIDER_TYPE_NOT_PARAMETER, + PROVIDER_TYPE_PARAMETER, + STREET_PARAMETER, + UNIT_GEOMETRY_3D_PARAMETER, + UNIT_GEOMETRY_PARAMETER, +) from services.utils import check_valid_concrete_field from services.utils.geocode_address import geocode_address @@ -68,6 +99,17 @@ def register_view(klass, name, basename=None): logger = logging.getLogger(__name__) +class TranslationsSerializer(serializers.Serializer): + fi = serializers.CharField(required=False) + sv = serializers.CharField(required=False) + en = serializers.CharField(required=False) + + +@extend_schema_field(TranslationsSerializer) +class TranslationsField(serializers.CharField): + pass + + class MPTTModelSerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): super(MPTTModelSerializer, self).__init__(*args, **kwargs) @@ -186,6 +228,13 @@ def to_representation(self, obj): return ret +class ServicesTranslatedModelSerializer(TranslatedModelSerializer): + def __init__(self, *args, **kwargs): + super(ServicesTranslatedModelSerializer, self).__init__(*args, **kwargs) + for field_name in self.translated_fields: + self.fields[field_name] = TranslationsField() + + def root_services(services): tree_ids = set(s.tree_id for s in services) return map( @@ -250,7 +299,7 @@ def to_representation(self, obj): class DepartmentSerializer( - TranslatedModelSerializer, MPTTModelSerializer, JSONAPISerializer + ServicesTranslatedModelSerializer, MPTTModelSerializer, JSONAPISerializer ): id = serializers.SerializerMethodField("get_uuid") parent = serializers.SerializerMethodField() @@ -272,7 +321,7 @@ def get_parent(self, obj): class ServiceNodeSerializer( - TranslatedModelSerializer, MPTTModelSerializer, JSONAPISerializer + ServicesTranslatedModelSerializer, MPTTModelSerializer, JSONAPISerializer ): children = serializers.PrimaryKeyRelatedField(many=True, read_only=True) @@ -326,7 +375,7 @@ class Meta: ) -class ServiceSerializer(TranslatedModelSerializer, JSONAPISerializer): +class ServiceSerializer(ServicesTranslatedModelSerializer, JSONAPISerializer): def to_representation(self, obj): ret = super(ServiceSerializer, self).to_representation(obj) ret["unit_count"] = {"municipality": {}} @@ -368,7 +417,7 @@ class Meta: fields = ["name", "root_service_node"] -class ServiceDetailsSerializer(TranslatedModelSerializer, JSONAPISerializer): +class ServiceDetailsSerializer(ServicesTranslatedModelSerializer, JSONAPISerializer): def to_representation(self, obj): ret = super(ServiceDetailsSerializer, self).to_representation(obj) service_data = RelatedServiceSerializer(obj.service).data @@ -472,7 +521,9 @@ def choicefield_string(choices, key, obj): return None -class UnitConnectionSerializer(TranslatedModelSerializer, serializers.ModelSerializer): +class UnitConnectionSerializer( + ServicesTranslatedModelSerializer, serializers.ModelSerializer +): section_type = serializers.SerializerMethodField() class Meta: @@ -491,7 +542,9 @@ class UnitConnectionViewSet(viewsets.ReadOnlyModelViewSet): register_view(UnitConnectionViewSet, "unit_connection") -class UnitEntranceSerializer(TranslatedModelSerializer, munigeo_api.GeoModelSerializer): +class UnitEntranceSerializer( + ServicesTranslatedModelSerializer, munigeo_api.GeoModelSerializer +): location = serializers.SerializerMethodField() class Meta: @@ -502,7 +555,11 @@ def get_location(self, obj): return munigeo_api.geom_to_json(obj.location, self.srs) -class UnitEntranceViewSet(munigeo_api.GeoModelAPIView, viewsets.ReadOnlyModelViewSet): +class UnitEntranceViewSet( + ServicesTranslatedModelSerializer, + munigeo_api.GeoModelAPIView, + viewsets.ReadOnlyModelViewSet, +): queryset = UnitEntrance.objects.all() serializer_class = UnitEntranceSerializer @@ -532,6 +589,7 @@ class Meta: exclude = ["unit", "id"] +@extend_schema(parameters=[ID_PARAMETER, ANCESTOR_ID_PARAMETER]) class ServiceNodeViewSet(JSONAPIViewSet, viewsets.ReadOnlyModelViewSet): queryset = ServiceNode.objects.all() serializer_class = ServiceNodeSerializer @@ -559,6 +617,7 @@ def get_queryset(self): register_view(ServiceNodeViewSet, "service_node") +@extend_schema(parameters=[ID_PARAMETER]) class ServiceViewSet(JSONAPIViewSet, viewsets.ReadOnlyModelViewSet): queryset = Service.objects.all() serializer_class = ServiceSerializer @@ -587,7 +646,7 @@ def get_queryset(self): class UnitSerializer( - TranslatedModelSerializer, munigeo_api.GeoModelSerializer, JSONAPISerializer + ServicesTranslatedModelSerializer, munigeo_api.GeoModelSerializer, JSONAPISerializer ): connections = UnitConnectionSerializer(many=True) entrances = UnitEntranceSerializer(many=True) @@ -749,6 +808,13 @@ def to_representation(self, obj): elif "geometry" in ret: del ret["geometry"] + if qparams.get("geometry_3d", "").lower() in ("true", "1"): + geom = obj.geometry_3d + if geom: + ret["geometry_3d"] = munigeo_api.geom_to_json(geom, self.srs) + elif "geometry_3d" in ret: + del ret["geometry_3d"] + if qparams.get("accessibility_description", "").lower() in ("true", "1"): ret["accessibility_description"] = shortcomings.accessibility_description return ret @@ -812,6 +878,20 @@ def render(self, data, media_type=None, renderer_context=None): return render_to_string("kml.xml", resp) +@extend_schema( + parameters=[ + ACCESSIBILITY_DESCRIPTION_PARAMETER, + ID_PARAMETER, + OCD_MUNICIPALITY_PARAMETER, + ORGANIZATION_PARAMETER, + CITY_AS_DEPARTMENT_PARAMETER, + PROVIDER_TYPE_PARAMETER, + PROVIDER_TYPE_NOT_PARAMETER, + LEVEL_PARAMETER, + UNIT_GEOMETRY_PARAMETER, + UNIT_GEOMETRY_3D_PARAMETER, + ] +) class UnitViewSet( munigeo_api.GeoModelAPIView, JSONAPIViewSet, viewsets.ReadOnlyModelViewSet ): @@ -846,6 +926,12 @@ def get_queryset(self): id_list = filters["id"].split(",") queryset = queryset.filter(id__in=id_list) + for f in filters: + if f.startswith("extra__"): + queryset = queryset.filter( + **{f: int(filters[f]) if filters[f].isnumeric() else filters[f]} + ) + if "municipality" in filters: val = filters["municipality"].lower().strip() if len(val) > 0: @@ -866,8 +952,18 @@ def get_queryset(self): queryset = queryset.filter(muni_sq) - if "city_as_department" in filters: - val = filters["city_as_department"].lower().strip() + if "organization" in filters or "city_as_department" in filters: + val = ( + filters["organization"].lower().strip() + if "organization" in filters + else "" + ) + if len(val) == 0: + val = ( + filters["city_as_department"].lower().strip() + if "city_as_department" in filters + else "" + ) if len(val) > 0: deps_uuids = val.split(",") @@ -877,16 +973,12 @@ def get_queryset(self): uuid.UUID(deps_uuid) except ValueError: raise serializers.ValidationError( - "'city_as_department' value must be a valid UUID" + "'organization' value must be a valid UUID" ) - deps = Department.objects.filter(uuid__in=deps_uuids).select_related( - "municipality" - ) - munis = [d.municipality for d in deps] - - queryset = queryset.filter(root_department__in=deps) | queryset.filter( - municipality__in=munis + deps = Department.objects.filter(uuid__in=deps_uuids) + queryset = queryset.filter(department__in=deps) | queryset.filter( + root_department__in=deps ) if "provider_type" in filters: @@ -1127,6 +1219,7 @@ def list(self, request, *args, **kwargs): ) +@extend_schema_serializer(deprecate_fields=["service_point_id"]) class AdministrativeDivisionSerializer(munigeo_api.AdministrativeDivisionSerializer): def to_representation(self, obj): ret = super(AdministrativeDivisionSerializer, self).to_representation(obj) @@ -1171,6 +1264,19 @@ def to_representation(self, obj): return ret +@extend_schema( + parameters=[ + DIVISION_TYPE_PARAMETER, + LATITUDE_PARAMETER, + LONGITUDE_PARAMETER, + INPUT_PARAMETER, + OCD_ID_PARAMETER, + GEOMETRY_PARAMETER, + ORIGIN_ID_PARAMETER, + MUNICIPALITY_PARAMETER, + DATE_PARAMETER, + ] +) class AdministrativeDivisionViewSet(munigeo_api.AdministrativeDivisionViewSet): serializer_class = AdministrativeDivisionSerializer @@ -1196,6 +1302,17 @@ def get_queryset(self): register_view(AdministrativeDivisionViewSet, "administrative_division") +@extend_schema( + parameters=[ + STREET_PARAMETER, + OCD_MUNICIPALITY_PARAMETER, + BUILDING_NUMBER_PARAMETER, + LATITUDE_PARAMETER, + LONGITUDE_PARAMETER, + DISTANCE_PARAMETER, + BBOX_PARAMETER, + ], +) class AddressViewSet(munigeo_api.AddressViewSet): serializer_class = munigeo_api.AddressSerializer @@ -1210,7 +1327,7 @@ class PostalCodeAreaViewSet(munigeo_api.PostalCodeAreaViewSet): register_view(PostalCodeAreaViewSet, "postalcodearea") -class AnnouncementSerializer(TranslatedModelSerializer, JSONAPISerializer): +class AnnouncementSerializer(ServicesTranslatedModelSerializer, JSONAPISerializer): class Meta: model = Announcement exclude = ["id", "active"] @@ -1224,7 +1341,7 @@ class AnnouncementViewSet(viewsets.ReadOnlyModelViewSet): register_view(AnnouncementViewSet, "announcement") -class ErrorMessageSerializer(TranslatedModelSerializer, JSONAPISerializer): +class ErrorMessageSerializer(ServicesTranslatedModelSerializer, JSONAPISerializer): class Meta: model = ErrorMessage exclude = ["id", "active"] diff --git a/services/migrations/0103_unit_geometry_3d.py b/services/migrations/0103_unit_geometry_3d.py new file mode 100644 index 000000000..537fb41e2 --- /dev/null +++ b/services/migrations/0103_unit_geometry_3d.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1.13 on 2024-07-04 06:06 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0102_unit_new_contract_type_fields"), + ] + + operations = [ + migrations.AddField( + model_name="unit", + name="geometry_3d", + field=django.contrib.gis.db.models.fields.GeometryField( + dim=3, null=True, srid=3067 + ), + ), + ] diff --git a/services/models/unit.py b/services/models/unit.py index 7100a0985..6dad5bee8 100644 --- a/services/models/unit.py +++ b/services/models/unit.py @@ -101,6 +101,8 @@ class Unit(SoftDeleteModel): location = models.PointField(null=True, srid=PROJECTION_SRID) # lat, lng? geometry = models.GeometryField(srid=PROJECTION_SRID, null=True) + geometry_3d = models.GeometryField(srid=PROJECTION_SRID, null=True, dim=3) + department = models.ForeignKey(Department, null=True, on_delete=models.CASCADE) root_department = models.ForeignKey( Department, null=True, related_name="descendant_units", on_delete=models.CASCADE diff --git a/services/open_api_parameters.py b/services/open_api_parameters.py new file mode 100644 index 000000000..83e57fde4 --- /dev/null +++ b/services/open_api_parameters.py @@ -0,0 +1,198 @@ +from drf_spectacular.utils import OpenApiParameter + +ACCESSIBILITY_DESCRIPTION_PARAMETER = OpenApiParameter( + name="accessibility_description", + location=OpenApiParameter.QUERY, + description="If given displays the accessibility description of unit.", + required=False, + type=bool, +) + +ANCESTOR_ID_PARAMETER = OpenApiParameter( + name="ancestor", + location=OpenApiParameter.QUERY, + description="Filter by ancestor ID.", + required=False, + type=str, +) + +BBOX_PARAMETER = OpenApiParameter( + name="bbox", + location=OpenApiParameter.QUERY, + description="Bounding box in the format 'left,bottom,right,top'. Values must be floating points or integers.", + required=False, + type=str, +) + +BUILDING_NUMBER_PARAMETER = OpenApiParameter( + name="number", + location=OpenApiParameter.QUERY, + description="Filter by building number.", + required=False, + type=str, +) + +CITY_AS_DEPARTMENT_PARAMETER = OpenApiParameter( + name="city_as_department", + location=OpenApiParameter.QUERY, + description="Filter by city UUID.", + required=False, + type=str, +) + +DATE_PARAMETER = ( + OpenApiParameter( + name="date", + location=OpenApiParameter.QUERY, + description="Filter divisions based on their validity date. Format: YYYY-MM-DD.", + required=False, + type=str, + ), +) + +DISTANCE_PARAMETER = OpenApiParameter( + name="distance", + location=OpenApiParameter.QUERY, + description="The maximum distance from the provided location, defined by the lat and lon parameters. If this" + " parameter is given also the 'lat' and 'lon' parameters are required.", + required=False, + type=float, +) + +DIVISION_TYPE_PARAMETER = OpenApiParameter( + name="type", + location=OpenApiParameter.QUERY, + description="Filter by administrative division type or type ID.", + required=False, + type=str, +) + +GEOMETRY_PARAMETER = OpenApiParameter( + name="geometry", + location=OpenApiParameter.QUERY, + description="Display administrative division boundary.", + required=False, + type=bool, +) + +ID_PARAMETER = OpenApiParameter( + name="id", + location=OpenApiParameter.QUERY, + description="Filter by ID or list of IDs.", + required=False, + type=str, +) + +INPUT_PARAMETER = OpenApiParameter( + name="input", + location=OpenApiParameter.QUERY, + description="Filter by partial match of name.", + required=False, + type=str, +) + +LATITUDE_PARAMETER = OpenApiParameter( + name="lat", + location=OpenApiParameter.QUERY, + description="Filter by location. Give latitude in WGS84 system. If this parameter is given also the 'lon' " + "parameter is required.", + required=False, + type=float, +) + +LEVEL_PARAMETER = OpenApiParameter( + name="level", + location=OpenApiParameter.QUERY, + description="Filter by level.", + required=False, + type=str, +) + +LONGITUDE_PARAMETER = OpenApiParameter( + name="lon", + location=OpenApiParameter.QUERY, + description="Filter by location. Give longitude in WGS84 system. If this parameter is given also the 'lat' " + "parameter is required.", + required=False, + type=float, +) + +MUNICIPALITY_PARAMETER = OpenApiParameter( + name="municipality", + location=OpenApiParameter.QUERY, + description="Filter by municipality.", + required=False, + type=str, +) + +OCD_ID_PARAMETER = OpenApiParameter( + name="ocd_id", + location=OpenApiParameter.QUERY, + description="Filter by OCD ID.", + required=False, + type=str, +) + +OCD_MUNICIPALITY_PARAMETER = OpenApiParameter( + name="municipality", + location=OpenApiParameter.QUERY, + description="Filter by municipality name or OCD ID.", + required=False, + type=str, +) + +ORGANIZATION_PARAMETER = OpenApiParameter( + name="organization", + location=OpenApiParameter.QUERY, + description="Filter by organization UUID.", + required=False, + type=str, +) + +ORIGIN_ID_PARAMETER = OpenApiParameter( + name="origin_id", + location=OpenApiParameter.QUERY, + description="Filter by origin ID.", + required=False, + type=str, +) + +PROVIDER_TYPE_NOT_PARAMETER = OpenApiParameter( + name="provider_type__not", + location=OpenApiParameter.QUERY, + description="Exclude by provider type numeric value.", + required=False, + type=int, +) + +PROVIDER_TYPE_PARAMETER = OpenApiParameter( + name="provider_type", + location=OpenApiParameter.QUERY, + description="Filter by provider type numeric value.", + required=False, + type=int, +) + +STREET_PARAMETER = OpenApiParameter( + name="street", + location=OpenApiParameter.QUERY, + description="Filter by street name.", + required=False, + type=str, +) + +UNIT_GEOMETRY_3D_PARAMETER = OpenApiParameter( + name="geometry_3d", + location=OpenApiParameter.QUERY, + description="If given displays the 3D geometry of unit if it exists.", + required=False, + type=bool, +) + +UNIT_GEOMETRY_PARAMETER = OpenApiParameter( + name="geometry", + location=OpenApiParameter.QUERY, + description="If given displays the geometry of unit if it exists.", + required=False, + type=bool, +) diff --git a/smbackend/settings.py b/smbackend/settings.py index 14ada4f3d..3fa06f3cb 100644 --- a/smbackend/settings.py +++ b/smbackend/settings.py @@ -358,6 +358,11 @@ def gettext(s): "/exceptional_situations/api/v1/situation/", "/exceptional_situations/api/v1/situation_type/", "/api/v2/search", + "/api/v2/address/", + "/api/v2/administrative_division/", + "/api/v2/service_node/", + "/api/v2/service/", + "/api/v2/unit/", ]