Terralego/django-terra-geocrud

View on GitHub
terra_geocrud/models.py

Summary

Maintainability
B
6 hrs
Test Coverage
from copy import deepcopy

from django.contrib.gis.db.models import Extent
from django.core.exceptions import ValidationError
from django.db.models import FloatField, CharField, IntegerField
from django.db.models.functions import Cast

try:
    from django.db.models import JSONField
except ImportError:  # TODO: Remove when dropping Django releases < 3.1
    from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.indexes import GinIndex
from django.db import models
from django.db.models import CheckConstraint, UniqueConstraint, Q
from django.utils.functional import cached_property
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from geostore.db.mixins import BaseUpdatableModel
from sorl.thumbnail import default, ImageField, get_thumbnail
from sorl.thumbnail.images import ImageFile

from terra_geocrud.map.styles import MapStyleModelMixin
from terra_geocrud.properties.files import delete_old_picture_property
from . import settings as app_settings
from .properties.files import get_storage
from .properties.schema import FormSchemaMixin
from .validators import validate_schema_property, validate_function_path


class CrudModelMixin(models.Model):
    name = models.CharField(max_length=100, unique=True, help_text=_("Display name in left menu"),
                            verbose_name=_('Name'))
    order = models.PositiveSmallIntegerField(verbose_name=_("Order"),
                                             help_text=_("Order entry in left menu"), db_index=True)

    def __str__(self):
        return self.name

    class Meta:
        abstract = True


class CrudGroupView(CrudModelMixin):
    """
    Used to defined group of view in CRUD
    """
    pictogram = models.ImageField(upload_to='terra_geocrud/groups/pictograms', null=True, blank=True,
                                  help_text=_("Picto displayed in left menu"))

    class Meta:
        verbose_name = _("Group")
        verbose_name_plural = _("Groups")
        ordering = ('order', )


class CrudView(FormSchemaMixin, MapStyleModelMixin, CrudModelMixin):
    """
    Used to defined ad layer's view in CRUD
    """
    object_name = models.CharField(verbose_name=_("Singular object name"), max_length=100, default="",
                                   blank=True, null=False)
    object_name_plural = models.CharField(verbose_name=_("Plural object name"), max_length=100, default="",
                                          blank=True, null=False)
    group = models.ForeignKey(CrudGroupView, verbose_name=_("Group"), on_delete=models.SET_NULL,
                              related_name='crud_views',
                              null=True, blank=True, help_text=_("Group this entry in left menu"))
    layer = models.OneToOneField('geostore.Layer', on_delete=models.CASCADE, related_name='crud_view',
                                 verbose_name=_("Layer"))
    templates = models.ManyToManyField('template_model.Template', related_name='crud_views', blank=True,
                                       verbose_name=_("Document templates"),
                                       help_text=_("Available templates for layer features document generation"))
    pictogram = models.ImageField(upload_to='terra_geocrud/views/pictograms', null=True, blank=True,
                                  help_text=_("Picto displayed in left menu"))
    map_style = JSONField(default=dict, blank=True, help_text=_("Custom mapbox style for this entry"),
                          verbose_name=_("Map style"))
    ui_schema = JSONField(default=dict, blank=True, editable=False,
                          help_text=_("""Custom ui:schema style for this entry.
                                         https://react-jsonschema-form.readthedocs.io/en/latest/form-customization/"""))
    # WARNING: settings is only used to wait for model definition
    settings = JSONField(default=dict, blank=True)
    default_list_properties = models.ManyToManyField('CrudViewProperty', verbose_name=_("Properties in feature list"),
                                                     blank=True, related_name='used_by_list',
                                                     help_text=_("Schema properties used in API list by default."),)
    feature_title_property = models.ForeignKey('CrudViewProperty', null=True, on_delete=models.SET_NULL,
                                               verbose_name=_("Title property"),
                                               help_text=_("Schema property used to define feature title."),
                                               related_name='used_by_title', blank=True)
    visible = models.BooleanField(default=True, db_index=True, help_text=_("Keep visible if ungrouped."))

    @cached_property
    def extent(self):
        features_extent = self.layer.features.aggregate(extent=Extent('geom'))
        extent = features_extent.get('extent')
        # get extent in settings if no features

        return extent if extent else app_settings.TERRA_GEOCRUD['EXTENT']

    @property
    def list_available_properties(self):
        """ exclude some properties in list (some arrays, data-url, html fields)"""
        # exclude file field
        properties = self.properties.exclude(
            json_schema__contains={"format": 'data-url'},
        )
        # exclude array object fields

        properties = properties.exclude(
            json_schema__contains={"type": "array", "items": {"type": "object"}}
        )
        # exclude textarea fields
        properties = properties.exclude(
            ui_schema__contains={'ui:widget': 'textarea'}
        )
        # exclude rte fields
        properties = properties.exclude(
            ui_schema__contains={'ui:field': 'rte'}
        )
        return properties

    def get_layer(self):
        return self.layer

    def get_feature_title(self, feature):
        """ Get feature title base on title field. Return identifier if empty or None """
        data = feature.properties.get(self.feature_title_property.key, '')\
            if self.feature_title_property else feature.identifier
        return data or feature.identifier

    class Meta:
        verbose_name = _("View")
        verbose_name_plural = _("Views")
        ordering = ('group', 'order')


