import typing from decimal import Decimal from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.core.validators import MinValueValidator, RegexValidator from django.db import models from django.urls import reverse from django.utils.functional import cached_property from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ from shapely.geometry import CAP_STYLE, JOIN_STYLE, mapping from c3nav.mapdata.fields import GeometryField, I18nField from c3nav.mapdata.grid import grid from c3nav.mapdata.models import Space, Level from c3nav.mapdata.models.access import AccessRestrictionMixin from c3nav.mapdata.models.base import SerializableMixin, TitledMixin from c3nav.mapdata.models.geometry.base import GeometryMixin from c3nav.mapdata.models.locations import SpecificLocation from c3nav.mapdata.utils.cache.changes import changed_geometries from c3nav.mapdata.utils.geometry import unwrap_geom from c3nav.mapdata.utils.json import format_geojson if typing.TYPE_CHECKING: from c3nav.mapdata.render.theme import ThemeColorManager class SpaceGeometryMixin(GeometryMixin): space = models.ForeignKey('mapdata.Space', on_delete=models.CASCADE, verbose_name=_('space')) class Meta: abstract = True @cached_property def level_id(self): try: return self.space.level_id except ObjectDoesNotExist: return None def get_geojson_properties(self, *args, **kwargs) -> dict: result = super().get_geojson_properties(*args, **kwargs) if hasattr(self, 'get_color'): from c3nav.mapdata.render.theme import ColorManager color = self.get_color(ColorManager.for_theme(None)) if color: result['color'] = color if hasattr(self, 'opacity'): result['opacity'] = self.opacity return result def _serialize(self, space=True, **kwargs): result = super()._serialize(**kwargs) if space: result['space'] = self.space_id return result @property def subtitle(self): base_subtitle = super().subtitle space = getattr(self, '_space_cache', None) if space is not None: level = getattr(space, '_level_cache', None) if level is not None: return format_lazy(_('{category}, {space}, {level}'), category=base_subtitle, space=space.title, level=level.title) return format_lazy(_('{category}, {space}'), category=base_subtitle, level=space.title) return base_subtitle @classmethod def q_for_request(cls, request, prefix='', allow_none=False): return ( super().q_for_request(request, prefix=prefix, allow_none=allow_none) & Space.q_for_request(request, prefix=prefix + 'space__', allow_none=allow_none) ) def register_change(self, force=False): space = self.space force = force or self.all_geometry_changed if force or self.geometry_changed: changed_geometries.register(space.level_id, space.geometry.intersection( unwrap_geom(self.geometry if force else self.get_changed_geometry()) )) def details_display(self, **kwargs): result = super().details_display(**kwargs) result['display'].insert(3, ( _('Space'), { 'id': self.space_id, 'slug': self.space.effective_slug, 'title': self.space.title, 'can_search': self.space.can_search, }, )) result['level'] = self.level_id return result def register_delete(self): space = self.space changed_geometries.register(space.level_id, space.geometry.intersection(unwrap_geom(self.geometry))) def save(self, *args, **kwargs): self.register_change() super().save(*args, **kwargs) class Column(SpaceGeometryMixin, AccessRestrictionMixin, models.Model): """ An column in a space, also used to be able to create rooms within rooms. """ geometry = GeometryField('polygon') class Meta: verbose_name = _('Column') verbose_name_plural = _('Columns') default_related_name = 'columns' class Area(SpaceGeometryMixin, SpecificLocation, models.Model): """ An area in a space. """ new_serialize = True geometry = GeometryField('polygon') slow_down_factor = models.DecimalField(_('slow down factor'), max_digits=6, decimal_places=2, default=1, validators=[MinValueValidator(Decimal('0.01'))], help_text=_('values of overlapping areas get multiplied!')) main_point = GeometryField('point', null=True, blank=True, help_text=_('main routing point (optional)')) class Meta: verbose_name = _('Area') verbose_name_plural = _('Areas') default_related_name = 'areas' @property def grid_square(self): if "geometry" in self.get_deferred_fields(): return None return grid.get_squares_for_bounds(self.geometry.bounds) or '' def details_display(self, editor_url=True, **kwargs): result = super().details_display(**kwargs) if editor_url: result['editor_url'] = reverse('editor.areas.edit', kwargs={'space': self.space_id, 'pk': self.pk}) return result class Stair(SpaceGeometryMixin, models.Model): """ A stair """ new_serialize = True geometry = GeometryField('linestring') class Meta: verbose_name = _('Stair') verbose_name_plural = _('Stairs') default_related_name = 'stairs' class Ramp(SpaceGeometryMixin, models.Model): """ A ramp """ new_serialize = True geometry = GeometryField('polygon') class Meta: verbose_name = _('Ramp') verbose_name_plural = _('Ramps') default_related_name = 'ramps' class ObstacleGroup(TitledMixin, models.Model): color = models.CharField(max_length=32, null=True, blank=True) in_legend = models.BooleanField(default=False, verbose_name=_('show in legend (if color set)')) class Meta: verbose_name = _('Obstacle Group') verbose_name_plural = _('Obstacle Groups') default_related_name = 'groups' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.orig_color = self.color def save(self, *args, **kwargs): if self.pk and (self.orig_color != self.color): self.register_changed_geometries() super().save(*args, **kwargs) def delete(self, *args, **kwargs): self.register_changed_geometries() super().delete(*args, **kwargs) def register_changed_geometries(self): for obj in self.obstacles.select_related('space'): obj.register_change(force=True) for obj in self.lineobstacles.select_related('space'): obj.register_change(force=True) class Obstacle(SpaceGeometryMixin, models.Model): """ An obstacle """ new_serialize = True group = models.ForeignKey(ObstacleGroup, null=True, blank=True, on_delete=models.SET_NULL) geometry = GeometryField('polygon') height = models.DecimalField(_('height'), max_digits=6, decimal_places=2, default=0.8, validators=[MinValueValidator(Decimal('0'))]) altitude = models.DecimalField(_('altitude above ground'), max_digits=6, decimal_places=2, default=0, validators=[MinValueValidator(Decimal('0'))]) class Meta: verbose_name = _('Obstacle') verbose_name_plural = _('Obstacles') default_related_name = 'obstacles' ordering = ('altitude', 'height') def get_geojson_properties(self, *args, **kwargs) -> dict: result = super().get_geojson_properties(*args, **kwargs) from c3nav.mapdata.render.theme import ColorManager color = self.get_color(ColorManager.for_theme(None)) if color: result['color'] = color return result @property def color(self): from c3nav.mapdata.render.theme import ColorManager return self.get_color(ColorManager.for_theme(None)) def get_color(self, color_manager: 'ThemeColorManager'): return ( color_manager.obstaclegroup_fill_color(self.group) if self.group is not None else color_manager.obstacles_default_fill ) class LineObstacle(SpaceGeometryMixin, models.Model): """ An obstacle that is a line with a specific width """ new_serialize = True group = models.ForeignKey(ObstacleGroup, null=True, blank=True, on_delete=models.SET_NULL) geometry = GeometryField('linestring') width = models.DecimalField(_('width'), max_digits=4, decimal_places=2, default=0.15) height = models.DecimalField(_('height'), max_digits=6, decimal_places=2, default=0.8, validators=[MinValueValidator(Decimal('0'))]) altitude = models.DecimalField(_('altitude above ground'), max_digits=6, decimal_places=2, default=0, validators=[MinValueValidator(Decimal('0'))]) class Meta: verbose_name = _('Line Obstacle') verbose_name_plural = _('Line Obstacles') default_related_name = 'lineobstacles' ordering = ('altitude', 'height') def get_geojson_properties(self, *args, **kwargs) -> dict: result = super().get_geojson_properties(*args, **kwargs) from c3nav.mapdata.render.theme import ColorManager color = self.get_color(ColorManager.for_theme(None)) if color: result['color'] = color return result def _serialize(self, geometry=True, **kwargs): result = super()._serialize(geometry=geometry, **kwargs) result['width'] = float(str(self.width)) result['height'] = float(str(self.height)) result['altitude'] = float(str(self.altitude)) from c3nav.mapdata.render.theme import ColorManager result['color'] = self.get_color(ColorManager.for_theme(None)) if geometry: result['buffered_geometry'] = format_geojson(mapping(self.buffered_geometry)) return result @property def color(self): from c3nav.mapdata.render.theme import ColorManager return self.get_color(ColorManager.for_theme(None)) def get_color(self, color_manager: 'ThemeColorManager'): # TODO: should line obstacles use border color? return ( color_manager.obstaclegroup_fill_color(self.group) if self.group is not None else color_manager.obstacles_default_fill ) @property def buffered_geometry(self): return self.geometry.buffer(float(self.width / 2), join_style=JOIN_STYLE.mitre, cap_style=CAP_STYLE.flat) def to_geojson(self, *args, **kwargs): result = super().to_geojson(*args, **kwargs) result['original_geometry'] = result['geometry'] result['geometry'] = format_geojson(mapping(self.buffered_geometry)) return result class POI(SpaceGeometryMixin, SpecificLocation, models.Model): """ A point of interest """ new_serialize = True geometry = GeometryField('point') class Meta: verbose_name = _('Point of Interest') verbose_name_plural = _('Points of Interest') default_related_name = 'pois' def details_display(self, editor_url=True, **kwargs): result = super().details_display(**kwargs) if editor_url: result['editor_url'] = reverse('editor.pois.edit', kwargs={'space': self.space_id, 'pk': self.pk}) return result @property def grid_square(self): if "geometry" in self.get_deferred_fields(): return None return grid.get_square_for_point(self.x, self.y) or '' @property def x(self): return self.geometry.x @property def y(self): return self.geometry.y class Hole(SpaceGeometryMixin, models.Model): """ A hole in the ground of a space, e.g. for stairs. """ new_serialize = True geometry = GeometryField('polygon') class Meta: verbose_name = _('Hole') verbose_name_plural = _('Holes') default_related_name = 'holes' class AltitudeMarker(SpaceGeometryMixin, models.Model): """ An altitude marker """ geometry = GeometryField('point') groundaltitude = models.ForeignKey('mapdata.GroundAltitude', on_delete=models.CASCADE, verbose_name=_('altitude')) class Meta: verbose_name = _('Altitude Marker') verbose_name_plural = _('Altitude Markers') default_related_name = 'altitudemarkers' @property def altitude(self) -> Decimal: return self.groundaltitude.altitude @property def title(self): return f'#{self.pk}: {self.groundaltitude.title}' class LeaveDescription(SerializableMixin): """ A description for leaving a space to another space """ space = models.ForeignKey('mapdata.Space', on_delete=models.CASCADE, verbose_name=_('space')) target_space = models.ForeignKey('mapdata.Space', on_delete=models.CASCADE, verbose_name=_('target space'), related_name='enter_descriptions') description = I18nField(_('description')) class Meta: verbose_name = _('Leave description') verbose_name_plural = _('Leave descriptions') default_related_name = 'leave_descriptions' unique_together = ( ('space', 'target_space') ) def _serialize(self, **kwargs): result = super()._serialize(**kwargs) result['space'] = self.space_id result['target_space'] = self.target_space_id result['descriptions'] = self.description_i18n result['description'] = self.description return result @cached_property def title(self): return self.target_space.title @classmethod def q_for_request(cls, request, prefix='', allow_none=False): return ( Space.q_for_request(request, prefix='space__', allow_none=allow_none) & Space.q_for_request(request, prefix='target_space__', allow_none=allow_none) ) class CrossDescription(SerializableMixin): """ A description for crossing a space from one space to another space """ space = models.ForeignKey('mapdata.Space', on_delete=models.CASCADE, verbose_name=_('space')) origin_space = models.ForeignKey('mapdata.Space', on_delete=models.CASCADE, verbose_name=_('origin space'), related_name='leave_cross_descriptions') target_space = models.ForeignKey('mapdata.Space', on_delete=models.CASCADE, verbose_name=_('target space'), related_name='cross_enter_descriptions') description = I18nField(_('description')) class Meta: verbose_name = _('Cross description') verbose_name_plural = _('Cross descriptions') default_related_name = 'cross_descriptions' unique_together = ( ('space', 'origin_space', 'target_space') ) def _serialize(self, **kwargs): result = super()._serialize(**kwargs) result['space'] = self.space_id result['origin_space'] = self.origin_space_id result['target_space'] = self.target_space_id result['descriptions'] = self.description_i18n result['description'] = self.description return result @cached_property def title(self): return '%s → %s' % (self.origin_space.title, self.target_space.title) @classmethod def q_for_request(cls, request, prefix='', allow_none=False): return ( Space.q_for_request(request, prefix='space__', allow_none=allow_none) & Space.q_for_request(request, prefix='origin_space__', allow_none=allow_none) & Space.q_for_request(request, prefix='target_space__', allow_none=allow_none) ) class BeaconMeasurement(SpaceGeometryMixin, models.Model): """ A Beacon (WiFI / iBeacon) measurement """ geometry = GeometryField('point') author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('author')) comment = models.TextField(null=True, blank=True, verbose_name=_('comment')) data = models.JSONField(_('Measurement list'), default=dict) # todo: would be nice if this used pydantic class Meta: verbose_name = _('Beacon Measurement') verbose_name_plural = _('Beacon Measurements') default_related_name = 'beacon_measurements' @property def all_geometry_changed(self): return False @property def geometry_changed(self): return False class RangingBeacon(SpaceGeometryMixin, models.Model): """ A ranging beacon """ geometry = GeometryField('point') node_number = models.PositiveSmallIntegerField(_('Node Number'), unique=True, null=True, blank=True) wifi_bssid = models.CharField(_('WiFi BSSID'), unique=True, null=True, blank=True, max_length=17, validators=[RegexValidator( regex='^([a-f0-9]{2}:){5}[a-f0-9]{2}$', message='Must be a lower-case bssid', code='invalid_bssid' )], help_text=_("uses node's value if not set")) bluetooth_address = models.CharField(_('Bluetooth Address'), unique=True, null=True, blank=True, max_length=17, validators=[RegexValidator( regex='^([a-f0-9]{2}:){5}[a-f0-9]{2}$', message='Must be a lower-case mac address', code='invalid_bluetooth_address' )], help_text=_("uses node's value if not set")) ibeacon_uuid = models.UUIDField(_('iBeacon UUID'), null=True, blank=True, help_text=_("uses node's value if not set")) ibeacon_major = models.PositiveIntegerField(_('iBeacon major value'), null=True, blank=True, help_text=_("uses node's value if not set")) ibeacon_minor = models.PositiveIntegerField(_('iBeacon minor value'), null=True, blank=True, help_text=_("uses node's value if not set")) uwb_address = models.CharField(_('UWB Address'), unique=True, null=True, blank=True, max_length=23, validators=[RegexValidator( regex='^([a-f0-9]{2}:){7}[a-f0-9]{2}$', message='Must be a lower-case 8-byte UWB address', code='invalid_uwb_address' )], help_text=_("uses node's value if not set")) altitude = models.DecimalField(_('altitude above ground'), max_digits=6, decimal_places=2, default=0, validators=[MinValueValidator(Decimal('0'))]) comment = models.TextField(null=True, blank=True, verbose_name=_('comment')) class Meta: verbose_name = _('Ranging beacon') verbose_name_plural = _('Ranging beacons') default_related_name = 'ranging_beacons' @property def all_geometry_changed(self): return False @property def geometry_changed(self): return False @property def title(self): if self.comment: return f'{self.node_number} {self.wifi_bssid} ({self.comment})'.strip() return f'{self.node_number} {self.wifi_bssid}'.strip()