From 11ba7b98419d1c8eff5ea8b8d52cbaa76f402d84 Mon Sep 17 00:00:00 2001 From: Oskar Hahn Date: Sat, 19 Jan 2019 14:02:13 +0100 Subject: [PATCH] Autoupdate on element deletion Make sure, that a related element gets an autoupdate, when the main object is deleted --- .../migrations/0006_auto_20190119_1425.py | 33 ++ openslides/agenda/models.py | 5 +- .../migrations/0006_auto_20190119_1425.py | 31 ++ openslides/assignments/models.py | 7 +- .../migrations/0011_auto_20190119_0958.py | 10 +- .../migrations/0012_auto_20190119_1425.py | 41 ++ openslides/core/models.py | 21 +- openslides/core/models.py.orig | 351 ++++++++++++++++++ .../migrations/0003_auto_20190119_1425.py | 23 ++ openslides/mediafiles/models.py | 4 +- .../migrations/0019_auto_20190119_1025.py | 16 +- .../migrations/0020_auto_20190119_1425.py | 123 ++++++ openslides/motions/models.py | 23 +- openslides/motions/views.py | 11 +- .../migrations/0009_auto_20190119_1425.py | 22 ++ openslides/users/models.py | 4 +- openslides/users/views.py | 2 - openslides/utils/autoupdate.py | 39 +- openslides/utils/models.py | 39 ++ tests/integration/utils/test_consumers.py | 2 - 20 files changed, 737 insertions(+), 70 deletions(-) create mode 100644 openslides/agenda/migrations/0006_auto_20190119_1425.py create mode 100644 openslides/assignments/migrations/0006_auto_20190119_1425.py create mode 100644 openslides/core/migrations/0012_auto_20190119_1425.py create mode 100644 openslides/core/models.py.orig create mode 100644 openslides/mediafiles/migrations/0003_auto_20190119_1425.py create mode 100644 openslides/motions/migrations/0020_auto_20190119_1425.py create mode 100644 openslides/users/migrations/0009_auto_20190119_1425.py diff --git a/openslides/agenda/migrations/0006_auto_20190119_1425.py b/openslides/agenda/migrations/0006_auto_20190119_1425.py new file mode 100644 index 000000000..0166eb741 --- /dev/null +++ b/openslides/agenda/migrations/0006_auto_20190119_1425.py @@ -0,0 +1,33 @@ +# Generated by Django 2.1.5 on 2019-01-19 13:25 + +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [("agenda", "0005_auto_20180815_1109")] + + operations = [ + migrations.AlterField( + model_name="item", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + related_name="children", + to="agenda.Item", + ), + ), + migrations.AlterField( + model_name="speaker", + name="user", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/openslides/agenda/models.py b/openslides/agenda/models.py index 42b655eeb..dac6006e2 100644 --- a/openslides/agenda/models.py +++ b/openslides/agenda/models.py @@ -15,6 +15,7 @@ from openslides.utils.exceptions import OpenSlidesError from openslides.utils.models import RESTModelMixin from openslides.utils.utils import to_roman +from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE from .access_permissions import ItemAccessPermissions @@ -233,7 +234,7 @@ class Item(RESTModelMixin, models.Model): parent = models.ForeignKey( "self", - on_delete=models.SET_NULL, + on_delete=SET_NULL_AND_AUTOUPDATE, null=True, blank=True, related_name="children", @@ -374,7 +375,7 @@ class Speaker(RESTModelMixin, models.Model): objects = SpeakerManager() - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE) """ ForeinKey to the user who speaks. """ diff --git a/openslides/assignments/migrations/0006_auto_20190119_1425.py b/openslides/assignments/migrations/0006_auto_20190119_1425.py new file mode 100644 index 000000000..6840a54fa --- /dev/null +++ b/openslides/assignments/migrations/0006_auto_20190119_1425.py @@ -0,0 +1,31 @@ +# Generated by Django 2.1.5 on 2019-01-19 13:25 + +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [("assignments", "0005_auto_20180822_1042")] + + operations = [ + migrations.AlterField( + model_name="assignmentoption", + name="candidate", + field=models.ForeignKey( + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="assignmentrelateduser", + name="user", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index fbcc8f01e..f418a4393 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -21,6 +21,7 @@ from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError from openslides.utils.models import RESTModelMixin +from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE from .access_permissions import AssignmentAccessPermissions @@ -36,7 +37,7 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model): ForeinKey to the assignment. """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE) """ ForeinKey to the user who is related to the assignment. """ @@ -366,7 +367,9 @@ class AssignmentOption(RESTModelMixin, BaseOption): poll = models.ForeignKey( "AssignmentPoll", on_delete=models.CASCADE, related_name="options" ) - candidate = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + candidate = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True + ) weight = models.IntegerField(default=0) vote_class = AssignmentVote diff --git a/openslides/core/migrations/0011_auto_20190119_0958.py b/openslides/core/migrations/0011_auto_20190119_0958.py index aa62f1070..c9d5c64dc 100644 --- a/openslides/core/migrations/0011_auto_20190119_0958.py +++ b/openslides/core/migrations/0011_auto_20190119_0958.py @@ -5,14 +5,10 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('core', '0010_auto_20190118_1908'), - ] + dependencies = [("core", "0010_auto_20190118_1908")] operations = [ migrations.AlterField( - model_name='history', - name='now', - field=models.DateTimeField(), - ), + model_name="history", name="now", field=models.DateTimeField() + ) ] diff --git a/openslides/core/migrations/0012_auto_20190119_1425.py b/openslides/core/migrations/0012_auto_20190119_1425.py new file mode 100644 index 000000000..3102e48b6 --- /dev/null +++ b/openslides/core/migrations/0012_auto_20190119_1425.py @@ -0,0 +1,41 @@ +# Generated by Django 2.1.5 on 2019-01-19 13:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [("core", "0011_auto_20190119_0958")] + + operations = [ + migrations.AlterField( + model_name="chatmessage", + name="user", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="history", + name="user", + field=models.ForeignKey( + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="projectiondefault", + name="projector", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="projectiondefaults", + to="core.Projector", + ), + ), + ] diff --git a/openslides/core/models.py b/openslides/core/models.py index 8293f233e..80a1d6117 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -6,7 +6,11 @@ from jsonfield import JSONField from ..utils.autoupdate import Element from ..utils.cache import element_cache, get_element_id -from ..utils.models import RESTModelMixin +from ..utils.models import ( + CASCADE_AND_AUTOUODATE, + SET_NULL_AND_AUTOUPDATE, + RESTModelMixin, +) from .access_permissions import ( ChatMessageAccessPermissions, ConfigAccessPermissions, @@ -108,7 +112,7 @@ class ProjectionDefault(RESTModelMixin, models.Model): display_name = models.CharField(max_length=256) projector = models.ForeignKey( - Projector, on_delete=models.CASCADE, related_name="projectiondefaults" + Projector, on_delete=models.PROTECT, related_name="projectiondefaults" ) def get_root_rest_element(self): @@ -179,7 +183,7 @@ class ChatMessage(RESTModelMixin, models.Model): timestamp = models.DateTimeField(auto_now_add=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE) class Meta: default_permissions = () @@ -269,7 +273,7 @@ class HistoryManager(models.Manager): history_time = now() for element in elements: if ( - element["disable_history"] + element.get("disable_history") or element["collection_string"] == self.model.get_collection_string() ): @@ -282,8 +286,8 @@ class HistoryManager(models.Manager): element["collection_string"], element["id"] ), now=history_time, - information=element["information"], - user_id=element["user_id"], + information=element.get("information", ""), + user_id=element.get("user_id"), full_data=data, ) instance.save( @@ -308,9 +312,6 @@ class HistoryManager(models.Manager): id=full_data["id"], collection_string=collection_string, full_data=full_data, - information="", - user_id=None, - disable_history=False, ) ) instances = self.add_elements(elements) @@ -336,7 +337,7 @@ class History(RESTModelMixin, models.Model): information = models.CharField(max_length=255) user = models.ForeignKey( - settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + settings.AUTH_USER_MODEL, null=True, on_delete=SET_NULL_AND_AUTOUPDATE ) full_data = models.OneToOneField(HistoryData, on_delete=models.CASCADE) diff --git a/openslides/core/models.py.orig b/openslides/core/models.py.orig new file mode 100644 index 000000000..a01a18017 --- /dev/null +++ b/openslides/core/models.py.orig @@ -0,0 +1,351 @@ +from asgiref.sync import async_to_sync +from django.conf import settings +from django.db import models, transaction +from django.utils.timezone import now +from jsonfield import JSONField + +from ..utils.autoupdate import Element +from ..utils.cache import element_cache, get_element_id +from ..utils.models import ( + CASCADE_AND_AUTOUODATE, + SET_NULL_AND_AUTOUPDATE, + RESTModelMixin, +) +from .access_permissions import ( + ChatMessageAccessPermissions, + ConfigAccessPermissions, + CountdownAccessPermissions, + HistoryAccessPermissions, + ProjectorAccessPermissions, + ProjectorMessageAccessPermissions, + TagAccessPermissions, +) + + +class ProjectorManager(models.Manager): + """ + Customized model manager to support our get_full_queryset method. + """ + + def get_full_queryset(self): + """ + Returns the normal queryset with all projectors. In the background + projector defaults are prefetched from the database. + """ + return self.get_queryset().prefetch_related("projectiondefaults") + + +class Projector(RESTModelMixin, models.Model): + """ + Model for all projectors. + + The elements field contains a list. Every element must have at least the + property "name". + + Example: + [ + { + "name": "topics/topic", + "id": 1, + }, + { + "name": "core/countdown", + "id": 1, + }, + { + "name": "core/clock", + "id": 1, + }, + ] + + If the config field is empty or invalid the projector shows a default + slide. + + There are two additional fields to control the behavior of the projector + view itself: scale and scroll. + + The projector can be controlled using the REST API with POST requests + on e. g. the URL /rest/core/projector/1/activate_elements/. + """ + + access_permissions = ProjectorAccessPermissions() + + objects = ProjectorManager() + + elements = JSONField() + elements_preview = JSONField() + elements_history = JSONField() + + scale = models.IntegerField(default=0) + scroll = models.IntegerField(default=0) + + width = models.PositiveIntegerField(default=1024) + height = models.PositiveIntegerField(default=768) + + name = models.CharField(max_length=255, unique=True, blank=True) + + class Meta: + """ + Contains general permissions that can not be placed in a specific app. + """ + + default_permissions = () + permissions = ( + ("can_see_projector", "Can see the projector"), + ("can_manage_projector", "Can manage the projector"), + ("can_see_frontpage", "Can see the front page"), + ) + + +class ProjectionDefault(RESTModelMixin, models.Model): + """ + Model for the projection defaults like motions, agenda, list of + speakers and thelike. The name is the technical name like 'topics' or + 'motions'. For apps the name should be the app name to get keep the + ProjectionDefault for apps generic. But it is possible to give some + special name like 'list_of_speakers'. The display_name is the shown + name on the front end for the user. + """ + + name = models.CharField(max_length=256) + + display_name = models.CharField(max_length=256) + + projector = models.ForeignKey( + Projector, on_delete=models.PROTECT, related_name="projectiondefaults" + ) + + def get_root_rest_element(self): + return self.projector + + class Meta: + default_permissions = () + + def __str__(self): + return self.display_name + + +class Tag(RESTModelMixin, models.Model): + """ + Model for tags. This tags can be used for other models like agenda items, + motions or assignments. + """ + + access_permissions = TagAccessPermissions() + + name = models.CharField(max_length=255, unique=True) + + class Meta: + ordering = ("name",) + default_permissions = () + permissions = (("can_manage_tags", "Can manage tags"),) + + def __str__(self): + return self.name + + +class ConfigStore(RESTModelMixin, models.Model): + """ + A model class to store all config variables in the database. + """ + + access_permissions = ConfigAccessPermissions() + + key = models.CharField(max_length=255, unique=True, db_index=True) + """A string, the key of the config variable.""" + + value = JSONField() + """The value of the config variable. """ + + class Meta: + default_permissions = () + permissions = ( + ("can_manage_config", "Can manage configuration"), + ("can_manage_logos_and_fonts", "Can manage logos and fonts"), + ) + + @classmethod + def get_collection_string(cls): + return "core/config" + + +class ChatMessage(RESTModelMixin, models.Model): + """ + Model for chat messages. + + At the moment we only have one global chat room for managers. + """ + + access_permissions = ChatMessageAccessPermissions() + can_see_permission = "core.can_use_chat" + + message = models.TextField() + + timestamp = models.DateTimeField(auto_now_add=True) + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE) + + class Meta: + default_permissions = () + permissions = ( + ("can_use_chat", "Can use the chat"), + ("can_manage_chat", "Can manage the chat"), + ) + + def __str__(self): + return f"Message {self.timestamp}" + + +class ProjectorMessage(RESTModelMixin, models.Model): + """ + Model for ProjectorMessages. + """ + + access_permissions = ProjectorMessageAccessPermissions() + + message = models.TextField(blank=True) + + class Meta: + default_permissions = () + + +class Countdown(RESTModelMixin, models.Model): + """ + Model for countdowns. + """ + + access_permissions = CountdownAccessPermissions() + + description = models.CharField(max_length=256, blank=True) + + running = models.BooleanField(default=False) + + default_time = models.PositiveIntegerField(default=60) + + countdown_time = models.FloatField(default=60) + + class Meta: + default_permissions = () + + def control(self, action, skip_autoupdate=False): + if action not in ("start", "stop", "reset"): + raise ValueError( + f"Action must be 'start', 'stop' or 'reset', not {action}." + ) + + if action == "start": + self.running = True + self.countdown_time = now().timestamp() + self.default_time + elif action == "stop" and self.running: + self.running = False + self.countdown_time = self.countdown_time - now().timestamp() + else: # reset + self.running = False + self.countdown_time = self.default_time + self.save(skip_autoupdate=skip_autoupdate) + + +class HistoryData(models.Model): + """ + Django model to save the history of OpenSlides. + + This is not a RESTModel. It is not cachable and can only be reached by a + special viewset. + """ + + full_data = JSONField() + + class Meta: + default_permissions = () + + +class HistoryManager(models.Manager): + """ + Customized model manager for the history model. + """ + + def add_elements(self, elements): + """ + Method to add elements to the history. This does not trigger autoupdate. + """ + with transaction.atomic(): + instances = [] + history_time = now() + for element in elements: + if ( + element.get("disable_history") + or element["collection_string"] + == self.model.get_collection_string() + ): + # Do not update history for history elements itself or if history is disabled. + continue + # HistoryData is not a root rest element so there is no autoupdate and not history saving here. + data = HistoryData.objects.create(full_data=element["full_data"]) + instance = self.model( + element_id=get_element_id( + element["collection_string"], element["id"] + ), +<<<<<<< HEAD + now=history_time, + information=element["information"], + user_id=element["user_id"], +======= + information=element.get("information", ""), + user_id=element.get("user_id"), +>>>>>>> Autoupdate on element deletion + full_data=data, + ) + instance.save( + skip_autoupdate=True + ) # Skip autoupdate and of course history saving. + instances.append(instance) + return instances + + def build_history(self): + """ + Method to add all cachables to the history. + """ + # TODO: Add lock to prevent multiple history builds at once. See #4039. + instances = None + if self.all().count() == 0: + elements = [] + all_full_data = async_to_sync(element_cache.get_all_full_data)() + for collection_string, data in all_full_data.items(): + for full_data in data: + elements.append( + Element( + id=full_data["id"], + collection_string=collection_string, + full_data=full_data, + ) + ) + instances = self.add_elements(elements) + return instances + + +class History(RESTModelMixin, models.Model): + """ + Django model to save the history of OpenSlides. + + This model itself is not part of the history. This means that if you + delete a user you may lose the information of the user field here. + """ + + access_permissions = HistoryAccessPermissions() + + objects = HistoryManager() + + element_id = models.CharField(max_length=255) + + now = models.DateTimeField() + + information = models.CharField(max_length=255) + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=SET_NULL_AND_AUTOUPDATE + ) + + full_data = models.OneToOneField(HistoryData, on_delete=models.CASCADE) + + class Meta: + default_permissions = () diff --git a/openslides/mediafiles/migrations/0003_auto_20190119_1425.py b/openslides/mediafiles/migrations/0003_auto_20190119_1425.py new file mode 100644 index 000000000..66d604bfa --- /dev/null +++ b/openslides/mediafiles/migrations/0003_auto_20190119_1425.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.5 on 2019-01-19 13:25 + +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [("mediafiles", "0002_mediafile_private")] + + operations = [ + migrations.AlterField( + model_name="mediafile", + name="uploader", + field=models.ForeignKey( + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + to=settings.AUTH_USER_MODEL, + ), + ) + ] diff --git a/openslides/mediafiles/models.py b/openslides/mediafiles/models.py index 8b5262b37..a602e480c 100644 --- a/openslides/mediafiles/models.py +++ b/openslides/mediafiles/models.py @@ -3,7 +3,7 @@ from django.db import models from ..core.config import config from ..utils.autoupdate import inform_changed_data -from ..utils.models import RESTModelMixin +from ..utils.models import SET_NULL_AND_AUTOUPDATE, RESTModelMixin from .access_permissions import MediafileAccessPermissions @@ -25,7 +25,7 @@ class Mediafile(RESTModelMixin, models.Model): """A string representing the title of the file.""" uploader = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True + settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True ) """A user – the uploader of a file.""" diff --git a/openslides/motions/migrations/0019_auto_20190119_1025.py b/openslides/motions/migrations/0019_auto_20190119_1025.py index 9b5f8ec53..b6567505b 100644 --- a/openslides/motions/migrations/0019_auto_20190119_1025.py +++ b/openslides/motions/migrations/0019_auto_20190119_1025.py @@ -6,20 +6,20 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('motions', '0018_auto_20190118_2101'), - ] + dependencies = [("motions", "0018_auto_20190118_2101")] operations = [ migrations.AddField( - model_name='motion', - name='created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + model_name="motion", + name="created", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), preserve_default=False, ), migrations.AddField( - model_name='motion', - name='last_modified', + model_name="motion", + name="last_modified", field=models.DateTimeField(auto_now=True), ), ] diff --git a/openslides/motions/migrations/0020_auto_20190119_1425.py b/openslides/motions/migrations/0020_auto_20190119_1425.py new file mode 100644 index 000000000..cc57844ec --- /dev/null +++ b/openslides/motions/migrations/0020_auto_20190119_1425.py @@ -0,0 +1,123 @@ +# Generated by Django 2.1.5 on 2019-01-19 13:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [("motions", "0019_auto_20190119_1025")] + + operations = [ + migrations.AlterField( + model_name="motion", + name="category", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + to="motions.Category", + ), + ), + migrations.AlterField( + model_name="motion", + name="motion_block", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + to="motions.MotionBlock", + ), + ), + migrations.AlterField( + model_name="motion", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + related_name="amendments", + to="motions.Motion", + ), + ), + migrations.AlterField( + model_name="motion", + name="recommendation", + field=models.ForeignKey( + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + related_name="+", + to="motions.State", + ), + ), + migrations.AlterField( + model_name="motion", + name="sort_parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + related_name="children", + to="motions.Motion", + ), + ), + migrations.AlterField( + model_name="motion", + name="statute_paragraph", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + related_name="motions", + to="motions.StatuteParagraph", + ), + ), + migrations.AlterField( + model_name="motionchangerecommendation", + name="author", + field=models.ForeignKey( + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="motionchangerecommendation", + name="motion", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + related_name="change_recommendations", + to="motions.Motion", + ), + ), + migrations.AlterField( + model_name="motionlog", + name="person", + field=models.ForeignKey( + null=True, + on_delete=openslides.utils.models.SET_NULL_AND_AUTOUPDATE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="submitter", + name="user", + field=models.ForeignKey( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="workflow", + name="first_state", + field=models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="motions.State", + ), + ), + ] diff --git a/openslides/motions/models.py b/openslides/motions/models.py index 01f915911..29efe2159 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -23,6 +23,7 @@ from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError from openslides.utils.models import RESTModelMixin +from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE from .access_permissions import ( CategoryAccessPermissions, MotionAccessPermissions, @@ -141,7 +142,7 @@ class Motion(RESTModelMixin, models.Model): """ recommendation = models.ForeignKey( - "State", related_name="+", on_delete=models.SET_NULL, null=True + "State", related_name="+", on_delete=SET_NULL_AND_AUTOUPDATE, null=True ) """ The recommendation of a person or committee for this motion. @@ -171,7 +172,7 @@ class Motion(RESTModelMixin, models.Model): sort_parent = models.ForeignKey( "self", - on_delete=models.SET_NULL, + on_delete=SET_NULL_AND_AUTOUPDATE, null=True, blank=True, related_name="children", @@ -181,14 +182,14 @@ class Motion(RESTModelMixin, models.Model): """ category = models.ForeignKey( - "Category", on_delete=models.SET_NULL, null=True, blank=True + "Category", on_delete=SET_NULL_AND_AUTOUPDATE, null=True, blank=True ) """ ForeignKey to one category of motions. """ motion_block = models.ForeignKey( - "MotionBlock", on_delete=models.SET_NULL, null=True, blank=True + "MotionBlock", on_delete=SET_NULL_AND_AUTOUPDATE, null=True, blank=True ) """ ForeignKey to one block of motions. @@ -207,7 +208,7 @@ class Motion(RESTModelMixin, models.Model): parent = models.ForeignKey( "self", - on_delete=models.SET_NULL, + on_delete=SET_NULL_AND_AUTOUPDATE, null=True, blank=True, related_name="amendments", @@ -220,7 +221,7 @@ class Motion(RESTModelMixin, models.Model): statute_paragraph = models.ForeignKey( StatuteParagraph, - on_delete=models.SET_NULL, + on_delete=SET_NULL_AND_AUTOUPDATE, null=True, blank=True, related_name="motions", @@ -704,7 +705,7 @@ class Submitter(RESTModelMixin, models.Model): Use custom Manager. """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE) """ ForeignKey to the user who is the submitter. """ @@ -754,7 +755,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model): objects = MotionChangeRecommendationManager() motion = models.ForeignKey( - Motion, on_delete=models.CASCADE, related_name="change_recommendations" + Motion, on_delete=CASCADE_AND_AUTOUODATE, related_name="change_recommendations" ) """The motion to which the change recommendation belongs.""" @@ -780,7 +781,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model): """The replacement for the section of the original text specified by motion, line_from and line_to""" author = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True + settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True ) """A user object, who created this change recommendation. Optional.""" @@ -921,7 +922,7 @@ class MotionLog(RESTModelMixin, models.Model): """ person = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True + settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True ) """A user object, who created the log message. Optional.""" @@ -1192,7 +1193,7 @@ class Workflow(RESTModelMixin, models.Model): """A string representing the workflow.""" first_state = models.OneToOneField( - State, on_delete=models.SET_NULL, related_name="+", null=True, blank=True + State, on_delete=models.CASCADE, related_name="+", null=True ) """A one-to-one relation to a state, the starting point for the workflow.""" diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 3d15ba12f..57892f3eb 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -569,7 +569,11 @@ class MotionViewSet(ModelViewSet): person=request.user, skip_autoupdate=True, ) - inform_changed_data(motion, information=f"State set to {motion.state.name}.", user_id=request.user.pk) + inform_changed_data( + motion, + information=f"State set to {motion.state.name}.", + user_id=request.user.pk, + ) return Response({"detail": message}) @list_route(methods=["post"]) @@ -1348,9 +1352,8 @@ class StateViewSet( Customized view endpoint to delete a state. """ state = self.get_object() - if ( - state.workflow.first_state.pk == state.pk - ): # is this the first state of the workflow? + if state.workflow.first_state.pk == state.pk: + # is this the first state of the workflow? raise ValidationError( {"detail": "You cannot delete the first state of the workflow."} ) diff --git a/openslides/users/migrations/0009_auto_20190119_1425.py b/openslides/users/migrations/0009_auto_20190119_1425.py new file mode 100644 index 000000000..cc096e53b --- /dev/null +++ b/openslides/users/migrations/0009_auto_20190119_1425.py @@ -0,0 +1,22 @@ +# Generated by Django 2.1.5 on 2019-01-19 13:25 + +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [("users", "0008_user_gender")] + + operations = [ + migrations.AlterField( + model_name="personalnote", + name="user", + field=models.OneToOneField( + on_delete=openslides.utils.models.CASCADE_AND_AUTOUODATE, + to=settings.AUTH_USER_MODEL, + ), + ) + ] diff --git a/openslides/users/models.py b/openslides/users/models.py index ccaba352a..6cc715b8b 100644 --- a/openslides/users/models.py +++ b/openslides/users/models.py @@ -19,7 +19,7 @@ from jsonfield import JSONField from ..core.config import config from ..utils.auth import GROUP_ADMIN_PK -from ..utils.models import RESTModelMixin +from ..utils.models import CASCADE_AND_AUTOUODATE, RESTModelMixin from .access_permissions import ( GroupAccessPermissions, PersonalNoteAccessPermissions, @@ -330,7 +330,7 @@ class PersonalNote(RESTModelMixin, models.Model): objects = PersonalNoteManager() - user = models.OneToOneField(User, on_delete=models.CASCADE) + user = models.OneToOneField(User, on_delete=CASCADE_AND_AUTOUODATE) notes = JSONField() class Meta: diff --git a/openslides/users/views.py b/openslides/users/views.py index a12b301b4..13b58d24b 100644 --- a/openslides/users/views.py +++ b/openslides/users/views.py @@ -369,8 +369,6 @@ class GroupViewSet(ModelViewSet): id=full_data["id"], collection_string=cachable.get_collection_string(), full_data=full_data, - information="", - user_id=None, disable_history=True, ) ) diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index 011301cfc..ab8204ee3 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -9,19 +9,21 @@ from mypy_extensions import TypedDict from .cache import element_cache, get_element_id from .projector import get_projectot_data +from .utils import get_model_from_collection_string -Element = TypedDict( - "Element", - { - "id": int, - "collection_string": str, - "full_data": Optional[Dict[str, Any]], - "information": str, - "user_id": Optional[int], - "disable_history": bool, - }, -) +class ElementBase(TypedDict): + id: int + collection_string: str + full_data: Optional[Dict[str, Any]] + + +class Element(ElementBase, total=False): + information: str + user_id: Optional[int] + disable_history: bool + reload: bool + AutoupdateFormat = TypedDict( "AutoupdateFormat", @@ -68,7 +70,6 @@ def inform_changed_data( full_data=root_instance.get_full_data(), information=information, user_id=user_id, - disable_history=False, ) bundle = autoupdate_bundle.get(threading.get_ident()) @@ -100,7 +101,6 @@ def inform_deleted_data( full_data=None, information=information, user_id=user_id, - disable_history=False, ) bundle = autoupdate_bundle.get(threading.get_ident()) @@ -201,6 +201,12 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: ) if elements: + for element in elements: + if element.get("reload"): + model = get_model_from_collection_string(element["collection_string"]) + instance = model.objects.get(pk=element["id"]) + element["full_data"] = instance.get_full_data() + # Save histroy here using sync code. history_instances = save_history(elements) @@ -212,8 +218,6 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: id=history_instance.get_rest_pk(), collection_string=history_instance.get_collection_string(), full_data=history_instance.get_full_data(), - information="", - user_id=None, disable_history=True, # This does not matter because history elements can never be part of the history itself. ) ) @@ -227,9 +231,8 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: ) -def save_history( - elements: Iterable[Element] -) -> Iterable: # TODO: Try to write Iterable[History] here +def save_history(elements: Iterable[Element]) -> Iterable: + # TODO: Try to write Iterable[History] here """ Thin wrapper around the call of history saving manager method. diff --git a/openslides/utils/models.py b/openslides/utils/models.py index b0c931f8b..ac77d9f93 100644 --- a/openslides/utils/models.py +++ b/openslides/utils/models.py @@ -4,6 +4,7 @@ from django.core.exceptions import ImproperlyConfigured from django.db import models from .access_permissions import BaseAccessPermissions +from .autoupdate import Element, inform_changed_data, inform_changed_elements from .rest_api import model_serializer_classes from .utils import convert_camel_case_to_pseudo_snake_case @@ -153,3 +154,41 @@ class RESTModelMixin: __import__(module_name) serializer_class = model_serializer_classes[type(self)] return serializer_class(self).data + + +def SET_NULL_AND_AUTOUPDATE( + collector: Any, field: Any, sub_objs: Any, using: Any +) -> None: + """ + Like models.SET_NULL but also informs the autoupdate system about the + instance that was reverenced. + """ + if len(sub_objs) != 1: + raise RuntimeError( + "SET_NULL_AND_AUTOUPDATE in an invalid usecase. Please open an issue!" + ) + setattr(sub_objs[0], field.name, None) + inform_changed_data(sub_objs[0]) + models.SET_NULL(collector, field, sub_objs, using) + + +def CASCADE_AND_AUTOUODATE( + collector: Any, field: Any, sub_objs: Any, using: Any +) -> None: + if len(sub_objs) != 1: + raise RuntimeError( + "CASCADE_AND_AUTOUPDATE in an invalid usecase. Please open an issue!" + ) + + root_rest_element = sub_objs[0].get_root_rest_element() + inform_changed_elements( + [ + Element( + collection_string=root_rest_element.get_collection_string(), + id=root_rest_element.pk, + full_data=None, + reload=True, + ) + ] + ) + models.CASCADE(collector, field, sub_objs, using) diff --git a/tests/integration/utils/test_consumers.py b/tests/integration/utils/test_consumers.py index 88bba1b90..da9999dff 100644 --- a/tests/integration/utils/test_consumers.py +++ b/tests/integration/utils/test_consumers.py @@ -80,8 +80,6 @@ async def set_config(): id=config_id, collection_string=collection_string, full_data=full_data, - information="", - user_id=None, disable_history=True, ) ]