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

489 lines
18 KiB
Python
Raw Normal View History

from collections import OrderedDict
from contextlib import contextmanager
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
2017-07-06 15:06:01 +02:00
from django.core.exceptions import FieldDoesNotExist
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 gettext_lazy as _
from django.utils.translation import ngettext_lazy
from django_pydantic_field import SchemaField
from c3nav.editor.operations import CollectedChanges
from c3nav.editor.tasks import send_changeset_proposed_notification
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
from c3nav.mapdata.utils.cache.changes import changed_geometries
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='+',
2018-09-18 17:38:26 +02:00
verbose_name=_('last object change'), on_delete=models.CASCADE)
last_update = models.ForeignKey('editor.ChangeSetUpdate', null=True, related_name='+',
2018-09-18 17:38:26 +02:00
verbose_name=_('last update'), on_delete=models.CASCADE)
2017-07-05 16:17:09 +02:00
last_state_update = models.ForeignKey('editor.ChangeSetUpdate', null=True, related_name='+',
2018-09-18 17:38:26 +02:00
verbose_name=_('last state update'), on_delete=models.CASCADE)
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'))
map_update = models.OneToOneField(MapUpdate, null=True, related_name='changeset',
verbose_name=_('map update'), on_delete=models.PROTECT)
changes: CollectedChanges = SchemaField(schema=CollectedChanges, default=CollectedChanges)
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.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-07-08 13:11:01 +02:00
self._original_state = self.state
2017-07-08 15:42:06 +02:00
self.direct_editing = False
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-12-19 17:14:07 +01:00
if request.user_permissions.review_changesets:
return ChangeSet.objects.all()
elif request.user.is_authenticated:
return ChangeSet.objects.filter(author=request.user)
2017-07-06 13:07:05 +02:00
elif 'changeset' in request.session:
return ChangeSet.objects.filter(pk=request.session['changeset'])
return ChangeSet.objects.none()
2017-06-29 17:40:33 +02:00
2017-06-21 13:31:41 +02:00
@classmethod
2018-11-23 21:41:02 +01:00
def get_for_request(cls, request, select_related=None, as_logged_out=False):
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'))
2018-11-23 21:41:02 +01:00
if request.user.is_authenticated and not as_logged_out:
2017-12-19 17:22:03 +01:00
if not request.user_permissions.review_changesets:
2018-11-23 21:41:02 +01:00
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-07-08 15:42:06 +02:00
if request.session.get('direct_editing', False) and ChangeSet.can_direct_edit(request):
changeset.direct_editing = True
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 fill_changes_cache(self):
return # todo: remove
2017-06-16 19:19:54 +02:00
2017-06-21 13:48:13 +02:00
"""
Analyse Changes
"""
2017-07-06 15:06:01 +02:00
def get_objects(self, many=True, changed_objects=None, prefetch_related=()):
# todo: reimplement, maybe
pass
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.pk is None or self.author == request.user or
(self.author is None and not request.user.is_authenticated and
request.session.get('changeset', None) == self.pk))
2017-07-04 22:44:21 +02:00
2017-06-29 17:40:33 +02:00
def can_see(self, request):
2017-12-19 17:06:11 +01:00
return self.is_author(request) or self.can_review(request)
2017-07-07 15:32:41 +02:00
object_changed_cache = {}
@property
def _object_changed(self):
return self.object_changed_cache.get(self.pk, None)
@_object_changed.setter
def _object_changed(self, value):
self.object_changed_cache[self.pk] = value
objects_changed_count = 0
@classmethod
def object_changed_handler(cls, sender, instance, **kwargs):
if sender._meta.app_label == 'mapdata':
cls.objects_changed_count += 1
@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)
yield changeset
else:
yield self
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
def can_activate(self, request):
return not self.closed and self.can_edit(request)
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):
2024-08-26 14:58:37 +02:00
return self.can_edit(request) and not self.proposed and self.changes.operations
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')
def has_space_access_on_all_objects(self, request, force=False):
if not request.user.is_authenticated:
return False
try:
request._has_space_access_on_all_objects_cache
except AttributeError:
request._has_space_access_on_all_objects_cache = {}
can_edit_spaces = {space_id for space_id, can_edit in request.user_space_accesses.items() if can_edit}
if not can_edit_spaces:
return False
if not force:
try:
return request._has_space_access_on_all_objects_cache[self.pk]
except KeyError:
pass
self.fill_changes_cache()
for model in self.changed_objects.keys():
if issubclass(model, LocationRedirect):
continue
try:
model._meta.get_field('space')
except FieldDoesNotExist:
return False
result = True
for model, objects in self.get_objects(many=False).items():
if issubclass(model, (LocationRedirect, LocationSlug)):
continue
try:
model._meta.get_field('space')
except FieldDoesNotExist:
result = False
break
for obj in objects:
if obj.space_id not in can_edit_spaces:
result = False
break
if not result:
break
try:
model._meta.get_field('origin_space')
except FieldDoesNotExist:
pass
else:
for obj in objects:
if obj.origin_space_id not in can_edit_spaces:
result = False
break
if not result:
break
try:
model._meta.get_field('target_space')
except FieldDoesNotExist:
pass
else:
for obj in objects:
if obj.target_space_id not in can_edit_spaces:
result = False
break
if not result:
break
request._has_space_access_on_all_objects_cache[self.pk] = result
return result
2017-07-05 19:40:35 +02:00
def can_review(self, request):
if not request.user.is_authenticated:
return False
if request.user_permissions.review_changesets:
return True
return self.has_space_access_on_all_objects(request)
2017-07-05 19:40:35 +02:00
2017-07-08 15:42:06 +02:00
@classmethod
2017-07-11 19:06:46 +02:00
def can_direct_edit(cls, request):
2017-12-19 17:18:55 +01:00
return request.user_permissions.direct_edit
2017-07-08 15:42:06 +02:00
2017-07-05 19:40:35 +02:00
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()
2017-12-20 13:50:32 +01:00
self.notify_reviewers()
def notify_reviewers(self):
send_changeset_proposed_notification.delay(pk=self.pk,
title=self.title,
author=self.author.username,
description=self.description)
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'
2018-12-24 13:35:20 +01:00
self.assigned_to = None
2017-07-05 19:40:35 +02:00
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):
2017-07-08 13:11:01 +02:00
with MapUpdate.lock():
# todo: reimplement
update = self.updates.create(user=user, state='applied')
2017-07-08 13:11:01 +02:00
map_update = MapUpdate.objects.create(user=user, type='changeset')
self.state = 'applied'
self.last_state_update = update
self.last_update = update
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
"""
2024-08-26 20:46:12 +02:00
return len([changed_object for changed_object in self.changes.changed_objects
if changed_object.obj.model != "locationredirect"])
2017-06-21 13:31:41 +02:00
2017-12-19 17:00:06 +01:00
def get_changed_objects_by_model(self, model):
if isinstance(model, str):
model = apps.get_model('mapdata', model)
self.fill_changes_cache()
return self.changed_objects.get(model, {})
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:
2017-07-08 15:42:06 +02:00
if self.direct_editing:
return _('Direct editing active')
return _('No objects changed')
return (ngettext_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-11 19:06:28 +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-11 19:06:28 +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-10-27 17:08:36 +02:00
return 'editor:changeset:' + self.raw_cache_key_by_changes
@property
def raw_cache_key_without_changes(self):
if self.pk is None:
return MapUpdate.current_cache_key()
return ':'.join((str(self.pk), MapUpdate.current_cache_key()))
2017-10-27 17:08:36 +02:00
@property
def raw_cache_key_by_changes(self):
if self.pk is None:
return MapUpdate.current_cache_key()
return ':'.join((str(self.pk), MapUpdate.current_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:
if self.author:
return reverse('editor.users.detail', kwargs={'pk': self.author_id})
2017-06-21 13:31:41 +02:00
return ''
return reverse('editor.changesets.detail', kwargs={'pk': self.pk})
def serialize(self):
return OrderedDict((
('id', self.pk),
('author', self.author_id),
2017-07-06 13:07:05 +02:00
('state', self.state),
('assigned_to', self.assigned_to_id),
('changed_objects_count', self.changed_objects_count),
('created', None if self.created is None else self.created.isoformat()),
2017-07-06 13:07:05 +02:00
('last_change', None if self.last_change is None else self.last_change.datetime.isoformat()),
('last_update', None if self.last_update is None else self.last_update.datetime.isoformat()),
('last_state_update', (None if self.last_state_update is None else
self.last_state_update.datetime.isoformat())),
('last_state_update_user', (None if self.last_state_update is None else
self.last_state_update.user_id)),
('last_state_update_comment', (None if self.last_state_update is None else
self.last_state_update.comment)),
))
def save(self, *args, **kwargs):
2017-07-08 13:11:01 +02:00
if self._original_state == 'applied':
2017-07-05 22:04:22 +02:00
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)
self._request = None
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]