team-3/src/c3nav/editor/models/changeset.py

487 lines
18 KiB
Python
Raw Normal View History

from collections import OrderedDict
from contextlib import contextmanager
from itertools import chain
2017-06-12 14:52:08 +02:00
from django.apps import apps
from django.conf import settings
2017-07-06 00:36:37 +02:00
from django.core.cache import cache
from django.db import models, transaction
2017-06-13 15:31:54 +02:00
from django.urls import reverse
2017-07-05 23:38:47 +02:00
from django.utils.http import int_to_base36
from django.utils.timezone import make_naive
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
from rest_framework.exceptions import PermissionDenied
from c3nav.editor.models.changedobject import ChangedObject
from c3nav.editor.utils import is_created_pk
from c3nav.editor.wrappers import ModelWrapper
2017-07-05 22:04:22 +02:00
from c3nav.mapdata.models import LocationSlug, MapUpdate
2017-06-21 13:54:00 +02:00
from c3nav.mapdata.models.locations import LocationRedirect
2017-06-22 21:33:22 +02:00
from c3nav.mapdata.utils.models import get_submodels
class ChangeSet(models.Model):
2017-07-01 14:18:39 +02:00
STATES = (
('unproposed', _('unproposed')),
('proposed', _('proposed')),
('review', _('in review')),
('rejected', _('rejected')),
('reproposed', _('proposed again')),
2017-07-01 14:18:39 +02:00
('finallyrejected', _('finally rejected')),
('applied', _('accepted and applied')),
2017-07-01 14:18:39 +02:00
)
created = models.DateTimeField(auto_now_add=True, verbose_name=_('created'))
last_change = models.ForeignKey('editor.ChangeSetUpdate', null=True, related_name='+',
verbose_name=_('last object change'))
last_update = models.ForeignKey('editor.ChangeSetUpdate', null=True, related_name='+',
verbose_name=_('last update'))
2017-07-05 16:17:09 +02:00
last_state_update = models.ForeignKey('editor.ChangeSetUpdate', null=True, related_name='+',
verbose_name=_('last state 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'))
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-07-05 22:04:22 +02:00
map_update = models.OneToOneField(MapUpdate, null=True, related_name='changeset', verbose_name=_('map update'))
class Meta:
verbose_name = _('Change Set')
verbose_name_plural = _('Change Sets')
default_related_name = 'changesets'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.changed_objects = None
self.created_objects = {}
self.updated_existing = {}
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
self._object_changed = False
self._request = None
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:
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, select_related=None):
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.
"""
if select_related is None:
select_related = ('last_change', )
changeset_pk = request.session.get('changeset')
if changeset_pk is not None:
qs = ChangeSet.objects.select_related(*select_related).exclude(state__in=('applied', 'finallyrejected'))
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
changeset = ChangeSet()
changeset._request = request
2017-06-21 13:31:41 +02:00
if request.user.is_authenticated:
changeset.author = request.user
2017-06-21 13:31:41 +02:00
return changeset
2017-06-21 13:31:41 +02:00
"""
Wrap Objects
"""
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
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
def relevant_changed_objects(self):
return self.changed_objects_set.exclude(existing_object_pk__isnull=True, deleted=True)
def fill_changes_cache(self, include_deleted_created=False):
2017-06-21 18:00:12 +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 = {}
return False
2017-06-20 15:39:22 +02:00
2017-07-06 00:36:37 +02:00
cache_key = self.cache_key_by_changes + ':cache'
if include_deleted_created:
2017-07-06 00:36:37 +02:00
cache_key += '_with_deleted'
qs = self.changed_objects_set.all()
2017-06-20 15:39:22 +02:00
else:
qs = self.relevant_changed_objects()
2017-07-06 00:36:37 +02:00
cached_cache = cache.get(cache_key)
if cached_cache is not None:
(self.changed_objects, self.created_objects, self.updated_existing,
self.deleted_existing, self.m2m_added, self.m2m_removed) = cached_cache
return True
self.changed_objects = {}
for change in qs:
change.update_changeset_cache()
2017-07-06 00:36:37 +02:00
cache.set(cache_key, (self.changed_objects, self.created_objects, self.updated_existing,
self.deleted_existing, self.m2m_added, self.m2m_removed), 300)
return True
2017-06-16 19:19:54 +02:00
2017-06-21 13:48:13 +02:00
"""
Analyse Changes
"""
def get_objects(self):
if self.changed_objects is None:
2017-06-21 13:48:13 +02:00
raise TypeError
# collect pks of relevant objects
object_pks = {}
for change in chain(*(objects.values() for objects in self.changed_objects.values())):
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
# 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
def get_changed_object(self, obj) -> ChangedObject:
model = obj.__class__
pk = obj.pk
if pk is None:
return ChangedObject(changeset=self, model_class=model)
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][1]
if is_created_pk(pk):
raise model.DoesNotExist
return ChangedObject(changeset=self, model_class=model, existing_object_pk=pk)
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
"""
self.fill_changes_cache()
2017-06-16 13:00:47 +02:00
if issubclass(model, ModelWrapper):
model = model._obj
2017-06-27 17:07:54 +02:00
obj = self.get_changed_object(model(pk=pk))
if obj.deleted and not allow_deleted:
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
"""
self.fill_changes_cache()
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
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
@property
def closed(self):
return self.state in ('finallyrejected', 'applied')
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)
@contextmanager
def lock_to_edit(self, request=None):
with transaction.atomic():
if self.pk is not None:
changeset = ChangeSet.objects.select_for_update().get(pk=self.pk)
if request is not None and not changeset.can_edit(request):
raise PermissionDenied
self._object_changed = False
yield changeset
if self._object_changed and request is not None:
update = changeset.updates.create(user=request.user if request.user.is_authenticated else None,
objects_changed=True)
2017-07-05 15:41:50 +02:00
changeset.last_update = update
changeset.last_change = update
changeset.save()
else:
yield
2017-06-29 17:40:33 +02:00
def can_edit(self, request):
if not self.proposed:
2017-07-04 22:44:21 +02:00
return self.is_author(request)
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):
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):
return self.can_edit(request) and not self.proposed and self.changed_objects_count
2017-06-29 17:40:33 +02:00
def can_unpropose(self, request):
return self.author_id == request.user.pk and self.state in ('proposed', 'reproposed')
2017-07-05 19:40:35 +02:00
def can_review(self, request):
# todo implement permissions
return self.is_author(request)
def can_start_review(self, request):
return self.can_review(request) and self.state in ('proposed', 'reproposed')
def can_end_review(self, request):
return self.can_review(request) and self.state == 'review' and self.assigned_to == request.user
def can_unreject(self, request):
return (self.can_review(request) and self.state in ('rejected', 'finallyrejected') and
self.assigned_to == request.user)
"""
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
2017-07-05 15:41:50 +02:00
self.last_update = update
2017-07-05 16:17:09 +02:00
self.last_state_update = update
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
2017-07-05 15:41:50 +02:00
self.last_update = update
2017-07-05 16:17:09 +02:00
self.last_state_update = update
self.save()
2017-06-29 17:40:33 +02:00
2017-07-05 19:40:35 +02:00
def start_review(self, user):
assign_to = user
if self.assigned_to == user:
assign_to = None
else:
self.assigned_to = user
if self.state != 'review':
update = self.updates.create(user=user, state='review', assigned_to=assign_to)
self.state = 'review'
self.last_state_update = update
elif assign_to is None:
return
else:
update = self.updates.create(user=user, assigned_to=assign_to)
self.last_update = update
self.save()
def reject(self, user, comment: str, final: bool):
state = 'finallyrejected' if final else 'rejected'
update = self.updates.create(user=user, state=state, comment=comment)
self.state = state
self.last_state_update = update
2017-07-05 19:40:35 +02:00
self.last_update = update
self.save()
def unreject(self, user):
update = self.updates.create(user=user, state='review')
self.state = 'review'
self.last_state_update = update
2017-07-05 19:40:35 +02:00
self.last_update = update
self.save()
def apply(self, user):
update = self.updates.create(user=user, state='applied')
2017-07-05 22:04:22 +02:00
map_update = MapUpdate.objects.create(user=user, type='changeset')
self.state = 'applied'
self.last_state_update = update
self.last_update = update
2017-07-05 22:04:22 +02:00
self.map_update = map_update
self.save()
2017-07-05 19:40:35 +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
def changed_objects_count(self):
2017-06-21 18:00:12 +02:00
"""
Get the number of changed objects.
2017-06-21 18:00:12 +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 14:27:42 +02:00
count += len(set(obj.updated_fields['target']
for obj in self.changed_objects.get(LocationRedirect, {}).values()) - changed_locationslug_pks)
return count
2017-06-21 13:31:41 +02:00
@property
def count_display(self):
2017-06-21 18:00:12 +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:
return _('No objects changed')
return (ungettext_lazy('%(num)d object changed', '%(num)d objects changed', 'num') %
{'num': self.changed_objects_count})
2017-06-21 13:31:41 +02:00
@property
def last_update_cache_key(self):
2017-07-05 23:38:47 +02:00
last_update = self.created if self.last_update_id is None else self.last_update.datetime
2017-07-05 23:42:52 +02:00
return (int_to_base36(self.last_update_id or 0)+'_'+int_to_base36(int(make_naive(last_update).timestamp())))
2017-07-05 23:38:47 +02:00
@property
def last_change_cache_key(self):
2017-07-05 23:38:47 +02:00
last_change = self.created if self.last_change_id is None else self.last_change.datetime
2017-07-05 23:42:52 +02:00
return (int_to_base36(self.last_change_id or 0)+'_'+int_to_base36(int(make_naive(last_change).timestamp())))
2017-06-21 13:31:41 +02:00
@property
def cache_key_by_changes(self):
2017-07-06 00:27:21 +02:00
return ':'.join(('editor:changeset', str(self.pk), MapUpdate.cache_key(), self.last_change_cache_key))
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})
def serialize(self):
return OrderedDict((
('id', self.pk),
('author', self.author_id),
('created', None if self.created is None else self.created.isoformat()),
))
def save(self, *args, **kwargs):
2017-07-05 22:04:22 +02:00
if self.state == 'applied':
raise TypeError('Applied change sets can not be edited.')
super().save(*args, **kwargs)
if self._request is not None:
2017-07-04 22:44:21 +02:00
self.activate(self._request)
2017-07-05 16:25:19 +02:00
STATE_ICONS = {
'unproposed': 'pencil',
'proposed': 'send',
'reproposed': 'send',
'review': 'hourglass',
'rejected': 'remove',
'finallyrejected': 'remove',
'applied': 'ok',
}
@property
def icon(self):
return self.STATE_ICONS[self.state]
STATE_STYLES = {
2017-07-05 21:06:51 +02:00
'unproposed': 'muted',
2017-07-05 16:25:19 +02:00
'proposed': 'info',
'reproposed': 'info',
'review': 'info',
'rejected': 'danger',
'finallyrejected': 'danger',
'applied': 'success',
}
@property
def style(self):
return self.STATE_STYLES[self.state]