diff --git a/taiga/base/utils/diff.py b/taiga/base/utils/diff.py index cff9b15e..3d4f0aa8 100644 --- a/taiga/base/utils/diff.py +++ b/taiga/base/utils/diff.py @@ -33,4 +33,10 @@ def make_diff(first:dict, second:dict, not_found_value=None) -> dict: if key not in first: diff[key] = (not_found_value, second[key]) + # Remove A -> A changes that usually happens with None -> None + for key, value in list(diff.items()): + frst, scnd = value + if frst == scnd: + del diff[key] + return diff diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index 30dcfc5d..6b41f7ca 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -212,7 +212,9 @@ def userstory_freezer(us) -> dict: "status": us.status_id, "is_closed": us.is_closed, "finish_date": str(us.finish_date), - "order": us.order, + "backlog_order": us.backlog_order, + "sprint_order": us.sprint_order, + "kanban_order": us.kanban_order, "subject": us.subject, "description": us.description, "description_html": mdrender(us.project, us.description), @@ -263,6 +265,8 @@ def task_freezer(task) -> dict: "assigned_to": task.assigned_to_id, "watchers": [x.pk for x in task.watchers.all()], "attachments": extract_attachments(task), + "taskboard_order": task.taskboard_order, + "us_order": task.us_order, "tags": task.tags, "user_story": task.user_story_id, "is_iocaine": task.is_iocaine, diff --git a/taiga/projects/history/migrations/0004_historyentry_is_hidden.py b/taiga/projects/history/migrations/0004_historyentry_is_hidden.py new file mode 100644 index 00000000..58b68bdc --- /dev/null +++ b/taiga/projects/history/migrations/0004_historyentry_is_hidden.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0003_auto_20140917_1405'), + ] + + operations = [ + migrations.AddField( + model_name='historyentry', + name='is_hidden', + field=models.BooleanField(default=False), + preserve_default=True, + ), + ] diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py index 40cb5448..300213a4 100644 --- a/taiga/projects/history/models.py +++ b/taiga/projects/history/models.py @@ -48,8 +48,6 @@ class HistoryEntry(models.Model): user = JsonField(blank=True, default=None, null=True) created_at = models.DateTimeField(default=timezone.now) type = models.SmallIntegerField(choices=HISTORY_TYPE_CHOICES) - is_snapshot = models.BooleanField(default=False) - key = models.CharField(max_length=255, null=True, default=None, blank=True) # Stores the last diff @@ -68,9 +66,15 @@ class HistoryEntry(models.Model): delete_comment_date = models.DateTimeField(null=True, blank=True, default=None) delete_comment_user = JsonField(blank=True, default=None, null=True) - @cached_property - def is_comment(self): - return self.type == HistoryType.comment + # Flag for mark some history entries as + # hidden. Hidden history entries are important + # for save but not important to preview. + # Order fields are the good example of this fields. + is_hidden = models.BooleanField(default=False) + + # Flag for mark some history entries as complete + # snapshot. The rest are partial snapshot. + is_snapshot = models.BooleanField(default=False) @cached_property def owner(self): diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py index 209e21c9..10b63bdf 100644 --- a/taiga/projects/history/services.py +++ b/taiga/projects/history/services.py @@ -55,6 +55,13 @@ _freeze_impl_map = {} # Dict containing registred containing with their values implementation. _values_impl_map = {} +# Not important fields for models (history entries with only +# this fields are marked as hidden). +_not_important_fields = { + "userstories.userstory": frozenset(["backlog_order", "sprint_order", "kanban_order"]), + "tasks.task": frozenset(["us_order", "taskboard_order"]), +} + log = logging.getLogger("taiga.history") @@ -130,7 +137,30 @@ def freeze_model_instance(obj:object) -> FrozenObj: key = make_key_from_model_object(obj) impl_fn = _freeze_impl_map[typename] - return FrozenObj(key, impl_fn(obj)) + snapshot = impl_fn(obj) + assert isinstance(snapshot, dict), "freeze handlers should return always a dict" + + return FrozenObj(key, snapshot) + + +def is_hidden_snapshot(obj:FrozenDiff) -> bool: + """ + Check if frozen object is considered + hidden or not. + """ + content_type, pk = obj.key.rsplit(":", 1) + snapshot_fields = frozenset(obj.diff.keys()) + + if content_type not in _not_important_fields: + return False + + nfields = _not_important_fields[content_type] + result = snapshot_fields - nfields + + if len(result) == 0: + return True + + return False def make_diff(oldobj:FrozenObj, newobj:FrozenObj) -> FrozenDiff: @@ -235,20 +265,7 @@ def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False): else: raise RuntimeError("Unexpected condition") - kwargs = { - "user": {"pk": user_id, "name": user_name}, - "key": key, - "type": entry_type, - "comment": "", - "comment_html": "", - "diff": None, - "values": None, - "snapshot": None, - "is_snapshot": False, - } - fdiff = make_diff(old_fobj, new_fobj) - fvals = make_diff_values(typename, fdiff) # If diff and comment are empty, do # not create empty history entry @@ -258,21 +275,29 @@ def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False): return None - kwargs.update({ + fvals = make_diff_values(typename, fdiff) + is_hidden = is_hidden_snapshot(fdiff) + + kwargs = { + "user": {"pk": user_id, "name": user_name}, + "key": key, + "type": entry_type, "snapshot": fdiff.snapshot if need_real_snapshot else None, - "is_snapshot": need_real_snapshot, + "diff": fdiff.diff, "values": fvals, "comment": comment, - "diff": fdiff.diff, "comment_html": mdrender(obj.project, comment), - }) + "is_hidden": is_hidden, + "is_snapshot": need_real_snapshot, + } return entry_model.objects.create(**kwargs) # High level query api -def get_history_queryset_by_model_instance(obj:object, types=(HistoryType.change,)): +def get_history_queryset_by_model_instance(obj:object, types=(HistoryType.change,), + include_hidden=False): """ Get one page of history for specified object. """ @@ -280,6 +305,9 @@ def get_history_queryset_by_model_instance(obj:object, types=(HistoryType.change history_entry_model = get_model("history", "HistoryEntry") qs = history_entry_model.objects.filter(key=key, type__in=types) + if not include_hidden: + qs = qs.filter(is_hidden=False) + return qs.order_by("created_at") diff --git a/taiga/projects/tasks/migrations/0002_tasks_order_fields.py b/taiga/projects/tasks/migrations/0002_tasks_order_fields.py new file mode 100644 index 00000000..c3d9e860 --- /dev/null +++ b/taiga/projects/tasks/migrations/0002_tasks_order_fields.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='taskboard_order', + field=models.IntegerField(default=1, verbose_name='taskboard order'), + preserve_default=True, + ), + migrations.AddField( + model_name='task', + name='us_order', + field=models.IntegerField(default=1, verbose_name='us order'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/tasks/models.py b/taiga/projects/tasks/models.py index 4aed662f..839285c5 100644 --- a/taiga/projects/tasks/models.py +++ b/taiga/projects/tasks/models.py @@ -55,6 +55,12 @@ class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M verbose_name=_("finished date")) subject = models.TextField(null=False, blank=False, verbose_name=_("subject")) + + us_order = models.IntegerField(null=False, blank=False, default=1, + verbose_name=_("us order")) + taskboard_order = models.IntegerField(null=False, blank=False, default=1, + verbose_name=_("taskboard order")) + description = models.TextField(null=False, blank=True, verbose_name=_("description")) assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, default=None, related_name="tasks_assigned_to_me", diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 0056e704..9999fa52 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -78,18 +78,47 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi return response.BadRequest(serializer.errors) @list_route(methods=["POST"]) - def bulk_update_order(self, request, **kwargs): - serializer = serializers.UpdateUserStoriesBulkSerializer(data=request.DATA) - if serializer.is_valid(): - data = serializer.data - project = Project.objects.get(id=data["project_id"]) - self.check_permissions(request, 'bulk_update_order', project) - services.update_userstories_order_in_bulk(data["bulk_stories"]) - services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) + def bulk_update_backlog_order(self, request, **kwargs): + serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) - return response.NoContent() + data = serializer.data + project = get_object_or_404(Project, pk=data["project_id"]) - return response.BadRequest(serializer.errors) + self.check_permissions(request, "bulk_update_order", project) + services.update_userstories_order_in_bulk(data["bulk_stories"], field="backlog_order") + services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) + + return response.NoContent() + + @list_route(methods=["POST"]) + def bulk_update_sprint_order(self, request, **kwargs): + serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) + + data = serializer.data + project = get_object_or_404(Project, pk=data["project_id"]) + + self.check_permissions(request, "bulk_update_order", project) + services.update_userstories_order_in_bulk(data["bulk_stories"], field="sprint_order") + services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) + return response.NoContent() + + @list_route(methods=["POST"]) + def bulk_update_kanban_order(self, request, **kwargs): + serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA) + if not serializer.is_valid(): + return response.BadRequest(serializer.errors) + + data = serializer.data + project = get_object_or_404(Project, pk=data["project_id"]) + + self.check_permissions(request, "bulk_update_order", project) + services.update_userstories_order_in_bulk(data["bulk_stories"], field="kanban_order") + services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) + return response.NoContent() @transaction.atomic def create(self, *args, **kwargs): diff --git a/taiga/projects/userstories/migrations/0003_userstory_order_fields.py b/taiga/projects/userstories/migrations/0003_userstory_order_fields.py new file mode 100644 index 00000000..d5aeec1c --- /dev/null +++ b/taiga/projects/userstories/migrations/0003_userstory_order_fields.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db.models import F + +def copy_backlog_order_to_kanban_order(apps, schema_editor): + UserStory = apps.get_model("userstories", "UserStory") + UserStory.objects.all().update(kanban_order=F("backlog_order")) + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0002_auto_20140903_1301'), + ] + + operations = [ + migrations.AlterModelOptions( + name='userstory', + options={'verbose_name_plural': 'user stories', 'ordering': ['project', 'backlog_order', 'ref'], 'verbose_name': 'user story', 'permissions': (('view_userstory', 'Can view user story'),)}, + ), + migrations.RenameField( + model_name='userstory', + old_name='order', + new_name='backlog_order', + ), + + migrations.AlterField( + model_name='userstory', + name='backlog_order', + field=models.IntegerField(default=1, verbose_name='backlog order'), + ), + + migrations.AddField( + model_name='userstory', + name='sprint_order', + field=models.IntegerField(default=1, verbose_name='sprint order'), + preserve_default=True, + ), + + migrations.AddField( + model_name='userstory', + name='kanban_order', + field=models.IntegerField(default=1, verbose_name='sprint order'), + preserve_default=True, + ), + + migrations.RunPython(copy_backlog_order_to_kanban_order), + ] diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index 5317a3a1..af50b0bd 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -73,8 +73,14 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod points = models.ManyToManyField("projects.Points", null=False, blank=False, related_name="userstories", through="RolePoints", verbose_name=_("points")) - order = models.PositiveSmallIntegerField(null=False, blank=False, default=100, - verbose_name=_("order")) + + backlog_order = models.IntegerField(null=False, blank=False, default=1, + verbose_name=_("backlog order")) + sprint_order = models.IntegerField(null=False, blank=False, default=1, + verbose_name=_("sprint order")) + kanban_order = models.IntegerField(null=False, blank=False, default=1, + verbose_name=_("sprint order")) + created_date = models.DateTimeField(null=False, blank=False, verbose_name=_("created date"), default=timezone.now) @@ -101,7 +107,7 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod class Meta: verbose_name = "user story" verbose_name_plural = "user stories" - ordering = ["project", "order", "ref"] + ordering = ["project", "backlog_order", "ref"] #unique_together = ("ref", "project") permissions = ( ("view_userstory", "Can view user story"), diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index 51d9a8a5..9cb3ba33 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -120,12 +120,15 @@ class UserStoriesBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsVal bulk_stories = serializers.CharField() -class UserStoryOrderBulkSerializer(UserStoryExistsValidator, Serializer): +## Order bulk serializers + +class _UserStoryOrderBulkSerializer(UserStoryExistsValidator, Serializer): us_id = serializers.IntegerField() order = serializers.IntegerField() -class UpdateUserStoriesBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, - Serializer): +class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, + UserStoryStatusExistsValidator, + Serializer): project_id = serializers.IntegerField() - bulk_stories = UserStoryOrderBulkSerializer(many=True) + bulk_stories = _UserStoryOrderBulkSerializer(many=True) diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index 202e5e92..54fb3810 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -48,20 +48,22 @@ def create_userstories_in_bulk(bulk_data, callback=None, precall=None, **additio return userstories -def update_userstories_order_in_bulk(bulk_data): - """Update the order of some user stories. - +def update_userstories_order_in_bulk(bulk_data:list, field:str): + """ + Update the order of some user stories. `bulk_data` should be a list of tuples with the following format: - [(, ), ...] + [(, {: , ...}), ...] """ user_story_ids = [] new_order_values = [] for us_data in bulk_data: - user_story_ids.append(us_data['us_id']) - new_order_values.append({"order": us_data['order']}) + user_story_ids.append(us_data["us_id"]) + new_order_values.append({field: us_data["order"]}) + db.update_in_bulk_with_ids(user_story_ids, new_order_values, model=models.UserStory) + def snapshot_userstories_in_bulk(bulk_data, user): user_story_ids = [] for us_data in bulk_data: diff --git a/tests/integration/resources_permissions/test_userstories_resources.py b/tests/integration/resources_permissions/test_userstories_resources.py index 7be4958b..bf41066d 100644 --- a/tests/integration/resources_permissions/test_userstories_resources.py +++ b/tests/integration/resources_permissions/test_userstories_resources.py @@ -261,7 +261,7 @@ def test_user_story_action_bulk_create(client, data): def test_user_story_action_bulk_update_order(client, data): - url = reverse('userstories-bulk-update-order') + url = reverse('userstories-bulk-update-backlog-order') users = [ None, diff --git a/tests/integration/test_project_history.py b/tests/integration/test_project_history.py index 341153f2..0631d338 100644 --- a/tests/integration/test_project_history.py +++ b/tests/integration/test_project_history.py @@ -51,6 +51,7 @@ def test_take_two_snapshots_with_changes(): qs_all = HistoryEntry.objects.all() qs_created = qs_all.filter(type=HistoryType.create) + qs_hidden = qs_all.filter(is_hidden=True) assert qs_all.count() == 0 @@ -62,6 +63,7 @@ def test_take_two_snapshots_with_changes(): services.take_snapshot(issue, user=issue.owner) assert qs_all.count() == 2 assert qs_created.count() == 1 + assert qs_hidden.count() == 0 def test_take_two_snapshots_without_changes(): @@ -69,6 +71,7 @@ def test_take_two_snapshots_without_changes(): qs_all = HistoryEntry.objects.all() qs_created = qs_all.filter(type=HistoryType.create) + qs_hidden = qs_all.filter(is_hidden=True) assert qs_all.count() == 0 @@ -79,7 +82,7 @@ def test_take_two_snapshots_without_changes(): assert qs_all.count() == 1 assert qs_created.count() == 1 - + assert qs_hidden.count() == 0 def test_take_snapshot_from_deleted_object(): issue = f.IssueFactory.create() @@ -174,3 +177,23 @@ def test_issue_resource_history_test(client): assert qs_created.count() == 1 assert qs_changed.count() == 0 assert qs_deleted.count() == 1 + + +def test_take_hidden_snapshot(): + task = f.TaskFactory.create() + + qs_all = HistoryEntry.objects.all() + qs_hidden = qs_all.filter(is_hidden=True) + + assert qs_all.count() == 0 + + # Two snapshots with modification should + # generate two snapshots. + services.take_snapshot(task, user=task.owner) + task.us_order = 3 + task.save() + + services.take_snapshot(task, user=task.owner) + assert qs_all.count() == 2 + assert qs_hidden.count() == 1 + diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index 6966bf9d..c58e7890 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -37,8 +37,8 @@ def test_update_userstories_order_in_bulk(): data = [{"us_id": 1, "order": 1}, {"us_id": 2, "order": 2}] with mock.patch("taiga.projects.userstories.services.db") as db: - services.update_userstories_order_in_bulk(data) - db.update_in_bulk_with_ids.assert_called_once_with([1, 2], [{"order": 1}, {"order": 2}], + services.update_userstories_order_in_bulk(data, "backlog_order") + db.update_in_bulk_with_ids.assert_called_once_with([1, 2], [{"backlog_order": 1}, {"backlog_order": 2}], model=models.UserStory) @@ -81,11 +81,15 @@ def test_api_create_in_bulk_with_status(client): assert response.data[0]["status"] == project.default_us_status.id -def test_api_update_order_in_bulk(client): +def test_api_update_backlog_order_in_bulk(client): project = f.create_project() us1 = f.create_userstory(project=project) us2 = f.create_userstory(project=project) - url = reverse("userstories-bulk-update-order") + + url1 = reverse("userstories-bulk-update-backlog-order") + url2 = reverse("userstories-bulk-update-kanban-order") + url3 = reverse("userstories-bulk-update-sprint-order") + data = { "project_id": project.id, "bulk_stories": [{"us_id": us1.id, "order": 1}, @@ -93,6 +97,11 @@ def test_api_update_order_in_bulk(client): } client.login(project.owner) - response = client.json.post(url, json.dumps(data)) - assert response.status_code == 204, response.data + response1 = client.json.post(url1, json.dumps(data)) + response2 = client.json.post(url2, json.dumps(data)) + response3 = client.json.post(url3, json.dumps(data)) + + assert response1.status_code == 204, response.data + assert response2.status_code == 204, response.data + assert response3.status_code == 204, response.data