Update notifications to use the new history module

remotes/origin/enhancement/email-actions
David Barragán Merino 2014-05-09 11:05:09 +02:00
parent a8bdb364ee
commit 09eced41a0
54 changed files with 345 additions and 618 deletions

View File

@ -15,23 +15,15 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import reversion
from django.db import transaction
from rest_framework import viewsets
from rest_framework import status
from rest_framework import mixins
from rest_framework import decorators as rf_decorators
from rest_framework.response import Response
from reversion.revisions import revision_context_manager
from reversion.models import Version
from . import pagination
from . import serializers
from . import decorators
# Transactional version of rest framework mixins.
@ -118,98 +110,9 @@ class DetailAndListSerializersMixin(object):
return super().get_serializer_class()
class ReversionMixin(object):
historical_model = Version
historical_serializer_class = serializers.VersionSerializer
historical_paginate_by = 5
def get_historical_queryset(self):
return reversion.get_unique_for_object(self.get_object())
def get_historical_serializer_class(self):
serializer_class = self.historical_serializer_class
if serializer_class is not None:
return serializer_class
assert self.historical_model is not None, \
"'%s' should either include a 'serializer_class' attribute, " \
"or use the 'model' attribute as a shortcut for " \
"automatically generating a serializer class." \
% self.__class__.__name__
class DefaultSerializer(self.model_serializer_class):
class Meta:
model = self.historical_model
return DefaultSerializer
def get_historical_serializer(self, instance=None, data=None, files=None,
many=False, partial=False):
serializer_class = self.get_historical_serializer_class()
return serializer_class(instance, data=data, files=files,
many=many, partial=partial)
def get_historical_pagination_serializer(self, page):
return self.get_historical_serializer(page.object_list, many=True)
@rf_decorators.link()
@decorators.change_instance_attr("paginate_by", historical_paginate_by)
def historical(self, request, *args, **kwargs):
obj = self.get_object()
self.historical_object_list = self.get_historical_queryset()
# Switch between paginated or standard style responses
page = self.paginate_queryset(self.historical_object_list)
if page is not None:
serializer = self.get_historical_pagination_serializer(page)
else:
serializer = self.get_historical_serializer(self.historical_object_list,
many=True)
return Response(serializer.data)
@rf_decorators.action()
def restore(self, request, *args, **kwargs):
vpk = request.QUERY_PARAMS.get("version", None)
if not vpk:
return Response(status=status.HTTP_404_NOT_FOUND)
try:
version = reversion.get_for_object(self.get_object()).get(pk=vpk)
version.revision.revert(delete=True)
serializer = self.get_serializer(self.get_object())
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_202_ACCEPTED,
headers=headers)
except Version.DoesNotExist:
return Response(status=status.HTTP_400_BAD_REQUEST)
def dispatch(self, request, *args, **kwargs):
revision_context_manager.start()
try:
response = super().dispatch(request, *args, **kwargs)
except Exception as e:
revision_context_manager.invalidate()
revision_context_manager.end()
raise
if self.request.user.is_authenticated():
revision_context_manager.set_user(self.request.user)
if response.status_code > 206:
revision_context_manager.invalidate()
revision_context_manager.end()
return response
# Own subclasses of django rest framework viewsets
class ModelCrudViewSet(DetailAndListSerializersMixin,
ReversionMixin,
PreconditionMixin,
pagination.HeadersPaginationMixin,
pagination.ConditionalPaginationMixin,
@ -223,7 +126,6 @@ class ModelCrudViewSet(DetailAndListSerializersMixin,
class ModelListViewSet(DetailAndListSerializersMixin,
ReversionMixin,
PreconditionMixin,
pagination.HeadersPaginationMixin,
pagination.ConditionalPaginationMixin,

View File

@ -14,35 +14,8 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from functools import wraps
import warnings
def change_instance_attr(name, new_value):
"""
Change the attribute value temporarily for a new one. If it raise an AttributeError (if the
instance hasm't the attribute) the attribute will not be changed.
"""
def change_instance_attr(fn):
@wraps(fn)
def wrapper(instance, *args, **kwargs):
try:
old_value = instance.__getattribute__(name)
changed = True
except AttributeError:
changed = False
if changed:
instance.__setattr__(name, new_value)
ret = fn(instance, *args, **kwargs)
if changed:
instance.__setattr__(name, old_value)
return ret
return wrapper
return change_instance_attr
## Rest Framework 2.4 backport some decorators.

View File

@ -1,51 +0,0 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from . import services
class NotificationSenderMixin(object):
create_notification_template = None
update_notification_template = None
destroy_notification_template = None
notification_service = services.NotificationService()
def _post_save_notification_sender(self, obj, created=False):
users = obj.get_watchers_to_notify(self.request.user)
comment = self.request.DATA.get("comment", None)
context = {'changer': self.request.user, "comment": comment, 'object': obj}
if created:
self.notification_service.send_notification_email(self.create_notification_template,
users=users, context=context)
else:
context["changed_fields"] = obj.get_changed_fields_list(self.request.DATA)
self.notification_service.send_notification_email(self.update_notification_template,
users=users, context=context)
def post_save(self, obj, created=False):
super().post_save(obj, created)
self._post_save_notification_sender(obj, created)
def destroy(self, request, *args, **kwargs):
obj = self.get_object()
users = obj.get_watchers_to_notify(self.request.user)
context = {'changer': self.request.user, 'object': obj}
self.notification_service.send_notification_email(self.destroy_notification_template,
users=users, context=context)
return super().destroy(request, *args, **kwargs)

View File

@ -16,9 +16,6 @@
from rest_framework import serializers
from reversion.models import Version
import reversion
from taiga.domains.base import get_active_domain
from taiga.domains.models import Domain
@ -57,67 +54,6 @@ class AutoDomainField(serializers.WritableField):
domain = get_active_domain()
return domain
class VersionSerializer(serializers.ModelSerializer):
created_date = serializers.SerializerMethodField("get_created_date")
content_type = serializers.SerializerMethodField("get_content_type")
object_id = serializers.SerializerMethodField("get_object_id")
user = serializers.SerializerMethodField("get_user")
comment = serializers.SerializerMethodField("get_comment")
fields = serializers.SerializerMethodField("get_object_fields")
changed_fields = serializers.SerializerMethodField("get_changed_fields")
class Meta:
model = Version
fields = ("id", "created_date", "content_type", "object_id", "user", "comment",
"fields", "changed_fields")
read_only = fields
def get_created_date(self, obj):
return obj.revision.date_created
def get_content_type(self, obj):
return obj.content_type.model
def get_object_id(self, obj):
return obj.object_id_int
def get_object_fields(self, obj):
return obj.field_dict
def get_user(self, obj):
return obj.revision.user.id if obj.revision.user else None
def get_comment(self, obj):
return obj.revision.comment
def get_object_old_fields(self, obj):
versions = reversion.get_unique_for_object(obj.object)
try:
return versions[versions.index(obj) + 1].field_dict
except IndexError:
return {}
def get_changed_fields(self, obj):
new_fields = self.get_object_fields(obj)
old_fields = self.get_object_old_fields(obj)
changed_fields = {}
for key in new_fields.keys() | old_fields.keys():
if key == "modified_date":
continue
if old_fields.get(key, "") == new_fields.get(key, ""):
continue
changed_fields[key] = {
"name": obj.object.__class__._meta.get_field_by_name(
key)[0].verbose_name,
"old": old_fields.get(key, None),
"new": new_fields.get(key, None),
}
return changed_fields
class NeighborsSerializerMixin:

View File

@ -27,6 +27,7 @@ URLS = {
"project-admin": "/#/project/{0}/admin",
"change-password": "/#/change-password/{0}",
"invitation": "/#/invitation/{0}",
"wiki": "/#/project/{0}/wiki/{1}"
}

View File

@ -47,7 +47,7 @@ class MembershipInline(admin.TabularInline):
extra = 0
class ProjectAdmin(reversion.VersionAdmin):
class ProjectAdmin(admin.ModelAdmin):
list_display = ["name", "owner", "created_date", "total_milestones",
"total_story_points", "domain"]
list_display_links = list_display

View File

@ -20,10 +20,8 @@ from taiga.projects.admin import AttachmentInline
from . import models
import reversion
class IssueAdmin(reversion.VersionAdmin):
class IssueAdmin(admin.ModelAdmin):
list_display = ["project", "milestone", "ref", "subject",]
list_display_links = ["ref", "subject",]
inlines = [AttachmentInline]

View File

@ -14,8 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import reversion
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import ugettext_lazy as _
from django.db.models import Q
@ -27,11 +25,12 @@ from rest_framework import filters
from taiga.base import filters
from taiga.base import exceptions as exc
from taiga.base.decorators import list_route
from taiga.base.api import ModelCrudViewSet, NeighborsApiMixin
from taiga.base.notifications.api import NotificationSenderMixin
from taiga.projects.permissions import AttachmentPermission
from taiga.projects.serializers import AttachmentSerializer
from taiga.projects.models import Attachment
from taiga.base.api import ModelCrudViewSet
from taiga.base.api import NeighborsApiMixin
from taiga.projects.mixins.notifications import NotificationSenderMixin
from . import models
from . import permissions
@ -142,15 +141,6 @@ class IssueViewSet(NeighborsApiMixin, NotificationSenderMixin, ModelCrudViewSet)
if obj.type and obj.type.project != obj.project:
raise exc.PermissionDenied(_("You don't have permissions for add/modify this issue."))
def post_save(self, obj, created=False):
with reversion.create_revision():
if "comment" in self.request.DATA:
# Update the comment in the last version
reversion.set_comment(self.request.DATA["comment"])
super().post_save(obj, created)
class IssueAttachmentViewSet(ModelCrudViewSet):
model = Attachment
serializer_class = AttachmentSerializer

View File

@ -28,7 +28,6 @@ from taiga.base.utils.slug import ref_uniquely
from taiga.base.notifications.models import WatchedMixin
from taiga.projects.mixins.blocked.models import BlockedMixin
import reversion
class Issue(NeighborsMixin, WatchedMixin, BlockedMixin):
@ -276,10 +275,6 @@ class Issue(NeighborsMixin, WatchedMixin, BlockedMixin):
}
# Reversion registration (usufull for base.notification and for meke a historical)
reversion.register(Issue)
# Model related signals handlers
@receiver(models.signals.pre_save, sender=Issue, dispatch_uid="issue_finished_date_handler")
def issue_finished_date_handler(sender, instance, **kwargs):

View File

@ -18,7 +18,7 @@ from rest_framework import serializers
from taiga.base.serializers import PickleField, NeighborsSerializerMixin
from taiga.projects.serializers import AttachmentSerializer
from taiga.projects.mixins.notifications.serializers import WatcherValidationSerializerMixin
from taiga.projects.mixins.notifications import WatcherValidationSerializerMixin
from . import models

View File

@ -14,24 +14,8 @@
<p>Comment <b>{{ comment|linebreaksbr }}</b></p>
{% endif %}
{% if changed_fields %}
<p>Updated fields:
<dl>
{% for field in changed_fields %}
<dt style="background: #669933; padding: 5px 15px; color: #fff">
<b>{{ field.verbose_name}}</b>
</dt>
{% if field.new_value != None or field.new_value != "" %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>to:</b> <i>{{ field.new_value|linebreaksbr }}</i>
</dd>
{% endif %}
{% if field.old_value != None or field.old_value != "" %}
<dd style="padding: 5px 15px; color: #bbb">
<b>from:</b> <i>{{ field.old_value|linebreaksbr }}</i>
</dd>
{% endif %}
{% endfor %}
</dl>
<p>Updated fields:</p>
{% include "emails/includes/fields_diff-html.jinja" %}
{% endif %}
</td>
</tr>

View File

@ -9,9 +9,7 @@ Comment: {{ comment|linebreaksbr }}
{% endif %}
{% if changed_fields %}
- Updated fields:
{% for field in changed_fields %}
* {{ field.verbose_name}}</b>: from '{{ field.old_value}}' to '{{ field.new_value }}'.
{% endfor %}
{% include "emails/includes/fields_diff-text.jinja" %}
{% endif %}
** More info at {{ final_url_name }} ({{ final_url }}) **

View File

@ -15,7 +15,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.contrib import admin
import reversion
from . import models
@ -25,7 +24,7 @@ class MilestoneInline(admin.TabularInline):
extra = 0
class MilestoneAdmin(reversion.VersionAdmin):
class MilestoneAdmin(admin.ModelAdmin):
list_display = ["name", "project", "owner", "closed", "estimated_start",
"estimated_finish"]
list_display_links = list_display

View File

@ -24,7 +24,7 @@ from taiga.base import filters
from taiga.base import exceptions as exc
from taiga.base.decorators import detail_route
from taiga.base.api import ModelCrudViewSet
from taiga.base.notifications.api import NotificationSenderMixin
from taiga.projects.mixins.notifications import NotificationSenderMixin
from . import serializers
from . import models

View File

@ -20,11 +20,10 @@ from django.utils.translation import ugettext_lazy as _
from taiga.base.utils.slug import slugify_uniquely
from taiga.base.utils.dicts import dict_sum
from taiga.base.notifications.models import WatchedMixin
from taiga.projects.notifications.models import WatchedMixin
from taiga.projects.userstories.models import UserStory
import reversion
import itertools
import datetime
@ -146,7 +145,3 @@ class Milestone(WatchedMixin, models.Model):
finish_date__lt=date + datetime.timedelta(days=1)
) if us.is_closed
])
# Reversion registration (usufull for base.notification and for meke a historical)
reversion.register(Milestone)

