separate overlay feature data and geometries into different api endpoints so they can be cached independently

This commit is contained in:
Gwendolyn 2024-12-26 21:50:21 +01:00
parent 0e19ce5dac
commit e5ac1e12df
7 changed files with 154 additions and 66 deletions

View file

@ -27,8 +27,9 @@ from c3nav.mapdata.schemas.models import (AccessRestrictionGroupSchema, AccessRe
LineObstacleSchema, LocationGroupCategorySchema, LocationGroupSchema,
ObstacleSchema, POISchema, RampSchema, SourceSchema, SpaceSchema, StairSchema,
DataOverlaySchema, DataOverlayFeatureSchema, LocationRedirectSchema,
WayTypeSchema,
DataOverlayFeatureUpdateSchema, DataOverlayFeatureBulkUpdateSchema)
WayTypeSchema, DataOverlayFeatureGeometrySchema,
DataOverlayFeatureUpdateSchema, DataOverlayFeatureBulkUpdateSchema,
)
mapdata_api_router = APIRouter(tags=["mapdata"])
@ -77,14 +78,16 @@ class MapdataEndpoint:
model: Type[Model]
schema: Type[BaseSchema]
filters: Type[FilterSchema] | None = None
no_cache: bool = False
name: Optional[str] = None
@property
def model_name(self):
return self.model._meta.model_name
@property
def model_name_plural(self):
return self.model._meta.default_related_name
def endpoint_name(self):
return self.name if self.name is not None else self.model._meta.default_related_name
@dataclass
@ -110,7 +113,7 @@ class MapdataAPIBuilder:
add_call_params = {}
call_param_values = set(add_call_params.values())
call_params = (
*(f"{name}={name}" for name in set(view_params.keys())-call_param_values),
*(f"{name}={name}" for name in set(view_params.keys()) - call_param_values),
*(f"{name}={value}" for name, value in add_call_params.items()),
)
method_code = "\n".join((
@ -139,12 +142,15 @@ class MapdataAPIBuilder:
)
list_func.__name__ = f"{endpoint.model_name}_list"
self.router.get(f"/{endpoint.model_name_plural}/", summary=f"{endpoint.model_name} list",
if not endpoint.no_cache:
list_func = api_etag()(list_func)
self.router.get(f"/{endpoint.endpoint_name}/", summary=f"{endpoint.model_name} list",
tags=[f"mapdata-{tag}"], description=schema_description(endpoint.schema),
response={200: list[endpoint.schema],
**(validate_responses if endpoint.filters else {}),
**auth_responses})(
api_etag()(list_func)
list_func
)
def add_by_id_endpoint(self, endpoint: MapdataEndpoint, tag: str):
@ -160,7 +166,7 @@ class MapdataAPIBuilder:
)
list_func.__name__ = f"{endpoint.model_name}_by_id"
self.router.get(f'/{endpoint.model_name_plural}/{{{id_field}}}/', summary=f"{endpoint.model_name} by ID",
self.router.get(f'/{endpoint.endpoint_name}/{{{id_field}}}/', summary=f"{endpoint.model_name} by ID",
tags=[f"mapdata-{tag}"], description=schema_description(endpoint.schema),
response={200: endpoint.schema, **API404.dict(), **auth_responses})(
api_etag()(list_func)
@ -227,6 +233,14 @@ mapdata_endpoints: dict[str, list[MapdataEndpoint]] = {
model=DataOverlayFeature,
schema=DataOverlayFeatureSchema,
filters=ByOverlayFilter,
no_cache=True,
),
MapdataEndpoint(
model=DataOverlayFeature,
schema=DataOverlayFeatureGeometrySchema,
filters=ByOverlayFilter,
no_cache=True,
name='dataoverlayfeaturegeometries'
),
MapdataEndpoint(
model=WayType,
@ -304,12 +318,11 @@ mapdata_endpoints: dict[str, list[MapdataEndpoint]] = {
],
}
MapdataAPIBuilder(router=mapdata_api_router).build_all_endpoints(mapdata_endpoints)
@mapdata_api_router.post('/dataoverlayfeatures/{id}', summary="update a data overlay feature (including geometries)",
response={204: None, **API404.dict(), **auth_permission_responses})
response={204: None, **API404.dict(), **auth_permission_responses})
def update_data_overlay_feature(request, id: int, parameters: DataOverlayFeatureUpdateSchema):
"""
update the data overlay feature
@ -317,7 +330,8 @@ def update_data_overlay_feature(request, id: int, parameters: DataOverlayFeature
feature = get_object_or_404(DataOverlayFeature, id=id)
if feature.overlay.edit_access_restriction_id is None or feature.overlay.edit_access_restriction_id not in AccessPermission.get_for_request(request):
if feature.overlay.edit_access_restriction_id is None or feature.overlay.edit_access_restriction_id not in AccessPermission.get_for_request(
request):
raise APIPermissionDenied('You are not allowed to edit this object.')
updates = parameters.dict(exclude_unset=True)
@ -329,10 +343,10 @@ def update_data_overlay_feature(request, id: int, parameters: DataOverlayFeature
return 204, None
@mapdata_api_router.post('/dataoverlayfeatures-bulk', summary="bulk-update data overlays (including geometries)",
response={204: None, **API404.dict(), **auth_permission_responses})
def update_data_overlay_features_bulk(request, parameters: DataOverlayFeatureBulkUpdateSchema):
permissions = AccessPermission.get_for_request(request)
updates = {
@ -343,7 +357,8 @@ def update_data_overlay_features_bulk(request, parameters: DataOverlayFeatureBul
forbidden_object_ids = []
with transaction.atomic():
features = DataOverlayFeature.objects.filter(id__in=updates.keys()).annotate(edit_access_restriction_id=F('overlay__edit_access_restriction_id'))
features = DataOverlayFeature.objects.filter(id__in=updates.keys()).annotate(
edit_access_restriction_id=F('overlay__edit_access_restriction_id'))
for feature in features:
if feature.edit_access_restriction_id is None or feature.edit_access_restriction_id not in permissions:
@ -362,4 +377,4 @@ def update_data_overlay_features_bulk(request, parameters: DataOverlayFeatureBul
raise APIPermissionDenied('You are not allowed to edit the objects with the following ids: %s.'
% ", ".join([str(x) for x in forbidden_object_ids]))
return 204, None
return 204, None

View file

@ -0,0 +1,23 @@
# Generated by Django 5.1.3 on 2024-12-26 19:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mapdata', '0130_dataoverlay_edit_access_restriction'),
]
operations = [
migrations.AddField(
model_name='dataoverlay',
name='update_interval',
field=models.PositiveIntegerField(blank=True, help_text='in seconds', null=True, verbose_name='frontend update interval'),
),
migrations.AlterField(
model_name='dataoverlay',
name='fill_opacity',
field=models.FloatField(blank=True, null=True, verbose_name='default fill opacity'),
),
]

View file

@ -24,16 +24,21 @@ class DataOverlay(TitledMixin, AccessRestrictionMixin, models.Model):
stroke_width = models.FloatField(blank=True, null=True, verbose_name=_('default stroke width'))
stroke_opacity = models.FloatField(blank=True, null=True, verbose_name=_('stroke opacity'))
fill_color = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('default fill color'))
fill_opacity = models.FloatField(blank=True, null=True, verbose_name=_('fill opacity'))
fill_opacity = models.FloatField(blank=True, null=True, verbose_name=_('default fill opacity'))
cluster_points = models.BooleanField(default=False, verbose_name=_('cluster points together when zoomed out'))
default_geomtype = models.CharField(max_length=255, blank=True, null=True, choices=GeometryType, verbose_name=_('default geometry type'))
default_geomtype = models.CharField(max_length=255, blank=True, null=True, choices=GeometryType,
verbose_name=_('default geometry type'))
pull_url = models.URLField(blank=True, null=True, verbose_name=_('pull URL'))
pull_headers: dict[str, str] = SchemaField(schema=dict[str, str], null=True,
verbose_name=_('headers for pull http request (JSON object)'))
pull_interval = models.DurationField(blank=True, null=True, verbose_name=_('pull interval'))
update_interval = models.PositiveIntegerField(blank=True, null=True, verbose_name=_('frontend update interval'),
help_text=_('in seconds'))
edit_access_restriction = models.ForeignKey(AccessRestriction, null=True, blank=True,
related_name='edit_access_restrictions',
verbose_name=_('Editor Access Restriction'),
@ -46,7 +51,8 @@ class DataOverlay(TitledMixin, AccessRestrictionMixin, models.Model):
class DataOverlayFeature(TitledMixin, LevelGeometryMixin, models.Model):
overlay = models.ForeignKey('mapdata.DataOverlay', on_delete=models.CASCADE, verbose_name=_('Overlay'), related_name='features')
overlay = models.ForeignKey('mapdata.DataOverlay', on_delete=models.CASCADE, verbose_name=_('Overlay'),
related_name='features')
geometry = GeometryField()
# level = models.ForeignKey('mapdata.Level', on_delete=models.CASCADE, verbose_name=_('level'), related_name='data_overlay_features')
external_url = models.URLField(blank=True, null=True, verbose_name=_('external URL'))
@ -62,9 +68,10 @@ class DataOverlayFeature(TitledMixin, LevelGeometryMixin, models.Model):
point_icon = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('point icon'),
help_text=_(
'use this material icon to display points, instead of drawing a small circle (only makes sense if the geometry is a point)'))
extra_data: Optional[dict[str, str|int|bool]] = SchemaField(schema=dict[str, str|int|bool], blank=True, null=True,
default=None,
verbose_name=_('extra data (JSON object)'))
extra_data: Optional[dict[str, str | int | bool]] = SchemaField(schema=dict[str, str | int | bool], blank=True,
null=True,
default=None,
verbose_name=_('extra data (JSON object)'))
def to_geojson(self, instance=None) -> dict:
result = {

View file

@ -376,14 +376,13 @@ class DataOverlaySchema(TitledSchema, DjangoModelSchema):
fill_color: Optional[str]
fill_opacity: Optional[float]
cluster_points: bool
update_interval: Optional[PositiveInt]
class DataOverlayFeatureSchema(TitledSchema, WithGeometrySchema, DjangoModelSchema):
class DataOverlayFeatureSchema(TitledSchema, DjangoModelSchema):
"""
A feature (any kind of geometry) to be displayed as part of a data overlay.
"""
geometry: AnyGeometrySchema
level_id: PositiveInt
stroke_color: Optional[str]
stroke_width: Optional[float]
@ -397,6 +396,13 @@ class DataOverlayFeatureSchema(TitledSchema, WithGeometrySchema, DjangoModelSche
external_url: Optional[str]
extra_data: Optional[dict[str, str | int | float]]
class DataOverlayFeatureGeometrySchema(WithGeometrySchema, DjangoModelSchema):
"""
A feature (any kind of geometry) to be displayed as part of a data overlay.
"""
geometry: AnyGeometrySchema
class DataOverlayFeatureUpdateSchema(BaseSchema):
"""
An update to a data overlay feature.