class FeaturePropertyDisplayGroup(models.Model):
    """ Model used to group layer properties in grouped_form_schema and displayed informations """
    crud_view = models.ForeignKey(CrudView, related_name='feature_display_groups', on_delete=models.CASCADE)
    order = models.PositiveSmallIntegerField(default=0, db_index=True)
    label = models.CharField(max_length=50)
    slug = models.SlugField(blank=True, editable=False)
    pictogram = models.ImageField(upload_to='terra_geocrud/property_groups/pictograms', null=True, blank=True)

    def __str__(self):
        return self.label

    @cached_property
    def form_schema(self):
        original_schema = deepcopy(self.crud_view.layer.schema)
        properties = {}
        required = []

        for prop in self.group_properties.all():
            properties[prop.key] = original_schema.get('properties', {}).get(prop.key)

            if prop.key in original_schema.get('required', []):
                required.append(prop.key)

        return {
            "type": "object",
            "title": self.label,
            "required": required,
            "properties": properties
        }

    class Meta:
        verbose_name = _("Feature properties display group")
        verbose_name_plural = _("Feature properties display groups")
        ordering = ('order', 'label',)
        unique_together = (
            ('crud_view', 'label'),
            ('crud_view', 'slug'),
        )

    def save(self, *args, **kwargs):
        # generate slug
        self.slug = slugify(self.label)

        super().save(*args, **kwargs)


class AttachmentCategory(models.Model):
    name = models.CharField(unique=True, max_length=255)
    pictogram = models.ImageField(upload_to='terra_geocrud/attachments_categories/pictograms', null=True, blank=True)

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = _('Attachment category')
        verbose_name_plural = _('Attachment categories')


class AttachmentMixin(BaseUpdatableModel):
    category = models.ForeignKey(AttachmentCategory, on_delete=models.PROTECT)
    legend = models.CharField(max_length=250)

    def __str__(self):
        return f"{self.legend} - ({self.category})"

    class Meta:
        abstract = True


def feature_attachment_directory_path(instance, filename):
    return f'terra_geocrud/features/{instance.feature_id}/attachments/{filename}'


def feature_picture_directory_path(instance, filename):
    return f'terra_geocrud/features/{instance.feature_id}/pictures/{filename}'


class FeatureAttachment(AttachmentMixin):
    feature = models.ForeignKey('geostore.Feature', on_delete=models.CASCADE, related_name='attachments')
    file = models.FileField(upload_to=feature_attachment_directory_path, storage=get_storage())

    def delete(self, *args, **kwargs):
        """ Delete file at deletion """
        self.file.storage.delete(self.file.name)
        super().delete(*args, **kwargs)

    class Meta:
        verbose_name = _('Feature attachment')
        verbose_name_plural = _('Feature attachments')
        ordering = (
            'feature', 'category', '-created_at'
        )