View File

@ -15,7 +15,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import reversion
from rest_framework import serializers

View File

@ -14,24 +14,8 @@
<p>Comment <b>{{ comment|linebreaksbr }}</b></p>
{% endif %}
{% if changed_fields %}
<p>Updated fields:
<dl>
{% for field in changed_fields %}
<dt style="background: #669933; padding: 5px 15px; color: #fff">
<b>{{ field.verbose_name}}</b>
</dt>
{% if field.new_value != None or field.new_value != "" %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>to:</b> <i>{{ field.new_value|linebreaksbr }}</i>
</dd>
{% endif %}
{% if field.old_value != None or field.old_value != "" %}
<dd style="padding: 5px 15px; color: #bbb">
<b>from:</b> <i>{{ field.old_value|linebreaksbr }}</i>
</dd>
{% endif %}
{% endfor %}
</dl>
<p>Updated fields:</p>
{% include "emails/includes/fields_diff-html.jinja" %}
{% endif %}
</td>
</tr>

View File

@ -9,9 +9,7 @@ Comment: {{ comment|linebreaksbr }}
{% endif %}
{% if changed_fields %}
- Updated fields:
{% for field in changed_fields %}
* {{ field.verbose_name}}</b>: from '{{ field.old_value}}' to '{{ field.new_value }}'.
{% endfor %}
{% include "emails/includes/fields_diff-text.jinja" %}
{% endif %}
** More info at {{ final_url_name }} ({{ final_url }}) **

View File

@ -0,0 +1,116 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#######
# API #
#######
from taiga.projects.notifications import services
from taiga.projects.history.services import take_snapshot
from taiga.projects.history.models import HistoryType
class NotificationSenderMixin(object):
create_notification_template = None
update_notification_template = None
destroy_notification_template = None
notification_service = services.NotificationService()
def _get_object_for_snapshot(self, obj):
return obj
def _post_save_notification_sender(self, obj, history):
users = obj.get_watchers_to_notify(history.owner)
context = {
"object": obj,
"changer": history.owner,
"comment": history.comment,
"changed_fields": history.values_diff
}
if history.type == HistoryType.create:
self.notification_service.send_notification_email(self.create_notification_template,
users=users, context=context)
else:
self.notification_service.send_notification_email(self.update_notification_template,
users=users, context=context)
def post_save(self, obj, created=False):
super().post_save(obj, created)
user = self.request.user
comment = self.request.DATA.get("comment", "")
obj = self._get_object_for_snapshot(obj)
history = take_snapshot(obj, comment=comment, user=user)
if history:
self._post_save_notification_sender(obj, history)
def pre_destroy(self, obj):
obj = self._get_object_for_snapshot(obj)
users = obj.get_watchers_to_notify(self.request.user)
context = {
"object": obj,
"changer": self.request.user
}
self.notification_service.send_notification_email(self.destroy_notification_template,
users=users, context=context)
def post_destroy(self, obj):
pass
def destroy(self, request, *args, **kwargs):
obj = self.get_object()
self.pre_destroy(obj)
result = super().destroy(request, *args, **kwargs)
self.post_destroy(obj)
return result
################
# SERIEALIZERS #
################
from django.db.models.loading import get_model
from rest_framework import serializers
class WatcherValidationSerializerMixin(object):
def validate_watchers(self, attrs, source):
values = set(attrs.get(source, []))
if values:
project = None
if "project" in attrs and attrs["project"]:
if self.object and attrs["project"] == self.object.project.id:
project = self.object.project
else:
project_model = get_model("projects", "Project")
try:
project = project_model.objects.get(project__id=attrs["project"])
except project_model.DoesNotExist:
pass
elif self.object:
project = self.object.project
if len(values) != get_model("projects", "Membership").objects.filter(project=project,
user__in=values).count():
raise serializers.ValidationError("Error, some watcher user is not a member of the project")
return attrs

View File

@ -1,42 +0,0 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db.models.loading import get_model
from rest_framework import serializers
class WatcherValidationSerializerMixin(object):
def validate_watchers(self, attrs, source):
values = set(attrs.get(source, []))
if values:
project = None
if "project" in attrs and attrs["project"]:
if self.object and attrs["project"] == self.object.project.id:
project = self.object.project
else:
project_model = get_model("projects", "Project")
try:
project = project_model.objects.get(project__id=attrs["project"])
except project_model.DoesNotExist:
pass
elif self.object:
project = self.object.project
if len(values) != get_model("projects", "Membership").objects.filter(project=project,
user__in=values).count():
raise serializers.ValidationError("Error, some watcher user is not a member of the project")
return attrs

View File

@ -749,9 +749,6 @@ class ProjectTemplate(models.Model):
return project
# Reversion registration (usufull for base.notification and for meke a historical)
reversion.register(Project)
reversion.register(Attachment)
# On membership object is created/changed, update
# role-points relation.

View File

@ -15,10 +15,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db import models
from django.db.models.fields import FieldDoesNotExist
from django.utils.translation import ugettext_lazy as _
import reversion
class WatcherMixin(models.Model):
@ -63,38 +61,8 @@ class WatcherMixin(models.Model):
def allow_notify_project(self, project):
return self.notify_level == "all_owned_projects"
class WatchedMixin(models.Model):
notifiable_fields = []
class Meta:
abstract = True
@property
def last_version(self):
version_list = reversion.get_for_object(self)
return version_list and version_list[0] or None
def get_changed_fields_list(self, data_dict):
def _key_by_notifiable_field(item):
try:
return self.notifiable_fields.index(item["name"])
except ValueError:
return 100000 # Emulate the maximum value
if self.notifiable_fields:
changed_data = {k:v for k, v in data_dict.items()
if k in self.notifiable_fields}
else:
changed_data = data_dict
fields_list = []
for field_name, data_value in changed_data.items():
field_dict = self._get_changed_field(field_name, data_value)
if field_dict["old_value"] != field_dict["new_value"]:
fields_list.append(field_dict)
return sorted(fields_list, key=_key_by_notifiable_field)
class WatchedMixin(object):
def get_watchers_to_notify(self, changer):
watchers_to_notify = set()
watchers_by_role = self._get_watchers_by_role()
@ -127,47 +95,6 @@ class WatchedMixin(models.Model):
return watchers_to_notify
def _get_changed_field_verbose_name(self, field_name):
try:
return self._meta.get_field(field_name).verbose_name
except FieldDoesNotExist:
return field_name
def _get_changed_field_old_value(self, field_name, data_value):
value = (self.last_version.field_dict.get(field_name, data_value)
if self.last_version else None)
field = self.__class__._meta.get_field_by_name(field_name)[0] or None
if value and field:
# Get the old value from a ForeignKey
if type(field) is models.fields.related.ForeignKey:
try:
value = field.related.parent_model.objects.get(pk=value)
except field.related.parent_model.DoesNotExist:
pass
display_method = getattr(self,"get_notifiable_{field_name}_display".format(
field_name=field_name) ,None)
return display_method(value) if display_method else value
def _get_changed_field_new_value(self, field_name, data_value):
value = getattr(self, field_name, data_value)
display_method = getattr(self,"get_notifiable_{field_name}_display".format(
field_name=field_name) ,None)
return display_method(value) if display_method else value
def _get_changed_field(self, field_name, data_value):
verbose_name = self._get_changed_field_verbose_name(field_name)
old_value = self._get_changed_field_old_value(field_name, None)
new_value = self._get_changed_field_new_value(field_name, data_value)
return {
"name": field_name,
"verbose_name": verbose_name,
"old_value": old_value,
"new_value": new_value,
}
def _get_watchers_by_role(self):
"""
Return the actual instances of watchers of this object, classified by role.

View File

@ -21,7 +21,7 @@ from taiga.projects.admin import AttachmentInline
from . import models
class TaskAdmin(reversion.VersionAdmin):
class TaskAdmin(admin.ModelAdmin):
list_display = ["project", "milestone", "user_story", "ref", "subject",]
list_display_links = ["ref", "subject",]
list_filter = ["project"]

View File

@ -27,19 +27,15 @@ from taiga.base import exceptions as exc
from taiga.base.decorators import list_route
from taiga.base.permissions import has_project_perm
from taiga.base.api import ModelCrudViewSet
from taiga.base.notifications.api import NotificationSenderMixin
from taiga.projects.permissions import AttachmentPermission
from taiga.projects.serializers import AttachmentSerializer
from taiga.projects.models import Attachment, Project
from taiga.projects.mixins.notifications import NotificationSenderMixin
from taiga.projects.userstories.models import UserStory
from . import models
from . import permissions
from . import serializers
import reversion
class TaskAttachmentViewSet(ModelCrudViewSet):
model = Attachment
serializer_class = AttachmentSerializer
@ -66,6 +62,7 @@ class TaskAttachmentViewSet(ModelCrudViewSet):
obj.project.memberships.filter(user=self.request.user).count() == 0):
raise exc.PermissionDenied(_("You don't have permissions for add "
"attachments to this task."))
from . import services
class TaskViewSet(NotificationSenderMixin, ModelCrudViewSet):
@ -102,13 +99,6 @@ class TaskViewSet(NotificationSenderMixin, ModelCrudViewSet):
if obj.status and obj.status.project != obj.project:
raise exc.PermissionDenied(_("You don't have permissions for add/modify this task."))
def post_save(self, obj, created=False):
with reversion.create_revision():
if "comment" in self.request.DATA:
# Update the comment in the last version
reversion.set_comment(self.request.DATA["comment"])
super().post_save(obj, created)
@list_route(methods=["POST"])
def bulk_create(self, request, **kwargs):
bulk_tasks = request.DATA.get('bulkTasks', None)
@ -124,21 +114,14 @@ class TaskViewSet(NotificationSenderMixin, ModelCrudViewSet):
raise exc.BadRequest(_('usId parameter is mandatory'))
project = get_object_or_404(Project, id=project_id)
us = get_object_or_404(UserStory, id=us_id)
user_story = get_object_or_404(UserStory, id=us_id)
if request.user != project.owner and not has_project_perm(request.user, project, 'add_task'):
raise exc.PermissionDenied(_("You don't have permisions to create tasks."))
items = filter(lambda s: len(s) > 0,
map(lambda s: s.strip(), bulk_tasks.split("\n")))
tasks = []
for item in items:
obj = models.Task.objects.create(subject=item, project=project,
user_story=us, owner=request.user,
status=project.default_task_status)
tasks.append(obj)
self._post_save_notification_sender(obj, True)
service = services.TasksService()
tasks = service.bulk_insert(project, request.user, user_story, bulk_tasks,
callback_on_success=self.post_save)
tasks_serialized = self.serializer_class(tasks, many=True)
return Response(data=tasks_serialized.data)

View File

@ -24,7 +24,7 @@ from django.utils.translation import ugettext_lazy as _
from picklefield.fields import PickledObjectField
from taiga.base.utils.slug import ref_uniquely
from taiga.base.notifications.models import WatchedMixin
from taiga.projects.notifications.models import WatchedMixin
from taiga.projects.userstories.models import UserStory
from taiga.projects.milestones.models import Milestone
from taiga.projects.mixins.blocked.models import BlockedMixin
@ -109,10 +109,6 @@ class Task(WatchedMixin, BlockedMixin):
}
# Reversion registration (usufull for base.notification and for meke a historical)
reversion.register(Task)
# Model related signals handlers
@receiver(models.signals.pre_save, sender=Task, dispatch_uid="task_ref_handler")
def task_ref_handler(sender, instance, **kwargs):

View File

@ -0,0 +1,41 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db import transaction
from django.db import connection
from . import models
class TasksService(object):
@transaction.atomic
def bulk_insert(self, project, user, user_story, data, callback_on_success=None):
tasks = []
items = filter(lambda s: len(s) > 0,
map(lambda s: s.strip(), data.split("\n")))
for item in items:
obj = models.Task.objects.create(subject=item, project=project,
user_story=user_story, owner=user,
status=project.default_task_status)
tasks.append(obj)
if callback_on_success:
callback_on_success(obj, True)
return tasks

View File

@ -14,24 +14,8 @@
<p>Comment <b>{{ comment|linebreaksbr }}</b></p>
{% endif %}
{% if changed_fields %}
<p>Updated fields:
<dl>
{% for field in changed_fields %}
<dt style="background: #669933; padding: 5px 15px; color: #fff">
<b>{{ field.verbose_name}}</b>
</dt>
{% if field.new_value != None or field.new_value != "" %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>to:</b> <i>{{ field.new_value|linebreaksbr }}</i>
</dd>
{% endif %}
{% if field.old_value != None or field.old_value != "" %}
<dd style="padding: 5px 15px; color: #bbb">
<b>from:</b> <i>{{ field.old_value|linebreaksbr }}</i>
</dd>
{% endif %}
{% endfor %}
</dl>
<p>Updated fields:</p>
{% include "emails/includes/fields_diff-html.jinja" %}
{% endif %}
</td>
</tr>

View File

@ -9,9 +9,7 @@ Comment: {{ comment|linebreaksbr }}
{% endif %}
{% if changed_fields %}
- Updated fields:
{% for field in changed_fields %}
* {{ field.verbose_name}}</b>: from '{{ field.old_value}}' to '{{ field.new_value }}'.
{% endfor %}
{% include "emails/includes/fields_diff-text.jinja" %}
{% endif %}
** More info at {{ final_url_name }} ({{ final_url }}) **

View File

@ -20,8 +20,6 @@ from django import test
from django.core import mail
from django.core.urlresolvers import reverse
import reversion
from taiga.users.tests import create_user
from taiga.projects.tests import create_project, add_membership
from taiga.projects.milestones.tests import create_milestone
@ -111,24 +109,6 @@ class TasksTestCase(test.TestCase):
response = self.client.get(reverse("tasks-detail", args=(self.task1.id,)))
self.assertEqual(response.status_code, 401)
def test_view_task_by_project_owner(self):
response = self.client.login(username=self.user1.username,
password=self.user1.username)
self.assertTrue(response)
# Change task for generate history/diff.
with reversion.create_revision():
self.task1.tags = ["LL"]
self.task1.save()
with reversion.create_revision():
self.task1.tags = ["LLKK"]
self.task1.save()
response = self.client.get(reverse("tasks-detail", args=(self.task1.id,)))
self.assertEqual(response.status_code, 200)
self.client.logout()
def test_view_task_by_owner(self):
response = self.client.login(username=self.user2.username,
password=self.user2.username)

View File

@ -13,24 +13,8 @@
<p>Comment <b>{{ comment|linebreaksbr }}</b></p>
{% endif %}
{% if changed_fields %}
<p>Updated fields:
<dl>
{% for field in changed_fields %}
<dt style="background: #669933; padding: 5px 15px; color: #fff">
<b>{{ field.verbose_name}}</b>
</dt>
{% if field.new_value != None or field.new_value != "" %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>to:</b> <i>{{ field.new_value|linebreaksbr }}</i>
</dd>
{% endif %}
{% if field.old_value != None or field.old_value != "" %}
<dd style="padding: 5px 15px; color: #bbb">
<b>from:</b> <i>{{ field.old_value|linebreaksbr }}</i>
</dd>
{% endif %}
{% endfor %}
</dl>
<p>Updated fields:</p>
{% include "emails/includes/fields_diff-html.jinja" %}
{% endif %}
</td>
</tr>

View File

@ -8,8 +8,8 @@ Comment: {{ comment|linebreaksbr }}
{% endif %}
{% if changed_fields %}
- Updated fields:
{% for field in changed_fields %}
* {{ field.verbose_name}}</b>: from '{{ field.old_value}}' to '{{ field.new_value }}'.
{% for field_name, values in changed_fields.items() %}
* {{ verbose_name(object, field_name) }}</b>: from '{{ values.0 }}' to '{{ values.1 }}'.
{% endfor %}
{% endif %}

View File

@ -15,14 +15,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.contrib import admin
import reversion
from taiga.projects.admin import AttachmentInline
from . import models
class RolePointsInline(admin.TabularInline):
model = models.RolePoints
sortable_field_name = 'role'
@ -39,7 +37,7 @@ class RolePointsAdmin(admin.ModelAdmin):
readonly_fields = ["user_story", "role", "points"]
class UserStoryAdmin(reversion.VersionAdmin):
class UserStoryAdmin(admin.ModelAdmin):
list_display = ["project", "milestone", "ref", "subject",]
list_display_links = ["ref", "subject",]
list_filter = ["project"]

View File

@ -14,8 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import reversion
from django.db import transaction
from django.utils.translation import ugettext_lazy as _
from django.contrib.contenttypes.models import ContentType
@ -27,13 +25,16 @@ from rest_framework import status
from taiga.base import filters
from taiga.base import exceptions as exc
from taiga.base.decorators import list_route, action
from taiga.base.decorators import list_route
from taiga.base.decorators import action
from taiga.base.permissions import has_project_perm
from taiga.base.api import ModelCrudViewSet, NeighborsApiMixin
from taiga.base.notifications.api import NotificationSenderMixin
from taiga.projects.permissions import AttachmentPermission
from taiga.projects.serializers import AttachmentSerializer
from taiga.projects.models import Attachment, Project
from taiga.base.api import ModelCrudViewSet
from taiga.base.api import NeighborsApiMixin
from taiga.projects.mixins.notifications import NotificationSenderMixin
from taiga.projects.history.services import take_snapshot
from . import models
from . import permissions
@ -103,16 +104,19 @@ class UserStoryViewSet(NeighborsApiMixin, NotificationSenderMixin, ModelCrudView
raise exc.PermissionDenied(_("You don't have permisions to create user stories."))
service = services.UserStoriesService()
service.bulk_insert(project, request.user, bulk_stories,
callback_on_success=self._post_save_notification_sender)
user_stories = service.bulk_insert(project, request.user, bulk_stories,
callback_on_success=self.post_save)
return Response(data=None, status=status.HTTP_204_NO_CONTENT)
user_stories_serialized = self.serializer_class(user_stories, many=True)
return Response(data=user_stories_serialized.data)
@list_route(methods=["POST"])
def bulk_update_order(self, request, **kwargs):
# bulkStories should be:
# [[1,1],[23, 2], ...]
# TODO: Generate the histoy snaptshot when change the uss order in the backlog.
# Implement order with linked lists \o/.
bulk_stories = request.DATA.get("bulkStories", None)
if bulk_stories is None:
@ -138,11 +142,13 @@ class UserStoryViewSet(NeighborsApiMixin, NotificationSenderMixin, ModelCrudView
# Added comment to the origin (issue)
if response.status_code == status.HTTP_201_CREATED and self.object.generated_from_issue:
with reversion.create_revision():
reversion.set_comment(_("Generated the user story [US #{ref} - {subject}](:us:{ref} \"US #{ref} - {subject}\")").format(
ref=self.object.ref, subject=self.object.subject))
self.object.generated_from_issue.save()
comment = _("Generate the user story [US #{ref} - "
"{subject}](:us:{ref} \"US #{ref} - {subject}\")")
comment = comment.format(ref=self.object.ref, subject=self.object.subject)
take_snapshot(self.object.generated_from_issue, comment=comment, user=self.request.user)
return response
def pre_save(self, obj):

View File

@ -21,12 +21,11 @@ from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from picklefield.fields import PickledObjectField
import reversion
from taiga.base.models import NeighborsMixin
from taiga.base.utils.slug import ref_uniquely
from taiga.base.notifications.models import WatchedMixin
from taiga.projects.mixins.blocked.models import BlockedMixin
from taiga.projects.notifications.models import WatchedMixin
class RolePoints(models.Model):
@ -177,10 +176,6 @@ class UserStory(NeighborsMixin, WatchedMixin, BlockedMixin, models.Model):
}
# Reversion registration (usufull for base.notification and for meke a historical)
reversion.register(UserStory)
# Model related signals handlers
@receiver(models.signals.pre_save, sender=UserStory, dispatch_uid="user_story_ref_handler")
def us_ref_handler(sender, instance, **kwargs):

View File

@ -18,23 +18,29 @@ from django.db import transaction
from django.db import connection
from . import models
import reversion
class UserStoriesService(object):
@transaction.atomic
def bulk_insert(self, project, user, data, callback_on_success=None):
user_stories = []
items = filter(lambda s: len(s) > 0,
map(lambda s: s.strip(), data.split("\n")))
for item in items:
obj = models.UserStory.objects.create(subject=item, project=project, owner=user,
status=project.default_us_status)
user_stories.append(obj)
if callback_on_success:
callback_on_success(obj, True)
return user_stories
@transaction.atomic
def bulk_update_order(self, project, user, data):
# TODO: Create a history snapshot of all updated USs
cursor = connection.cursor()
sql = """

View File

@ -14,24 +14,8 @@
<p>Comment <b>{{ comment|linebreaksbr }}</b></p>
{% endif %}
{% if changed_fields %}
<p>Updated fields:
<dl>
{% for field in changed_fields %}
<dt style="background: #669933; padding: 5px 15px; color: #fff">
<b>{{ field.verbose_name}}</b>
</dt>
{% if field.new_value != None or field.new_value != "" %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>to:</b> <i>{{ field.new_value|linebreaksbr }}</i>
</dd>
{% endif %}
{% if field.old_value != None or field.old_value != "" %}
<dd style="padding: 5px 15px; color: #bbb">
<b>from:</b> <i>{{ field.old_value|linebreaksbr }}</i>
</dd>
{% endif %}
{% endfor %}
</dl>
<p>Updated fields:</p>
{% include "emails/includes/fields_diff-html.jinja" %}
{% endif %}
</td>
</tr>

View File

@ -9,9 +9,7 @@ Comment: {{ comment|linebreaksbr }}
{% endif %}
{% if changed_fields %}
- Updated fields:
{% for field in changed_fields %}
* {{ field.verbose_name}}</b>: from '{{ field.old_value}}' to '{{ field.new_value }}'.
{% endfor %}
{% include "emails/includes/fields_diff-text.jinja" %}
{% endif %}
** More info at {{ final_url_name }} ({{ final_url }}) **

View File

@ -27,7 +27,6 @@ from taiga.projects.issues.tests import create_issue
from . import create_userstory
import json
import reversion
class UserStoriesTestCase(test.TestCase):
@ -689,8 +688,6 @@ class UserStoriesTestCase(test.TestCase):
self.assertEqual(len(mail.outbox), 2)
self.assertEqual(response.data["origin_issue"]["subject"], issue.subject)
issue_historical = reversion.get_unique_for_object(issue)
self.assertTrue(data["subject"] in issue_historical[0].revision.comment)
self.client.logout()
@ -721,8 +718,6 @@ class UserStoriesTestCase(test.TestCase):
self.assertEqual(len(mail.outbox), 2)
self.assertEqual(response.data["origin_issue"]["subject"], issue.subject)
issue_historical = reversion.get_unique_for_object(issue)
self.assertTrue(data["subject"] in issue_historical[0].revision.comment)
self.client.logout()

View File

@ -16,8 +16,7 @@
from django.contrib import admin
from taiga.projects.wiki.models import WikiPage
from taiga.projects.admin import AttachmentInline
from taiga.projects.attachments.admin import AttachmentInline
from . import models

View File

@ -14,60 +14,55 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import ugettext_lazy as _
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from taiga.base import filters
from taiga.base import exceptions as exc
from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.notifications.api import NotificationSenderMixin
from taiga.projects.permissions import AttachmentPermission
from taiga.projects.serializers import AttachmentSerializer
from taiga.projects.models import Attachment
from taiga.base.api import ModelCrudViewSet
from taiga.base.decorators import list_route
from taiga.projects.mixins.notifications import NotificationSenderMixin
from taiga.projects.attachments.api import BaseAttachmentViewSet
from taiga.projects.models import Project
from taiga.mdrender.service import render as mdrender
from . import models
from . import permissions
from . import serializers
class WikiAttachmentViewSet(ModelCrudViewSet):
model = Attachment
serializer_class = AttachmentSerializer
permission_classes = (IsAuthenticated, AttachmentPermission)
filter_backends = (filters.IsProjectMemberFilterBackend,)
filter_fields = ["project", "object_id"]
def get_queryset(self):
ct = ContentType.objects.get_for_model(models.WikiPage)
qs = super().get_queryset()
qs = qs.filter(content_type=ct)
return qs.distinct()
def pre_conditions_on_save(self, obj):
super().pre_conditions_on_save(obj)
if (obj.project.owner != self.request.user and
obj.project.memberships.filter(user=self.request.user).count() == 0):
raise exc.PermissionDenied(_("You don't have permissions for add "
"attachments to this wiki page."))
def pre_save(self, obj):
if not obj.id:
obj.content_type = ContentType.objects.get_for_model(models.WikiPage)
obj.owner = self.request.user
super().pre_save(obj)
class WikiViewSet(ModelCrudViewSet):
class WikiViewSet(NotificationSenderMixin, ModelCrudViewSet):
model = models.WikiPage
serializer_class = serializers.WikiPageSerializer
permission_classes = (IsAuthenticated,)
filter_backends = (filters.IsProjectMemberFilterBackend,)
filter_fields = ["project", "slug"]
create_notification_template = "create_wiki_notification"
update_notification_template = "update_wiki_notification"
destroy_notification_template = "destroy_wiki_notification"
@list_route(methods=["POST"])
def render(self, request, **kwargs):
content = request.DATA.get("content", None)
project_id = request.DATA.get("project_id", None)
if not content:
return Response({"content": "No content parameter"}, status=status.HTTP_400_BAD_REQUEST)
if not project_id:
return Response({"project_id": "No project_id parameter"}, status=status.HTTP_400_BAD_REQUEST)
try:
project = Project.objects.get(pk=project_id)
except Project.DoesNotExist:
return Response({"project_id": "Not valid project id"}, status=status.HTTP_400_BAD_REQUEST)
data = mdrender(project, content)
return Response({"data": data})
def pre_conditions_on_save(self, obj):
super().pre_conditions_on_save(obj)

View File

@ -19,8 +19,6 @@ from django.contrib.contenttypes import generic
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
import reversion
class WikiPage(models.Model):
project = models.ForeignKey("projects.Project", null=False, blank=False,
@ -51,6 +49,3 @@ class WikiPage(models.Model):
def __str__(self):
return "project {0} - {1}".format(self.project_id, self.slug)
reversion.register(WikiPage)

View File

@ -0,0 +1,21 @@
{% extends "emails/base.jinja" %}
{% set final_url = resolve_front_url("wiki", object.project.slug, object.slug) %}
{% set final_url_name = "Taiga - View Wiki Page '{0}'".format(object.slug) %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<h1>Project: {{ object.project.name }}</h1>
<h2>Wiki Page: {{ object.slug }}</h2>
<p>Created by <b>{{ changer.get_full_name() }}</b>.</p>
</td>
</tr>
</table>
{% endblock %}
{% block footer %}
<p style="padding: 10px; border-top: 1px solid #eee;">
More info at: <a href="{{ final_url }}" style="color: #666;">{{ final_url_name }}</a>
</p>
{% endblock %}

View File

@ -0,0 +1,8 @@
{% set final_url = resolve_front_url("wiki", object.project.slug, object.slug) %}
{% set final_url_name = "Taiga - View Wiki Page '{0}'".format(object.slug) %}
- Project: {{ object.project.name }}
- Wiki Page: {{ object.slug }}
- Created by {{ changer.get_full_name() }}
** More info at {{ final_url_name }} ({{ final_url }}) **

View File

@ -0,0 +1 @@
[{{ object.project.name|safe }}] Created the Wiki Page "{{ object.slug }}"

View File

@ -0,0 +1,13 @@
{% extends "emails/base.jinja" %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<h1>{{ object.project.name }}</h1>
<h2>Wiki Page: {{ object.slug }}</h2>
<p>Deleted by <b>{{ changer.get_full_name() }}</b></p>
</td>
</tr>
</table>
{% endblock %}

View File

@ -0,0 +1,3 @@
- Project: {{ object.project.name }}
- Wiki Page: {{ object.slug }}
- Deleted by {{ changer.get_full_name() }}

View File

@ -0,0 +1 @@
[{{ object.project.name|safe }}] Deleted the Wiki Page "{{ object.slug }}"

View File

@ -0,0 +1,28 @@
{% extends "emails/base.jinja" %}
{% set final_url = resolve_front_url("wiki", object.project.slug, object.slug) %}
{% set final_url_name = "Taiga - View Wiki Page '{0}'".format(object.slug) %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<h1>Project: {{ object.project.name }}</h1>
<h2>Wiki Page: {{ object.slug }}</h2>
<p>Updated by <b>{{ changer.get_full_name() }}</b>.</p>
{% if comment %}
<p>Comment <b>{{ comment|linebreaksbr }}</b></p>
{% endif %}
{% if changed_fields %}
<p>Updated fields:</p>
{% include "emails/includes/fields_diff-html.jinja" %}
{% endif %}
</td>
</tr>
</table>
{% endblock %}
{% block footer %}
<p style="padding: 10px; border-top: 1px solid #eee;">
More info at: <a href="{{ final_url }}" style="color: #666;">{{ final_url_name }}</a>
</p>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% set final_url = resolve_front_url("wiki", object.project.slug, object.slug) %}
{% set final_url_name = "Taiga - View Wiki Page '{0}'".format(object.slug) %}
- Project: {{ object.project.name }}
- Wiki Page: {{ object.slug }}
- Updated by {{ changer.get_full_name() }}
{% if comment %}
Comment: {{ comment|linebreaksbr }}
{% endif %}
{% if changed_fields %}
- Updated fields:
{% include "emails/includes/fields_diff-text.jinja" %}
{% endif %}
** More info at {{ final_url_name }} ({{ final_url }}) **

View File

@ -0,0 +1 @@
[{{ object.project.name|safe }}] Updated the Wiki Page "{{ object.slug }}"

View File

@ -20,7 +20,7 @@ from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import UserManager, AbstractUser
from taiga.base.utils.slug import slugify_uniquely
from taiga.base.notifications.models import WatcherMixin
from taiga.projects.notifications.models import WatcherMixin
import random