2017-06-16 20:27:07 +02:00
|
|
|
from collections import OrderedDict
|
2017-07-04 17:05:29 +02:00
|
|
|
from contextlib import contextmanager
|
2017-06-27 03:20:50 +02:00
|
|
|
from itertools import chain
|
2017-06-12 14:52:08 +02:00
|
|
|
|
|
|
|
from django.apps import apps
|
2017-06-12 13:20:26 +02:00
|
|
|
from django.conf import settings
|
2017-07-04 17:05:29 +02:00
|
|
|
from django.db import models, transaction
|
2017-06-13 15:31:54 +02:00
|
|
|
from django.urls import reverse
|
2017-06-12 13:20:26 +02:00
|
|
|
from django.utils.translation import ugettext_lazy as _
|
2017-06-12 17:22:38 +02:00
|
|
|
from django.utils.translation import ungettext_lazy
|
2017-06-12 13:20:26 +02:00
|
|
|
|
2017-06-27 03:20:50 +02:00
|
|
|
from c3nav.editor.models.changedobject import ChangedObject
|
|
|
|
from c3nav.editor.utils import is_created_pk
|
|
|
|
from c3nav.editor.wrappers import ModelWrapper
|
2017-06-21 13:54:00 +02:00
|
|
|
from c3nav.mapdata.models import LocationSlug
|
|
|
|
from c3nav.mapdata.models.locations import LocationRedirect
|
2017-06-22 21:33:22 +02:00
|
|
|
from c3nav.mapdata.utils.models import get_submodels
|
2017-06-12 22:56:39 +02:00
|
|
|
|
2017-06-12 13:20:26 +02:00
|
|
|
|
|
|
|
class ChangeSet(models.Model):
|
2017-07-01 14:18:39 +02:00
|
|
|
STATES = (
|
|
|
|
('unproposed', _('unproposed')),
|
|
|
|
('proposed', _('proposed')),
|
|
|
|
('review', _('in review')),
|
|
|
|
('rejected', _('rejected')),
|
2017-07-04 19:05:30 +02:00
|
|
|
('reproposed', _('reproposed')),
|
2017-07-01 14:18:39 +02:00
|
|
|
('finallyrejected', _('finally rejected')),
|
|
|
|
('applied', _('accepted')),
|
|
|
|
)
|
2017-06-12 13:20:26 +02:00
|
|
|
created = models.DateTimeField(auto_now_add=True, verbose_name=_('created'))
|
2017-07-04 17:05:29 +02:00
|
|
|
last_change = models.DateTimeField(auto_now_add=True, verbose_name=_('last change'))
|
2017-07-04 19:05:30 +02:00
|
|
|
last_update = models.DateTimeField(auto_now_add=True, verbose_name=_('last update'))
|
|
|
|
state = models.CharField(max_length=20, db_index=True, choices=STATES, default='unproposed')
|
2017-06-12 16:59:57 +02:00
|
|
|
author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT, verbose_name=_('Author'))
|
2017-07-01 14:18:39 +02:00
|
|
|
title = models.CharField(max_length=100, default='', verbose_name=_('Title'))
|
2017-06-29 15:53:26 +02:00
|
|
|
description = models.TextField(max_length=1000, default='', verbose_name=_('Description'))
|
2017-06-29 17:01:44 +02:00
|
|
|
assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT,
|
|
|
|
related_name='assigned_changesets', verbose_name=_('assigned to'))
|
2017-06-12 13:20:26 +02:00
|
|
|
|
|
|
|
class Meta:
|
|
|
|
verbose_name = _('Change Set')
|
|
|
|
verbose_name_plural = _('Change Sets')
|
|
|
|
default_related_name = 'changesets'
|
|
|
|
|
2017-06-12 22:56:39 +02:00
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
super().__init__(*args, **kwargs)
|
2017-06-27 03:20:50 +02:00
|
|
|
self.changed_objects = None
|
|
|
|
|
2017-06-14 02:19:37 +02:00
|
|
|
self.created_objects = {}
|
2017-06-14 00:51:55 +02:00
|
|
|
self.updated_existing = {}
|
2017-06-13 16:36:18 +02:00
|
|
|
self.deleted_existing = {}
|
2017-06-16 19:19:54 +02:00
|
|
|
self.m2m_added = {}
|
|
|
|
self.m2m_removed = {}
|
2017-06-29 15:06:14 +02:00
|
|
|
|
2017-07-04 19:05:30 +02:00
|
|
|
self._object_changed = False
|
2017-07-04 22:14:43 +02:00
|
|
|
self._request = None
|
2017-06-13 16:15:28 +02:00
|
|
|
|
2017-06-21 13:31:41 +02:00
|
|
|
"""
|
|
|
|
Get Changesets for Request/Session/User
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
|
|
def qs_for_request(cls, request):
|
2017-06-21 18:00:12 +02:00
|
|
|
"""
|
|
|
|
Returns a base QuerySet to get only changesets the current user is allowed to see
|
|
|
|
"""
|
2017-06-21 13:31:41 +02:00
|
|
|
if request.user.is_authenticated:
|
2017-07-04 22:14:43 +02:00
|
|
|
return ChangeSet.objects.filter(author=request.user)
|
|
|
|
return ChangeSet.objects.none()
|
2017-06-29 17:40:33 +02:00
|
|
|
|
2017-06-21 13:31:41 +02:00
|
|
|
@classmethod
|
|
|
|
def get_for_request(cls, request):
|
2017-06-21 18:00:12 +02:00
|
|
|
"""
|
|
|
|
Get the changeset for the current request.
|
|
|
|
If a changeset is associated with the session id, it will be returned.
|
|
|
|
Otherwise, if the user is authenticated, the last created queryset
|
|
|
|
for this user will be returned and the session id will be added to it.
|
|
|
|
If both fails, an empty unsaved changeset will be returned which will
|
|
|
|
be automatically saved when a change is added to it.
|
|
|
|
In any case, the default autor for changes added to the queryset during
|
|
|
|
this request will be set to the current user.
|
|
|
|
"""
|
2017-07-04 22:14:43 +02:00
|
|
|
changeset_pk = request.session.get('changeset')
|
|
|
|
if changeset_pk is not None:
|
|
|
|
qs = ChangeSet.objects.exclude(state='applied')
|
|
|
|
if request.user.is_authenticated:
|
|
|
|
qs = qs.filter(author=request.user)
|
|
|
|
else:
|
|
|
|
qs = qs.filter(author__isnull=True)
|
|
|
|
try:
|
|
|
|
return qs.get(pk=changeset_pk)
|
|
|
|
except ChangeSet.DoesNotExist:
|
|
|
|
pass
|
2017-06-21 13:31:41 +02:00
|
|
|
|
2017-07-04 22:14:43 +02:00
|
|
|
changeset = ChangeSet()
|
|
|
|
changeset._request = request
|
2017-06-27 18:54:12 +02:00
|
|
|
|
2017-06-21 13:31:41 +02:00
|
|
|
if request.user.is_authenticated:
|
2017-07-04 22:14:43 +02:00
|
|
|
changeset.author = request.user
|
2017-06-21 13:31:41 +02:00
|
|
|
|
2017-07-04 22:14:43 +02:00
|
|
|
return changeset
|
2017-06-21 13:31:41 +02:00
|
|
|
|
|
|
|
"""
|
|
|
|
Wrap Objects
|
|
|
|
"""
|
2017-06-27 03:20:50 +02:00
|
|
|
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)
|
2017-06-21 13:31:41 +02:00
|
|
|
|
2017-06-27 03:20:50 +02:00
|
|
|
def wrap_instance(self, instance):
|
|
|
|
assert isinstance(instance, models.Model)
|
|
|
|
return self.wrap_model(instance.__class__).create_wrapped_model_class()(self, instance)
|
2017-06-20 15:39:22 +02:00
|
|
|
|
2017-06-27 03:20:50 +02:00
|
|
|
def relevant_changed_objects(self):
|
2017-07-04 17:05:29 +02:00
|
|
|
return self.changed_objects_set.exclude(existing_object_pk__isnull=True, deleted=True)
|
2017-06-27 03:20:50 +02:00
|
|
|
|
|
|
|
def fill_changes_cache(self, include_deleted_created=False):
|
2017-06-21 18:00:12 +02:00
|
|
|
"""
|
2017-06-27 03:20:50 +02:00
|
|
|
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
|
2017-06-21 18:00:12 +02:00
|
|
|
"""
|
2017-06-27 16:10:28 +02:00
|
|
|
if self.changed_objects is not None:
|
|
|
|
return False
|
|
|
|
|
|
|
|
if self.pk is None:
|
|
|
|
self.changed_objects = {}
|
2017-06-27 03:20:50 +02:00
|
|
|
return False
|
2017-06-20 15:39:22 +02:00
|
|
|
|
2017-06-27 03:20:50 +02:00
|
|
|
if include_deleted_created:
|
2017-07-04 17:05:29 +02:00
|
|
|
qs = self.changed_objects_set.all()
|
2017-06-20 15:39:22 +02:00
|
|
|
else:
|
2017-06-27 03:20:50 +02:00
|
|
|
qs = self.relevant_changed_objects()
|
|
|
|
|
|
|
|
self.changed_objects = {}
|
|
|
|
for change in qs:
|
|
|
|
change.update_changeset_cache()
|
|
|
|
|
|
|
|
return True
|
2017-06-16 19:19:54 +02:00
|
|
|
|
2017-06-21 13:48:13 +02:00
|
|
|
"""
|
|
|
|
Analyse Changes
|
|
|
|
"""
|
|
|
|
def get_objects(self):
|
2017-06-27 03:20:50 +02:00
|
|
|
if self.changed_objects is None:
|
2017-06-21 13:48:13 +02:00
|
|
|
raise TypeError
|
|
|
|
|
|
|
|
# collect pks of relevant objects
|
|
|
|
object_pks = {}
|
2017-06-27 14:31:50 +02:00
|
|
|
for change in chain(*(objects.values() for objects in self.changed_objects.values())):
|
2017-06-27 03:20:50 +02:00
|
|
|
change.add_relevant_object_pks(object_pks)
|
2017-06-21 13:48:13 +02:00
|
|
|
|
|
|
|
# retrieve relevant objects
|
|
|
|
objects = {}
|
|
|
|
for model, pks in object_pks.items():
|
|
|
|
created_pks = set(pk for pk in pks if is_created_pk(pk))
|
|
|
|
existing_pks = pks - created_pks
|
|
|
|
model_objects = {}
|
|
|
|
if existing_pks:
|
|
|
|
for obj in model.objects.filter(pk__in=existing_pks):
|
|
|
|
if model == LocationSlug:
|
|
|
|
obj = obj.get_child()
|
|
|
|
model_objects[obj.pk] = obj
|
|
|
|
if created_pks:
|
|
|
|
for pk in created_pks:
|
|
|
|
model_objects[pk] = self.get_created_object(model, pk, allow_deleted=True)._obj
|
|
|
|
objects[model] = model_objects
|
|
|
|
|
2017-06-29 13:20:35 +02:00
|
|
|
# add LocationSlug objects as their correct model
|
|
|
|
for pk, obj in objects.get(LocationSlug, {}).items():
|
|
|
|
objects.setdefault(obj.__class__, {})[pk] = obj
|
|
|
|
|
2017-06-21 13:48:13 +02:00
|
|
|
return objects
|
|
|
|
|
2017-06-21 19:13:23 +02:00
|
|
|
def get_changed_values(self, model: models.Model, name: str) -> tuple:
|
2017-06-21 18:00:12 +02:00
|
|
|
"""
|
|
|
|
Get all changes values for a specific field on existing models
|
|
|
|
:param model: model class
|
|
|
|
:param name: field name
|
|
|
|
:return: returns a dictionary with primary keys as keys and new values as values
|
|
|
|
"""
|
2017-06-15 17:53:00 +02:00
|
|
|
r = tuple((pk, values[name]) for pk, values in self.updated_existing.get(model, {}).items() if name in values)
|
|
|
|
return r
|
|
|
|
|
2017-06-27 14:57:43 +02:00
|
|
|
def get_changed_object(self, obj) -> ChangedObject:
|
|
|
|
model = obj.__class__
|
|
|
|
pk = obj.pk
|
2017-06-27 14:31:50 +02:00
|
|
|
if pk is None:
|
|
|
|
return ChangedObject(changeset=self, model_class=model)
|
|
|
|
|
2017-06-27 03:20:50 +02:00
|
|
|
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:
|
2017-06-27 14:31:50 +02:00
|
|
|
return objects[0][1]
|
2017-06-27 03:20:50 +02:00
|
|
|
|
|
|
|
if is_created_pk(pk):
|
|
|
|
raise model.DoesNotExist
|
|
|
|
|
2017-06-27 14:31:50 +02:00
|
|
|
return ChangedObject(changeset=self, model_class=model, existing_object_pk=pk)
|
2017-06-27 03:20:50 +02:00
|
|
|
|
|
|
|
def get_created_object(self, model, pk, get_foreign_objects=False, allow_deleted=False):
|
2017-06-21 18:00:12 +02:00
|
|
|
"""
|
|
|
|
Gets a created model instance.
|
|
|
|
:param model: model class
|
|
|
|
:param pk: primary key
|
|
|
|
: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
|
|
|
|
"""
|
2017-06-27 03:20:50 +02:00
|
|
|
self.fill_changes_cache()
|
2017-06-16 13:00:47 +02:00
|
|
|
if issubclass(model, ModelWrapper):
|
|
|
|
model = model._obj
|
2017-06-18 15:30:35 +02:00
|
|
|
|
2017-06-27 17:07:54 +02:00
|
|
|
obj = self.get_changed_object(model(pk=pk))
|
|
|
|
if obj.deleted and not allow_deleted:
|
2017-06-18 15:30:35 +02:00
|
|
|
raise model.DoesNotExist
|
2017-06-27 17:07:54 +02:00
|
|
|
return obj.get_obj(get_foreign_objects=get_foreign_objects)
|
2017-06-16 13:00:47 +02:00
|
|
|
|
2017-06-21 18:00:12 +02:00
|
|
|
def get_created_pks(self, model) -> set:
|
|
|
|
"""
|
|
|
|
Returns a set with the primary keys of created objects from this model
|
|
|
|
"""
|
2017-06-27 19:09:06 +02:00
|
|
|
self.fill_changes_cache()
|
2017-06-16 16:03:51 +02:00
|
|
|
if issubclass(model, ModelWrapper):
|
|
|
|
model = model._obj
|
|
|
|
return set(self.created_objects.get(model, {}).keys())
|
|
|
|
|
2017-06-29 17:15:11 +02:00
|
|
|
"""
|
|
|
|
Permissions
|
|
|
|
"""
|
|
|
|
@property
|
2017-07-04 20:11:26 +02:00
|
|
|
def changes_editable(self):
|
|
|
|
return self.state in ('unproposed', 'rejected', 'review')
|
|
|
|
|
|
|
|
@property
|
|
|
|
def proposed(self):
|
|
|
|
return self.state not in ('unproposed', 'rejected')
|
2017-06-29 17:15:11 +02:00
|
|
|
|
2017-07-04 22:44:21 +02:00
|
|
|
def is_author(self, request):
|
|
|
|
return (self.author == request.user or (self.author is None and not request.user.is_authenticated and
|
|
|
|
request.session.get('changeset', None) == self.pk))
|
|
|
|
|
2017-06-29 17:40:33 +02:00
|
|
|
def can_see(self, request):
|
2017-07-04 22:44:21 +02:00
|
|
|
return self.is_author(request)
|
2017-07-04 17:05:29 +02:00
|
|
|
|
|
|
|
@contextmanager
|
2017-07-04 20:11:26 +02:00
|
|
|
def lock_to_edit(self, request=None):
|
2017-07-04 17:05:29 +02:00
|
|
|
with transaction.atomic():
|
|
|
|
if self.pk is not None:
|
|
|
|
changeset = ChangeSet.objects.select_for_update().get(pk=self.pk)
|
2017-07-04 20:11:26 +02:00
|
|
|
if request is not None and not changeset.can_edit(request):
|
2017-07-04 17:05:29 +02:00
|
|
|
raise PermissionError
|
2017-07-04 20:11:26 +02:00
|
|
|
|
2017-07-04 19:05:30 +02:00
|
|
|
self._object_changed = False
|
2017-07-04 20:11:26 +02:00
|
|
|
yield changeset
|
|
|
|
if self._object_changed and request is not None:
|
2017-07-04 19:05:30 +02:00
|
|
|
update = changeset.updates.create(user=request.user if request.user.is_authenticated else None,
|
|
|
|
objects_changed=True)
|
|
|
|
changeset.last_update = update.datetime
|
|
|
|
changeset.last_change = update.datetime
|
|
|
|
changeset.save()
|
2017-07-04 17:05:29 +02:00
|
|
|
else:
|
|
|
|
yield
|
2017-06-29 17:40:33 +02:00
|
|
|
|
2017-07-04 22:14:43 +02:00
|
|
|
def can_edit(self, request):
|
|
|
|
if not self.proposed:
|
2017-07-04 22:44:21 +02:00
|
|
|
return self.is_author(request)
|
2017-07-04 17:05:29 +02:00
|
|
|
elif self.state == 'review':
|
|
|
|
return self.assigned_to == request.user
|
|
|
|
return False
|
|
|
|
|
2017-06-29 17:48:02 +02:00
|
|
|
def can_delete(self, request):
|
2017-07-04 20:11:26 +02:00
|
|
|
return self.can_edit(request) and self.state == 'unproposed'
|
2017-06-29 17:48:02 +02:00
|
|
|
|
2017-06-29 17:40:33 +02:00
|
|
|
def can_propose(self, request):
|
2017-07-04 20:11:26 +02:00
|
|
|
return self.can_edit(request) and not self.proposed
|
2017-06-29 17:40:33 +02:00
|
|
|
|
|
|
|
def can_unpropose(self, request):
|
2017-07-04 20:11:26 +02:00
|
|
|
return self.author_id == request.user.pk and self.state in ('proposed', 'reproposed')
|
|
|
|
|
|
|
|
"""
|
|
|
|
Update methods
|
|
|
|
"""
|
|
|
|
def propose(self, user):
|
|
|
|
new_state = {'unproposed': 'proposed', 'rejected': 'reproposed'}[self.state]
|
|
|
|
update = self.updates.create(user=user, state=new_state)
|
|
|
|
self.state = new_state
|
|
|
|
self.last_update = update.datetime
|
|
|
|
self.save()
|
|
|
|
|
|
|
|
def unpropose(self, user):
|
|
|
|
new_state = {'proposed': 'unproposed', 'reproposed': 'rejected'}[self.state]
|
|
|
|
update = self.updates.create(user=user, state=new_state)
|
|
|
|
self.state = new_state
|
|
|
|
self.last_update = update.datetime
|
|
|
|
self.save()
|
2017-06-29 17:40:33 +02:00
|
|
|
|
2017-07-04 22:44:21 +02:00
|
|
|
def activate(self, request):
|
|
|
|
request.session['changeset'] = self.pk
|
|
|
|
|
2017-06-21 13:31:41 +02:00
|
|
|
"""
|
|
|
|
Methods for display
|
|
|
|
"""
|
|
|
|
@property
|
2017-06-27 03:20:50 +02:00
|
|
|
def changed_objects_count(self):
|
2017-06-21 18:00:12 +02:00
|
|
|
"""
|
2017-06-29 13:20:35 +02:00
|
|
|
Get the number of changed objects.
|
2017-06-21 18:00:12 +02:00
|
|
|
"""
|
2017-06-29 13:20:35 +02:00
|
|
|
self.fill_changes_cache()
|
|
|
|
count = 0
|
|
|
|
changed_locationslug_pks = set()
|
|
|
|
for model, objects in self.changed_objects.items():
|
|
|
|
if issubclass(model, LocationSlug):
|
|
|
|
if model == LocationRedirect:
|
|
|
|
continue
|
|
|
|
changed_locationslug_pks.update(objects.keys())
|
2017-06-29 16:42:47 +02:00
|
|
|
count += sum(1 for obj in objects.values() if not obj.is_created or not obj.deleted)
|
2017-06-29 13:20:35 +02:00
|
|
|
|
2017-06-29 14:27:42 +02:00
|
|
|
count += len(set(obj.updated_fields['target']
|
|
|
|
for obj in self.changed_objects.get(LocationRedirect, {}).values()) - changed_locationslug_pks)
|
2017-06-29 13:20:35 +02:00
|
|
|
return count
|
2017-06-21 13:31:41 +02:00
|
|
|
|
|
|
|
@property
|
|
|
|
def count_display(self):
|
2017-06-21 18:00:12 +02:00
|
|
|
"""
|
2017-06-27 03:20:50 +02:00
|
|
|
Get “%d changed objects” display text.
|
2017-06-21 18:00:12 +02:00
|
|
|
"""
|
2017-06-21 13:31:41 +02:00
|
|
|
if self.pk is None:
|
2017-07-04 17:11:31 +02:00
|
|
|
return _('No objects changed')
|
|
|
|
return (ungettext_lazy('%(num)d object changed', '%(num)d objects changed', 'num') %
|
2017-06-27 03:20:50 +02:00
|
|
|
{'num': self.changed_objects_count})
|
2017-06-21 13:31:41 +02:00
|
|
|
|
|
|
|
@property
|
|
|
|
def cache_key(self):
|
|
|
|
if self.pk is None:
|
|
|
|
return None
|
2017-06-29 15:06:14 +02:00
|
|
|
return str(self.pk)+'-'+str(self.last_change)
|
2017-06-21 13:31:41 +02:00
|
|
|
|
|
|
|
def get_absolute_url(self):
|
|
|
|
if self.pk is None:
|
|
|
|
return ''
|
|
|
|
return reverse('editor.changesets.detail', kwargs={'pk': self.pk})
|
|
|
|
|
2017-06-16 20:27:07 +02:00
|
|
|
def serialize(self):
|
|
|
|
return OrderedDict((
|
|
|
|
('id', self.pk),
|
|
|
|
('author', self.author_id),
|
|
|
|
('created', None if self.created is None else self.created.isoformat()),
|
|
|
|
))
|
2017-07-04 22:14:43 +02:00
|
|
|
|
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
if self._request is not None:
|
2017-07-04 22:44:21 +02:00
|
|
|
self.activate(self._request)
|