class FeaturePicture(AttachmentMixin):
    feature = models.ForeignKey('geostore.Feature', on_delete=models.CASCADE, related_name='pictures')
    image = ImageField(upload_to=feature_picture_directory_path, storage=get_storage())

    @cached_property
    def thumbnail(self):
        return get_thumbnail(self.image, "500x500", crop='noop', upscale=False)

    def delete(self, *args, **kwargs):
        """ Delete image and thumbnail at deletion """
        image_file = ImageFile(self.image, storage=get_storage())
        image_file.delete()
        default.kvstore.delete(image_file)
        super().delete(*args, **kwargs)

    class Meta:
        verbose_name = _('Feature picture')
        verbose_name_plural = _('Feature pictures')
        ordering = (
            'feature', 'category', '-created_at'
        )


class ExtraLayerStyle(MapStyleModelMixin, models.Model):
    crud_view = models.ForeignKey(CrudView, related_name='extra_layer_style', on_delete=models.CASCADE)
    layer_extra_geom = models.OneToOneField('geostore.LayerExtraGeom', related_name='style', on_delete=models.CASCADE)
    map_style = JSONField(help_text=_("Custom mapbox style for this entry"))

    def get_layer(self):
        return self.layer_extra_geom

    class Meta:
        verbose_name = _('ExtraLayer style')
        verbose_name_plural = _('ExtraLayer styles')
        unique_together = (
            ('crud_view', 'layer_extra_geom'),
        )


class CrudViewProperty(models.Model):
    view = models.ForeignKey(CrudView, on_delete=models.CASCADE, related_name='properties')
    group = models.ForeignKey(FeaturePropertyDisplayGroup, on_delete=models.SET_NULL,
                              related_name='group_properties', null=True, blank=True)
    key = models.SlugField()
    json_schema = JSONField(blank=False, null=False, default=dict, validators=[validate_schema_property])
    ui_schema = JSONField(blank=True, null=False, default=dict)
    include_in_tile = models.BooleanField(default=False, db_index=True)
    required = models.BooleanField(default=False, db_index=True)
    order = models.PositiveSmallIntegerField(default=0, db_index=True)

    """ This field is used to order properties in a table view """
    table_order = models.PositiveSmallIntegerField(null=True, blank=True, db_index=False)
    editable = models.BooleanField(default=True)
    function_path = models.CharField(max_length=255, blank=True, validators=[validate_function_path])

    class Meta:
        unique_together = (
            ('view', 'key'),
        )
        ordering = (
            'view', 'group', 'order'
        )
        indexes = (
            GinIndex(name='json_schema_index', fields=['json_schema'], opclasses=['jsonb_path_ops']),
            GinIndex(name='ui_schema_index', fields=['ui_schema'], opclasses=['jsonb_path_ops']),
        )
        constraints = [
            CheckConstraint(check=(
                Q(required=True, editable=True)
                | Q(required=False, editable=True)
                | Q(required=False, editable=False)),
                name='check_required_editable'),
        ]

    def __str__(self):
        return f"{self.title} ({self.key})"

    def clean(self):
        if self.required and not self.editable:
            raise ValidationError(
                _("Property cannot be required but not editable")
            )

    @property
    def title(self):
        """ Title: ui schema -> json schema -> key capitalized """
        return self.ui_schema.get('title',
                                  self.json_schema.get('title',
                                                       self.key.capitalize()))

    def delete(self, *args, **kwargs):
        """ Delete file at deletion """
        if self.json_schema.get('format') == "data-url":
            features = self.view.layer.features.all()
            for feature in features:
                delete_old_picture_property(self.key, feature.properties)
        super().delete(*args, **kwargs)

    @cached_property
    def full_json_schema(self):
        """
        Generate full json schema by adding custom conf to store schema.
        All keys defined in json_schema column are kept if already present.
        """
        output_field = CharField()
        if self.json_schema.get("type") == "number" or (
                self.json_schema.get("type") == "array" and self.json_schema.get("items").get("type") == "number"):
            # final values should be float
            output_field = FloatField()
        elif self.json_schema.get("type") == "integer" or (
                self.json_schema.get("type") == "array" and self.json_schema.get("items").get("type") == "integer"):
            # final values should be integer
            output_field = IntegerField()
        values = self.values.all().annotate(final_value=Cast('value', output_field=output_field))
        if values:
            json_schema = deepcopy(self.json_schema)
            if self.json_schema.get('type') != "array":
                # in non array properties, enum are defined in enum key
                json_schema.setdefault('enum', list(values.values_list('final_value', flat=True)))
            else:
                # in array, enum values are defined in 'items__enum' key
                json_schema['items'].setdefault('enum', list(values.values_list('final_value', flat=True)))
            return json_schema
        return self.json_schema


