team-3/src/c3nav/editor/models/changeset.py
2017-06-27 13:11:31 +02:00

289 lines
11 KiB
Python

from collections import OrderedDict
from itertools import chain
from operator import attrgetter
from django.apps import apps
from django.conf import settings
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.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
class ChangeSet(models.Model):
created = models.DateTimeField(auto_now_add=True, verbose_name=_('created'))
author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT, verbose_name=_('Author'))
session_id = models.CharField(unique=True, null=True, max_length=32)
proposed = models.DateTimeField(null=True, verbose_name=_('proposed'))
applied = models.DateTimeField(null=True, verbose_name=_('applied'))
applied_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.PROTECT,
related_name='applied_changesets', verbose_name=_('applied by'))
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.ever_created_objects = {}
self.created_objects = {}
self.updated_existing = {}
self.deleted_existing = {}
self.m2m_added = {}
self.m2m_removed = {}
self._last_change_pk = 0
"""
Get Changesets for Request/Session/User
"""
@classmethod
def qs_base(cls, hide_applied=True):
qs = cls.objects.select_related('author')
if hide_applied:
qs = qs.filter(applied__isnull=True)
return qs
@classmethod
def qs_for_request(cls, request):
"""
Returns a base QuerySet to get only changesets the current user is allowed to see
"""
qs = cls.qs_base()
if request.user.is_authenticated:
qs = qs.filter(author=request.user)
else:
qs = qs.filter(author__isnull=True)
return qs
@classmethod
def get_for_request(cls, request):
"""
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.
"""
qs = cls.qs_for_request(request)
if request.session.session_key is not None:
changeset = qs.filter(session_id=request.session.session_key).first()
if changeset is not None:
if changeset.author_id is None and request.user.is_authenticated:
changeset.author = request.user
changeset.save()
return changeset
new_changeset = cls()
new_changeset.request = request
if request.user.is_authenticated:
changeset = qs.filter(Q(author=request.user)).order_by('-created').first()
if changeset is not None:
if request.session.session_key is None:
request.session.save()
changeset.session_id = request.session.session_key
changeset.save()
return changeset
new_changeset.author = request.user
new_changeset.session_id = request.session.session_key
return new_changeset
"""
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)
def wrap_instance(self, instance):
assert isinstance(instance, models.Model)
return self.wrap_model(instance.__class__).create_wrapped_model_class()(self, instance)
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):
"""
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:
qs = self.relevant_changed_objects()
self.changed_objects = {}
for change in qs:
change.update_changeset_cache()
return True
"""
Analyse Changes
"""
def get_objects(self):
if self.changed_objects is None:
raise TypeError
# collect pks of relevant objects
object_pks = {}
for change in chain(self.changed_objects.values()):
change.add_relevant_object_pks(object_pks)
# 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
return objects
"""
Lookup changes and created objects
"""
def get_changed_values(self, model: models.Model, name: str) -> tuple:
"""
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
"""
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, 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 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()
if issubclass(model, ModelWrapper):
model = model._obj
object = self.get_changed_object(model, pk)
if object.deleted and not allow_deleted:
raise model.DoesNotExist
return object.get_obj(get_foreign_objects=get_foreign_objects)
def get_created_pks(self, model) -> set:
"""
Returns a set with the primary keys of created objects from this model
"""
if issubclass(model, ModelWrapper):
model = model._obj
return set(self.created_objects.get(model, {}).keys())
"""
Methods for display
"""
@property
def changed_objects_count(self):
"""
Get the number of changed objects. Does not need a query if cache is already filled.
"""
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()
return sum((len(objects) for model, objects in self.changed_objects.items() if model != LocationRedirect))
@property
def count_display(self):
"""
Get “%d changed objects” display text.
"""
if self.pk is None:
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):
if self.pk is None:
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_update)
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()),
('proposed', None if self.proposed is None else self.proposed.isoformat()),
('applied', None if self.applied is None else self.applied.isoformat()),
('applied_by', None if self.applied_by_id is None else self.applied_by_id),
('changes', tuple(change.serialize() for change in self.changes.all())),
))