From 9d5811bc1575f8f789f8a6f47a5d046c6627ac83 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laura=20Kl=C3=BCnder?=
Date: Tue, 27 Jun 2017 03:20:50 +0200
Subject: [PATCH] replace Change with ChangedObject (wrapper integration still
missing)
---
src/c3nav/editor/api.py | 6 +-
src/c3nav/editor/forms.py | 4 +-
.../migrations/0005_auto_20170627_0027.py | 62 +++
src/c3nav/editor/models/change.py | 309 ---------------
src/c3nav/editor/models/changedobject.py | 157 ++++++++
src/c3nav/editor/models/changeset.py | 362 ++++--------------
.../editor/templates/editor/changeset.html | 53 ++-
.../templates/editor/changeset_history.html | 13 -
.../editor/fragment_change_groups.html | 51 ---
src/c3nav/editor/urls.py | 2 -
src/c3nav/editor/views/changes.py | 267 +++++--------
src/c3nav/editor/views/edit.py | 26 +-
12 files changed, 457 insertions(+), 855 deletions(-)
create mode 100644 src/c3nav/editor/migrations/0005_auto_20170627_0027.py
delete mode 100644 src/c3nav/editor/models/change.py
create mode 100644 src/c3nav/editor/models/changedobject.py
delete mode 100644 src/c3nav/editor/templates/editor/changeset_history.html
delete mode 100644 src/c3nav/editor/templates/editor/fragment_change_groups.html
diff --git a/src/c3nav/editor/api.py b/src/c3nav/editor/api.py
index fa76d31c..9862462e 100644
--- a/src/c3nav/editor/api.py
+++ b/src/c3nav/editor/api.py
@@ -46,7 +46,7 @@ class EditorViewSet(ViewSet):
return results
def _get_levels_pk(self, request, level):
- Level = request.changeset.wrap('Level')
+ Level = request.changeset.wrap_model('Level')
levels_under = ()
levels_on_top = ()
lower_level = level.lower(Level).first()
@@ -63,8 +63,8 @@ class EditorViewSet(ViewSet):
def geometries(self, request, *args, **kwargs):
request.changeset = ChangeSet.get_for_request(request)
- Level = request.changeset.wrap('Level')
- Space = request.changeset.wrap('Space')
+ Level = request.changeset.wrap_model('Level')
+ Space = request.changeset.wrap_model('Space')
level = request.GET.get('level')
space = request.GET.get('space')
diff --git a/src/c3nav/editor/forms.py b/src/c3nav/editor/forms.py
index ee316b60..4774164c 100644
--- a/src/c3nav/editor/forms.py
+++ b/src/c3nav/editor/forms.py
@@ -29,7 +29,7 @@ class MapitemFormMixin(ModelForm):
self.initial['geometry'] = json.dumps(mapping(self.instance.geometry), separators=(',', ':'))
if 'groups' in self.fields:
- LocationGroup = self.request.changeset.wrap('LocationGroup')
+ LocationGroup = self.request.changeset.wrap_model('LocationGroup')
self.fields['groups'].label_from_instance = lambda obj: obj.title_for_forms
self.fields['groups'].queryset = LocationGroup.objects.all()
@@ -71,7 +71,7 @@ class MapitemFormMixin(ModelForm):
for slug in self.add_redirect_slugs:
self.fields['slug'].run_validators(slug)
- LocationSlug = self.request.changeset.wrap('LocationSlug')
+ LocationSlug = self.request.changeset.wrap_model('LocationSlug')
for slug in LocationSlug.objects.filter(slug__in=self.add_redirect_slugs).values_list('slug', flat=True)[:1]:
raise ValidationError(
_('Can not add redirecting slug “%s”: it is already used elsewhere.') % slug
diff --git a/src/c3nav/editor/migrations/0005_auto_20170627_0027.py b/src/c3nav/editor/migrations/0005_auto_20170627_0027.py
new file mode 100644
index 00000000..c0833613
--- /dev/null
+++ b/src/c3nav/editor/migrations/0005_auto_20170627_0027.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.2 on 2017-06-27 00:27
+from __future__ import unicode_literals
+
+import c3nav.mapdata.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('editor', '0004_auto_20170620_0934'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ChangedObject',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
+ ('last_update', models.DateTimeField(auto_now=True, verbose_name='last update')),
+ ('existing_object_pk', models.PositiveIntegerField(null=True, verbose_name='id of existing object')),
+ ('updated_fields', c3nav.mapdata.fields.JSONField(default={}, verbose_name='updated fields')),
+ ('m2m_added', c3nav.mapdata.fields.JSONField(default={}, verbose_name='added m2m values')),
+ ('m2m_removed', c3nav.mapdata.fields.JSONField(default={}, verbose_name='removed m2m values')),
+ ('deleted', models.BooleanField(default=False, verbose_name='new field value')),
+ ('changeset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changed_objects_set', to='editor.ChangeSet', verbose_name='Change Set')),
+ ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changed_objects_set', to='contenttypes.ContentType')),
+ ],
+ options={
+ 'verbose_name': 'Changed object',
+ 'verbose_name_plural': 'Changed objects',
+ 'ordering': ['created', 'pk'],
+ 'default_related_name': 'changed_objects_set',
+ },
+ ),
+ migrations.RemoveField(
+ model_name='change',
+ name='author',
+ ),
+ migrations.RemoveField(
+ model_name='change',
+ name='changeset',
+ ),
+ migrations.RemoveField(
+ model_name='change',
+ name='created_object',
+ ),
+ migrations.RemoveField(
+ model_name='change',
+ name='discarded_by',
+ ),
+ migrations.DeleteModel(
+ name='Change',
+ ),
+ migrations.AlterUniqueTogether(
+ name='changedobject',
+ unique_together=set([('changeset', 'content_type', 'existing_object_pk')]),
+ ),
+ ]
diff --git a/src/c3nav/editor/models/change.py b/src/c3nav/editor/models/change.py
deleted file mode 100644
index c99266b0..00000000
--- a/src/c3nav/editor/models/change.py
+++ /dev/null
@@ -1,309 +0,0 @@
-import json
-import typing
-from collections import OrderedDict
-
-from django.apps import apps
-from django.conf import settings
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
-from django.db import models
-from django.db.models import FieldDoesNotExist, Q
-from django.utils.translation import ugettext_lazy as _
-
-from c3nav.editor.utils import get_current_obj, is_created_pk
-from c3nav.editor.wrappers import ModelInstanceWrapper
-
-
-class Change(models.Model):
- ACTIONS = (
- ('create', _('create object')),
- ('delete', _('delete object')),
- ('update', _('update attribute')),
- ('restore', _('restore attribute')),
- ('m2m_add', _('add many to many relation')),
- ('m2m_remove', _('add many to many relation')),
- )
- changeset = models.ForeignKey('editor.ChangeSet', on_delete=models.CASCADE, verbose_name=_('Change Set'))
- author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT, verbose_name=_('Author'))
- created = models.DateTimeField(auto_now_add=True, verbose_name=_('created'))
- action = models.CharField(max_length=16, choices=ACTIONS, verbose_name=_('action'))
- discarded_by = models.OneToOneField('Change', null=True, on_delete=models.CASCADE, related_name='discards',
- verbose_name=_('discarded by other change'))
- model_name = models.CharField(max_length=50, verbose_name=_('model name'))
- existing_object_pk = models.PositiveIntegerField(null=True, verbose_name=_('id of existing object'))
- created_object = models.ForeignKey('Change', null=True, on_delete=models.CASCADE, related_name='changed_by',
- verbose_name=_('changed object'))
- field_name = models.CharField(max_length=50, null=True, verbose_name=_('field name'))
- field_value = models.TextField(null=True, verbose_name=_('new field value'))
-
- class Meta:
- verbose_name = _('Change')
- verbose_name_plural = _('Changes')
- default_related_name = 'changes'
- ordering = ['created', 'pk']
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self._set_object = None
-
- @property
- def model_class(self) -> typing.Optional[typing.Type[models.Model]]:
- if not self.model_name:
- return None
- return apps.get_model('mapdata', self.model_name)
-
- @model_class.setter
- def model_class(self, value: typing.Optional[typing.Type[models.Model]]):
- if value is None:
- self.model_name = None
- return
- if not issubclass(value, models.Model):
- raise ValueError('value is not a django model')
- if value._meta.abstract:
- raise ValueError('value is an abstract model')
- if value._meta.app_label != 'mapdata':
- raise ValueError('value is not a mapdata model')
- self.model_name = value.__name__
-
- @property
- def obj_pk(self) -> typing.Union[int, str]:
- if self.existing_object_pk is not None:
- return self.existing_object_pk
- if self.created_object_id is not None:
- return 'c'+str(self.created_object_id)
- if self.action == 'create':
- return 'c'+str(self.pk)
- raise TypeError('existing_model_pk or created_object have to be set.')
-
- def other_changes(self):
- """
- get queryset of other active changes on the same object
- """
- qs = self.changeset.changes.filter(~Q(pk=self.pk), model_name=self.model_name, discarded_by__isnull=True)
- if self.existing_object_pk is not None:
- return qs.filter(existing_object_pk=self.existing_object_pk)
- if self.action == 'create':
- return qs.filter(created_object_id=self.pk)
- return qs.filter(Q(pk=self.created_object_id) | Q(created_object_id=self.created_object_id))
-
- @property
- def obj(self) -> ModelInstanceWrapper:
- if self._set_object is not None:
- return self._set_object
-
- if self.existing_object_pk is not None:
- if self.created_object is not None:
- raise TypeError('existing_object_pk and created_object can not both be set.')
- self._set_object = self.changeset.wrap(self.model_class.objects.get(pk=self.existing_object_pk))
- # noinspection PyTypeChecker
- return self._set_object
- elif self.created_object is not None:
- if self.created_object.model_class != self.model_class:
- raise TypeError('created_object model and change model do not match.')
- if self.created_object.changeset_id != self.changeset_id:
- raise TypeError('created_object belongs to a different changeset.')
- return self.changeset.get_created_object(self.model_class, self.created_object_id)
- raise TypeError('existing_model_pk or created_object have to be set.')
-
- @obj.setter
- def obj(self, value: typing.Union[models.Model, ModelInstanceWrapper]):
- if not isinstance(value, ModelInstanceWrapper):
- value = self.changeset.wrap(value)
-
- if is_created_pk(value.pk):
- if value._changeset.id != self.changeset.pk:
- raise ValueError('value is a Change instance but belongs to a different changeset.')
- self.model_class = type(value._obj)
- self.created_object = Change.objects.get(pk=value.pk[1:])
- self.created_object_id = int(value.pk[1:])
- self.existing_object_pk = None
- self._set_object = value
- return
-
- model_class_before = self.model_class
- self.model_class = type(value._obj) if isinstance(value, ModelInstanceWrapper) else type(value)
- if value.pk is None:
- self.model_class = model_class_before
- raise ValueError('object is not saved yet and cannot be referenced')
- self.existing_object_pk = value.pk
- self.created_object = None
- self._set_object = value
-
- @property
- def field(self):
- return self.model_class._meta.get_field(self.field_name)
-
- def check_apply_problem(self):
- if self.discarded_by_id is not None or self.action in ('create', 'restore'):
- return None
-
- try:
- model = self.model_class
- except LookupError:
- return _('Type %(model_name)s does not exist any more.' % {'model_name': self.model_name})
-
- try:
- obj = get_current_obj(model, self.obj_pk)
- except model.DoesNotExist:
- return _('This %(model_title)s does not exist any more.' % {'model_title': model._meta.verbose_name})
-
- if self.action == 'delete':
- return None
-
- if self.action == 'update' and self.field_name.startswith('title_'):
- return None
-
- try:
- field = self.field
- except FieldDoesNotExist:
- return _('This field does not exist any more.')
- value = json.loads(self.field_value)
-
- if self.action == 'update':
- if field.many_to_many:
- return _('This is a m2m field, so it can\'t be updated.')
- if field.many_to_one:
- if value is None and not field.null:
- return _('This field has to be set.')
- try:
- self.changeset.wrap(field.related_model).objects.get(pk=value)
- except ObjectDoesNotExist:
- return (_('Referenced %(model_title)s does not exist any more.') %
- {'model_title': field.related_model._meta.verbose_name})
- return None
- if field.is_relation:
- raise NotImplementedError
- try:
- field.clean(value, obj)
- except ValidationError as e:
- return str(e)
- return None
-
- if self.action in ('m2m_add', 'm2m_remove'):
- if not field.many_to_many:
- return _('This is not a m2m field, so it can\'t be trated as such.')
- try:
- self.changeset.wrap(field.related_model).objects.get(pk=value)
- except ObjectDoesNotExist:
- return _('Referenced object does not exist any more.')
- return None
-
- @property
- def can_restore(self):
- if self.discarded_by_id is not None:
- return False
-
- if self.action == 'delete':
- return not is_created_pk(self.obj_pk)
-
- if self.action == 'create':
- return False
-
- try:
- obj = get_current_obj(self.model_class, self.obj_pk)
- field = self.field
- except (LookupError, ObjectDoesNotExist, FieldDoesNotExist):
- return True
-
- if field.many_to_one:
- return field.null
-
- if field.name == 'geometry':
- return False
-
- try:
- field.clean(field.get_prep_value(getattr(obj, field.name)), obj)
- except ValidationError:
- return False
-
- return True
-
- def check_has_no_effect(self):
- if self.discarded_by_id is not None or self.action in ('create', 'restore'):
- return False
-
- if self.action == 'delete':
- if is_created_pk(self.obj_pk):
- return False
-
- try:
- current_obj = get_current_obj(self.model_class, self.obj_pk)
- except (LookupError, ObjectDoesNotExist):
- return not is_created_pk(self.obj_pk)
-
- if self.action == 'delete':
- return False
-
- if self.field_name.startswith('title_'):
- return self.field_value == current_obj.titles.get(self.field_name[6:], '')
-
- try:
- field = self.field
- except FieldDoesNotExist:
- return True
-
- if self.action == 'update':
- if field.many_to_one:
- return self.field_value == getattr(current_obj, field.attname)
- if field.is_relation:
- raise NotImplementedError
- return self.field_value == field.get_prep_value(getattr(current_obj, field.name))
-
- if self.action == 'm2m_add':
- if is_created_pk(self.field_value):
- return False
- return getattr(current_obj, field.name).filter(pk=self.field_value).exists()
-
- if self.action == 'm2m_remove':
- if is_created_pk(self.field_value):
- return True
- return not getattr(current_obj, field.name).filter(pk=self.field_value).exists()
-
- def restore(self, author):
- if not self.can_restore:
- return
-
- def save(self, *args, **kwargs):
- if self.pk is not None:
- raise TypeError('change objects can not be edited (use update to set discarded_by)')
- if self.changeset.proposed is not None or self.changeset.applied is not None:
- raise TypeError('can not add change object to uneditable changeset.')
- super().save(*args, **kwargs)
-
- def delete(self, *args, **kwargs):
- raise TypeError('change objects can not be deleted directly.')
-
- def __repr__(self):
- result = ''
- return result
-
- def serialize(self):
- result = OrderedDict((
- ('id', self.pk),
- ('author', self.author_id),
- ('created', None if self.created is None else self.created.isoformat()),
- ('action', self.action),
- ('object_type', self.model_class.__name__.lower()),
- ('object_id', ('c'+str(self.pk)) if self.action == 'create' else self.obj_pk),
- ))
- if self.action in ('update', 'm2m_add', 'm2m_remove'):
- result.update(OrderedDict((
- ('name', self.field_name),
- ('value', json.loads(self.field_value)),
- )))
- return result
diff --git a/src/c3nav/editor/models/changedobject.py b/src/c3nav/editor/models/changedobject.py
new file mode 100644
index 00000000..b91fa8c1
--- /dev/null
+++ b/src/c3nav/editor/models/changedobject.py
@@ -0,0 +1,157 @@
+import typing
+from itertools import chain
+
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from c3nav.editor.utils import is_created_pk
+from c3nav.editor.wrappers import ModelInstanceWrapper
+from c3nav.mapdata.fields import JSONField
+
+
+class ChangedObject(models.Model):
+ changeset = models.ForeignKey('editor.ChangeSet', on_delete=models.CASCADE, verbose_name=_('Change Set'))
+ created = models.DateTimeField(auto_now_add=True, verbose_name=_('created'))
+ last_update = models.DateTimeField(auto_now=True, verbose_name=_('last update'))
+ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+ existing_object_pk = models.PositiveIntegerField(null=True, verbose_name=_('id of existing object'))
+ updated_fields = JSONField(default={}, verbose_name=_('updated fields'))
+ m2m_added = JSONField(default={}, verbose_name=_('added m2m values'))
+ m2m_removed = JSONField(default={}, verbose_name=_('removed m2m values'))
+ deleted = models.BooleanField(default=False, verbose_name=_('new field value'))
+
+ class Meta:
+ verbose_name = _('Changed object')
+ verbose_name_plural = _('Changed objects')
+ default_related_name = 'changed_objects_set'
+ unique_together = ('changeset', 'content_type', 'existing_object_pk')
+ ordering = ['created', 'pk']
+
+ def __init__(self, *args, **kwargs):
+ model_class = kwargs.pop('model_class', None)
+ super().__init__(*args, **kwargs)
+ self._set_object = None
+ self._m2m_added_cache = {name: set(values) for name, values in self.m2m_added}
+ self._m2m_removed_cache = {name: set(values) for name, values in self.m2m_added}
+ if model_class is not None:
+ self.model_class = model_class
+
+ @property
+ def model_class(self) -> typing.Optional[typing.Type[models.Model]]:
+ return self.content_type.model_class()
+
+ @model_class.setter
+ def model_class(self, value: typing.Optional[typing.Type[models.Model]]):
+ self.content_type = ContentType.objects.get_for_model(value)
+
+ @property
+ def obj_pk(self) -> typing.Union[int, str]:
+ if not self.is_created:
+ return self.existing_object_pk
+ return 'c'+str(self.pk)
+
+ @property
+ def obj(self) -> ModelInstanceWrapper:
+ return self.get_obj(get_foreign_objects=True)
+
+ @property
+ def is_created(self):
+ return self.existing_object_pk is None
+
+ def get_obj(self, get_foreign_objects=False) -> ModelInstanceWrapper:
+ model = self.model_class
+
+ if not self.is_created:
+ if self._set_object is None:
+ self._set_object = self.changeset.wrap_instance(model.objects.get(pk=self.existing_object_pk))
+
+ # noinspection PyTypeChecker
+ return self._set_object
+
+ pk = self.obj_pk
+
+ obj = model()
+ obj.pk = pk
+ if hasattr(model._meta.pk, 'related_model'):
+ setattr(obj, model._meta.pk.related_model._meta.pk.attname, pk)
+ obj._state.adding = False
+
+ for name, value in self.updated_fields.items():
+ if name.startswith('title_'):
+ if value:
+ obj.titles[name[6:]] = value
+ continue
+
+ field = model._meta.get_field(name)
+
+ if field.many_to_many:
+ continue
+
+ if field.many_to_one:
+ setattr(obj, field.attname, value)
+ if is_created_pk(value):
+ setattr(obj, field.get_cache_name(), self.changeset.get_created_object(field.related_model, value))
+ elif get_foreign_objects:
+ related_obj = self.changeset.wrap_model(field.related_model).objects.get(pk=value)
+ setattr(obj, field.get_cache_name(), related_obj)
+ continue
+
+ setattr(obj, name, field.to_python(value))
+ return self.changeset.wrap_instance(obj)
+
+ def add_relevant_object_pks(self, object_pks):
+ object_pks.setdefault(self.model_class, set()).add(self.obj_pk)
+ for name, value in self.updated_fields.items():
+ if name.startswith('title_'):
+ continue
+ field = self.model_class._meta.get_field(name)
+ if field.is_relation:
+ object_pks.setdefault(field.related_model, set()).add(value)
+
+ for name, value in chain(self._m2m_added_cache.items(), self._m2m_removed_cache.items()):
+ field = self.model_class._meta.get_field(name)
+ object_pks.setdefault(field.related_model, set()).update(value)
+
+ def update_changeset_cache(self):
+ if self.pk is None:
+ return
+
+ model = self.model_class
+ pk = self.obj_pk
+
+ self.changeset.changed_objects.setdefault(model, {})[pk] = self
+
+ if self.is_created:
+ if not self.deleted:
+ self.changeset.created_objects.setdefault(model, {})[pk] = self.updated_fields
+ self.changeset.ever_created_objects.setdefault(model, {})[pk] = self.updated_fields
+ else:
+ if not self.deleted:
+ self.changeset.updated_existing.setdefault(model, {})[pk] = self.updated_fields
+ self.changeset.deleted_existing.setdefault(model, set()).discard(pk)
+ else:
+ self.changeset.updated_existing.setdefault(model, {}).pop(pk, None)
+ self.changeset.deleted_existing.setdefault(model, set()).add(pk)
+
+ if not self.deleted:
+ self.changeset.m2m_added.get(model, {})[pk] = self._m2m_added_cache
+ self.changeset.m2m_removed.get(model, {})[pk] = self._m2m_removed_cache
+ else:
+ self.changeset.m2m_added.get(model, {}).pop(pk, None)
+ self.changeset.m2m_removed.get(model, {}).pop(pk, None)
+
+ def save(self, *args, **kwargs):
+ self.m2m_added = {name: tuple(values) for name, values in self._m2m_added_cache}
+ self.m2m_removed = {name: tuple(values) for name, values in self._m2m_added_cache}
+ if self.changeset.proposed is not None or self.changeset.applied is not None:
+ raise TypeError('can not add change object to uneditable changeset.')
+ super().save(*args, **kwargs)
+ if not self.changeset.fill_changes_cache():
+ self.update_changeset_cache()
+
+ def delete(self, *args, **kwargs):
+ raise TypeError('change objects can not be deleted directly.')
+
+ def __repr__(self):
+ return '' % (str(self.pk), str(self.changeset_id))
diff --git a/src/c3nav/editor/models/changeset.py b/src/c3nav/editor/models/changeset.py
index 0b14f3bb..63950f75 100644
--- a/src/c3nav/editor/models/changeset.py
+++ b/src/c3nav/editor/models/changeset.py
@@ -1,18 +1,19 @@
-import json
from collections import OrderedDict
+from itertools import chain
+from operator import attrgetter
from django.apps import apps
from django.conf import settings
-from django.core.serializers.json import DjangoJSONEncoder
-from django.db import models, transaction
-from django.db.models import Q
+from django.contrib.contenttypes.models import ContentType
+from django.db import models
+from django.db.models import Max, Q
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
-from c3nav.editor.models.change import Change
-from c3nav.editor.utils import get_current_obj, get_field_value, is_created_pk
-from c3nav.editor.wrappers import ModelInstanceWrapper, ModelWrapper
+from c3nav.editor.models.changedobject import ChangedObject
+from c3nav.editor.utils import is_created_pk
+from c3nav.editor.wrappers import ModelWrapper
from c3nav.mapdata.models import LocationSlug
from c3nav.mapdata.models.locations import LocationRedirect
from c3nav.mapdata.utils.models import get_submodels
@@ -35,7 +36,8 @@ class ChangeSet(models.Model):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.default_author = None
- self.changes_qs = None
+ self.changed_objects = None
+
self.ever_created_objects = {}
self.created_objects = {}
self.updated_existing = {}
@@ -111,129 +113,53 @@ class ChangeSet(models.Model):
"""
Wrap Objects
"""
- def wrap(self, obj, author=None):
- """
- Wrap the given object in a changeset wrapper.
- :param obj: A model, a model instance or the name of a model as a string.
- :param author: Author to which ne changes will be assigned. This changesets default author (if set) if None.
- """
- self.parse_changes()
- if author is None:
- author = self.default_author
- if author is not None and not author.is_authenticated:
- author = None
- if isinstance(obj, str):
- return ModelWrapper(self, apps.get_model('mapdata', obj), author)
- if isinstance(obj, type) and issubclass(obj, models.Model):
- return ModelWrapper(self, obj, author)
- if isinstance(obj, models.Model):
- return ModelWrapper(self, type(obj), author).create_wrapped_model_class()(self, obj, author)
- raise ValueError
+ def wrap_model(self, model):
+ if isinstance(model, str):
+ model = apps.get_model('mapdata', model)
+ assert isinstance(model, type) and issubclass(model, models.Model)
+ return ModelWrapper(self, model)
- """
- Parse Changes
- """
- def relevant_changes(self):
- """
- Get all changes of this queryset that have not been discarded and do not restore original data.
- You should not call this, but instead call parse_changes(), it will store the result in self.changes_qs.
- """
- qs = self.changes.filter(discarded_by__isnull=True).exclude(action='restore')
- qs = qs.exclude(action='delete', created_object_id__isnull=False)
- return qs
+ def wrap_instance(self, instance):
+ assert isinstance(instance, models.Model)
+ return self.wrap_model(instance.__class__).create_wrapped_model_class()(self, instance)
- def parse_changes(self, get_history=False):
- """
- Parse changes of this changeset so they can be reflected when querying data.
- Only executable once, if changes are added later they are automatically parsed.
- This method gets automatically called when parsed changes are needed or when adding a new change.
- The queryset used/created by this method can be found in changes_qs afterwards.
- :param get_history: Whether to get all changes (True) or only relevant ones (False)
- """
- if self.pk is None or self.changes_qs is not None:
- return
+ def relevant_changed_objects(self):
+ return self.changed_objects_set.exclude(existing_object_pk__isnull=True, deleted=True)
- if get_history:
- self.changes_qs = self.changes.all()
+ def fill_changes_cache(self, include_deleted_created=False):
+ """
+ Get all changed objects and fill this ChangeSet's changes cache.
+ Only executable once, if something is changed later the cache will be automatically updated.
+ This method gets called automatically when the cache is needed.
+ Only call it if you need to set include_deleted_created to True.
+ :param include_deleted_created: Fetch created objects that were deleted.
+ :rtype: True if the method was executed, else False
+ """
+ if self.pk is None or self.changed_objects is not None:
+ return False
+
+ if include_deleted_created:
+ qs = self.changed_objects_set.all()
else:
- self.changes_qs = self.relevant_changes()
+ qs = self.relevant_changed_objects()
- # noinspection PyTypeChecker
- for change in self.changes_qs:
- self._parse_change(change)
+ self.changed_objects = {}
+ for change in qs:
+ change.update_changeset_cache()
- def _parse_change(self, change):
- self._last_change_pk = change.pk
-
- model = change.model_class
- pk = change.obj_pk
- if change.action == 'create':
- new = {}
- self.created_objects.setdefault(model, {})[pk] = new
- self.ever_created_objects.setdefault(model, {})[pk] = new
- return
- elif change.action == 'delete':
- if not is_created_pk(pk):
- self.deleted_existing.setdefault(model, set()).add(pk)
- else:
- self.created_objects[model].pop(pk)
- self.m2m_added.get(model, {}).pop(pk, None)
- self.m2m_removed.get(model, {}).pop(pk, None)
- return
-
- name = change.field_name
-
- if change.action == 'restore' and change.field_value is None:
- if is_created_pk(pk):
- self.created_objects[model][pk].pop(name, None)
- else:
- self.updated_existing.setdefault(model, {}).setdefault(pk, {}).pop(name, None)
- return
-
- value = json.loads(change.field_value)
- if change.action == 'update':
- if is_created_pk(pk):
- self.created_objects[model][pk][name] = value
- else:
- self.updated_existing.setdefault(model, {}).setdefault(pk, {})[name] = value
-
- if change.action == 'restore':
- self.m2m_removed.get(model, {}).get(pk, {}).get(name, set()).discard(value)
- self.m2m_added.get(model, {}).get(pk, {}).get(name, set()).discard(value)
- elif change.action == 'm2m_add':
- m2m_removed = self.m2m_removed.get(model, {}).get(pk, {}).get(name, ())
- if value in m2m_removed:
- m2m_removed.remove(value)
- self.m2m_added.setdefault(model, {}).setdefault(pk, {}).setdefault(name, set()).add(value)
- elif change.action == 'm2m_remove':
- m2m_added = self.m2m_added.get(model, {}).get(pk, {}).get(name, ())
- if value in m2m_added:
- m2m_added.discard(value)
- self.m2m_removed.setdefault(model, {}).setdefault(pk, {}).setdefault(name, set()).add(value)
+ return True
"""
Analyse Changes
"""
def get_objects(self):
- if self.changes_qs is None:
+ if self.changed_objects is None:
raise TypeError
# collect pks of relevant objects
object_pks = {}
- for change in self.changes_qs:
- object_pks.setdefault(change.model_class, set()).add(change.obj_pk)
- model = None
- if change.action == 'update':
- if change.model_class == LocationRedirect:
- if change.field_name == 'target':
- object_pks.setdefault(LocationSlug, set()).add(json.loads(change.field_value))
- continue
- elif not change.field_name.startswith('title_'):
- model = getattr(change.field, 'related_model', None)
- if change.action in ('m2m_add', 'm2m_remove'):
- model = change.field.related_model
- if model is not None:
- object_pks.setdefault(model, set()).add(json.loads(change.field_value))
+ for change in chain(self.changed_objects.values()):
+ change.add_relevant_object_pks(object_pks)
# retrieve relevant objects
objects = {}
@@ -266,58 +192,38 @@ class ChangeSet(models.Model):
r = tuple((pk, values[name]) for pk, values in self.updated_existing.get(model, {}).items() if name in values)
return r
- def get_created_object(self, model, pk, author=None, get_foreign_objects=False, allow_deleted=False):
+ def get_changed_object(self, model, pk=None):
+ self.fill_changes_cache()
+
+ objects = tuple(obj for obj in ((submodel, self.changed_objects.get(submodel, {}).get(pk, None))
+ for submodel in get_submodels(model)) if obj[1] is not None)
+ if len(objects) > 1:
+ raise model.MultipleObjectsReturned
+ if objects:
+ return objects[0]
+
+ if is_created_pk(pk):
+ raise model.DoesNotExist
+
+ return ChangedObject(changeset=self, model_class=model, existing_obj_pk=pk)
+
+ def get_created_object(self, model, pk, get_foreign_objects=False, allow_deleted=False):
"""
Gets a created model instance.
:param model: model class
:param pk: primary key
- :param author: overwrite default author for changes made to that model
:param get_foreign_objects: whether to fetch foreign objects and not just set their id to field.attname
:param allow_deleted: return created objects that have already been deleted (needs get_history=True)
:return: a wrapped model instance
"""
- self.parse_changes()
+ self.fill_changes_cache()
if issubclass(model, ModelWrapper):
model = model._obj
- objects = self.ever_created_objects if allow_deleted else self.created_objects
-
- objects = tuple(obj for obj in ((submodel, objects.get(submodel, {}).get(pk, None))
- for submodel in get_submodels(model)) if obj[1] is not None)
- if not objects:
+ object = self.get_changed_object(model, pk)
+ if object.deleted and not allow_deleted:
raise model.DoesNotExist
- if len(objects) > 1:
- raise model.MultipleObjectsReturned
-
- model, data = objects[0]
-
- obj = model()
- obj.pk = pk
- if hasattr(model._meta.pk, 'related_model'):
- setattr(obj, model._meta.pk.related_model._meta.pk.attname, pk)
- obj._state.adding = False
-
- for name, value in data.items():
- if name.startswith('title_'):
- if value:
- obj.titles[name[6:]] = value
- continue
-
- field = model._meta.get_field(name)
-
- if field.many_to_many:
- continue
-
- if field.many_to_one:
- setattr(obj, field.attname, value)
- if is_created_pk(value):
- setattr(obj, field.get_cache_name(), self.get_created_object(field.related_model, value))
- elif get_foreign_objects:
- setattr(obj, field.get_cache_name(), self.wrap(field.related_model.objects.get(pk=value)))
- continue
-
- setattr(obj, name, field.to_python(value))
- return self.wrap(obj, author=author)
+ return object.get_obj(get_foreign_objects=get_foreign_objects)
def get_created_pks(self, model) -> set:
"""
@@ -327,144 +233,29 @@ class ChangeSet(models.Model):
model = model._obj
return set(self.created_objects.get(model, {}).keys())
- """
- add changes
- """
- def _new_change(self, author, **kwargs):
- if self.pk is None:
- if self.session_id is None:
- try:
- # noinspection PyUnresolvedReferences
- session = self.request.session
- if session.session_key is None:
- session.save()
- self.session_id = session.session_key
- except AttributeError:
- pass # ok, so lets keep it this way
- self.save()
- self.parse_changes()
- change = Change(changeset=self)
- change.changeset_id = self.pk
- author = self.default_author if author is None else author
- if author is not None and author.is_authenticated:
- change.author = author
- for name, value in kwargs.items():
- setattr(change, name, value)
- change.save()
- self._parse_change(change)
- return change
-
- def add_create(self, obj, author=None):
- """
- Creates a new object in this changeset. Called when a new ModelInstanceWrapper is saved.
- """
- change = self._new_change(author=author, action='create', model_class=type(obj._obj))
- obj.pk = 'c%d' % change.pk
-
- def _add_value(self, action, obj, name, value, author=None):
- return self._new_change(author=author, action=action, obj=obj, field_name=name,
- field_value=json.dumps(value, ensure_ascii=False, cls=DjangoJSONEncoder))
-
- def add_restore(self, obj, name, value=None, author=None):
- """
- Restore a models field value (= remove it from the changeset).
- """
- return self._new_change(author=author, action='restore', obj=obj, field_name=name, field_value=value)
-
- def add_update(self, obj, name, value, author=None):
- """
- Update a models field value. Called when a ModelInstanceWrapper is saved.
- """
- if isinstance(obj, ModelInstanceWrapper):
- obj = obj._obj
- model = type(obj)
- with transaction.atomic():
- current_obj = get_current_obj(model, obj.pk)
- current_value = get_field_value(current_obj, name)
-
- if current_value != value:
- change = self._add_value('update', obj, name, value, author)
- else:
- change = self.add_restore(obj, name, author)
- change.other_changes().filter(field_name=name).update(discarded_by=change)
- return change
-
- def add_m2m_add(self, obj, name, value, author=None):
- """
- Add an object to a m2m relation. Called by ManyRelatedManagerWrapper.
- """
- if isinstance(obj, ModelInstanceWrapper):
- obj = obj._obj
- with transaction.atomic():
- if is_created_pk(obj.pk) or is_created_pk(value) or not getattr(obj, name).filter(pk=value).exists():
- change = self._add_value('m2m_add', obj, name, value, author)
- else:
- change = self.add_restore(obj, name, value, author)
- change.other_changes().filter(field_name=name, field_value=change.field_value).update(discarded_by=change)
- return change
-
- def add_m2m_remove(self, obj, name, value, author=None):
- """
- Remove an object from a m2m reltation. Called by ManyRelatedManagerWrapper.
- """
- if isinstance(obj, ModelInstanceWrapper):
- obj = obj._obj
- with transaction.atomic():
- if is_created_pk(obj.pk) or is_created_pk(value) or not getattr(obj, name).filter(pk=value).exists():
- change = self.add_restore(obj, name, value, author)
- else:
- change = self._add_value('m2m_remove', obj, name, value, author)
- change.other_changes().filter(field_name=name, field_value=change.field_value).update(discarded_by=change)
- return change
-
- def add_delete(self, obj, author=None):
- """
- Delete an object. Called by ModelInstanceWrapper.delete().
- """
- with transaction.atomic():
- change = self._new_change(author=author, action='delete', obj=obj)
- change.other_changes().update(discarded_by=change)
- return change
-
"""
Methods for display
"""
@property
- def changes_count(self):
+ def changed_objects_count(self):
"""
- Get the number of relevant changes. Does not need a query if changes are already parsed.
+ Get the number of changed objects. Does not need a query if cache is already filled.
"""
- if self.changes_qs is None:
- return self.relevant_changes().exclude(model_name='LocationRedirect', action='update').count()
+ if self.changed_objects is None:
+ location_redirect_type = ContentType.objects.get_for_model(LocationRedirect)
+ return self.relevant_changed_objects().exclude(content_type=location_redirect_type).count()
- result = 0
-
- for model, objects in self.created_objects.items():
- result += len(objects)
- if model == LocationRedirect:
- continue
- result += sum(len(values) for values in objects.values())
-
- for objects in self.updated_existing.values():
- result += sum(len(values) for values in objects.values())
-
- result += sum(len(objs) for objs in self.deleted_existing.values())
-
- for m2m in self.m2m_added, self.m2m_removed:
- for objects in m2m.values():
- for obj in objects.values():
- result += sum(len(values) for values in obj.values())
-
- return result
+ return sum((len(objects) for model, objects in self.changed_objects.items() if model != LocationRedirect))
@property
def count_display(self):
"""
- Get “%d changes” display text.
+ Get “%d changed objects” display text.
"""
if self.pk is None:
- return _('No changes')
- return ungettext_lazy('%(num)d change', '%(num)d changes', 'num') % {'num': self.changes_count}
+ return _('No changed objects')
+ return (ungettext_lazy('%(num)d changed object', '%(num)d changed objects', 'num') %
+ {'num': self.changed_objects_count})
@property
def title(self):
@@ -472,11 +263,18 @@ class ChangeSet(models.Model):
return ''
return _('Changeset #%d') % self.pk
+ @property
+ def last_update(self):
+ if self.changed_objects is None:
+ return self.relevant_changed_objects().aggregate(Max('last_update'))['last_update__max']
+
+ return max(chain(*self.changed_objects.values()), key=attrgetter('last_update'))
+
@property
def cache_key(self):
if self.pk is None:
return None
- return str(self.pk)+'-'+str(self._last_change_pk)
+ return str(self.pk)+'-'+str(self.last_update)
def get_absolute_url(self):
if self.pk is None:
diff --git a/src/c3nav/editor/templates/editor/changeset.html b/src/c3nav/editor/templates/editor/changeset.html
index 6a187d82..7457bdb8 100644
--- a/src/c3nav/editor/templates/editor/changeset.html
+++ b/src/c3nav/editor/templates/editor/changeset.html
@@ -12,13 +12,58 @@
{% trans 'Edit' %}
-
- {% trans 'View History' %}
-
{% bootstrap_messages %}
-{% include 'editor/fragment_change_groups.html' %}
+{% for group in grouped_changes %}
+
+
+
+
+ {% if group.edit_url %}
+
+ {% trans 'Edit' %}
+
+ {% endif %}
+ {% if group.obj_title %}
+ {{ group.obj_title }} ({{ group.obj }})
+ {% else %}
+ {{ group.obj }}
+ {% endif %}
+ |
+
+
+
+ {% for change in group.changes %}
+
+ |
+
+ {% if change.value %}{% else %}{% endif %}{{ change.title }}{% if change.value %}:{% else %}{% endif %}
+ {{ change.value }}
+ |
+
+ {% if change.apply_problem %}
+
+ {% elif change.has_no_effect %}
+
+ {% endif %}
+ {% if change.author and change.author != changeset.author %}
+
+ {% endif %}
+ {% if change.can_restore %}
+
+ {% endif %}
+ {% if change.created %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+{% endfor %}
{% buttons %}
diff --git a/src/c3nav/editor/templates/editor/changeset_history.html b/src/c3nav/editor/templates/editor/changeset_history.html
deleted file mode 100644
index b37685c3..00000000
--- a/src/c3nav/editor/templates/editor/changeset_history.html
+++ /dev/null
@@ -1,13 +0,0 @@
-{% load bootstrap3 %}
-{% load i18n %}
-
-{% include 'editor/fragment_modal_close.html' %}
-{{ changeset.title }}
-{% trans 'Changeset History' %}
-
diff --git a/src/c3nav/editor/templates/editor/fragment_change_groups.html b/src/c3nav/editor/templates/editor/fragment_change_groups.html
deleted file mode 100644
index ea73716e..00000000
--- a/src/c3nav/editor/templates/editor/fragment_change_groups.html
+++ /dev/null
@@ -1,51 +0,0 @@
-{% load i18n %}
-
-{% for group in grouped_changes %}
-
-
-
-
- {% if group.edit_url %}
-
- {% trans 'Edit' %}
-
- {% endif %}
- {% if group.obj_title %}
- {{ group.obj_title }} ({{ group.obj }})
- {% else %}
- {{ group.obj }}
- {% endif %}
- |
-
-
-
- {% for change in group.changes %}
-
- |
-
- {% if change.value %}{% else %}{% endif %}{{ change.title }}{% if change.value %}:{% else %}{% endif %}
- {{ change.value }}
- |
-
- {% if change.apply_problem %}
-
- {% elif change.has_no_effect %}
-
- {% endif %}
- {% if change.author and change.author != changeset.author %}
-
- {% endif %}
- {% if change.can_restore %}
-
- {% endif %}
- {% if change.created %}
-
- {% endif %}
- |
-
- {% endfor %}
-
-
-{% endfor %}
diff --git a/src/c3nav/editor/urls.py b/src/c3nav/editor/urls.py
index a774a94f..2fe8fe7b 100644
--- a/src/c3nav/editor/urls.py
+++ b/src/c3nav/editor/urls.py
@@ -37,8 +37,6 @@ urlpatterns = [
url(r'^levels/(?Pc?[0-9]+)/levels_on_top/create$', edit, name='editor.levels_on_top.create',
kwargs={'model': 'Level'}),
url(r'^changesets/(?P[0-9]+)/$', changeset_detail, name='editor.changesets.detail'),
- url(r'^changesets/(?P[0-9]+)/history$', changeset_detail, name='editor.changesets.history',
- kwargs={'show_history': True}),
url(r'^login$', login_view, name='editor.login'),
url(r'^logout$', logout_view, name='editor.logout'),
]
diff --git a/src/c3nav/editor/views/changes.py b/src/c3nav/editor/views/changes.py
index b9b3a59b..b6035531 100644
--- a/src/c3nav/editor/views/changes.py
+++ b/src/c3nav/editor/views/changes.py
@@ -1,4 +1,3 @@
-import json
from operator import itemgetter
from django.conf import settings
@@ -11,11 +10,10 @@ from django.utils.translation import ugettext_lazy as _
from c3nav.editor.models import ChangeSet
from c3nav.editor.utils import is_created_pk
from c3nav.editor.views.base import sidebar_view
-from c3nav.mapdata.models.locations import LocationRedirect, LocationSlug
@sidebar_view
-def changeset_detail(request, pk, show_history=False):
+def changeset_detail(request, pk):
can_edit = True
changeset = request.changeset
if str(pk) != str(request.changeset.pk):
@@ -32,7 +30,7 @@ def changeset_detail(request, pk, show_history=False):
change.restore(request.user if request.user.is_authenticated else None)
messages.success(request, _('Original state has been restored!'))
- if not show_history and request.POST.get('delete') == '1':
+ elif request.POST.get('delete') == '1':
if request.POST.get('delete_confirm') == '1':
changeset.delete()
return redirect(reverse('editor.index'))
@@ -42,48 +40,19 @@ def changeset_detail(request, pk, show_history=False):
'obj_title': changeset.title,
})
- ctx = group_changes(changeset, can_edit=can_edit, show_history=show_history)
-
- if show_history:
- return render(request, 'editor/changeset_history.html', ctx)
-
- return render(request, 'editor/changeset.html', ctx)
-
-
-def group_changes(changeset, can_edit=False, show_history=False):
- changeset.parse_changes(get_history=show_history)
+ changeset.fill_changes_cache(include_deleted_created=True)
objects = changeset.get_objects()
- if show_history:
- grouped_changes = []
- for obj in objects:
- if is_created_pk(obj.pk):
- obj.titles = {}
- grouped_changes = [] if show_history else {}
- changes = []
- last_obj = None
- for change in changeset.changes_qs:
- pk = change.obj_pk
- obj = objects[change.model_class][pk]
- if change.model_class == LocationRedirect:
- if change.action not in ('create', 'delete'):
- continue
- change.action = 'm2m_add' if change.action == 'create' else 'm2m_remove'
- change.field_name = 'redirects'
- change.field_value = obj.slug
- pk = obj.target_id
- obj = objects[LocationSlug][pk]
+ changed_objects_data = []
+
+ for model, changed_objects in changeset.changed_objects.items():
+ for changed_object in changed_objects:
+ pk = changed_object.obj_pk
+ obj = objects[model][pk]
- if obj != last_obj and not show_history and pk in grouped_changes:
- # noinspection PyTypeChecker
- changes = grouped_changes[pk]['changes']
- elif obj != last_obj:
- changes = []
obj_desc = _('%(model)s #%(id)s') % {'model': obj.__class__._meta.verbose_name, 'id': pk}
if is_created_pk(pk):
- if show_history:
- obj_desc = _('%s (created)') % obj_desc
obj_still_exists = pk in changeset.created_objects[obj.__class__]
else:
obj_still_exists = pk not in changeset.deleted_existing.get(obj.__class__, ())
@@ -98,166 +67,112 @@ def group_changes(changeset, can_edit=False, show_history=False):
edit_url = reverse('editor.' + obj.__class__._meta.default_related_name + '.edit',
kwargs=reverse_kwargs)
- change_group = {
+ changes = []
+ changed_object_data = {
'model': obj.__class__,
'model_title': obj.__class__._meta.verbose_name,
'obj': obj_desc,
'obj_title': obj.title if obj.titles else None,
'changes': changes,
'edit_url': edit_url,
+ 'order': changed_object.created,
}
- if show_history:
- grouped_changes.append(change_group)
- else:
- change_group['order'] = (0, int(pk[1:])) if is_created_pk(pk) else (1, int(pk))
- grouped_changes[pk] = change_group
+ changed_objects_data.append(changed_object_data)
- last_obj = obj
+ form_fields = changeset.wrap(type(obj)).EditorForm._meta.fields
- form = changeset.wrap(type(obj)).EditorForm
-
- change_data = {
- 'pk': change.pk,
- 'author': change.author,
- 'discarded': change.discarded_by_id is not None,
- 'apply_problem': change.check_apply_problem(),
- 'has_no_effect': change.check_has_no_effect(),
- }
- if show_history:
- change_data.update({
- 'created': _('created at %(datetime)s') % {'datetime': date_format(change.created, 'DATETIME_FORMAT')},
- })
- if not show_history or change.action == 'delete' and can_edit:
- change_data.update({
- 'can_restore': change.can_restore,
- })
- changes.append(change_data)
-
- if change.action == 'create':
- change_data.update({
- 'icon': 'plus',
- 'class': 'success',
- 'title': _('created'),
- 'order': (0, ),
- })
- elif change.action == 'delete':
- change_data.update({
- 'icon': 'minus',
- 'class': 'danger',
- 'title': _('deleted'),
- 'order': (9, ),
- })
- elif change.action == 'update':
- change_data.update({
- 'icon': 'option-vertical',
- 'class': 'muted',
- })
- if change.field_name == 'geometry':
- change_data.update({
- 'icon': 'map-marker',
- 'class': 'info',
- 'title': _('edited geometry'),
- 'order': (8, ),
+ if changed_object.is_created:
+ changes.append({
+ 'icon': 'plus',
+ 'class': 'success',
+ 'title': _('created'),
})
- else:
- if change.field_name.startswith('title_'):
- lang = change.field_name[6:]
- field_title = _('Title (%(lang)s)') % {'lang': dict(settings.LANGUAGES).get(lang, lang)}
- field_value = str(json.loads(change.field_value))
- if field_value:
- obj.titles[lang] = field_value
- else:
- obj.titles.pop(lang, None)
+
+ update_changes = []
+
+ for name, value in changed_object.updated_fields:
+ change_data = {
+ 'icon': 'option-vertical',
+ 'class': 'muted',
+ }
+ if name == 'geometry':
change_data.update({
- 'order': (4, tuple(code for code, title in settings.LANGUAGES).index(lang)),
+ 'icon': 'map-marker',
+ 'class': 'info',
+ 'title': _('edited geometry'),
+ 'order': (8,),
})
else:
- field = change.field
- field_title = field.verbose_name
- field_value = field.to_python(json.loads(change.field_value))
- if field.related_model is not None:
- field_value = objects[field.related_model][field_value].title
- order = 5
- if change.field_name == 'slug':
- order = 1
- if change.field_name not in form._meta.fields:
- order = 0
- change_data.update({
- 'order': (order, form._meta.fields.index(change.field_name) if order else 1),
- })
- if not field_value:
- change_data.update({
- 'title': _('remove %(field_title)s') % {'field_title': field_title},
- })
- else:
- change_data.update({
- 'title': field_title,
- 'value': field_value,
- })
- elif change.action == 'restore':
- change_data.update({
- 'icon': 'share-alt',
- 'class': 'muted',
- })
- if change.field_name == 'geometry':
- change_data.update({
- 'icon': 'map-marker',
- 'title': _('reverted geometry'),
- })
- else:
- if change.field_name.startswith('title_'):
- lang = change.field_name[6:]
- field_title = _('Title (%(lang)s)') % {'lang': dict(settings.LANGUAGES).get(lang, lang)}
- else:
- field = change.field
- field_title = field.verbose_name
- model = getattr(field, 'related_model', None)
- if model is not None:
+ if name.startswith('title_'):
+ lang = name[6:]
+ field_title = _('Title (%(lang)s)') % {'lang': dict(settings.LANGUAGES).get(lang, lang)}
+ field_value = str(value)
+ if field_value:
+ obj.titles[lang] = field_value
+ else:
+ obj.titles.pop(lang, None)
change_data.update({
- 'value': objects[model][json.loads(change.field_value)].title
+ 'order': (4, tuple(code for code, title in settings.LANGUAGES).index(lang)),
})
- change_data.update({
- 'title': _('reverted %(field_title)s') % {'field_title': field_title},
+ else:
+ field = model._meta.get_field(name)
+ field_title = field.verbose_name
+ field_value = field.to_python(value)
+ if field.related_model is not None:
+ field_value = objects[field.related_model][field_value].title
+ order = 5
+ if name == 'slug':
+ order = 1
+ if name not in form_fields:
+ order = 0
+ change_data.update({
+ 'order': (order, form_fields.index(name) if order else 1),
+ })
+ if not field_value:
+ change_data.update({
+ 'title': _('remove %(field_title)s') % {'field_title': field_title},
+ })
+ else:
+ change_data.update({
+ 'title': field_title,
+ 'value': field_value,
+ })
+ update_changes.append(change_data)
+
+ changes.extend(sorted(update_changes, key=itemgetter('order')))
+
+ for m2m_mode in ('m2m_added', 'm2m_removed'):
+ m2m_list = getattr(changed_object, m2m_mode).items()
+ for name, values in sorted(m2m_list, key=lambda name, value: form_fields.index(name)):
+ field = model._meta.get_field(name)
+ for value in values:
+ change_data.update({
+ 'icon': 'chevron-right' if m2m_mode == 'm2m_added' else 'chevron-left',
+ 'class': 'info',
+ 'title': field.verbose_name,
+ 'value': objects[field.related_model][value].title,
+ })
+
+ if changed_object.deleted:
+ changes.append({
+ 'icon': 'minus',
+ 'class': 'danger',
+ 'title': _('deleted'),
+ 'order': (9,),
})
- elif change.action in ('m2m_add', 'm2m_remove'):
- change_data.update({
- 'icon': 'chevron-right' if change.action == 'm2m_add' else 'chevron-left',
- 'class': 'info',
- })
- if change.field_name == 'redirects':
- change_data.update({
- 'title': _('Redirecting slugs'),
- 'value': change.field_value,
- 'order': (6, -1, (change.action == 'm2m_remove')),
- })
- else:
- field = obj.__class__._meta.get_field(change.field_name)
- change_data.update({
- 'title': field.verbose_name,
- 'value': objects[field.related_model][json.loads(change.field_value)].title,
- 'order': (6, form._meta.fields.index(change.field_name),
- (change.action == 'm2m_remove')),
- })
- else:
- change_data.update({
- 'title': '???',
- 'order': (10, )
- })
+ changed_objects_data = sorted(changed_objects_data, key=itemgetter('order'))
+
if changeset.author:
desc = _('created at %(datetime)s by') % {'datetime': date_format(changeset.created, 'DATETIME_FORMAT')}
else:
desc = _('created at %(datetime)s') % {'datetime': date_format(changeset.created, 'DATETIME_FORMAT')}
- if not show_history:
- grouped_changes = sorted(grouped_changes.values(), key=itemgetter('order'))
- for group in grouped_changes:
- group['changes'] = sorted(group['changes'], key=itemgetter('order'))
-
ctx = {
'pk': changeset.pk,
'changeset': changeset,
'desc': desc,
- 'grouped_changes': grouped_changes,
+ 'changed_objects': changed_objects_data,
}
- return ctx
+
+ return render(request, 'editor/changeset.html', ctx)
diff --git a/src/c3nav/editor/views/edit.py b/src/c3nav/editor/views/edit.py
index 12dcebd4..b669753d 100644
--- a/src/c3nav/editor/views/edit.py
+++ b/src/c3nav/editor/views/edit.py
@@ -19,19 +19,19 @@ def child_model(model, kwargs=None, parent=None):
@sidebar_view
def main_index(request):
- Level = request.changeset.wrap('Level')
+ Level = request.changeset.wrap_model('Level')
return render(request, 'editor/index.html', {
'levels': Level.objects.filter(on_top_of__isnull=True),
'child_models': [
- child_model(request.changeset.wrap('LocationGroup')),
- child_model(request.changeset.wrap('Source')),
+ child_model(request.changeset.wrap_model('LocationGroup')),
+ child_model(request.changeset.wrap_model('Source')),
],
})
@sidebar_view
def level_detail(request, pk):
- Level = request.changeset.wrap('Level')
+ Level = request.changeset.wrap_model('Level')
level = get_object_or_404(Level.objects.select_related('on_top_of').prefetch_related('levels_on_top'), pk=pk)
return render(request, 'editor/level.html', {
@@ -40,7 +40,7 @@ def level_detail(request, pk):
'level_url': 'editor.levels.detail',
'level_as_pk': True,
- 'child_models': [child_model(request.changeset.wrap(model_name), kwargs={'level': pk}, parent=level)
+ 'child_models': [child_model(request.changeset.wrap_model(model_name), kwargs={'level': pk}, parent=level)
for model_name in ('Building', 'Space', 'Door')],
'levels_on_top': level.levels_on_top.all(),
'geometry_url': '/api/editor/geometries/?level='+str(level.primary_level_pk),
@@ -49,14 +49,14 @@ def level_detail(request, pk):
@sidebar_view
def space_detail(request, level, pk):
- Space = request.changeset.wrap('Space')
+ Space = request.changeset.wrap_model('Space')
space = get_object_or_404(Space.objects.select_related('level'), level__pk=level, pk=pk)
return render(request, 'editor/space.html', {
'level': space.level,
'space': space,
- 'child_models': [child_model(request.changeset.wrap(model_name), kwargs={'space': pk}, parent=space)
+ 'child_models': [child_model(request.changeset.wrap_model(model_name), kwargs={'space': pk}, parent=space)
for model_name in ('Hole', 'Area', 'Stair', 'Obstacle', 'LineObstacle', 'Column', 'Point')],
'geometry_url': '/api/editor/geometries/?space='+pk,
})
@@ -64,11 +64,11 @@ def space_detail(request, level, pk):
@sidebar_view
def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, explicit_edit=False):
- model = request.changeset.wrap(model)
+ model = request.changeset.wrap_model(model)
related_name = model._meta.default_related_name
- Level = request.changeset.wrap('Level')
- Space = request.changeset.wrap('Space')
+ Level = request.changeset.wrap_model('Level')
+ Space = request.changeset.wrap_model('Space')
obj = None
if pk is not None:
@@ -229,10 +229,10 @@ def list_objects(request, model=None, level=None, space=None, explicit_edit=Fals
if not request.resolver_match.url_name.endswith('.list'):
raise ValueError('url_name does not end with .list')
- model = request.changeset.wrap(model)
+ model = request.changeset.wrap_model(model)
- Level = request.changeset.wrap('Level')
- Space = request.changeset.wrap('Space')
+ Level = request.changeset.wrap_model('Level')
+ Space = request.changeset.wrap_model('Space')
ctx = {
'path': request.path,