class PropertyEnum(models.Model):
    value = models.CharField(max_length=250, help_text=_("Value should always be casted in property type."))
    pictogram = models.ImageField(upload_to='terra_geocrud/enums/pictograms', null=True, blank=True,
                                  help_text=_("Picto. associated to value."))
    property = models.ForeignKey(CrudViewProperty, on_delete=models.CASCADE, related_name='values')

    def clean(self):
        try:
            if self.property.json_schema.get('type') == 'integer':
                int(self.value)
            elif self.property.json_schema.get('type') == 'number':
                float(self.value)
        except ValueError:
            raise ValidationError(
                _(f"Value '{self.value}' should be casted as property type ({self.property.json_schema.get('type')})")
            )

    def __str__(self):
        return self.value

    class Meta:
        unique_together = (
            ('value', 'property'),
        )


class RoutingSettings(models.Model):
    CHOICES_EXTERNAL = (("mapbox", _("Mapbox")), )
    CHOICES = CHOICES_EXTERNAL + (("geostore", _("Geostore")), )
    label = models.CharField(max_length=250, help_text=_("Label that will be shown on the list"))
    provider = models.CharField(max_length=250, help_text=_("Provider's name"), choices=CHOICES)
    layer = models.ForeignKey('geostore.Layer', related_name='routing_settings', on_delete=models.PROTECT, blank=True,
                              null=True)
    mapbox_transit = models.CharField(max_length=250, help_text=_("Mabox transit"), choices=(("driving", _("Driving")),
                                                                                             ("walking", _("Walking")),
                                                                                             ("cycling", _("Cycling"))
                                                                                             ), blank=True)
    crud_view = models.ForeignKey(CrudView, related_name='routing_settings', on_delete=models.CASCADE)

    def __str__(self):
        return self.label

    class Meta:
        unique_together = (
            ('label', 'crud_view'),
            ('layer', 'crud_view'),
        )
        constraints = [
            UniqueConstraint(fields=['provider', 'layer', 'crud_view'], condition=Q(layer__isnull=False),
                             name='check_provider_layer'
                             ),
            UniqueConstraint(fields=['provider', 'mapbox_transit', 'crud_view'], condition=~Q(mapbox_transit=''),
                             name='check_provider_mapbox_transit'
                             ),
        ]

    def clean(self):
        if self.layer and not self.layer.routable:
            raise ValidationError(
                _("You should define layer with a routable layer")
            )
        if self.mapbox_transit and self.layer:
            raise ValidationError(
                _("You shouldn't define layer and mapbox_transit")
            )
        if self.provider == "mapbox" and self.layer or self.provider == "geostore" and self.mapbox_transit:
            raise ValidationError(
                _("You use the wrong provider")
            )
        if self.provider == "mapbox" and not self.mapbox_transit:
            raise ValidationError(
                _("You should define a mapbox_transit with this provider")
            )
        if self.provider == "geostore" and not self.layer:
            raise ValidationError(
                _("You should define a layer with this provider")
            )
        if RoutingSettings.objects.filter(Q(mapbox_transit=self.mapbox_transit) & ~Q(mapbox_transit=''),
                                          crud_view=self.crud_view).exclude(label=self.label):
            raise ValidationError(
                _("This transit is already used")
            )
        if RoutingSettings.objects.filter(Q(layer=self.layer) & Q(layer__isnull=False),
                                          crud_view=self.crud_view).exclude(label=self.label):
            raise ValidationError(
                _("This layer is already used")
            )


class RoutingInformations(models.Model):
    feature = models.OneToOneField('geostore.Feature', on_delete=models.CASCADE,
                                   related_name='routing_information')
    route_description = JSONField(blank=True, null=False, default=dict)

    def __str__(self):
        return f"Routing infos : {self.feature.identifier}"