convert django lazy string proxies to strings in the base schema validator, rather than in the serialization code of each source model

This commit is contained in:
Gwendolyn 2023-12-11 20:49:50 +01:00
parent 4c06abd400
commit 4b1ac9f194
21 changed files with 126 additions and 104 deletions

View file

@ -1,16 +1,16 @@
from django.conf import settings from django.conf import settings
from ninja import Field as APIField from ninja import Field as APIField
from ninja import Router as APIRouter from ninja import Router as APIRouter
from ninja import Schema
from c3nav.api.auth import APIKeyType, auth_responses from c3nav.api.auth import APIKeyType, auth_responses
from c3nav.api.schema import BaseSchema
from c3nav.api.utils import NonEmptyStr from c3nav.api.utils import NonEmptyStr
from c3nav.control.models import UserPermissions from c3nav.control.models import UserPermissions
auth_api_router = APIRouter(tags=["auth"]) auth_api_router = APIRouter(tags=["auth"])
class AuthStatusSchema(Schema): class AuthStatusSchema(BaseSchema):
""" """
Current auth state and permissions Current auth state and permissions
""" """
@ -44,7 +44,7 @@ def get_status(request):
) )
class APIKeySchema(Schema): class APIKeySchema(BaseSchema):
key: NonEmptyStr = APIField( key: NonEmptyStr = APIField(
title="API key", title="API key",
description="API secret to be directly used with `X-API-Key` HTTP header." description="API secret to be directly used with `X-API-Key` HTTP header."

View file

@ -1,3 +1,5 @@
from django.utils.functional import Promise
from c3nav.api.schema import APIErrorSchema from c3nav.api.schema import APIErrorSchema
@ -7,7 +9,10 @@ class CustomAPIException(Exception):
def __init__(self, detail=None): def __init__(self, detail=None):
if detail is not None: if detail is not None:
self.detail = detail if isinstance(detail, Promise):
self.detail = str(detail)
else:
self.detail = detail
def get_response(self, api, request): def get_response(self, api, request):
return api.create_response(request, {"detail": self.detail}, status=self.status_code) return api.create_response(request, {"detail": self.detail}, status=self.status_code)

View file

@ -1,13 +1,44 @@
from types import NoneType
from typing import Annotated, Any, Literal, Union from typing import Annotated, Any, Literal, Union
from django.utils.functional import Promise
from ninja import Schema from ninja import Schema
from pydantic import Discriminator from pydantic import Discriminator, model_validator
from pydantic import Field as APIField from pydantic import Field as APIField
from pydantic.functional_validators import ModelWrapValidatorHandler
from pydantic_core.core_schema import ValidationInfo
from c3nav.api.utils import NonEmptyStr from c3nav.api.utils import NonEmptyStr
class APIErrorSchema(Schema): class BaseSchema(Schema):
@model_validator(mode="wrap") # noqa
@classmethod
def _run_root_validator(cls, values: Any, handler: ModelWrapValidatorHandler[Schema], info: ValidationInfo) -> Any:
""" overwriting this, we need to call serialize to get the correct data """
return handler(cls.convert(values))
@classmethod
def convert(cls, values: Any):
if isinstance(values, Schema):
return values
if isinstance(values, (str, bool, int, float, complex, NoneType)):
return values
if isinstance(values, dict):
return {
key: cls.convert(val)
for key, val in values.items()
}
if isinstance(values, (list, tuple, set, frozenset)):
return type(values)(cls.convert(val) for val in values)
if isinstance(values, Promise):
return str(values)
if hasattr(values, 'serialize') and callable(values.serialize):
return values.serialize()
return values
class APIErrorSchema(BaseSchema):
""" """
An error has occured with this request An error has occured with this request
""" """
@ -16,7 +47,7 @@ class APIErrorSchema(Schema):
) )
class PolygonSchema(Schema): class PolygonSchema(BaseSchema):
""" """
A GeoJSON Polygon A GeoJSON Polygon
""" """
@ -29,7 +60,7 @@ class PolygonSchema(Schema):
title = "GeoJSON Polygon" title = "GeoJSON Polygon"
class LineStringSchema(Schema): class LineStringSchema(BaseSchema):
""" """
A GeoJSON LineString A GeoJSON LineString
""" """
@ -42,7 +73,7 @@ class LineStringSchema(Schema):
title = "GeoJSON LineString" title = "GeoJSON LineString"
class LineSchema(Schema): class LineSchema(BaseSchema):
""" """
A GeoJSON LineString with only two points A GeoJSON LineString with only two points
""" """
@ -55,7 +86,7 @@ class LineSchema(Schema):
title = "GeoJSON LineString (only two points)" title = "GeoJSON LineString (only two points)"
class PointSchema(Schema): class PointSchema(BaseSchema):
""" """
A GeoJSON Point A GeoJSON Point
""" """
@ -77,7 +108,7 @@ GeometrySchema = Annotated[
] ]
class AnyGeometrySchema(Schema): class AnyGeometrySchema(BaseSchema):
""" """
A GeoJSON Geometry A GeoJSON Geometry
""" """

