Merge pull request #70 from taigaio/django1.7-step3-order-refactor
Order refactorremotes/origin/enhancement/email-actions
commit
1dd5c4c768
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -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):
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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),
|
||||
]
|
|
@ -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"),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 id>, <new user story order value>), ...]
|
||||
[(<user story id>, {<field>: <value>, ...}), ...]
|
||||
"""
|
||||
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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue