update django-ninja, including pydantic v2 and add provisional level api

This commit is contained in:
Laura Klünder 2023-11-18 21:29:35 +01:00
parent f89d069ab1
commit b2aa76ba2d
20 changed files with 510 additions and 61 deletions

View file

@ -0,0 +1,199 @@
import argparse
import logging
import re
from xml.etree import ElementTree
from django.core.management.base import BaseCommand, CommandError
from django.utils.translation import gettext_lazy as _
from shapely.affinity import scale, translate
from shapely.geometry import Polygon
from c3nav.mapdata.models import Area, MapUpdate, Obstacle, Space
from c3nav.mapdata.utils.cache.changes import changed_geometries
class Command(BaseCommand):
help = 'render the map'
@staticmethod
def space_value(value):
try:
space = Space.objects.get(pk=value)
except Space.DoesNotExist:
raise argparse.ArgumentTypeError(
_('unknown space')
)
return space
def add_arguments(self, parser):
parser.add_argument('svgfile', type=argparse.FileType('r'), help=_('svg file to import'))
parser.add_argument('name', type=str, help=_('name of the import'))
parser.add_argument('--type', type=str, required=True, choices=('buildings', 'areas', 'obstacles'),
help=_('type of objects to create'))
parser.add_argument('--space', type=self.space_value, required=True,
help=_('space to add the objects to'))
parser.add_argument('--minx', type=float, required=True,
help=_('minimum x coordinate, everthing left of it will be cropped'))
parser.add_argument('--miny', type=float, required=True,
help=_('minimum y coordinate, everthing below it will be cropped'))
parser.add_argument('--maxx', type=float, required=True,
help=_('maximum x coordinate, everthing right of it will be cropped'))
parser.add_argument('--maxy', type=float, required=True,
help=_('maximum y coordinate, everthing above it will be cropped'))
@staticmethod
def parse_svg_data(data):
first = False
last_point = (0, 0)
last_end_point = None
done_subpaths = []
current_subpath = []
while data:
data = data.lstrip().replace(',', ' ')
command = data[0]
if first and command not in 'Mm':
raise ValueError('path data has to start with moveto command.')
data = data[1:].lstrip()
first = False
numbers = []
while True:
match = re.match(r'^-?[0-9]+(\.[0-9]+)?(e-?[0-9]+)?', data)
if match is None:
break
numbers.append(float(match.group(0)))
data = data[len(match.group(0)):].lstrip()
relative = command.islower()
if command in 'Mm':
if not len(numbers) or len(numbers) % 2:
raise ValueError('Invalid number of arguments for moveto command!')
numbers = iter(numbers)
first = True
for x, y in zip(numbers, numbers):
if relative:
x, y = last_point[0] + x, last_point[1] + y
if first:
first = False
if current_subpath:
done_subpaths.append(current_subpath)
last_end_point = current_subpath[-1]
current_subpath = []
current_subpath.append((x, y))
last_point = (x, y)
elif command in 'Ll':
if not len(numbers) or len(numbers) % 2:
raise ValueError('Invalid number of arguments for lineto command!')
numbers = iter(numbers)
for x, y in zip(numbers, numbers):
if relative:
x, y = last_point[0] + x, last_point[1] + y
if not current_subpath:
current_subpath.append(last_end_point)
current_subpath.append((x, y))
last_point = (x, y)
elif command in 'Hh':
if not len(numbers):
raise ValueError('Invalid number of arguments for horizontal lineto command!')
y = last_point[1]
for x in numbers:
if relative:
x = last_point[0] + x
if not current_subpath:
current_subpath.append(last_end_point)
current_subpath.append((x, y))
last_point = (x, y)
elif command in 'Vv':
if not len(numbers):
raise ValueError('Invalid number of arguments for vertical lineto command!')
x = last_point[0]
for y in numbers:
if relative:
y = last_point[1] + y
if not current_subpath:
current_subpath.append(last_end_point)
current_subpath.append((x, y))
last_point = (x, y)
elif command in 'Zz':
if numbers:
raise ValueError('Invalid number of arguments for closepath command!')
current_subpath.append(current_subpath[0])
done_subpaths.append(current_subpath)
last_end_point = current_subpath[-1]
current_subpath = []
else:
raise ValueError('unknown svg command: ' + command)
if current_subpath:
done_subpaths.append(current_subpath)
return done_subpaths
def handle(self, *args, **options):
minx = options['minx']
miny = options['miny']
maxx = options['maxx']
maxy = options['maxy']
if minx >= maxx:
raise CommandError(_('minx has to be lower than maxx'))
if miny >= maxy:
raise CommandError(_('miny has to be lower than maxy'))
width = maxx-minx
height = maxy-miny
model = {'areas': Area, 'obstacles': Obstacle}[options['type']]
namespaces = {'svg': 'http://www.w3.org/2000/svg'}
svg = ElementTree.fromstring(options['svgfile'].read())
svg_width = float(svg.attrib['width'])
svg_height = float(svg.attrib['height'])
svg_viewbox = svg.attrib.get('viewBox')
if svg_viewbox:
offset_x, offset_y, svg_width, svg_height = [float(i) for i in svg_viewbox.split(' ')]
else:
offset_x, offset_y = 0, 0
for element in svg.findall('.//svg:clipPath/..', namespaces):
for clippath in element.findall('./svg:clipPath', namespaces):
element.remove(clippath)
for element in svg.findall('.//svg:symbol/..', namespaces):
for clippath in element.findall('./svg:symbol', namespaces):
element.remove(clippath)
if svg.findall('.//*[@transform]'):
raise CommandError(_('svg contains transform attributes. Use inkscape apply transforms.'))
if model.objects.filter(space=options['space'], import_tag=options['name']).exists():
raise CommandError(_('objects with this import tag already exist in this space.'))
with MapUpdate.lock():
changed_geometries.reset()
for path in svg.findall('.//svg:path', namespaces):
for polygon in self.parse_svg_data(path.attrib['d']):
if len(polygon) < 3:
continue
polygon = Polygon(polygon)
polygon = translate(polygon, xoff=-offset_x, yoff=-offset_y)
polygon = scale(polygon, xfact=1, yfact=-1, origin=(0, svg_height/2))
polygon = scale(polygon, xfact=width / svg_width, yfact=height / svg_height, origin=(0, 0))
polygon = translate(polygon, xoff=minx, yoff=miny)
obj = model(geometry=polygon, space=options['space'], import_tag=options['name'])
obj.save()
MapUpdate.objects.create(type='importsvg')
logger = logging.getLogger('c3nav')
logger.info('Imported, map update created.')
logger.info('Next step: go into the shell and edit them using '
'%s.objects.filter(space_id=%r, import_tag=%r)' %
(model.__name__, options['space'].pk, options['name']))

View file

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

View file

View file

@ -0,0 +1,39 @@
from ninja import Query
from ninja import Router as APIRouter
from ninja.pagination import paginate
from c3nav.api.exceptions import API404
from c3nav.api.newauth import auth_responses
from c3nav.mapdata.models import Level, Source
from c3nav.mapdata.schemas.filters import LevelFilters
from c3nav.mapdata.schemas.models import LevelSchema
from c3nav.mapdata.schemas.responses import BoundsSchema
map_api_router = APIRouter(tags=["map"])
@map_api_router.get('/bounds/', summary="Get map boundaries",
response={200: BoundsSchema, **auth_responses})
def bounds(request):
return {
"bounds": Source.max_bounds(),
}
@map_api_router.get('/levels/', response=list[LevelSchema],
summary="List available levels")
@paginate
def levels_list(request, filters: Query[LevelFilters]):
# todo: access, caching, filtering, etc
return Level.objects.all()
@map_api_router.get('/levels/{level_id}/', response=LevelSchema,
summary="List available levels")
def level_detail(request, level_id: int):
# todo: access, caching, filtering, etc
try:
level = Level.objects.get(id=level_id)
except Level.DoesNotExist:
raise API404("level not found")
return level

View file

View file

@ -0,0 +1,16 @@
from typing import Literal, Optional
from ninja import Schema
from pydantic import Field as APIField
class LevelFilters(Schema):
on_top_of: Optional[Literal["null"] | int] = APIField(
None,
name='filter by on top of level ID (or "null")',
description='if set, only levels on top of this level (or "null" for no level) will be shown'
)
group: Optional[int] = APIField(
None,
name="filter by location group"
)

View file

