Merge pull request #70 from taigaio/django1.7-step3-order-refactor

Order refactor
remotes/origin/enhancement/email-actions
Jesús Espino 2014-09-17 17:52:07 +02:00
commit 1dd5c4c768
15 changed files with 272 additions and 56 deletions

View File

@ -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

View File

@ -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,

View File

@ -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,
),
]

View File

@ -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):

View File

@ -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")

View File

@ -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,
),
]

View File

@ -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",

View File

@ -78,19 +78,48 @@ 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():
def bulk_update_backlog_order(self, request, **kwargs):
serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA)
if not serializer.is_valid():
return response.BadRequest(serializer.errors)
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"])
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="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):
response = super().create(*args, **kwargs)

View File

@ -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),
]

View File

@ -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"),

View File

@ -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,
class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator,
UserStoryStatusExistsValidator,
Serializer):
project_id = serializers.IntegerField()
bulk_stories = UserStoryOrderBulkSerializer(many=True)
bulk_stories = _UserStoryOrderBulkSerializer(many=True)

View File

@ -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:

View File

@ -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,

View File

@ -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

View File

@ -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