class LocationGroupCategory(SerializableMixin, models.Model): name = models.SlugField(_('Name'), unique=True, max_length=50) single = models.BooleanField(_('single selection'), default=False) title = I18nField(_('Title'), plural_name='titles', fallback_any=True) title_plural = I18nField(_('Title (Plural)'), plural_name='titles_plural', fallback_any=True) allow_levels = models.BooleanField(_('allow levels'), db_index=True, default=True) allow_spaces = models.BooleanField(_('allow spaces'), db_index=True, default=True) allow_areas = models.BooleanField(_('allow areas'), db_index=True, default=True) allow_pois = models.BooleanField(_('allow pois'), db_index=True, default=True) priority = models.IntegerField(default=0, db_index=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.orig_priority = self.priority class Meta: verbose_name = _('Location Group Category') verbose_name_plural = _('Location Group Categories') default_related_name = 'locationgroupcategories' ordering = ('-priority', ) def _serialize(self, detailed=True, **kwargs): result = super()._serialize(detailed=detailed, **kwargs) result['name'] = self.name if detailed: result['titles'] = self.titles result['title'] = self.title return result def register_changed_geometries(self): from c3nav.mapdata.models.geometry.space import SpaceGeometryMixin query = self.groups.all() for model in get_submodels(SpecificLocation): related_name = model._meta.default_related_name subquery = model.objects.all() if issubclass(model, SpaceGeometryMixin): subquery = subquery.select_related('space') query.prefetch_related( Prefetch('groups__' + related_name, subquery)) for group in query: group.register_changed_geometries(do_query=False) def save(self, *args, **kwargs): if self.pk and self.priority != self.orig_priority: self.register_changed_geometries() super().save(*args, **kwargs)
class Space(LevelGeometryMixin, SpecificLocation, models.Model): """ An accessible space. Shouldn't overlap with spaces on the same level. """ geometry = GeometryField('polygon') height = models.DecimalField(_('height'), max_digits=6, decimal_places=2, null=True, blank=True, validators=[MinValueValidator(Decimal('0'))]) outside = models.BooleanField(default=False, verbose_name=_('only outside of building')) enter_description = I18nField(_('Enter description'), blank=True, fallback_language=None) class Meta: verbose_name = _('Space') verbose_name_plural = _('Spaces') default_related_name = 'spaces' def _serialize(self, geometry=True, **kwargs): result = super()._serialize(geometry=geometry, **kwargs) result['outside'] = self.outside result['height'] = None if self.height is None else float(str(self.height)) return result def details_display(self, editor_url=True, **kwargs): result = super().details_display(**kwargs) result['display'].extend([ (_('height'), self.height), (_('outside only'), _('Yes') if self.outside else _('No')), ]) if editor_url: result['editor_url'] = reverse('editor.spaces.detail', kwargs={'level': self.level_id, 'pk': self.pk}) return result
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['description_i18n'] = 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 TitledMixin(SerializableMixin, models.Model): title = I18nField(_('Title'), plural_name='titles', blank=True, fallback_any=True, fallback_value='{model} {pk}') class Meta: abstract = True def serialize(self, **kwargs): result = super().serialize(**kwargs) return result def _serialize(self, detailed=True, **kwargs): result = super()._serialize(detailed=detailed, **kwargs) if detailed: result['titles'] = self.titles result['title'] = self.title return result def details_display(self, **kwargs): result = super().details_display(**kwargs) for lang, title in sorted(self.titles.items(), key=lambda item: item[0] != get_language()): language = _('Title ({lang})').format( lang=get_language_info(lang)['name_translated']) result['display'].append((language, title)) return result
class WayType(SerializableMixin, models.Model): """ A special way type """ title = I18nField(_('Title'), plural_name='titles', fallback_any=True) title_plural = I18nField(_('Title (Plural)'), plural_name='titles_plural', fallback_any=True) join_edges = models.BooleanField(_('join consecutive edges'), default=True) up_separate = models.BooleanField(_('upwards separately'), default=True) walk = models.BooleanField(_('walking'), default=False) color = models.CharField(max_length=32, verbose_name=_('edge color')) icon_name = models.CharField(_('icon name'), max_length=32, null=True, blank=True) extra_seconds = models.PositiveSmallIntegerField( _('extra seconds per edge'), default=0) speed = models.DecimalField(_('speed (m/s)'), max_digits=3, decimal_places=1, default=1, validators=[MinValueValidator(Decimal('0.1'))]) description = I18nField(_('description'), fallback_any=True) speed_up = models.DecimalField( _('speed upwards (m/s)'), max_digits=3, decimal_places=1, default=1, validators=[MinValueValidator(Decimal('0.1'))]) description_up = I18nField(_('description upwards'), fallback_any=True) level_change_description = I18nField(_('level change description')) class Meta: verbose_name = _('Way Type') verbose_name_plural = _('Way Types') default_related_name = 'waytypes'
class Announcement(models.Model): created = models.DateTimeField(auto_now_add=True, verbose_name=_('created')) active_until = models.DateTimeField(null=True, verbose_name=_('active until')) active = models.BooleanField(default=True, verbose_name=_('active')) author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT, verbose_name=_('author')) text = I18nField(_('Text'), fallback_any=True) class Meta: verbose_name = _('Announcement') verbose_name_plural = _('Announcements') default_related_name = 'announcements' get_latest_by = 'created' @classmethod def get_current(cls): result = cache.get('site:announcement', False) if result is not False: return result try: result = cls.objects.filter( Q(active=True) & (Q(active_until__isnull=True) | Q(active_until__gt=timezone.now()))).latest() except cls.DoesNotExist: result = None timeout = 300 if result and result.active_until: timeout = max( 0, min(timeout, (result.active_until - timezone.now()).total_seconds())) cache.set('site:announcement', result, timeout) return result def save(self, *args, **kwargs): super().save(*args, **kwargs) cache.delete('site:announcement')
class LabelSettings(SerializableMixin, models.Model): title = I18nField(_('Title'), plural_name='titles', fallback_any=True) min_zoom = models.DecimalField(_('min zoom'), max_digits=3, decimal_places=1, default=-10, validators=[ MinValueValidator(Decimal('-10')), MaxValueValidator(Decimal('10')) ]) max_zoom = models.DecimalField(_('max zoom'), max_digits=3, decimal_places=1, default=10, validators=[ MinValueValidator(Decimal('-10')), MaxValueValidator(Decimal('10')) ]) font_size = models.IntegerField( _('font size'), default=12, validators=[MinValueValidator(12), MaxValueValidator(30)]) def _serialize(self, detailed=True, **kwargs): result = super()._serialize(detailed=detailed, **kwargs) if detailed: result['titles'] = self.titles if self.min_zoom > -10: result['min_zoom'] = self.min_zoom if self.max_zoom < 10: result['max_zoom'] = self.max_zoom result['font_size'] = self.font_size return result class Meta: verbose_name = _('Label Settings') verbose_name_plural = _('Label Settings') default_related_name = 'labelsettings' ordering = ('min_zoom', '-font_size')
class Report(models.Model): CATEGORIES = ( ('location-issue', _('location issue')), ('missing-location', _('missing location')), ('route-issue', _('route issue')), ) created = models.DateTimeField(auto_now_add=True, verbose_name=_('created')) category = models.CharField(max_length=20, db_index=True, choices=CATEGORIES, verbose_name=_('category')) author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT, verbose_name=_('author')) open = models.BooleanField(default=True, verbose_name=_('open')) last_update = models.DateTimeField(auto_now=True, verbose_name=_('last_update')) title = models.CharField(max_length=100, default='', verbose_name=_('title'), help_text=_('a short title for your report')) description = models.TextField(max_length=1000, default='', verbose_name=_('description'), help_text=_('tell us precisely what\'s wrong')) assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT, related_name='assigned_reports', verbose_name=_('assigned to')) location = models.ForeignKey('mapdata.LocationSlug', null=True, on_delete=models.SET_NULL, related_name='reports', verbose_name=_('location')) coordinates_id = models.CharField(_('coordinates'), null=True, max_length=48) origin_id = models.CharField(_('origin'), null=True, max_length=48) destination_id = models.CharField(_('destination'), null=True, max_length=48) route_options = models.CharField(_('route options'), null=True, max_length=128) created_title = I18nField(_('new location title'), plural_name='titles', blank=False, fallback_any=True, help_text=_('you have to supply a title in at least one language')) created_groups = models.ManyToManyField('mapdata.LocationGroup', verbose_name=_('location groups'), blank=True, limit_choices_to={'can_report_missing': True}, help_text=_('select all groups that apply, if any'), related_name='+') secret = models.CharField(_('secret'), max_length=32, default=get_report_secret) coordinates = LocationById() origin = LocationById() destination = LocationById() class Meta: verbose_name = _('Report') verbose_name_plural = _('Reports') default_related_name = 'reports' @property def form_cls(self): from c3nav.site.forms import ReportMissingLocationForm, ReportIssueForm return ReportMissingLocationForm if self.category == 'missing-location' else ReportIssueForm @cached_property def location_specific(self): if self.location is None: return None return self.location.get_child() @classmethod def qs_for_request(cls, request): if request.user_permissions.review_all_reports: return cls.objects.all() elif request.user.is_authenticated: location_ids = set() review_group_ids = request.user_permissions.review_group_ids for model in get_submodels(SpecificLocation): location_ids.update(set( model.objects.filter(groups__in=review_group_ids).values_list('pk', flat=True) )) return cls.objects.filter( Q(author=request.user) | Q(location_id__in=location_ids) | Q(created_groups__in=review_group_ids) ) else: return cls.objects.none() def get_affected_group_ids(self): if self.category == 'missing-location': return tuple(self.created_groups.values_list('pk', flat=True)) elif self.category == 'location-issue': return tuple(self.location.get_child().groups.values_list('pk', flat=True)) return () def get_reviewers_qs(self): return get_user_model().objects.filter( Q(permissions__review_all_reports=True) | Q(permissions__review_group_reports__in=self.get_affected_group_ids()) ) def request_can_review(self, request): return ( request.user_permissions.review_all_reports or set(request.user_permissions.review_group_ids) & set(self.get_affected_group_ids()) ) def notify_reviewers(self): reviewers = tuple(self.get_reviewers_qs().values_list('pk', flat=True)) send_report_notification.delay(pk=self.pk, title=self.title, author=self.author.username, description=self.description, reviewers=reviewers) @classmethod def user_has_reports(cls, user): if not user.is_authenticated: return False result = cache.get('user:has-reports:%d' % user.pk, None) if result is None: result = user.reports.exists() cache.set('user:has-reports:%d' % user.pk, result, 900) return result @cached_property def editor_url(self): if self.category == 'missing-location': space = self.coordinates.space if space is not None: return reverse('editor.spaces.detail', kwargs={ 'pk': space.pk, 'level': space.level_id, })+'?x=%.2f&y=%.2f' % (self.coordinates.x, self.coordinates.y) return None elif self.category == 'location-issue': location = self.location_specific if location is None: return None url_name = 'editor.%s.edit' % location.__class__._meta.default_related_name if isinstance(location, SpaceGeometryMixin): return reverse(url_name, kwargs={ 'pk': location.pk, 'space': location.space.pk }) if isinstance(location, LevelGeometryMixin): return reverse(url_name, kwargs={ 'pk': location.pk, 'level': location.level.pk }) return reverse(url_name, kwargs={ 'pk': location.pk, }) def save(self, *args, **kwargs): created = self.pk is None if self.author: cache.delete('user:has-reports:%d' % self.author.pk) super().save(*args, **kwargs) if created: self.notify_reviewers()
class SpecificLocation(Location, models.Model): groups = models.ManyToManyField('mapdata.LocationGroup', verbose_name=_('Location Groups'), blank=True) label_settings = models.ForeignKey('mapdata.LabelSettings', null=True, blank=True, on_delete=models.PROTECT, verbose_name=_('label settings')) label_override = I18nField(_('Label override'), plural_name='label_overrides', blank=True, fallback_any=True) class Meta: abstract = True def _serialize(self, detailed=True, **kwargs): result = super()._serialize(detailed=detailed, **kwargs) if grid.enabled: grid_square = self.grid_square if grid_square is not None: result['grid_square'] = grid_square or None if detailed: groups = {} for group in self.groups.all(): groups.setdefault(group.category, []).append(group.pk) groups = { category.name: (items[0] if items else None) if category.single else items for category, items in groups.items() if getattr( category, 'allow_' + self.__class__._meta.default_related_name) } result['groups'] = groups label_settings = self.get_label_settings() if label_settings: result['label_settings'] = label_settings.serialize(detailed=False) if self.label_overrides: # todo: what if only one language is set? result['label_override'] = self.label_override return result def get_label_settings(self): if self.label_settings: return self.label_settings for group in self.groups.all(): if group.label_settings: return group.label_settings return None def details_display(self, **kwargs): result = super().details_display(**kwargs) groupcategories = {} for group in self.groups.all(): groupcategories.setdefault(group.category, []).append(group) if grid.enabled: grid_square = self.grid_square if grid_square is not None: grid_square_title = (_('Grid Squares') if grid_square and '-' in grid_square else _('Grid Square')) result['display'].insert( 3, (grid_square_title, grid_square or None)) for category, groups in sorted(groupcategories.items(), key=lambda item: item[0].priority): result['display'].insert( 3, (category.title if category.single else category.title_plural, tuple({ 'id': group.pk, 'slug': group.get_slug(), 'title': group.title, 'can_search': group.can_search, } for group in sorted( groups, key=attrgetter('priority'), reverse=True)))) return result @cached_property def describing_groups(self): groups = tuple(self.groups.all() if 'groups' in getattr(self, '_prefetched_objects_cache', ()) else ()) groups = tuple(group for group in groups if group.can_describe) return groups @property def subtitle(self): subtitle = self.describing_groups[ 0].title if self.describing_groups else self.__class__._meta.verbose_name if self.grid_square: return '%s, %s' % (subtitle, self.grid_square) return subtitle @cached_property def order(self): groups = tuple(self.groups.all()) if not groups: return (0, 0, 0) return (0, groups[0].category.priority, groups[0].priority) def get_icon(self): icon = super().get_icon() if icon: return icon for group in self.groups.all(): if group.icon and getattr( group.category, 'allow_' + self.__class__._meta.default_related_name): return group.icon return None