@ -0,0 +1,122 @@
from functools import cached_property
from typing import Annotated, Any, Optional
import annotated_types
from ninja import Schema
from pydantic import Field as APIField
from pydantic import PositiveInt, model_validator
from pydantic.functional_validators import ModelWrapValidatorHandler
from pydantic.utils import GetterDict
from pydantic_core.core_schema import ValidationInfo
from c3nav.api.utils import NonEmptyStr
class DjangoModelSchema(Schema):
id: PositiveInt = APIField(
title="ID",
)
class SerializableSchema(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 """
values = values.serialize()
return handler(values)
class LocationSlugSchema(Schema):
slug: NonEmptyStr = APIField(
title="location slug",
description="a slug is a unique way to refer to a location across all location types. "
"locations can have a human-readable slug. "
"if it doesn't, this field holds a slug generated based from the location type and ID. "
"this slug will work even if a human-readable slug is defined later. "
"even dynamic locations like coordinates have a slug.",
)
class AccessRestrictionSchema(Schema):
access_restriction: Optional[PositiveInt] = APIField(
default=None,
title="access restriction ID",
)
class TitledSchema(Schema):
titles: dict[NonEmptyStr, NonEmptyStr] = APIField(
title="title (all languages)",
description="property names are the ISO-language code. languages may be missing.",
example={
"en": "Title",
"de": "Titel",
}
)
title: NonEmptyStr = APIField(
title="title (preferred language)",
description="preferred language based on the Accept-Language header."
)
class LocationSchema(AccessRestrictionSchema, TitledSchema, LocationSlugSchema, SerializableSchema):
subtitle: NonEmptyStr = APIField(
title="subtitle (preferred language)",
description="an automatically generated short description for this location. "
"preferred language based on the Accept-Language header."
)
icon: Optional[NonEmptyStr] = APIField(
default=None,
title="icon name",
description="any material design icon name"
)
can_search: bool = APIField(
title="can be searched",
)
can_describe: bool = APIField(
title="can describe locations",
)
# todo: add_search
class LabelSettingsSchema(TitledSchema, Schema):
min_zoom: float = APIField(
title="min zoom",
)
max_zoom: float = APIField(
title="max zoom",
)
font_size: PositiveInt = APIField(
title="font size",
)
class SpecificLocationSchema(LocationSchema, DjangoModelSchema):
grid_square: Optional[NonEmptyStr] = APIField(
default=None,
title="grid square",
description="if a grid is defined and this location is within it",
)
groups: dict[NonEmptyStr, list[PositiveInt] | Optional[PositiveInt]] = APIField(
title="location groups",
description="grouped by location group categories. "
"property names are the names of location groupes. "
"property values are integer, None or a list of integers, see example."
"see location group category endpoint for currently available possibilities."
"categories may be missing if no groups apply.",
example={
"category_with_single_true": 5,
"other_category_with_single_true": None,
"categoryother_category_with_single_false": [1, 3, 7],
}
)
label_settings: Optional[LabelSettingsSchema] = APIField(
default=None,
title="label settings",
)
label_override: Optional[NonEmptyStr] = APIField(
default=None,
title="label override (preferred language)",
description="preferred language based on the Accept-Language header."
)

View file

@ -0,0 +1,31 @@
from typing import Optional
from ninja import Schema
from pydantic import Field as APIField
from pydantic import PositiveFloat, PositiveInt
from c3nav.api.utils import NonEmptyStr
from c3nav.mapdata.schemas.model_base import SpecificLocationSchema
class LevelSchema(SpecificLocationSchema):
short_label: NonEmptyStr = APIField(
title="short label (for level selector)",
description="unique among levels",
)
on_top_of: Optional[PositiveInt] = APIField(
title="on top of level ID",
description="if set, this is not a main level, but it's on top of this other level"
)
base_altitude: float = APIField(
title="base/default altitude",
)
default_height: PositiveFloat = APIField(
title="default ceiling height",
)
door_height: PositiveFloat = APIField(
title="door height",
)
class Config(Schema.Config):
title = "Level"

View file

@ -0,0 +1,6 @@
from ninja import Schema
from pydantic import Field as APIField
class BoundsSchema(Schema):
bounds: tuple[tuple[float, float], tuple[float, float]] = APIField(..., example=((-10, -20), (20, 30)))