View file

@ -125,7 +125,7 @@ def get_view_as_api(request, path: str):
resolved = resolve_editor_path_api(request, path) resolved = resolve_editor_path_api(request, path)
if not resolved: if not resolved:
raise API404(str(_('No matching editor view endpoint found.'))) raise API404(_('No matching editor view endpoint found.'))
if not getattr(resolved.func, 'api_hybrid', False): if not getattr(resolved.func, 'api_hybrid', False):
raise API404(_('Matching editor view point does not provide an API.')) raise API404(_('Matching editor view point does not provide an API.'))

View file

@ -1,10 +1,9 @@
from typing import Annotated, Literal, Optional, Union from typing import Annotated, Literal, Optional, Union
from ninja import Schema
from pydantic import Field as APIField from pydantic import Field as APIField
from pydantic import PositiveInt from pydantic import PositiveInt
from c3nav.api.schema import AnyGeometrySchema, GeometrySchema, LineSchema from c3nav.api.schema import AnyGeometrySchema, GeometrySchema, LineSchema, BaseSchema
from c3nav.api.utils import NonEmptyStr from c3nav.api.utils import NonEmptyStr
GeometryStylesSchema = Annotated[ GeometryStylesSchema = Annotated[
@ -47,7 +46,7 @@ EditorGeometriesCacheReferenceElem = Annotated[
] ]
class EditorGeometriesPropertiesSchema(Schema): class EditorGeometriesPropertiesSchema(BaseSchema):
id: EditorID id: EditorID
type: NonEmptyStr type: NonEmptyStr
space: Union[ space: Union[
@ -63,20 +62,20 @@ class EditorGeometriesPropertiesSchema(Schema):
opacity: Optional[float] = None # todo: range opacity: Optional[float] = None # todo: range
class EditorGeometriesGraphEdgePropertiesSchema(Schema): class EditorGeometriesGraphEdgePropertiesSchema(BaseSchema):
id: EditorID id: EditorID
type: Literal["graphedge"] type: Literal["graphedge"]
from_node: EditorID from_node: EditorID
to_node: EditorID to_node: EditorID
class EditorGeometriesGraphEdgeElemSchema(Schema): class EditorGeometriesGraphEdgeElemSchema(BaseSchema):
type: Literal["Feature"] type: Literal["Feature"]
properties: EditorGeometriesGraphEdgePropertiesSchema properties: EditorGeometriesGraphEdgePropertiesSchema
geometry: LineSchema geometry: LineSchema
class EditorGeometriesGeometryElemSchema(Schema): class EditorGeometriesGeometryElemSchema(BaseSchema):
type: Literal["Feature"] type: Literal["Feature"]
geometry: AnyGeometrySchema = APIField(description="geometry, potentially modified for displaying") geometry: AnyGeometrySchema = APIField(description="geometry, potentially modified for displaying")
original_geometry: Optional[GeometrySchema] = APIField( original_geometry: Optional[GeometrySchema] = APIField(

View file

@ -187,7 +187,7 @@ class APIHybridFormTemplateResponse(APIHybridResponse):
def get_api_response(self, request): def get_api_response(self, request):
result = {} result = {}
if self.error: if self.error:
result['error'] = str(self.error.message) result['error'] = self.error.message
self.status_code = self.error.status_code self.status_code = self.error.status_code
if request.method == 'POST': if request.method == 'POST':
if not self.form.is_valid(): if not self.form.is_valid():

View file

@ -6,12 +6,12 @@ from django.shortcuts import redirect
from django.utils import timezone from django.utils import timezone
from ninja import Query from ninja import Query
from ninja import Router as APIRouter from ninja import Router as APIRouter
from ninja import Schema
from pydantic import Field as APIField from pydantic import Field as APIField
from pydantic import PositiveInt from pydantic import PositiveInt
from c3nav.api.auth import auth_permission_responses, auth_responses, validate_responses from c3nav.api.auth import auth_permission_responses, auth_responses, validate_responses
from c3nav.api.exceptions import API404, APIPermissionDenied, APIRequestValidationFailed from c3nav.api.exceptions import API404, APIPermissionDenied, APIRequestValidationFailed
from c3nav.api.schema import BaseSchema
from c3nav.api.utils import NonEmptyStr from c3nav.api.utils import NonEmptyStr
from c3nav.mapdata.api.base import api_etag, api_stats, can_access_geometry from c3nav.mapdata.api.base import api_etag, api_stats, can_access_geometry
from c3nav.mapdata.models import Source from c3nav.mapdata.models import Source
@ -39,7 +39,7 @@ def bounds(request):
} }
class LocationEndpointParameters(Schema): class LocationEndpointParameters(BaseSchema):
searchable: bool = APIField( searchable: bool = APIField(
False, False,
title='only list searchable locations', title='only list searchable locations',
@ -133,7 +133,7 @@ def _location_geometry(request, location):
) )
class ShowRedirects(Schema): class ShowRedirects(BaseSchema):
show_redirects: bool = APIField( show_redirects: bool = APIField(
False, False,
name="show redirects", name="show redirects",
@ -268,7 +268,7 @@ def get_position_by_id(request, position_id: AnyPositionID):
return location.serialize_position() return location.serialize_position()
class UpdatePositionSchema(Schema): class UpdatePositionSchema(BaseSchema):
coordinates_id: Union[ coordinates_id: Union[
Annotated[CustomLocationID, APIField(title="set coordinates")], Annotated[CustomLocationID, APIField(title="set coordinates")],
Annotated[None, APIField(title="unset coordinates")], Annotated[None, APIField(title="unset coordinates")],

View file

@ -4,10 +4,10 @@ from urllib.parse import urlparse
from django.http import HttpResponse from django.http import HttpResponse
from ninja import Field as APIField from ninja import Field as APIField
from ninja import Router as APIRouter from ninja import Router as APIRouter
from ninja import Schema
from pydantic import PositiveInt from pydantic import PositiveInt
from c3nav.api.auth import auth_responses from c3nav.api.auth import auth_responses
from c3nav.api.schema import BaseSchema
from c3nav.api.utils import NonEmptyStr from c3nav.api.utils import NonEmptyStr
from c3nav.mapdata.models import MapUpdate from c3nav.mapdata.models import MapUpdate
from c3nav.mapdata.utils.cache.stats import increment_cache_key from c3nav.mapdata.utils.cache.stats import increment_cache_key
@ -17,7 +17,7 @@ from c3nav.mapdata.views import set_tile_access_cookie
updates_api_router = APIRouter(tags=["updates"]) updates_api_router = APIRouter(tags=["updates"])
class UserDataSchema(Schema): class UserDataSchema(BaseSchema):
logged_in: bool = APIField( logged_in: bool = APIField(
title="logged in", title="logged in",
description="whether a user is logged in", description="whether a user is logged in",
@ -52,7 +52,7 @@ class UserDataSchema(Schema):
) )
class FetchUpdatesResponseSchema(Schema): class FetchUpdatesResponseSchema(BaseSchema):
last_site_update: Optional[PositiveInt] = APIField( last_site_update: Optional[PositiveInt] = APIField(
title="ID of the last site update", title="ID of the last site update",
description="If this ID increments, it means a major code change may have occurred. " description="If this ID increments, it means a major code change may have occurred. "

View file

@ -74,7 +74,7 @@ class TitledMixin(SerializableMixin, models.Model):
result = super()._serialize(detailed=detailed, **kwargs) result = super()._serialize(detailed=detailed, **kwargs)
if detailed: if detailed:
result['titles'] = self.titles result['titles'] = self.titles
result['title'] = str(self.title) result['title'] = self.title
return result return result
@property @property

View file

@ -88,7 +88,7 @@ class LocationSlug(SerializableMixin, models.Model):
def details_display(self, **kwargs): def details_display(self, **kwargs):
result = super().details_display(**kwargs) result = super().details_display(**kwargs)
result['display'].insert(2, (_('Slug'), str(self.get_slug()))) result['display'].insert(2, (_('Slug'), self.get_slug()))
return result return result
@cached_property @cached_property
@ -120,7 +120,7 @@ class Location(LocationSlug, AccessRestrictionMixin, TitledMixin, models.Model):
def _serialize(self, search=False, **kwargs): def _serialize(self, search=False, **kwargs):
result = super()._serialize(**kwargs) result = super()._serialize(**kwargs)
result['subtitle'] = str(self.subtitle) result['subtitle'] = self.subtitle
result['icon'] = self.get_icon() result['icon'] = self.get_icon()
result['can_search'] = self.can_search result['can_search'] = self.can_search
result['can_describe'] = self.can_search result['can_describe'] = self.can_search
@ -202,7 +202,7 @@ class SpecificLocation(Location, models.Model):
result['label_settings'] = label_settings.serialize(detailed=False) result['label_settings'] = label_settings.serialize(detailed=False)
if self.label_overrides: if self.label_overrides:
# todo: what if only one language is set? # todo: what if only one language is set?
result['label_override'] = str(self.label_override) result['label_override'] = self.label_override
return result return result
def get_label_settings(self): def get_label_settings(self):
@ -300,9 +300,9 @@ class LocationGroupCategory(SerializableMixin, models.Model):
result['titles'] = self.titles result['titles'] = self.titles
result['titles_plural'] = self.titles_plural result['titles_plural'] = self.titles_plural
result['help_texts'] = self.help_texts result['help_texts'] = self.help_texts
result['title'] = str(self.title) result['title'] = self.title
result['title_plural'] = str(self.title_plural) result['title_plural'] = self.title_plural
result['help_text'] = str(self.help_text) result['help_text'] = self.help_text
result['allow_levels'] = self.allow_levels result['allow_levels'] = self.allow_levels
result['allow_spaces'] = self.allow_spaces result['allow_spaces'] = self.allow_spaces
result['allow_areas'] = self.allow_areas result['allow_areas'] = self.allow_areas

View file

@ -2,10 +2,10 @@ from typing import Literal, Optional, Type
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Model, QuerySet from django.db.models import Model, QuerySet
from ninja import Schema
from pydantic import Field as APIField from pydantic import Field as APIField
from c3nav.api.exceptions import APIRequestValidationFailed from c3nav.api.exceptions import APIRequestValidationFailed
from c3nav.api.schema import BaseSchema
from c3nav.mapdata.models import Level, LocationGroup, LocationGroupCategory, MapUpdate, Space from c3nav.mapdata.models import Level, LocationGroup, LocationGroupCategory, MapUpdate, Space
from c3nav.mapdata.models.access import AccessPermission from c3nav.mapdata.models.access import AccessPermission
@ -38,7 +38,7 @@ def assert_valid_value(request, model: Type[Model], key: str, values: set):
raise APIRequestValidationFailed("Unknown %s: %r" % (model.__name__, remainder)) raise APIRequestValidationFailed("Unknown %s: %r" % (model.__name__, remainder))
class FilterSchema(Schema): class FilterSchema(BaseSchema):
def filter_qs(self, qs: QuerySet) -> QuerySet: def filter_qs(self, qs: QuerySet) -> QuerySet:
return qs return qs

View file

@ -1,13 +1,11 @@
import re import re
from typing import Annotated, Any, Optional, Union from typing import Annotated, Optional, Union
from ninja import Schema from ninja import Schema
from pydantic import Field as APIField from pydantic import Field as APIField
from pydantic import PositiveInt, model_validator from pydantic import PositiveInt
from pydantic.functional_validators import ModelWrapValidatorHandler
from pydantic_core.core_schema import ValidationInfo
from c3nav.api.schema import LineStringSchema, PointSchema, PolygonSchema from c3nav.api.schema import LineStringSchema, PointSchema, PolygonSchema, BaseSchema
from c3nav.api.utils import NonEmptyStr from c3nav.api.utils import NonEmptyStr
@ -36,24 +34,14 @@ BoundsSchema = tuple[
] ]
class SerializableSchema(Schema): class DjangoModelSchema(BaseSchema):
@model_validator(mode="wrap") # noqa
@classmethod
def _run_root_validator(cls, values: Any, handler: ModelWrapValidatorHandler[Schema], info: ValidationInfo) -> Any:
""" overwriting this, we need to call serialize to get the correct data """
if hasattr(values, 'serialize') and callable(values.serialize):
values = values.serialize()
return handler(values)
class DjangoModelSchema(SerializableSchema):
id: PositiveInt = APIField( id: PositiveInt = APIField(
title="ID", title="ID",
example=1, example=1,
) )
class LocationSlugSchema(Schema): class LocationSlugSchema(BaseSchema):
slug: NonEmptyStr = APIField( slug: NonEmptyStr = APIField(
title="location slug", title="location slug",
description="a slug is a unique way to refer to a location. while locations have a shared ID space, slugs" description="a slug is a unique way to refer to a location. while locations have a shared ID space, slugs"
@ -65,7 +53,7 @@ class LocationSlugSchema(Schema):
) )
class WithAccessRestrictionSchema(Schema): class WithAccessRestrictionSchema(BaseSchema):
access_restriction: Union[ access_restriction: Union[
Annotated[PositiveInt, APIField(title="access restriction ID")], Annotated[PositiveInt, APIField(title="access restriction ID")],
Annotated[None, APIField(title="null", description="no access restriction")], Annotated[None, APIField(title="null", description="no access restriction")],
@ -76,7 +64,7 @@ class WithAccessRestrictionSchema(Schema):
) )
class TitledSchema(Schema): class TitledSchema(BaseSchema):
titles: dict[NonEmptyStr, NonEmptyStr] = APIField( titles: dict[NonEmptyStr, NonEmptyStr] = APIField(
title="title (all languages)", title="title (all languages)",
description="title in all available languages. property names are the ISO-language code. " description="title in all available languages. property names are the ISO-language code. "
@ -213,7 +201,7 @@ class SpecificLocationSchema(LocationSchema):
) )
class WithPolygonGeometrySchema(Schema): class WithPolygonGeometrySchema(BaseSchema):
geometry: Union[ geometry: Union[
PolygonSchema, PolygonSchema,
Annotated[None, APIField(title="null", description="geometry not available of excluded from endpoint")] Annotated[None, APIField(title="null", description="geometry not available of excluded from endpoint")]
@ -224,7 +212,7 @@ class WithPolygonGeometrySchema(Schema):
) )
class WithLineStringGeometrySchema(Schema): class WithLineStringGeometrySchema(BaseSchema):
geometry: Union[ geometry: Union[
LineStringSchema, LineStringSchema,
Annotated[None, APIField(title="null", description="geometry not available of excluded from endpoint")] Annotated[None, APIField(title="null", description="geometry not available of excluded from endpoint")]
@ -235,7 +223,7 @@ class WithLineStringGeometrySchema(Schema):
) )
class WithPointGeometrySchema(Schema): class WithPointGeometrySchema(BaseSchema):
geometry: Union[ geometry: Union[
PointSchema, PointSchema,
Annotated[None, APIField(title="null", description="geometry not available of excluded from endpoint")] Annotated[None, APIField(title="null", description="geometry not available of excluded from endpoint")]
@ -246,7 +234,7 @@ class WithPointGeometrySchema(Schema):
) )
class WithLevelSchema(SerializableSchema): class WithLevelSchema(BaseSchema):
level: PositiveInt = APIField( level: PositiveInt = APIField(
title="level", title="level",
description="level id this object belongs to.", description="level id this object belongs to.",
@ -254,7 +242,7 @@ class WithLevelSchema(SerializableSchema):
) )
class WithSpaceSchema(SerializableSchema): class WithSpaceSchema(BaseSchema):
space: PositiveInt = APIField( space: PositiveInt = APIField(
title="space", title="space",
description="space id this object belongs to.", description="space id this object belongs to.",
@ -262,7 +250,7 @@ class WithSpaceSchema(SerializableSchema):
) )
class SimpleGeometryPointSchema(Schema): class SimpleGeometryPointSchema(BaseSchema):
point: tuple[ point: tuple[
Annotated[PositiveInt, APIField(title="level ID")], Annotated[PositiveInt, APIField(title="level ID")],
Annotated[float, APIField(title="x coordinate")], Annotated[float, APIField(title="x coordinate")],
@ -281,7 +269,7 @@ class SimpleGeometryPointAndBoundsSchema(SimpleGeometryPointSchema):
) )
class SimpleGeometryLocationsSchema(Schema): class SimpleGeometryLocationsSchema(BaseSchema):
locations: list[PositiveInt] = APIField( # todo: this should be a set… but json serialization? locations: list[PositiveInt] = APIField( # todo: this should be a set… but json serialization?
description="IDs of all locations that belong to this grouo", description="IDs of all locations that belong to this grouo",
example=(1, 2, 3), example=(1, 2, 3),

View file

@ -5,10 +5,10 @@ from pydantic import Discriminator
from pydantic import Field as APIField from pydantic import Field as APIField
from pydantic import NonNegativeFloat, PositiveFloat, PositiveInt from pydantic import NonNegativeFloat, PositiveFloat, PositiveInt
from c3nav.api.schema import GeometrySchema, PointSchema from c3nav.api.schema import GeometrySchema, PointSchema, BaseSchema
from c3nav.api.utils import NonEmptyStr from c3nav.api.utils import NonEmptyStr
from c3nav.mapdata.schemas.model_base import (AnyLocationID, AnyPositionID, CustomLocationID, DjangoModelSchema, from c3nav.mapdata.schemas.model_base import (AnyLocationID, AnyPositionID, CustomLocationID, DjangoModelSchema,
LabelSettingsSchema, LocationSchema, PositionID, SerializableSchema, LabelSettingsSchema, LocationSchema, PositionID,
SimpleGeometryLocationsSchema, SimpleGeometryPointAndBoundsSchema, SimpleGeometryLocationsSchema, SimpleGeometryPointAndBoundsSchema,
SimpleGeometryPointSchema, SpecificLocationSchema, TitledSchema, SimpleGeometryPointSchema, SpecificLocationSchema, TitledSchema,
WithAccessRestrictionSchema, WithLevelSchema, WithAccessRestrictionSchema, WithLevelSchema,
@ -357,7 +357,7 @@ class AccessRestrictionGroupSchema(WithAccessRestrictionSchema, DjangoModelSchem
pass pass
class CustomLocationSchema(SerializableSchema): class CustomLocationSchema(BaseSchema):
""" """
A custom location represents coordinates that have been put in or calculated. A custom location represents coordinates that have been put in or calculated.
@ -439,7 +439,7 @@ class CustomLocationSchema(SerializableSchema):
) )
class TrackablePositionSchema(Schema): class TrackablePositionSchema(BaseSchema):
""" """
A trackable position. Its position can be set or reset. A trackable position. Its position can be set or reset.
""" """
@ -468,7 +468,7 @@ class TrackablePositionSchema(Schema):
) )
class LocationTypeSchema(Schema): class LocationTypeSchema(BaseSchema):
locationtype: str = APIField(title="location type", locationtype: str = APIField(title="location type",
description="indicates what kind of location is included. " description="indicates what kind of location is included. "
"different location types have different fields.") "different location types have different fields.")
@ -544,7 +544,7 @@ class TrackablePositionLocationSchema(TrackablePositionSchema, LocationTypeSchem
locationtype: Literal["position"] = LocationTypeAPIField() locationtype: Literal["position"] = LocationTypeAPIField()
class SlimLocationMixin(Schema): class SlimLocationMixin(BaseSchema):
level: ClassVar[None] level: ClassVar[None]
space: ClassVar[None] space: ClassVar[None]
titles: ClassVar[None] titles: ClassVar[None]
@ -662,7 +662,7 @@ all_location_definitions = listable_location_definitions + "\n" + schema_definit
) )
class DisplayLink(Schema): class DisplayLink(BaseSchema):
""" """
A link for the location display A link for the location display
""" """
@ -672,7 +672,7 @@ class DisplayLink(Schema):
can_search: bool can_search: bool
class LocationDisplay(SerializableSchema): class LocationDisplay(BaseSchema):
id: AnyLocationID = APIField( id: AnyLocationID = APIField(
description="a numeric ID for a map location or a string ID for generated locations", description="a numeric ID for a map location or a string ID for generated locations",
example=1, example=1,
@ -749,7 +749,7 @@ class LocationDisplay(SerializableSchema):
) )
class PositionStatusSchema(Schema): class PositionStatusSchema(BaseSchema):
id: AnyPositionID = APIField( id: AnyPositionID = APIField(
description="the ID of the dynamic position that has been queries", description="the ID of the dynamic position that has been queries",
) )
@ -758,7 +758,7 @@ class PositionStatusSchema(Schema):
) )
class PositionAvailabilitySchema(Schema): class PositionAvailabilitySchema(BaseSchema):
available: str available: str

View file

@ -1,14 +1,13 @@
from typing import Annotated, Union from typing import Annotated, Union
from ninja import Schema
from pydantic import Field as APIField from pydantic import Field as APIField
from pydantic import PositiveInt from pydantic import PositiveInt
from c3nav.api.schema import GeometrySchema from c3nav.api.schema import GeometrySchema, BaseSchema
from c3nav.mapdata.schemas.model_base import AnyLocationID, BoundsSchema from c3nav.mapdata.schemas.model_base import AnyLocationID, BoundsSchema
class WithBoundsSchema(Schema): class WithBoundsSchema(BaseSchema):
""" """
Describing a bounding box Describing a bounding box
""" """
@ -18,7 +17,7 @@ class WithBoundsSchema(Schema):
) )
class LocationGeometry(Schema): class LocationGeometry(BaseSchema):
id: AnyLocationID = APIField( id: AnyLocationID = APIField(
description="ID of the location that the geometry is being queried for", description="ID of the location that the geometry is being queried for",
) )

View file

@ -416,7 +416,7 @@ class CustomLocation:
@cached_property @cached_property
def grid_square(self): def grid_square(self):
return grid.get_square_for_point(self.x, self.y) or '' return grid.get_square_for_point(self.x, self.y)
@cached_property @cached_property
def title_subtitle(self): def title_subtitle(self):

View file

@ -17,13 +17,13 @@ def get_user_data(request):
} }
if permissions: if permissions:
result.update({ result.update({
'title': str(_('not logged in')), 'title': _('not logged in'),
'subtitle': ngettext_lazy('%d area unlocked', '%d areas unlocked', len(permissions)) % len(permissions), 'subtitle': ngettext_lazy('%d area unlocked', '%d areas unlocked', len(permissions)) % len(permissions),
'permissions': tuple(permissions), 'permissions': tuple(permissions),
}) })
else: else:
result.update({ result.update({
'title': str(_('Login')), 'title': _('Login'),
'subtitle': None, 'subtitle': None,
'permissions': (), 'permissions': (),
}) })

View file

@ -11,6 +11,7 @@ from pydantic import PositiveInt, field_validator
from c3nav.api.auth import APIKeyAuth, auth_permission_responses, auth_responses, validate_responses from c3nav.api.auth import APIKeyAuth, auth_permission_responses, auth_responses, validate_responses
from c3nav.api.exceptions import API404, APIConflict, APIRequestValidationFailed from c3nav.api.exceptions import API404, APIConflict, APIRequestValidationFailed
from c3nav.api.schema import BaseSchema
from c3nav.mesh.dataformats import BoardType, ChipType, FirmwareImage from c3nav.mesh.dataformats import BoardType, ChipType, FirmwareImage
from c3nav.mesh.messages import MeshMessageType from c3nav.mesh.messages import MeshMessageType
from c3nav.mesh.models import FirmwareBuild, FirmwareVersion, NodeMessage from c3nav.mesh.models import FirmwareBuild, FirmwareVersion, NodeMessage
@ -18,7 +19,7 @@ from c3nav.mesh.models import FirmwareBuild, FirmwareVersion, NodeMessage
mesh_api_router = APIRouter(tags=["mesh"], auth=APIKeyAuth(permissions={"mesh_control"})) mesh_api_router = APIRouter(tags=["mesh"], auth=APIKeyAuth(permissions={"mesh_control"}))
class FirmwareBuildSchema(Schema): class FirmwareBuildSchema(BaseSchema):
""" """
A build belonging to a firmware version. A build belonging to a firmware version.
""" """
@ -53,7 +54,7 @@ class FirmwareBuildSchema(Schema):
pass pass
class FirmwareSchema(Schema): class FirmwareSchema(BaseSchema):
""" """
A firmware version, usually with multiple build variants. A firmware version, usually with multiple build variants.
""" """
@ -128,7 +129,7 @@ def firmware_project_description(request, firmware_id: int, variant: str):
raise API404("Firmware or firmware build not found") raise API404("Firmware or firmware build not found")
class UploadFirmwareBuildSchema(Schema): class UploadFirmwareBuildSchema(BaseSchema):
""" """
A firmware build to upload, with at least one build variant A firmware build to upload, with at least one build variant
""" """
@ -138,7 +139,7 @@ class UploadFirmwareBuildSchema(Schema):
uploaded_filename: str = APIField(..., example="firmware.bin") uploaded_filename: str = APIField(..., example="firmware.bin")
class UploadFirmwareSchema(Schema): class UploadFirmwareSchema(BaseSchema):
""" """
A firmware version to upload, with at least one build variant A firmware version to upload, with at least one build variant
""" """
@ -205,14 +206,14 @@ def firmware_upload(request, firmware_data: UploadFirmwareSchema, binary_files:
NodeAddress = Annotated[str, APIField(pattern=r"^[a-z0-9]{2}(:[a-z0-9]{2}){5}$")] NodeAddress = Annotated[str, APIField(pattern=r"^[a-z0-9]{2}(:[a-z0-9]{2}){5}$")]
class MessagesFilter(Schema): class MessagesFilter(BaseSchema):
src_node: Optional[NodeAddress] = None src_node: Optional[NodeAddress] = None
msg_type: Optional[MeshMessageType] = None msg_type: Optional[MeshMessageType] = None
time_from: Optional[datetime] = None time_from: Optional[datetime] = None
time_until: Optional[datetime] = None time_until: Optional[datetime] = None
class NodeMessageSchema(Schema): class NodeMessageSchema(BaseSchema):
id: int id: int
src_node: NodeAddress src_node: NodeAddress
message_type: MeshMessageType message_type: MeshMessageType

View file

@ -3,9 +3,9 @@ from typing import Annotated, Union
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from ninja import Field as APIField from ninja import Field as APIField
from ninja import Router as APIRouter from ninja import Router as APIRouter
from ninja import Schema
from c3nav.api.auth import auth_responses from c3nav.api.auth import auth_responses
from c3nav.api.schema import BaseSchema
from c3nav.mapdata.models.access import AccessPermission from c3nav.mapdata.models.access import AccessPermission
from c3nav.mapdata.schemas.models import CustomLocationSchema from c3nav.mapdata.schemas.models import CustomLocationSchema
from c3nav.mapdata.utils.cache.stats import increment_cache_key from c3nav.mapdata.utils.cache.stats import increment_cache_key
@ -15,13 +15,13 @@ from c3nav.routing.schemas import BSSIDSchema, LocateRequestPeerSchema
positioning_api_router = APIRouter(tags=["positioning"]) positioning_api_router = APIRouter(tags=["positioning"])
class LocateRequestSchema(Schema): class LocateRequestSchema(BaseSchema):
peers: list[LocateRequestPeerSchema] = APIField( peers: list[LocateRequestPeerSchema] = APIField(
title="list of visible/measured location beacons", title="list of visible/measured location beacons",
) )
class PositioningResult(Schema): class PositioningResult(BaseSchema):
location: Union[ location: Union[
Annotated[CustomLocationSchema, APIField(title="location")], Annotated[CustomLocationSchema, APIField(title="location")],
Annotated[None, APIField(title="null", description="position could not be determined")] Annotated[None, APIField(title="null", description="position could not be determined")]

View file

@ -11,6 +11,7 @@ from pydantic import PositiveInt
from c3nav.api.auth import APIKeyAuth, auth_responses, validate_responses from c3nav.api.auth import APIKeyAuth, auth_responses, validate_responses
from c3nav.api.exceptions import APIRequestValidationFailed from c3nav.api.exceptions import APIRequestValidationFailed
from c3nav.api.schema import BaseSchema
from c3nav.api.utils import NonEmptyStr from c3nav.api.utils import NonEmptyStr
from c3nav.mapdata.api.base import api_stats_clean_location_value from c3nav.mapdata.api.base import api_stats_clean_location_value
from c3nav.mapdata.models.access import AccessPermission from c3nav.mapdata.models.access import AccessPermission
@ -53,7 +54,7 @@ class AltitudeWayTypeChoice(StrEnum):
AVOID = "avoid" AVOID = "avoid"
class UpdateRouteOptionsSchema(Schema): class UpdateRouteOptionsSchema(BaseSchema):
# todo: default is wrong, this should be optional # todo: default is wrong, this should be optional
mode: Union[ mode: Union[
Annotated[RouteMode, APIField(title="route mode", description="routing mode to use")], Annotated[RouteMode, APIField(title="route mode", description="routing mode to use")],
@ -81,7 +82,7 @@ class UpdateRouteOptionsSchema(Schema):
) )
class RouteOptionsSchema(Schema): class RouteOptionsSchema(BaseSchema):
# todo: default is wrong, this should be optional # todo: default is wrong, this should be optional
mode: RouteMode = APIField(name="routing mode") mode: RouteMode = APIField(name="routing mode")
walk_speed: WalkSpeed = APIField(name="walk speed") walk_speed: WalkSpeed = APIField(name="walk speed")
@ -96,7 +97,7 @@ class RouteOptionsSchema(Schema):
) )
class RouteParametersSchema(Schema): class RouteParametersSchema(BaseSchema):
origin: AnyLocationID origin: AnyLocationID
destination: AnyLocationID destination: AnyLocationID
options_override: Optional[UpdateRouteOptionsSchema] = APIField( options_override: Optional[UpdateRouteOptionsSchema] = APIField(
@ -105,7 +106,7 @@ class RouteParametersSchema(Schema):
) )
class RouteItemSchema(Schema): class RouteItemSchema(BaseSchema):
id: PositiveInt id: PositiveInt
coordinates: Coordinates3D coordinates: Coordinates3D
waytype: Union[ waytype: Union[
@ -132,7 +133,7 @@ class RouteItemSchema(Schema):
]] ]]
class RouteSchema(Schema): class RouteSchema(BaseSchema):
origin: dict # todo: improve this origin: dict # todo: improve this
destination: dict # todo: improve this destination: dict # todo: improve this
distance: float distance: float
@ -144,7 +145,7 @@ class RouteSchema(Schema):
items: list[RouteItemSchema] items: list[RouteItemSchema]
class RouteResponse(Schema): class RouteResponse(BaseSchema):
request: RouteParametersSchema request: RouteParametersSchema
options: RouteOptionsSchema options: RouteOptionsSchema
report_issue_url: NonEmptyStr report_issue_url: NonEmptyStr
@ -154,7 +155,7 @@ class RouteResponse(Schema):
title = "route found" title = "route found"
class NoRouteResponse(Schema): class NoRouteResponse(BaseSchema):
request: RouteParametersSchema request: RouteParametersSchema
options: RouteOptionsSchema options: RouteOptionsSchema
error: NonEmptyStr = APIField( error: NonEmptyStr = APIField(
@ -197,19 +198,19 @@ def get_route(request, parameters: RouteParametersSchema):
return NoRouteResponse( return NoRouteResponse(
request=parameters, request=parameters,
options=_new_serialize_route_options(options), options=_new_serialize_route_options(options),
error=str(_('Not yet routable, try again shortly.')), error=_('Not yet routable, try again shortly.'),
) )
except LocationUnreachable: except LocationUnreachable:
return NoRouteResponse( return NoRouteResponse(
request=parameters, request=parameters,
options=_new_serialize_route_options(options), options=_new_serialize_route_options(options),
error=str(_('Unreachable location.')) error=_('Unreachable location.')
) )
except NoRouteFound: except NoRouteFound:
return NoRouteResponse( return NoRouteResponse(
request=parameters, request=parameters,
options=_new_serialize_route_options(options), options=_new_serialize_route_options(options),
error=str(_('No route found.')) error=_('No route found.')
) )
origin_values = api_stats_clean_location_value(form.cleaned_data['origin'].pk) origin_values = api_stats_clean_location_value(form.cleaned_data['origin'].pk)
@ -282,12 +283,12 @@ def set_route_options(request, new_options: UpdateRouteOptionsSchema):
return _new_serialize_route_options(options) return _new_serialize_route_options(options)
class RouteOptionsFieldChoices(Schema): class RouteOptionsFieldChoices(BaseSchema):
name: NonEmptyStr name: NonEmptyStr
title: NonEmptyStr title: NonEmptyStr
class RouteOptionsField(Schema): class RouteOptionsField(BaseSchema):
name: NonEmptyStr name: NonEmptyStr
type: NonEmptyStr type: NonEmptyStr
label: NonEmptyStr label: NonEmptyStr

View file

@ -240,9 +240,7 @@ class RouteItem:
if self.new_level: if self.new_level:
result['level'] = describe_location(self.level, locations) result['level'] = describe_location(self.level, locations)
# convert all the string proxies to strings result['descriptions'] = [(icon, instruction) for (icon, instruction) in self.descriptions]
# todo: better to let the api response serializer do this somehow?
result['descriptions'] = [(icon, str(instruction)) for (icon, instruction) in self.descriptions]
return result return result

View file

@ -1,15 +1,15 @@
from typing import Annotated, Union from typing import Annotated, Union
from ninja import Schema
from pydantic import Field as APIField from pydantic import Field as APIField
from pydantic import NegativeInt, PositiveInt from pydantic import NegativeInt, PositiveInt
from c3nav.api.schema import BaseSchema
from c3nav.api.utils import NonEmptyStr from c3nav.api.utils import NonEmptyStr
BSSIDSchema = Annotated[str, APIField(pattern=r"^[a-z0-9]{2}(:[a-z0-9]{2}){5}$", title="BSSID")] BSSIDSchema = Annotated[str, APIField(pattern=r"^[a-z0-9]{2}(:[a-z0-9]{2}){5}$", title="BSSID")]
class LocateRequestPeerSchema(Schema): class LocateRequestPeerSchema(BaseSchema):
bssid: BSSIDSchema = APIField( bssid: BSSIDSchema = APIField(
title="BSSID", title="BSSID",
description="BSSID of the peer", description="BSSID of the peer",