diff --git a/.gitignore b/.gitignore index 12ecaa9a..c6e43b78 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ media .coverage .cache .\#* +.project diff --git a/.travis.yml b/.travis.yml index 8e5be2e4..ebb26f00 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ addons: before_script: - psql -c 'create database taiga;' -U postgres install: + - sudo apt-get update - sudo apt-get install postgresql-plpython-9.3 - pip install -r requirements-devel.txt --use-mirrors script: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f63d14b..1ba4dbd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog # +## 1.5.0 Betula Pendula - FOSDEM 2015 (2015-01-29) + +### Features +- Improving SQL queries and performance. +- Now you can export and import projects between Taiga instances. +- Email redesign. +- Support for archived status (not shown by default in Kanban). +- Removing files from filesystem when deleting attachments. +- Support for contrib plugins (existing yet: slack, hall and gogs). +- Webhooks added (crazy integrations are welcome). + +### Misc +- Lots of small and not so small bugfixes. + ## 1.4.0 Abies veitchii (2014-12-10) diff --git a/README.md b/README.md index 5e23fc97..be47ccdd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Travis Badge](https://img.shields.io/travis/taigaio/taiga-back.svg?style=flat)](https://travis-ci.org/taigaio/taiga-back "Travis Badge") -[![Coveralls](http://img.shields.io/coveralls/taigaio/taiga-back.svg?style=flat)](https://travis-ci.org/taigaio/taiga-back "Coveralls") +[![Coveralls](http://img.shields.io/coveralls/taigaio/taiga-back.svg?style=flat)](https://coveralls.io/r/taigaio/taiga-back?branch=master "Coveralls") ## Setup development environment ## @@ -24,7 +24,7 @@ Taiga only runs with python 3.4+ Initial auth data: admin/123123 If you want a complete environment for production usage, you can try the taiga bootstrapping -scripts https://github.com/taigaio/taiga-scripts (warning: alpha state) +scripts https://github.com/taigaio/taiga-scripts (warning: alpha state). All the information about the different installation methods (production, development, vagrant, docker...) can be found here http://taigaio.github.io/taiga-doc/dist/#_installation_guide. ## Community ## diff --git a/requirements.txt b/requirements.txt index b741b4f9..71fb578a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,14 +19,16 @@ Markdown==2.4.1 fn==0.2.13 diff-match-patch==20121119 requests==2.4.1 - +django-sr==0.0.4 easy-thumbnails==2.1 -celery==3.1.12 +celery==3.1.17 redis==2.10.3 Unidecode==0.04.16 raven==5.1.1 bleach==1.4 django-ipware==0.1.0 +premailer==2.8.1 +django-transactional-cleanup==0.1.12 # Comment it if you are using python >= 3.4 enum34==1.0 diff --git a/settings/common.py b/settings/common.py index 81a91568..f6ec3a8a 100644 --- a/settings/common.py +++ b/settings/common.py @@ -33,7 +33,7 @@ LANGUAGES = ( DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql_psycopg2", + "ENGINE": "transaction_hooks.backends.postgresql_psycopg2", "NAME": "taiga", } } @@ -197,12 +197,16 @@ INSTALLED_APPS = [ "taiga.hooks.github", "taiga.hooks.gitlab", "taiga.hooks.bitbucket", + "taiga.webhooks", "rest_framework", "djmail", "django_jinja", + "django_jinja.contrib._humanize", + "sr", "easy_thumbnails", "raven.contrib.django.raven_compat", + "django_transactional_cleanup", ] WSGI_APPLICATION = "taiga.wsgi.application" @@ -300,7 +304,8 @@ REST_FRAMEWORK = { "DEFAULT_THROTTLE_RATES": { "anon": None, "user": None, - "import-mode": None + "import-mode": None, + "import-dump-mode": "1/minute", }, "FILTER_BACKEND": "taiga.base.filters.FilterBackend", "EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler", @@ -362,6 +367,13 @@ PROJECT_MODULES_CONFIGURATORS = { BITBUCKET_VALID_ORIGIN_IPS = ["131.103.20.165", "131.103.20.166"] GITLAB_VALID_ORIGIN_IPS = [] +EXPORTS_TTL = 60 * 60 * 24 # 24 hours +CELERY_ENABLED = False +WEBHOOKS_ENABLED = False + +from .sr import * + + # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE TEST_RUNNER="django.test.runner.DiscoverRunner" diff --git a/settings/sr.py b/settings/sr.py new file mode 100644 index 00000000..cd1bc113 --- /dev/null +++ b/settings/sr.py @@ -0,0 +1,29 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# 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 . + + +SR = { + "taigaio_url": "https://taiga.io", + "social": { + "twitter_url": "https://twitter.com/taigaio", + "github_url": "https://github.com/taigaio", + }, + "support": { + "url": "https://taiga.io/support", + "email": "support@taiga.io", + "mailing_list": "https://groups.google.com/forum/#!forum/taigaio", + } +} diff --git a/settings/testing.py b/settings/testing.py index 2df79576..c20da9eb 100644 --- a/settings/testing.py +++ b/settings/testing.py @@ -19,6 +19,7 @@ from .development import * SKIP_SOUTH_TESTS = True SOUTH_TESTS_MIGRATE = False CELERY_ALWAYS_EAGER = True +CELERY_ENABLED = False MEDIA_ROOT = "/tmp" @@ -28,5 +29,6 @@ INSTALLED_APPS = INSTALLED_APPS + ["tests"] REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { "anon": None, "user": None, - "import-mode": None + "import-mode": None, + "import-dump-mode": None, } diff --git a/taiga/auth/services.py b/taiga/auth/services.py index dee8c606..1c0f64de 100644 --- a/taiga/auth/services.py +++ b/taiga/auth/services.py @@ -29,7 +29,7 @@ from django.db import transaction as tx from django.db import IntegrityError from django.utils.translation import ugettext as _ -from djmail.template_mail import MagicMailBuilder +from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail from taiga.base import exceptions as exc from taiga.users.serializers import UserSerializer @@ -46,7 +46,7 @@ def send_register_email(user) -> bool: """ cancel_token = get_token_for_user(user, "cancel_account") context = {"user": user, "cancel_token": cancel_token} - mbuilder = MagicMailBuilder() + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) email = mbuilder.registered_user(user.email, context) return bool(email.send()) diff --git a/taiga/base/filters.py b/taiga/base/filters.py index f596c409..7be6fcbd 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -204,6 +204,52 @@ class IsProjectMemberFilterBackend(FilterBackend): return super().filter_queryset(request, queryset.distinct(), view) +class BaseIsProjectAdminFilterBackend(object): + def get_project_ids(self, request, view): + project_id = None + if hasattr(view, "filter_fields") and "project" in view.filter_fields: + project_id = request.QUERY_PARAMS.get("project", None) + + if request.user.is_authenticated() and request.user.is_superuser: + return None + + if not request.user.is_authenticated(): + return [] + + memberships_qs = Membership.objects.filter(user=request.user, is_owner=True) + if project_id: + memberships_qs = memberships_qs.filter(project_id=project_id) + + projects_list = [membership.project_id for membership in memberships_qs] + + return projects_list + + +class IsProjectAdminFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend): + def filter_queryset(self, request, queryset, view): + project_ids = self.get_project_ids(request, view) + if project_ids is None: + queryset = queryset + elif project_ids == []: + queryset = queryset.none() + else: + queryset = queryset.filter(project_id__in=project_ids) + + return super().filter_queryset(request, queryset.distinct(), view) + + +class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend): + def filter_queryset(self, request, queryset, view): + project_ids = self.get_project_ids(request, view) + if project_ids is None: + queryset = queryset + elif project_ids == []: + queryset = queryset.none() + else: + queryset = queryset.filter(webhook__project_id__in=project_ids) + + return super().filter_queryset(request, queryset, view) + class TagsFilter(FilterBackend): def __init__(self, filter_name='tags'): diff --git a/taiga/base/management/__init__.py b/taiga/base/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/base/management/commands/__init__.py b/taiga/base/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/base/management/commands/test_emails.py b/taiga/base/management/commands/test_emails.py new file mode 100644 index 00000000..6b7f8bf5 --- /dev/null +++ b/taiga/base/management/commands/test_emails.py @@ -0,0 +1,172 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import datetime + +from django.db.models.loading import get_model +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail + +from taiga.projects.models import Project, Membership +from taiga.projects.history.models import HistoryEntry +from taiga.projects.history.services import get_history_queryset_by_model_instance +from taiga.users.models import User + + +class Command(BaseCommand): + args = '' + help = 'Send an example of all emails' + + def handle(self, *args, **options): + if len(args) != 1: + print("Usage: ./manage.py test_emails ") + return + + test_email = args[0] + + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) + + # Register email + context = {"user": User.objects.all().order_by("?").first(), "cancel_token": "cancel-token"} + email = mbuilder.registered_user(test_email, context) + email.send() + + # Membership invitation + membership = Membership.objects.order_by("?").filter(user__isnull=True).first() + membership.invited_by = User.objects.all().order_by("?").first() + membership.invitation_extra_text = "Text example, Text example,\nText example,\n\nText example" + + context = {"membership": membership} + email = mbuilder.membership_invitation(test_email, context) + email.send() + + # Membership notification + context = {"membership": Membership.objects.order_by("?").filter(user__isnull=False).first()} + email = mbuilder.membership_notification(test_email, context) + email.send() + + # Feedback + context = { + "feedback_entry": { + "full_name": "Test full name", + "email": "test@email.com", + "comment": "Test comment", + }, + "extra": { + "key1": "value1", + "key2": "value2", + }, + } + email = mbuilder.feedback_notification(test_email, context) + email.send() + + # Password recovery + context = {"user": User.objects.all().order_by("?").first()} + email = mbuilder.password_recovery(test_email, context) + email.send() + + # Change email + context = {"user": User.objects.all().order_by("?").first()} + email = mbuilder.change_email(test_email, context) + email.send() + + # Export/Import emails + context = { + "user": User.objects.all().order_by("?").first(), + "project": Project.objects.all().order_by("?").first(), + "error_subject": "Error generating project dump", + "error_message": "Error generating project dump", + } + email = mbuilder.export_error(test_email, context) + email.send() + context = { + "user": User.objects.all().order_by("?").first(), + "error_subject": "Error importing project dump", + "error_message": "Error importing project dump", + } + email = mbuilder.import_error(test_email, context) + email.send() + + deletion_date = timezone.now() + datetime.timedelta(seconds=60*60*24) + context = { + "url": "http://dummyurl.com", + "user": User.objects.all().order_by("?").first(), + "project": Project.objects.all().order_by("?").first(), + "deletion_date": deletion_date, + } + email = mbuilder.dump_project(test_email, context) + email.send() + + context = { + "user": User.objects.all().order_by("?").first(), + "project": Project.objects.all().order_by("?").first(), + } + email = mbuilder.load_dump(test_email, context) + email.send() + + # Notification emails + notification_emails = [ + ("issues.Issue", "issues/issue-change"), + ("issues.Issue", "issues/issue-create"), + ("issues.Issue", "issues/issue-delete"), + ("tasks.Task", "tasks/task-change"), + ("tasks.Task", "tasks/task-create"), + ("tasks.Task", "tasks/task-delete"), + ("userstories.UserStory", "userstories/userstory-change"), + ("userstories.UserStory", "userstories/userstory-create"), + ("userstories.UserStory", "userstories/userstory-delete"), + ("milestones.Milestone", "milestones/milestone-change"), + ("milestones.Milestone", "milestones/milestone-create"), + ("milestones.Milestone", "milestones/milestone-delete"), + ("wiki.WikiPage", "wiki/wikipage-change"), + ("wiki.WikiPage", "wiki/wikipage-create"), + ("wiki.WikiPage", "wiki/wikipage-delete"), + ] + + context = { + "project": Project.objects.all().order_by("?").first(), + "changer": User.objects.all().order_by("?").first(), + "history_entries": HistoryEntry.objects.all().order_by("?")[0:5], + "user": User.objects.all().order_by("?").first(), + } + + for notification_email in notification_emails: + model = get_model(*notification_email[0].split(".")) + snapshot = { + "subject": "Tests subject", + "ref": 123123, + "name": "Tests name", + "slug": "test-slug" + } + queryset = model.objects.all().order_by("?") + for obj in queryset: + end = False + entries = get_history_queryset_by_model_instance(obj).filter(is_snapshot=True).order_by("?") + + for entry in entries: + if entry.snapshot: + snapshot = entry.snapshot + end = True + break + if end: + break + context["snapshot"] = snapshot + + cls = type("InlineCSSTemplateMail", (InlineCSSTemplateMail,), {"name": notification_email[1]}) + email = cls() + email.send(test_email, context) diff --git a/taiga/base/serializers.py b/taiga/base/serializers.py index 213a7746..8c54677b 100644 --- a/taiga/base/serializers.py +++ b/taiga/base/serializers.py @@ -21,7 +21,7 @@ from rest_framework import serializers from .neighbors import get_neighbors -class PickleField(serializers.WritableField): +class TagsField(serializers.WritableField): """ Pickle objects serializer. """ @@ -29,7 +29,11 @@ class PickleField(serializers.WritableField): return obj def from_native(self, data): - return data + if not data: + return data + + ret = sum([tag.split(",") for tag in data], []) + return ret class JsonField(serializers.WritableField): diff --git a/taiga/base/static/emails/logo-color.png b/taiga/base/static/emails/logo-color.png new file mode 100644 index 00000000..49888fa4 Binary files /dev/null and b/taiga/base/static/emails/logo-color.png differ diff --git a/taiga/base/static/emails/logo.png b/taiga/base/static/emails/logo.png new file mode 100644 index 00000000..612f19e0 Binary files /dev/null and b/taiga/base/static/emails/logo.png differ diff --git a/taiga/base/static/emails/top-bg-hero.png b/taiga/base/static/emails/top-bg-hero.png new file mode 100644 index 00000000..9bb905bf Binary files /dev/null and b/taiga/base/static/emails/top-bg-hero.png differ diff --git a/taiga/base/static/emails/top-bg-update.png b/taiga/base/static/emails/top-bg-update.png new file mode 100644 index 00000000..928636df Binary files /dev/null and b/taiga/base/static/emails/top-bg-update.png differ diff --git a/taiga/base/templates/emails/base-body-html.jinja b/taiga/base/templates/emails/base-body-html.jinja new file mode 100644 index 00000000..1f6c23a1 --- /dev/null +++ b/taiga/base/templates/emails/base-body-html.jinja @@ -0,0 +1,453 @@ + + + + + + {{ _("Taiga") }} + + + +
+ + + + +
+ + + + + + + + +
+ + + + + + {% block social %} + + + + {% endblock %} +
+ + + Taiga logo + + {% block body %} + {% endblock %} +
+ +
+ + + + + +
+ {% block footer %} + {% trans support_url=sr("support.url"), + support_email=sr("support.email"), + mailing_list_url=sr("support.mailing_list") %} + Taiga Support: + {{ support_url}} +
+ Contact us: + + {{ support_email }} + +
+ Mailing list: + + {{ mailing_list_url }} + + {% endtrans %} + {% endblock %} +
+ +
+ +
+
+ + diff --git a/taiga/base/templates/emails/base.jinja b/taiga/base/templates/emails/base.jinja deleted file mode 100644 index 0138a1b7..00000000 --- a/taiga/base/templates/emails/base.jinja +++ /dev/null @@ -1,159 +0,0 @@ - - -{% set home_url = resolve_front_url("home") %} -{% set home_url_name = "Taiga" %} - - - - - - - - - - Taiga - - - - - - - - -
- - - - - - - - - - - - - - - - - - -
- - - - -
- - Taiga - -
-
- - {% block body %}{% endblock %} - {# - - - - -
-

{{ project_name }}

-

{{ type }}: {{ subject }}

-

Updated fields by {{ user.get_full_name() }}

- {% block body_changes %} -
    -
  • severity: from "10" to "project 2 - Normal".
  • -
- {% endblock %} -
- #} -
- {% block footer %} - {# -

- More info at: - - {{ final_url_name }} - -

- #} - {% endblock %} -
-
- - diff --git a/taiga/base/templates/emails/hero-body-html.jinja b/taiga/base/templates/emails/hero-body-html.jinja new file mode 100644 index 00000000..4738a3e4 --- /dev/null +++ b/taiga/base/templates/emails/hero-body-html.jinja @@ -0,0 +1,427 @@ + + + + + + {{ _("You have been Taigatized") }} + + + +
+ + + + +
+ + + + + + + + + + + +
+ + + + + +
+ + Taiga logo + + {% trans %} +

You have been Taigatized!

+

Welcome to Taiga, an Open Source, Agile Project Management Tool

+ {% endtrans %} +
+ +
+ + + + + {% block social %} + + + + {% endblock %} +
+ {% block body %}{% endblock %} +
+
+ + + + + + +
+ {% block footer %} + {% trans support_url=sr("support.url"), + support_email=sr("support.email"), + mailing_list_url=sr("support.mailing_list") %} + Taiga Support: + {{ support_url}} +
+ Contact us: + + {{ support_email }} + +
+ Mailing list: + + {{ mailing_list_url }} + + {% endtrans %} + {% endblock %} +
+ +
+ +
+
+ + diff --git a/taiga/base/templates/emails/updates-body-html.jinja b/taiga/base/templates/emails/updates-body-html.jinja new file mode 100644 index 00000000..ebac0678 --- /dev/null +++ b/taiga/base/templates/emails/updates-body-html.jinja @@ -0,0 +1,489 @@ + + + + + + {{ _("[Taiga] Updates") }} + + + +
+ + + + +
+ + + + + + + + + + + +
+ + + + + +
+ + + Taiga logo + + {% block head %} + {% endblock %} +
+ +
+ + + + + + {% block social %} + + + + {% endblock %} +
+ + {% block body %} + + + + {% for entry in history_entries%} + {% if entry.comment %} + + + + {% endif %} + {% set changed_fields = entry.values_diff %} + {% if changed_fields %} + {% include "emails/includes/fields_diff-html.jinja" %} + {% endif %} + {% endfor %} + {% endblock %} +

{{ _("Updates") }}

+ {% trans comment=mdrender(project, entry.comment) %} +

comment:

+

{{ comment }}

+ {% endtrans %} +
+
+ +
+ + + + + +
+ {% block footer %} + {% trans support_url=sr("support.url"), + support_email=sr("support.email"), + mailing_list_url=sr("support.mailing_list") %} + Taiga Support: + {{ support_url}} +
+ Contact us: + + {{ support_email }} + +
+ Mailing list: + + {{ mailing_list_url }} + + {% endtrans %} + {% endblock %} +
+ +
+ +
+
+ + diff --git a/taiga/base/templates/emails/updates-body-text.jinja b/taiga/base/templates/emails/updates-body-text.jinja new file mode 100644 index 00000000..470382c1 --- /dev/null +++ b/taiga/base/templates/emails/updates-body-text.jinja @@ -0,0 +1,15 @@ +{% block head %}{% endblock %} + +{% block body %} +{% for entry in history_entries %} + {% if entry.comment %} + {% trans comment=entry.comment %} + Comment: {{ comment }} + {% endtrans %} + {% endif %} + {% set changed_fields = entry.values_diff %} + {% if changed_fields %} + {% include "emails/includes/fields_diff-text.jinja" %} + {% endif %} +{% endfor %} +{% endblock %} diff --git a/taiga/base/utils/db.py b/taiga/base/utils/db.py index 5cb5ace3..02f25d11 100644 --- a/taiga/base/utils/db.py +++ b/taiga/base/utils/db.py @@ -31,6 +31,13 @@ def get_typename_for_model_class(model:object, for_concrete_model=True) -> str: return "{0}.{1}".format(model._meta.app_label, model._meta.model_name) +def get_typename_for_model_instance(model_instance): + """ + Get content type tuple from model instance. + """ + ct = ContentType.objects.get_for_model(model_instance) + return ".".join([ct.app_label, ct.model]) + def reload_attribute(model_instance, attr_name): """Fetch the stored value of a model instance attribute. diff --git a/taiga/contrib_routers.py b/taiga/contrib_routers.py new file mode 100644 index 00000000..311f96c3 --- /dev/null +++ b/taiga/contrib_routers.py @@ -0,0 +1,19 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +from taiga.base import routers + +router = routers.DefaultRouter(trailing_slash=False) diff --git a/taiga/events/events.py b/taiga/events/events.py index f1d053af..d04fdd61 100644 --- a/taiga/events/events.py +++ b/taiga/events/events.py @@ -18,6 +18,7 @@ import collections from django.contrib.contenttypes.models import ContentType from taiga.base.utils import json +from taiga.base.utils.db import get_typename_for_model_instance from . import middleware as mw from . import backends @@ -32,14 +33,6 @@ watched_types = set([ ]) -def _get_type_for_model(model_instance): - """ - Get content type tuple from model instance. - """ - ct = ContentType.objects.get_for_model(model_instance) - return ".".join([ct.app_label, ct.model]) - - def emit_event(data:dict, routing_key:str, *, sessionid:str=None, channel:str="events"): if not sessionid: @@ -64,7 +57,7 @@ def emit_event_for_model(obj, *, type:str="change", channel:str="events", assert hasattr(obj, "project_id") if not content_type: - content_type = _get_type_for_model(obj) + content_type = get_typename_for_model_instance(obj) projectid = getattr(obj, "project_id") pk = getattr(obj, "pk", None) diff --git a/taiga/events/signal_handlers.py b/taiga/events/signal_handlers.py index 9514fada..9c8c5921 100644 --- a/taiga/events/signal_handlers.py +++ b/taiga/events/signal_handlers.py @@ -17,13 +17,15 @@ from django.db.models import signals from django.dispatch import receiver +from taiga.base.utils.db import get_typename_for_model_instance + from . import middleware as mw from . import events def on_save_any_model(sender, instance, created, **kwargs): # Ignore any object that can not have project_id - content_type = events._get_type_for_model(instance) + content_type = get_typename_for_model_instance(instance) # Ignore any other events if content_type not in events.watched_types: @@ -39,7 +41,7 @@ def on_save_any_model(sender, instance, created, **kwargs): def on_delete_any_model(sender, instance, **kwargs): # Ignore any object that can not have project_id - content_type = events._get_type_for_model(instance) + content_type = get_typename_for_model_instance(instance) # Ignore any other changes if content_type not in events.watched_types: diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index 8277be63..a993b6af 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -14,33 +14,74 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from rest_framework.exceptions import APIException +import json +import codecs +import uuid + from rest_framework.response import Response +from rest_framework.decorators import throttle_classes from rest_framework import status from django.utils.decorators import method_decorator +from django.utils.translation import ugettext_lazy as _ from django.db.transaction import atomic from django.db.models import signals +from django.conf import settings +from django.core.files.storage import default_storage +from django.core.files.base import ContentFile from taiga.base.api.mixins import CreateModelMixin from taiga.base.api.viewsets import GenericViewSet -from taiga.base.decorators import detail_route +from taiga.base.decorators import detail_route, list_route +from taiga.base import exceptions as exc from taiga.projects.models import Project, Membership from taiga.projects.issues.models import Issue +from taiga.projects.serializers import ProjectSerializer from . import mixins from . import serializers from . import service from . import permissions +from . import tasks +from . import dump_service +from . import throttling +from .renderers import ExportRenderer + +from taiga.base.api.utils import get_object_or_404 -class Http400(APIException): - status_code = 400 +class ProjectExporterViewSet(mixins.ImportThrottlingPolicyMixin, GenericViewSet): + model = Project + permission_classes = (permissions.ImportExportPermission, ) + + def retrieve(self, request, pk, *args, **kwargs): + throttle = throttling.ImportDumpModeRateThrottle() + + if not throttle.allow_request(request, self): + self.throttled(request, throttle.wait()) + + project = get_object_or_404(self.get_queryset(), pk=pk) + self.check_permissions(request, 'export_project', project) + + if settings.CELERY_ENABLED: + task = tasks.dump_project.delay(request.user, project) + tasks.delete_project_dump.apply_async((project.pk, project.slug), countdown=settings.EXPORTS_TTL) + return Response({"export_id": task.id}, status=status.HTTP_202_ACCEPTED) + + path = "exports/{}/{}-{}.json".format(project.pk, project.slug, uuid.uuid4().hex) + content = ContentFile(ExportRenderer().render(service.project_to_dict(project), + renderer_context={"indent": 4}).decode('utf-8')) + + default_storage.save(path, content) + response_data = { + "url": default_storage.url(path) + } + return Response(response_data, status=status.HTTP_200_OK) class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixin, GenericViewSet): model = Project - permission_classes = (permissions.ImportPermission, ) + permission_classes = (permissions.ImportExportPermission, ) @method_decorator(atomic) def create(self, request, *args, **kwargs): @@ -52,7 +93,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi project_serialized = service.store_project(data) if project_serialized is None: - raise Http400(service.get_errors()) + raise exc.BadRequest(service.get_errors()) if "points" in data: service.store_choices(project_serialized.object, data, @@ -106,13 +147,47 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi errors = service.get_errors() if errors: - raise Http400(errors) + raise exc.BadRequest(errors) response_data = project_serialized.data response_data['id'] = project_serialized.object.id headers = self.get_success_headers(response_data) return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) + @list_route(methods=["POST"]) + @method_decorator(atomic) + def load_dump(self, request): + throttle = throttling.ImportDumpModeRateThrottle() + + if not throttle.allow_request(request, self): + self.throttled(request, throttle.wait()) + + self.check_permissions(request, "load_dump", None) + + dump = request.FILES.get('dump', None) + + if not dump: + raise exc.WrongArguments(_("Needed dump file")) + + reader = codecs.getreader("utf-8") + + try: + dump = json.load(reader(dump)) + except Exception: + raise exc.WrongArguments(_("Invalid dump format")) + + if Project.objects.filter(slug=dump['slug']).exists(): + del dump['slug'] + + if settings.CELERY_ENABLED: + task = tasks.load_project_dump.delay(request.user, dump) + return Response({"import_id": task.id}, status=status.HTTP_202_ACCEPTED) + + project = dump_service.dict_to_project(dump, request.user.email) + response_data = ProjectSerializer(project).data + return Response(response_data, status=status.HTTP_201_CREATED) + + @detail_route(methods=['post']) @method_decorator(atomic) def issue(self, request, *args, **kwargs): @@ -126,7 +201,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi errors = service.get_errors() if errors: - raise Http400(errors) + raise exc.BadRequest(errors) headers = self.get_success_headers(issue.data) return Response(issue.data, status=status.HTTP_201_CREATED, headers=headers) @@ -141,7 +216,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi errors = service.get_errors() if errors: - raise Http400(errors) + raise exc.BadRequest(errors) headers = self.get_success_headers(task.data) return Response(task.data, status=status.HTTP_201_CREATED, headers=headers) @@ -156,7 +231,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi errors = service.get_errors() if errors: - raise Http400(errors) + raise exc.BadRequest(errors) headers = self.get_success_headers(us.data) return Response(us.data, status=status.HTTP_201_CREATED, headers=headers) @@ -171,7 +246,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi errors = service.get_errors() if errors: - raise Http400(errors) + raise exc.BadRequest(errors) headers = self.get_success_headers(milestone.data) return Response(milestone.data, status=status.HTTP_201_CREATED, headers=headers) @@ -186,7 +261,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi errors = service.get_errors() if errors: - raise Http400(errors) + raise exc.BadRequest(errors) headers = self.get_success_headers(wiki_page.data) return Response(wiki_page.data, status=status.HTTP_201_CREATED, headers=headers) @@ -201,7 +276,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi errors = service.get_errors() if errors: - raise Http400(errors) + raise exc.BadRequest(errors) headers = self.get_success_headers(wiki_link.data) return Response(wiki_link.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/taiga/export_import/dump_service.py b/taiga/export_import/dump_service.py index 376f5f28..e4758e97 100644 --- a/taiga/export_import/dump_service.py +++ b/taiga/export_import/dump_service.py @@ -72,6 +72,12 @@ def store_issues(project, data): return issues +def store_tags_colors(project, data): + project.tags_colors = data.get("tags_colors", []) + project.save() + return None + + def dict_to_project(data, owner=None): if owner: data['owner'] = owner @@ -148,3 +154,7 @@ def dict_to_project(data, owner=None): if service.get_errors(clear=False): raise TaigaImportError('error importing issues') + + store_tags_colors(proj, data) + + return proj diff --git a/taiga/export_import/management/commands/load_dump.py b/taiga/export_import/management/commands/load_dump.py index 3078cdde..3840e349 100644 --- a/taiga/export_import/management/commands/load_dump.py +++ b/taiga/export_import/management/commands/load_dump.py @@ -1,3 +1,19 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + from django.core.management.base import BaseCommand, CommandError from django.db import transaction from django.db.models import signals diff --git a/taiga/export_import/permissions.py b/taiga/export_import/permissions.py index 00e34fa1..2f63d272 100644 --- a/taiga/export_import/permissions.py +++ b/taiga/export_import/permissions.py @@ -19,6 +19,8 @@ from taiga.base.api.permissions import (TaigaResourcePermission, IsProjectOwner, IsAuthenticated) -class ImportPermission(TaigaResourcePermission): +class ImportExportPermission(TaigaResourcePermission): import_project_perms = IsAuthenticated() import_item_perms = IsProjectOwner() + export_project_perms = IsProjectOwner() + load_dump_perms = IsAuthenticated() diff --git a/taiga/export_import/serializers.py b/taiga/export_import/serializers.py index 77773db3..08225694 100644 --- a/taiga/export_import/serializers.py +++ b/taiga/export_import/serializers.py @@ -46,8 +46,10 @@ class AttachedFileField(serializers.WritableField): if not obj: return None + data = base64.b64encode(obj.read()).decode('utf-8') + return OrderedDict([ - ("data", base64.b64encode(obj.read()).decode('utf-8')), + ("data", data), ("name", os.path.basename(obj.name)), ]) @@ -120,7 +122,7 @@ class ProjectRelatedField(serializers.RelatedField): class HistoryUserField(JsonField): def to_native(self, obj): - if obj is None: + if obj is None or obj == {}: return [] try: user = users_models.User.objects.get(pk=obj['pk']) @@ -190,7 +192,7 @@ class HistoryExportSerializer(serializers.ModelSerializer): class Meta: model = history_models.HistoryEntry - exclude = ("id", "comment_html") + exclude = ("id", "comment_html", "key") class HistoryExportSerializerMixin(serializers.ModelSerializer): diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py index 3f1b3145..797ae81f 100644 --- a/taiga/export_import/service.py +++ b/taiga/export_import/service.py @@ -44,6 +44,7 @@ def add_errors(section, errors): else: _errors_log[section] = [errors] + def project_to_dict(project): return serializers.ProjectExportSerializer(project).data @@ -84,7 +85,7 @@ def store_choice(project, data, field, serializer): def store_choices(project, data, field, serializer): result = [] - for choice_data in data[field]: + for choice_data in data.get(field, []): result.append(store_choice(project, choice_data, field, serializer)) return result @@ -102,7 +103,7 @@ def store_role(project, role): def store_roles(project, data): results = [] - for role in data['roles']: + for role in data.get('roles', []): results.append(store_role(project, role)) return results diff --git a/taiga/export_import/tasks.py b/taiga/export_import/tasks.py new file mode 100644 index 00000000..3158f7ba --- /dev/null +++ b/taiga/export_import/tasks.py @@ -0,0 +1,91 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import datetime + +from django.core.files.storage import default_storage +from django.core.files.base import ContentFile +from django.utils import timezone +from django.conf import settings + +from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail + +from taiga.celery import app + +from .service import project_to_dict +from .dump_service import dict_to_project +from .renderers import ExportRenderer + + +@app.task(bind=True) +def dump_project(self, user, project): + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) + path = "exports/{}/{}-{}.json".format(project.pk, project.slug, self.request.id) + + try: + content = ExportRenderer().render(project_to_dict(project), renderer_context={"indent": 4}) + content = content.decode('utf-8') + content = ContentFile(content) + + default_storage.save(path, content) + url = default_storage.url(path) + except Exception: + ctx = { + "user": user, + "error_subject": "Error generating project dump", + "error_message": "Error generating project dump", + "project": project + } + email = mbuilder.export_error(user.email, ctx) + email.send() + return + + + deletion_date = timezone.now() + datetime.timedelta(seconds=settings.EXPORTS_TTL) + ctx = { + "url": url, + "project": project, + "user": user, + "deletion_date": deletion_date + } + email = mbuilder.dump_project(user.email, ctx) + email.send() + + +@app.task +def delete_project_dump(project_id, project_slug, task_id): + default_storage.delete("exports/{}/{}-{}.json".format(project_id, project_slug, task_id)) + + +@app.task +def load_project_dump(user, dump): + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) + + try: + project = dict_to_project(dump, user.email) + except Exception: + ctx = { + "user": user, + "error_subject": "Error loading project dump", + "error_message": "Error loading project dump", + } + email = mbuilder.import_error(user.email, ctx) + email.send() + return + + ctx = {"user": user, "project": project} + email = mbuilder.load_dump(user.email, ctx) + email.send() diff --git a/taiga/export_import/templates/emails/dump_project-body-html.jinja b/taiga/export_import/templates/emails/dump_project-body-html.jinja new file mode 100644 index 00000000..bac303e7 --- /dev/null +++ b/taiga/export_import/templates/emails/dump_project-body-html.jinja @@ -0,0 +1,16 @@ +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans user=user.get_full_name()|safe, + project=project.name|safe, + url=url, + deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %} +

Project dump generated

+

Hello {{ user }},

+

Your dump from project {{ project }} has been correctly generated.

+

You can download it here:

+ Download the dump file +

This file will be deleted on {{ deletion_date }}.

+

The Taiga Team

+ {% endtrans %} +{% endblock %} diff --git a/taiga/export_import/templates/emails/dump_project-body-text.jinja b/taiga/export_import/templates/emails/dump_project-body-text.jinja new file mode 100644 index 00000000..f8b94d5d --- /dev/null +++ b/taiga/export_import/templates/emails/dump_project-body-text.jinja @@ -0,0 +1,15 @@ +{% trans user=user.get_full_name()|safe, + project=project.name|safe, + url=url, + deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %} +Hello {{ user }}, + +Your dump from project {{ project }} has been correctly generated. You can download it here: + +{{ url }} + +This file will be deleted on {{ deletion_date }}. + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/export_import/templates/emails/dump_project-subject.jinja b/taiga/export_import/templates/emails/dump_project-subject.jinja new file mode 100644 index 00000000..7ad0ef61 --- /dev/null +++ b/taiga/export_import/templates/emails/dump_project-subject.jinja @@ -0,0 +1 @@ +{% trans project=project.name|safe %}[{{ project }}] Your project dump has been generated{% endtrans %} diff --git a/taiga/export_import/templates/emails/export_error-body-html.jinja b/taiga/export_import/templates/emails/export_error-body-html.jinja new file mode 100644 index 00000000..aacc09c0 --- /dev/null +++ b/taiga/export_import/templates/emails/export_error-body-html.jinja @@ -0,0 +1,15 @@ +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans user=user.get_full_name()|safe, + error_message=error_message, + support_email=sr("support.email"), + project=project.name|safe %} +

{{ error_message }}

+

Hello {{ user }},

+

Your project {{ project }} has not been exported correctly.

+

The Taiga system administrators have been informed.
Please, try it again or contact with the support team at + {{ support_email }}

+

The Taiga Team

+ {% endtrans %} +{% endblock %} diff --git a/taiga/export_import/templates/emails/export_error-body-text.jinja b/taiga/export_import/templates/emails/export_error-body-text.jinja new file mode 100644 index 00000000..0b463d67 --- /dev/null +++ b/taiga/export_import/templates/emails/export_error-body-text.jinja @@ -0,0 +1,16 @@ +{% trans user=user.get_full_name()|safe, + error_message=error_message, + support_email=sr("support.email"), + project=project.name|safe %} +Hello {{ user }}, + +{{ error_message }} +Your project {{ project }} has not been exported correctly. + +The Taiga system administrators have been informed. + +Please, try it again or contact with the support team at {{ support_email }} + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/export_import/templates/emails/export_error-subject.jinja b/taiga/export_import/templates/emails/export_error-subject.jinja new file mode 100644 index 00000000..fed22ace --- /dev/null +++ b/taiga/export_import/templates/emails/export_error-subject.jinja @@ -0,0 +1 @@ +{% trans error_subject=error_subject, project=project.name|safe %}[{{ project }}] {{ error_subject }}{% endtrans %} diff --git a/taiga/export_import/templates/emails/import_error-body-html.jinja b/taiga/export_import/templates/emails/import_error-body-html.jinja new file mode 100644 index 00000000..9f178f2a --- /dev/null +++ b/taiga/export_import/templates/emails/import_error-body-html.jinja @@ -0,0 +1,14 @@ +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans user=user.get_full_name()|safe, + error_message=error_message, + support_email=sr("support.email") %} +

{{ error_message }}

+

Hello {{ user }},

+

Your project has not been importer correctly.

+

The Taiga system administrators have been informed.
Please, try it again or contact with the support team at + {{ support_email }}

+

The Taiga Team

+ {% endtrans %} +{% endblock %} diff --git a/taiga/export_import/templates/emails/import_error-body-text.jinja b/taiga/export_import/templates/emails/import_error-body-text.jinja new file mode 100644 index 00000000..affc8dc9 --- /dev/null +++ b/taiga/export_import/templates/emails/import_error-body-text.jinja @@ -0,0 +1,16 @@ + {% trans user=user.get_full_name()|safe, + error_message=error_message, + support_email=sr("support.email") %} +Hello {{ user }}, + +{{ error_message }} + +Your project has not been importer correctly. + +The Taiga system administrators have been informed. + +Please, try it again or contact with the support team at {{ support_email }} + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/export_import/templates/emails/import_error-subject.jinja b/taiga/export_import/templates/emails/import_error-subject.jinja new file mode 100644 index 00000000..44506406 --- /dev/null +++ b/taiga/export_import/templates/emails/import_error-subject.jinja @@ -0,0 +1 @@ +{% trans error_subject=error_subject %}[Taiga] {{ error_subject }}{% endtrans %} diff --git a/taiga/export_import/templates/emails/load_dump-body-html.jinja b/taiga/export_import/templates/emails/load_dump-body-html.jinja new file mode 100644 index 00000000..b3c64b4a --- /dev/null +++ b/taiga/export_import/templates/emails/load_dump-body-html.jinja @@ -0,0 +1,13 @@ +{% extends "emails/base-body-html.jinja" %} + +{% block body %} + {% trans user=user.get_full_name()|safe, + url=resolve_front_url("project", project.slug), + project=project.name|safe %} +

Project dump imported

+

Hello {{ user }},

+

Your project dump has been correctly imported.

+ Go to {{ project }} +

The Taiga Team

+ {% endtrans %} +{% endblock %} diff --git a/taiga/export_import/templates/emails/load_dump-body-text.jinja b/taiga/export_import/templates/emails/load_dump-body-text.jinja new file mode 100644 index 00000000..e38c99a6 --- /dev/null +++ b/taiga/export_import/templates/emails/load_dump-body-text.jinja @@ -0,0 +1,14 @@ +{% trans user=user.get_full_name()|safe, + url=resolve_front_url("project", project.slug), + project=project.name|safe %} +Hello {{ user }}, + +Your project dump has been correctly imported. + +You can see the project {{ project }} here: + +{{ url }} + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/export_import/templates/emails/load_dump-subject.jinja b/taiga/export_import/templates/emails/load_dump-subject.jinja new file mode 100644 index 00000000..a258d42e --- /dev/null +++ b/taiga/export_import/templates/emails/load_dump-subject.jinja @@ -0,0 +1 @@ +{% trans project=project.name|safe %}[{{ project }}] Your project dump has been imported{% endtrans %} diff --git a/taiga/export_import/throttling.py b/taiga/export_import/throttling.py index 3457ee44..77b1bb34 100644 --- a/taiga/export_import/throttling.py +++ b/taiga/export_import/throttling.py @@ -19,3 +19,6 @@ from taiga.base import throttling class ImportModeRateThrottle(throttling.UserRateThrottle): scope = "import-mode" + +class ImportDumpModeRateThrottle(throttling.UserRateThrottle): + scope = "import-dump-mode" diff --git a/taiga/feedback/api.py b/taiga/feedback/api.py index 8476c365..c0efb23d 100644 --- a/taiga/feedback/api.py +++ b/taiga/feedback/api.py @@ -46,6 +46,6 @@ class FeedbackViewSet(viewsets.ViewSet): "HTTP_REFERER": request.META.get("HTTP_REFERER", None), "HTTP_USER_AGENT": request.META.get("HTTP_USER_AGENT", None), } - services.send_feedback(self.object, extra) + services.send_feedback(self.object, extra, reply_to=[request.user.email]) return response.Ok(serializer.data) diff --git a/taiga/feedback/services.py b/taiga/feedback/services.py index 10362208..e5f92c3c 100644 --- a/taiga/feedback/services.py +++ b/taiga/feedback/services.py @@ -16,14 +16,21 @@ from django.conf import settings -from djmail.template_mail import MagicMailBuilder +from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail -def send_feedback(feedback_entry, extra): +def send_feedback(feedback_entry, extra, reply_to=[]): support_email = settings.FEEDBACK_EMAIL if support_email: - mbuilder = MagicMailBuilder() - email = mbuilder.feedback_notification(support_email, {"feedback_entry": feedback_entry, - "extra": extra}) + reply_to.append(support_email) + + ctx = { + "feedback_entry": feedback_entry, + "extra": extra + } + + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) + email = mbuilder.feedback_notification(support_email, ctx) + email.extra_headers["Reply-To"] = ", ".join(reply_to) email.send() diff --git a/taiga/feedback/templates/emails/feedback_notification-body-html.jinja b/taiga/feedback/templates/emails/feedback_notification-body-html.jinja index 2888f56f..b2c38449 100644 --- a/taiga/feedback/templates/emails/feedback_notification-body-html.jinja +++ b/taiga/feedback/templates/emails/feedback_notification-body-html.jinja @@ -1,37 +1,29 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - - - - - - + {% trans full_name=feedback_entry.full_name|safe, email=feedback_entry.email %} +

Feedback

+

Taiga has received feedback from {{ full_name }} <{{ email }}>

+ {% endtrans %} + + {% trans comment=feedback_entry.comment|linebreaksbr %} +

Comment

+

{{ comment }}

+ {% endtrans %} + {% if extra %} - - - - -
- From: - - {{ feedback_entry.full_name }} [{{ feedback_entry.email }}] -
- Comment: - - {{ feedback_entry.comment|linebreaks }} -
- Extra: - -
- {% for k, v in extra.items() %} -
{{ k }}
-
{{ v }}
- {% endfor %} -
-
+ + + + +
+

{{ _("Extra info") }}

+
+ {% for k, v in extra.items() %} +
{{ k }}
+
{{ v }}
+ {% endfor %} +
+
+ {% endif %} {% endblock %} diff --git a/taiga/feedback/templates/emails/feedback_notification-body-text.jinja b/taiga/feedback/templates/emails/feedback_notification-body-text.jinja index fd23785b..414501ae 100644 --- a/taiga/feedback/templates/emails/feedback_notification-body-text.jinja +++ b/taiga/feedback/templates/emails/feedback_notification-body-text.jinja @@ -1,10 +1,11 @@ ---------- -- From: {{ feedback_entry.full_name }} [{{ feedback_entry.email }}] +{% trans full_name=feedback_entry.full_name|safe, email=feedback_entry.email, comment=feedback_entry.comment %}--------- +- From: {{ full_name }} <{{ email }}> --------- - Comment: -{{ feedback_entry.comment }} ----------{% if extra %} -- Extra: +{{ comment }} +---------{% endtrans %} +{% if extra %} +{{ _("- Extra info:") }} {% for k, v in extra.items() %} - {{ k }}: {{ v }} {% endfor %} diff --git a/taiga/feedback/templates/emails/feedback_notification-subject.jinja b/taiga/feedback/templates/emails/feedback_notification-subject.jinja index 8f0f4b9c..e93fdbd1 100644 --- a/taiga/feedback/templates/emails/feedback_notification-subject.jinja +++ b/taiga/feedback/templates/emails/feedback_notification-subject.jinja @@ -1 +1,3 @@ -[Taiga] Feedback from {{ feedback_entry.full_name }} <{{ feedback_entry.email }}> +{% trans full_name=feedback_entry.full_name|safe, email=feedback_entry.email %} +[Taiga] Feedback from {{ full_name }} <{{ email }}> +{% endtrans %} diff --git a/taiga/locale/es/LC_MESSAGES/django.po b/taiga/locale/es/LC_MESSAGES/django.po index 89792063..e9e3458a 100644 --- a/taiga/locale/es/LC_MESSAGES/django.po +++ b/taiga/locale/es/LC_MESSAGES/django.po @@ -1,16 +1,19 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. +# SPANISH LANGUAGE PACKAGE FOR TAIGA IO. +# Copyright (C) 2014 +# This file is distributed under the same license as the taiga io package. # FIRST AUTHOR , YEAR. # +#. Spanish Translators: please, consider don't use informal expressions +#. Traductores al español, por favor, es necesario considerar el uso +#. de la 3era persona en lugar del "tú" #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2013-12-09 22:06+0100\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" +"PO-Revision-Date: 2014-12-19 19:48-0430\n" +"Last-Translator: Hector Colina \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" @@ -28,7 +31,7 @@ msgstr "No encontrado." #: base/exceptions.py:36 base/exceptions.py:44 msgid "Wrong arguments." -msgstr "Argumentos erroneos." +msgstr "Argumentos erróneos." #: base/exceptions.py:59 msgid "Precondition error" @@ -48,23 +51,23 @@ msgstr "Permiso denegado" #: base/auth/api.py:52 msgid "Public register is disabled for this domain." -msgstr "El registro publico está deshabilitado para este dominio." +msgstr "El registro público está deshabilitado para este dominio." #: base/auth/api.py:91 msgid "Invalid token" -msgstr "Token invalido" +msgstr "Token inválido" #: base/auth/api.py:100 msgid "Incorrect password" -msgstr "Password incorrecto" +msgstr "Contraseña incorrecta" #: base/auth/api.py:133 msgid "invalid register type" -msgstr "tipo de registro no valido" +msgstr "tipo de registro no válido" #: base/auth/api.py:142 base/auth/api.py:145 msgid "Invalid username or password" -msgstr "usuario o contraseña invalidos" +msgstr "usuario o contraseña inválidos" #: base/domains/__init__.py:54 msgid "domain not found" @@ -72,7 +75,7 @@ msgstr "dominio no encontrado" #: base/domains/models.py:24 msgid "The domain name cannot contain any spaces or tabs." -msgstr "El nombre de dominio no puede tener espacios o tabs." +msgstr "El nombre de dominio no puede tener espacios o tabuladores." #: base/domains/models.py:30 msgid "domain name" @@ -100,15 +103,15 @@ msgstr "Todos los eventos en mis proyectos" #: base/notifications/models.py:13 msgid "Only events for objects i watch" -msgstr "Solo eventos para objetos que observo" +msgstr "Sólo eventos para objetos a los cuales les hago seguimiento" #: base/notifications/models.py:14 msgid "Only events for objects assigned to me" -msgstr "Solo eventos para objetos asignados a mi" +msgstr "Sólo eventos para objetos que me han sido asignados" #: base/notifications/models.py:15 msgid "Only events for objects owned by me" -msgstr "Solo eventos para mis objetos" +msgstr "Sólo eventos para mis objetos" #: base/notifications/models.py:16 msgid "No events" @@ -144,11 +147,11 @@ msgstr "fechas importantes" #: base/users/api.py:45 base/users/api.py:52 msgid "Invalid username or email" -msgstr "usuario o email invalido" +msgstr "usuario o correos inválidos" #: base/users/api.py:61 msgid "Mail sended successful!" -msgstr "¡Mail enviado correctamente!" +msgstr "¡Correo enviado correctamente!" #: base/users/api.py:70 msgid "Token is invalid" @@ -160,7 +163,7 @@ msgstr "Argumentos incompletos" #: base/users/api.py:90 msgid "Invalid password length" -msgstr "longitud del password no válida" +msgstr "longitud de la contraseña no válida" #: base/users/models.py:13 projects/models.py:281 projects/models.py:331 #: projects/models.py:356 projects/models.py:379 projects/models.py:404 @@ -176,7 +179,7 @@ msgstr "descripción" #: base/users/models.py:17 msgid "photo" -msgstr "foto" +msgstr "fotografía" #: base/users/models.py:21 msgid "default timezone" @@ -216,11 +219,11 @@ msgstr "orden" #: base/users/serializers.py:33 msgid "invalid token" -msgstr "token invalido" +msgstr "token inválido" #: projects/api.py:89 msgid "Email address is already taken." -msgstr "" +msgstr "Dirección de correo ya utilizada" #: projects/choices.py:7 msgid "Open" @@ -273,7 +276,7 @@ msgstr "Importante" #: projects/choices.py:45 msgid "Critical" -msgstr "Critica" +msgstr "Crítica" #: projects/choices.py:54 msgid "Rejected" @@ -357,19 +360,19 @@ msgstr "miembros" #: projects/models.py:105 msgid "public" -msgstr "publico" +msgstr "público" #: projects/models.py:107 msgid "last us ref" -msgstr "ultima referencia de US" +msgstr "última referencia de US" #: projects/models.py:109 msgid "last task ref" -msgstr "ultima referencia de tarea" +msgstr "última referencia de tarea" #: projects/models.py:111 msgid "last issue ref" -msgstr "ultima referencia de issue" +msgstr "última referencia de issue" #: projects/models.py:113 msgid "total of milestones" @@ -418,7 +421,7 @@ msgstr "valor" #: projects/documents/models.py:15 msgid "title" -msgstr "titulo" +msgstr "título" #: projects/documents/models.py:30 msgid "attached_file" @@ -428,11 +431,11 @@ msgstr "fichero_adjunto" #: projects/issues/api.py:88 projects/issues/api.py:91 #: projects/issues/api.py:94 projects/issues/api.py:97 msgid "You don't have permissions for add/modify this issue." -msgstr "Tu no tienes permisos para crear/modificar esta peticion." +msgstr "No tienes permisos para crear/modificar esta petición." #: projects/issues/api.py:131 msgid "You don't have permissions for add attachments to this issue" -msgstr "Tu no tienes permisos para añadir ficheros adjuntos a esta peticion" +msgstr "No tienes permisos para añadir ficheros adjuntos a esta petición" #: projects/issues/models.py:20 projects/questions/models.py:20 #: projects/tasks/models.py:22 projects/userstories/models.py:42 @@ -487,7 +490,7 @@ msgstr "Sin asignar" #: projects/milestones/api.py:37 msgid "You must not add a new milestone to this project." -msgstr "Tu no debes añadir un nuevo sprint a este proyecto." +msgstr "No debes añadir un nuevo sprint a este proyecto." #: projects/milestones/models.py:28 msgid "estimated start" @@ -511,12 +514,12 @@ msgstr "assignada_a" #: projects/tasks/api.py:47 msgid "You don't have permissions for add attachments to this task." -msgstr "Tu no tienes permisos para añadir ficheros adjuntos a esta tarea." +msgstr "No tienes permisos para añadir ficheros adjuntos a esta tarea." #: projects/tasks/api.py:74 projects/tasks/api.py:77 projects/tasks/api.py:80 #: projects/tasks/api.py:83 msgid "You don't have permissions for add/modify this task." -msgstr "Tu no tienes permisos para añadir/modificar esta tarea." +msgstr "No tienes permisos para añadir/modificar esta tarea." #: projects/tasks/models.py:20 projects/userstories/models.py:20 msgid "user story" @@ -529,29 +532,29 @@ msgstr "es iocaina" #: projects/userstories/api.py:55 msgid "You don't have permissions for add attachments to this user story" msgstr "" -"Tu no tienes permisos para añadir ficheros adjuntos a esta historia de " +"No tienes permisos para añadir ficheros adjuntos a esta historia de " "usuario." #: projects/userstories/api.py:75 projects/userstories/api.py:99 msgid "bulkStories parameter is mandatory" -msgstr "" +msgstr "El parámetro bulkStories es obligatorio" #: projects/userstories/api.py:79 msgid "projectId parameter is mandatory" -msgstr "" +msgstr "El parámetro projectID es obligatorio" #: projects/userstories/api.py:84 projects/userstories/api.py:108 msgid "You don't have permisions to create user stories." -msgstr "Tu no tienes permisos para crear historias de usuario." +msgstr "No tienes permisos para crear historias de usuario." #: projects/userstories/api.py:103 msgid "projectId parameter ir mandatory" -msgstr "" +msgstr "El parámetro projectID es obligatorio" #: projects/userstories/api.py:126 projects/userstories/api.py:129 #: projects/userstories/api.py:132 msgid "You don't have permissions for add/modify this user story" -msgstr "Tu no tienes permisos para crear o modificar esta historia de usuario." +msgstr "No tienes permisos para crear o modificar esta historia de usuario." #: projects/userstories/models.py:23 msgid "role" @@ -576,15 +579,15 @@ msgstr "es requisito del equipo" #: projects/wiki/api.py:39 msgid "You don't have permissions for add attachments to this wiki page." msgstr "" -"Tu no tienes permisos para añadir ficheros adjuntos a esta pagina de wiki." +"No tienes permisos para añadir ficheros adjuntos a esta página de wiki." #: projects/wiki/api.py:65 msgid "You don't haver permissions for add/modify this wiki page." -msgstr "Tu no tienes permisos para crear or modificar esta pagina de wiki." +msgstr "No tienes permisos para crear or modificar esta página de wiki." #: settings/common.py:28 msgid "English" -msgstr "Ingles" +msgstr "Inglés" #: settings/common.py:29 msgid "Spanish" diff --git a/taiga/mdrender/extensions/wikilinks.py b/taiga/mdrender/extensions/wikilinks.py index b603e1cf..b2f58df2 100644 --- a/taiga/mdrender/extensions/wikilinks.py +++ b/taiga/mdrender/extensions/wikilinks.py @@ -32,7 +32,7 @@ class WikiLinkExtension(Extension): return super().__init__(*args, **kwargs) def extendMarkdown(self, md, md_globals): - WIKILINK_RE = r"\[\[([\w0-9_ -]+)(\|[\w0-9_ -]+)?\]\]" + WIKILINK_RE = r"\[\[([\w0-9_ -]+)(\|[^\]]+)?\]\]" md.inlinePatterns.add("wikilinks", WikiLinksPattern(md, WIKILINK_RE, self.project), " bool: nfields = _not_important_fields[content_type] result = snapshot_fields - nfields - if len(result) == 0: + if snapshot_fields and len(result) == 0: return True return False diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja index 12090cd6..6d3728e4 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja @@ -9,141 +9,162 @@ "us_order" ] %} -
{% for field_name, values in changed_fields.items() %} {% if field_name not in excluded_fields %} -
- {{ verbose_name(object, field_name) }} -
- - {# POINTS #} + {# POINTS #} {% if field_name == "points" %} - {% for role, points in values.items() %} -
- {{ role }} -
-
- to: {{ points.1|linebreaksbr }} -
-
- from: {{ points.0|linebreaksbr }} -
+ + +

{% trans role=role %}{{ role }} role points{% endtrans %}

+ + + {{ _("from") }}
+ {{ points.1 }} + + + + + {{ _("to") }}
+ {{ points.0 }} + + {% endfor %} {# ATTACHMENTS #} {% elif field_name == "attachments" %} - {% if values.new %} -
- {{ _("Added") }} -
- {% for att in values['new']%} -
- - {{ att.filename|linebreaksbr }} - - {% if att.description %} {{ att.description|linebreaksbr }}{% endif %} -
+ + +

{{ _("Added new attachment") }}

+

+ + {{ att.filename }} + +

+ {% if att.description %} +

{{ att.description }}

+ {% endif %} + + {% endfor %} {% endif %} {% if values.changed %} -
- {{ _("Changed") }} -
- {% for att in values['changed'] %} -
- - {{ att.filename|linebreaksbr }} - - -
+ + {% endfor %} {% endif %} - {% if values.deleted %} -
- {{ _("Deleted") }} -
- {% for att in values['deleted']%} -
- {{ att.filename|linebreaksbr }} -
+ + +

{{ _("Deleted attachment") }}

+ {% if att.changes.description %} +

{{ att.filename|linebreaksbr }}

+ {% endif %} + + {% endfor %} {% endif %} - {# TAGS AND WATCHERS #} {% elif field_name in ["tags", "watchers"] %} - -
- to: {{ ', '.join(values.1)|linebreaksbr }} -
- - {% if values.0 %} -
- from: {{ ', '.join(values.0)|linebreaksbr }} -
- {% endif %} - + + +

{{ field_name }}

+ + + {{ _("from") }}
+ {{ ', '.join(values.0) }} + + + + + {{ _("to") }}
+ {{ ', '.join(values.1) }} + + {# DESCRIPTIONS #} {% elif field_name in ["description_diff"] %} -
- diff: {{ mdrender(project, values.1) }} -
+ + +

{{ _("Description diff") }}

+

{{ mdrender(project, values.1) }}

+ + {# CONTENT #} {% elif field_name in ["content_diff"] %} -
- diff: {{ mdrender(project, values.1) }} -
+ + +

{{ _("Content diff") }}

+

{{ mdrender(project, values.1) }}

+ + {# ASSIGNED TO #} {% elif field_name == "assigned_to" %} -
- {% if values.1 != None and values.1 != "" %} - to: {{ values.1|linebreaksbr }} - {% else %} - to: {{ _("Unassigned") }} - {% endif %} -
- -
- {% if values.0 != None and values.0 != "" %} - from: {{ values.0|linebreaksbr }} - {% else %} - from: {{ _("Unassigned") }} - {% endif %} -
+ + +

{{ field_name }}

+ + + {% if values.0 != None and values.0 != "" %} + {{ _("from") }}
+ {{ values.0 }} + {% else %} + {{ _("from") }}
+ {{ _("Unassigned") }} + {% endif %} + + + + + {% if values.1 != None and values.1 != "" %} + {{ _("to") }}
+ {{ values.1 }} + {% else %} + {{ _("to") }}
+ {{ _("Unassigned") }} + {% endif %} + + {# * #} {% else %} - {% if values.1 != None and values.1 != "" %} -
- to: {{ values.1|linebreaksbr }} -
- {% endif %} - - {% if values.0 != None and values.0 != "" %} -
- from: {{ values.0|linebreaksbr }} -
- {% endif %} + + +

{{ field_name }}

+ + + {{ _("from") }}
+ {{ values.1|linebreaksbr }} + + + + + {{ _("to") }}
+ {{ values.0|linebreaksbr }} + + {% endif %} - {% endif %} - {% endfor %} -
diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja index 88d80fbc..d53a205b 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja @@ -14,43 +14,43 @@ {# POINTS #} {% if field_name == "points" %} {% for role, points in values.items() %} - * {{ role }} to: {{ points.1|linebreaksbr }} from: {{ points.0|linebreaksbr }} + * {{ role }} {{ _("to:") }} {{ points.1 }} {{ _("from:") }} {{ points.0 }} {% endfor %} {# ATTACHMENTS #} {% elif field_name == "attachments" %} {% if values.new %} * {{ _("Added") }}: {% for att in values['new']%} - - {{ att.filename|linebreaksbr }} + - {{ att.filename }} {% endfor %} {% endif %} {% if values.changed %} * {{ _("Changed") }} {% for att in values['changed'] %} - - {{ att.filename|linebreaksbr }} + - {{ att.filename }} {% endfor %} {% endif %} {% if values.deleted %} * {{ _("Deleted") }} {% for att in values['deleted']%} - - {{ att.filename|linebreaksbr }} + - {{ att.filename }} {% endfor %} {% endif %} {# TAGS AND WATCHERS #} {% elif field_name in ["tags", "watchers"] %} - * to: {{ ', '.join(values.1)|linebreaksbr }} + * {{ _("to:") }} {{ ', '.join(values.1) }} {% if values.0 %} - * from: {{ ', '.join(values.0)|linebreaksbr }} + * {{ _("from:") }} {{ ', '.join(values.0) }} {% endif %} {# * #} {% else %} {% if values.1 != None and values.1 != "" %} - * to: {{ values.1|linebreaksbr }} + * {{ _("to:") }} {{ values.1|linebreaksbr }} {% endif %} {% if values.0 != None and values.0 != "" %} - * from: {{ values.0|linebreaksbr }} + * {{ _("from:") }} {{ values.0|linebreaksbr }} {% endif %} {% endif %} {% endif %} diff --git a/taiga/projects/issues/migrations/0003_auto_20141210_1108.py b/taiga/projects/issues/migrations/0003_auto_20141210_1108.py new file mode 100644 index 00000000..b8ee567c --- /dev/null +++ b/taiga/projects/issues/migrations/0003_auto_20141210_1108.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db import connection +from taiga.projects.userstories.models import * +from taiga.projects.tasks.models import * +from taiga.projects.issues.models import * +from taiga.projects.models import * + +def _fix_tags_model(tags_model): + table_name = tags_model._meta.db_table + query = "select id from (select id, unnest(tags) tag from %s) x where tag LIKE '%%,%%'"%(table_name) + cursor = connection.cursor() + cursor.execute(query) + for row in cursor.fetchall(): + id = row[0] + instance = tags_model.objects.get(id=id) + instance.tags = [tag.replace(",", "") for tag in instance.tags] + instance.save() + + +def fix_tags(apps, schema_editor): + print("Fixing user issue tags") + _fix_tags_model(Issue) + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0002_issue_external_reference'), + ] + + operations = [ + migrations.RunPython(fix_tags), + ] diff --git a/taiga/projects/issues/migrations/0004_auto_20150114_0954.py b/taiga/projects/issues/migrations/0004_auto_20150114_0954.py new file mode 100644 index 00000000..f87f5066 --- /dev/null +++ b/taiga/projects/issues/migrations/0004_auto_20150114_0954.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('issues', '0003_auto_20141210_1108'), + ] + + operations = [ + migrations.AlterModelOptions( + name='issue', + options={'ordering': ['project', '-id'], 'permissions': (('view_issue', 'Can view issue'),), 'verbose_name_plural': 'issues', 'verbose_name': 'issue'}, + ), + ] diff --git a/taiga/projects/issues/models.py b/taiga/projects/issues/models.py index 0d591b3f..397e03db 100644 --- a/taiga/projects/issues/models.py +++ b/taiga/projects/issues/models.py @@ -69,7 +69,7 @@ class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models. class Meta: verbose_name = "issue" verbose_name_plural = "issues" - ordering = ["project", "-created_date"] + ordering = ["project", "-id"] permissions = ( ("view_issue", "Can view issue"), ) diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index cc025185..91034a68 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers -from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin, PgArrayField +from taiga.base.serializers import Serializer, TagsField, NeighborsSerializerMixin, PgArrayField from taiga.mdrender.service import render as mdrender from taiga.projects.validators import ProjectExistsValidator from taiga.projects.notifications.validators import WatchersValidator @@ -25,7 +25,7 @@ from . import models class IssueSerializer(WatchersValidator, serializers.ModelSerializer): - tags = PickleField(required=False) + tags = TagsField(required=False) external_reference = PgArrayField(required=False) is_closed = serializers.Field(source="is_closed") comment = serializers.SerializerMethodField("get_comment") diff --git a/taiga/projects/migrations/0008_auto_20141024_1012.py b/taiga/projects/migrations/0008_auto_20141024_1012.py index a15b4713..2d4dd1b2 100644 --- a/taiga/projects/migrations/0008_auto_20141024_1012.py +++ b/taiga/projects/migrations/0008_auto_20141024_1012.py @@ -40,19 +40,19 @@ def update_many(objects, fields=[], using="default"): def update_slug(apps, schema_editor): - update_qs = UserStoryStatus.objects.all() + update_qs = UserStoryStatus.objects.all().only("name") for us_status in update_qs: us_status.slug = slugify(unidecode(us_status.name)) update_many(update_qs, fields=["slug"]) - update_qs = TaskStatus.objects.all() + update_qs = TaskStatus.objects.all().only("name") for task_status in update_qs: task_status.slug = slugify(unidecode(task_status.name)) update_many(update_qs, fields=["slug"]) - update_qs = IssueStatus.objects.all() + update_qs = IssueStatus.objects.all().only("name") for issue_status in update_qs: issue_status.slug = slugify(unidecode(issue_status.name)) diff --git a/taiga/projects/migrations/0013_auto_20141210_1040.py b/taiga/projects/migrations/0013_auto_20141210_1040.py new file mode 100644 index 00000000..93c093bc --- /dev/null +++ b/taiga/projects/migrations/0013_auto_20141210_1040.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db import connection +from taiga.projects.userstories.models import * +from taiga.projects.tasks.models import * +from taiga.projects.issues.models import * +from taiga.projects.models import * + +def _fix_tags_model(tags_model): + table_name = tags_model._meta.db_table + query = "select id from (select id, unnest(tags) tag from %s) x where tag LIKE '%%,%%'"%(table_name) + cursor = connection.cursor() + cursor.execute(query) + for row in cursor.fetchall(): + id = row[0] + instance = tags_model.objects.get(id=id) + instance.tags = [tag.replace(",", "") for tag in instance.tags] + instance.save() + + +def fix_tags(apps, schema_editor): + print("Fixing project tags") + _fix_tags_model(Project) + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0012_auto_20141210_1009'), + ] + + operations = [ + migrations.RunPython(fix_tags), + ] diff --git a/taiga/projects/migrations/0014_userstorystatus_is_archived.py b/taiga/projects/migrations/0014_userstorystatus_is_archived.py new file mode 100644 index 00000000..889ee10b --- /dev/null +++ b/taiga/projects/migrations/0014_userstorystatus_is_archived.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0013_auto_20141210_1040'), + ] + + operations = [ + migrations.AddField( + model_name='userstorystatus', + name='is_archived', + field=models.BooleanField(default=False, verbose_name='is archived'), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0015_auto_20141230_1212.py b/taiga/projects/migrations/0015_auto_20141230_1212.py new file mode 100644 index 00000000..220ba920 --- /dev/null +++ b/taiga/projects/migrations/0015_auto_20141230_1212.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from django.db import models, migrations + + +def fix_project_template_us_status_archived(apps, schema_editor): + ProjectTemplate = apps.get_model("projects", "ProjectTemplate") + for pt in ProjectTemplate.objects.all(): + for us_status in pt.us_statuses: + us_status["is_archived"] = False + + pt.us_statuses.append({ + "color": "#5c3566", + "order": 6, + "is_closed": True, + "is_archived": True, + "wip_limit": None, + "name": "Archived", + "slug": "archived"}) + + pt.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0014_userstorystatus_is_archived'), + ] + + operations = [ + migrations.RunPython(fix_project_template_us_status_archived), + ] diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py index e5c58b76..d669a5bf 100644 --- a/taiga/projects/milestones/api.py +++ b/taiga/projects/milestones/api.py @@ -40,14 +40,19 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView serializer_class = serializers.MilestoneSerializer permission_classes = (permissions.MilestonePermission,) filter_backends = (filters.CanViewMilestonesFilterBackend,) - filter_fields = ("project",) + filter_fields = ("project", "closed") def get_queryset(self): qs = models.Milestone.objects.all() qs = qs.prefetch_related("user_stories", "user_stories__role_points", "user_stories__role_points__points", - "user_stories__role_points__role") + "user_stories__role_points__role", + "user_stories__generated_from_issue", + "user_stories__project", + "watchers", + "user_stories__watchers") + qs = qs.select_related("project") qs = qs.order_by("-estimated_start") return qs diff --git a/taiga/projects/milestones/models.py b/taiga/projects/milestones/models.py index 2e4e9199..22a9b179 100644 --- a/taiga/projects/milestones/models.py +++ b/taiga/projects/milestones/models.py @@ -91,46 +91,62 @@ class Milestone(WatchedModelMixin, models.Model): @property def total_points(self): return self._get_user_stories_points( - [us for us in self.user_stories.all().prefetch_related('role_points', 'role_points__points')] + [us for us in self.user_stories.all()] ) @property def closed_points(self): return self._get_user_stories_points( - [us for us in self.user_stories.all().prefetch_related('role_points', 'role_points__points') if us.is_closed] + [us for us in self.user_stories.all() if us.is_closed] ) - def _get_points_increment(self, client_requirement, team_requirement): + def _get_increment_points(self): + if hasattr(self, "_increments"): + return self._increments + + self._increments = { + "client_increment": {}, + "team_increment": {}, + "shared_increment": {}, + } user_stories = UserStory.objects.none() if self.estimated_start and self.estimated_finish: - user_stories = UserStory.objects.filter( - created_date__gte=self.estimated_start, - created_date__lt=self.estimated_finish, - project_id=self.project_id, - client_requirement=client_requirement, - team_requirement=team_requirement - ).prefetch_related('role_points', 'role_points__points') - return self._get_user_stories_points(user_stories) + user_stories = filter( + lambda x: x.created_date.date() >= self.estimated_start and x.created_date.date() < self.estimated_finish, + self.project.user_stories.all() + ) + self._increments['client_increment'] = self._get_user_stories_points( + [us for us in user_stories if us.client_requirement is True and us.team_requirement is False] + ) + self._increments['team_increment'] = self._get_user_stories_points( + [us for us in user_stories if us.client_requirement is False and us.team_requirement is True] + ) + self._increments['shared_increment'] = self._get_user_stories_points( + [us for us in user_stories if us.client_requirement is True and us.team_requirement is True] + ) + return self._increments + @property def client_increment_points(self): - client_increment = self._get_points_increment(True, False) + self._get_increment_points() + client_increment = self._get_increment_points()["client_increment"] shared_increment = { - key: value/2 for key, value in self.shared_increment_points.items() + key: value/2 for key, value in self._get_increment_points()["shared_increment"].items() } return dict_sum(client_increment, shared_increment) @property def team_increment_points(self): - team_increment = self._get_points_increment(False, True) + team_increment = self._get_increment_points()["team_increment"] shared_increment = { - key: value/2 for key, value in self.shared_increment_points.items() + key: value/2 for key, value in self._get_increment_points()["shared_increment"].items() } return dict_sum(team_increment, shared_increment) @property def shared_increment_points(self): - return self._get_points_increment(True, True) + return self._get_increment_points()["shared_increment"] def closed_points_by_date(self, date): return self._get_user_stories_points([ diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 4c615d05..7912ceab 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -254,23 +254,20 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): return dict_sum(*flat_role_dicts) def _get_points_increment(self, client_requirement, team_requirement): - userstory_model = apps.get_model("userstories", "UserStory") - user_stories = userstory_model.objects.none() last_milestones = self.milestones.order_by('-estimated_finish') last_milestone = last_milestones[0] if last_milestones else None if last_milestone: - user_stories = userstory_model.objects.filter( + user_stories = self.user_stories.filter( created_date__gte=last_milestone.estimated_finish, - project_id=self.id, client_requirement=client_requirement, team_requirement=team_requirement - ).prefetch_related('role_points', 'role_points__points') + ) else: - user_stories = userstory_model.objects.filter( - project_id=self.id, + user_stories = self.user_stories.filter( client_requirement=client_requirement, team_requirement=team_requirement - ).prefetch_related('role_points', 'role_points__points') + ) + user_stories = user_stories.prefetch_related('role_points', 'role_points__points') return self._get_user_stories_points(user_stories) @property @@ -291,15 +288,26 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): @property def closed_points(self): - return self._get_user_stories_points(self.user_stories.filter(is_closed=True).prefetch_related('role_points', 'role_points__points')) + return self.calculated_points["closed"] @property def defined_points(self): - return self._get_user_stories_points(self.user_stories.all().prefetch_related('role_points', 'role_points__points')) + return self.calculated_points["defined"] @property def assigned_points(self): - return self._get_user_stories_points(self.user_stories.filter(milestone__isnull=False).prefetch_related('role_points', 'role_points__points')) + return self.calculated_points["assigned"] + + @property + def calculated_points(self): + user_stories = self.user_stories.all().prefetch_related('role_points', 'role_points__points') + closed_user_stories = user_stories.filter(is_closed=True) + assigned_user_stories = user_stories.filter(milestone__isnull=False) + return { + "defined": self._get_user_stories_points(user_stories), + "closed": self._get_user_stories_points(closed_user_stories), + "assigned": self._get_user_stories_points(assigned_user_stories), + } class ProjectModulesConfig(models.Model): @@ -323,6 +331,8 @@ class UserStoryStatus(models.Model): verbose_name=_("order")) is_closed = models.BooleanField(default=False, null=False, blank=True, verbose_name=_("is closed")) + is_archived = models.BooleanField(default=False, null=False, blank=True, + verbose_name=_("is archived")) color = models.CharField(max_length=20, null=False, blank=False, default="#999999", verbose_name=_("color")) wip_limit = models.IntegerField(null=True, blank=True, default=None, @@ -690,6 +700,7 @@ class ProjectTemplate(models.Model): name=us_status["name"], slug=us_status["slug"], is_closed=us_status["is_closed"], + is_archived=us_status["is_archived"], color=us_status["color"], wip_limit=us_status["wip_limit"], order=us_status["order"], diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py index a278cee2..8accc657 100644 --- a/taiga/projects/notifications/services.py +++ b/taiga/projects/notifications/services.py @@ -198,8 +198,8 @@ def _make_template_mail(name:str): instance for specified name, and return an instance of it. """ - cls = type("TemplateMail", - (template_mail.TemplateMail,), + cls = type("InlineCSSTemplateMail", + (template_mail.InlineCSSTemplateMail,), {"name": name}) return cls() @@ -250,7 +250,8 @@ def send_sync_notifications(notification_id): history_entries = tuple(notification.history_entries.all().order_by("created_at")) obj, _ = get_last_snapshot_for_key(notification.key) - context = {"snapshot": obj.snapshot, + context = { + "snapshot": obj.snapshot, "project": notification.project, "changer": notification.owner, "history_entries": history_entries} @@ -260,6 +261,7 @@ def send_sync_notifications(notification_id): email = _make_template_mail(template_name) for user in notification.notify_users.distinct(): + context["user"] = user email.send(user.email, context) notification.delete() diff --git a/taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja b/taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja index 301f351b..12c1178d 100644 --- a/taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja @@ -1,30 +1,15 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates-body-html.jinja" %} -{% set final_url = resolve_front_url("issue", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %} - -{% block body %} - - - - -
-

Project: {{ project.name }}

-

Issue #{{ snapshot.ref }}: {{ snapshot.subject }}

-

Updated by {{ changer.get_full_name() }}.

- {% for entry in history_entries%} - {% if entry.comment %} -

Comment {{ mdrender(project, entry.comment) }}

- {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-html.jinja" %} - {% endif %} - {% endfor %} -
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

+{% block head %} + {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("issue", project.slug, snapshot.ref) %} +

Issue updated

+

Hello {{ user }},
{{ changer }} has updated an issue on {{ project }}

+

Issue #{{ ref }} {{ subject }}

+ See issue + {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja b/taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja index efcd3214..98935cea 100644 --- a/taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/issues/issue-change-body-text.jinja @@ -1,17 +1,13 @@ -{% set final_url = resolve_front_url("issue", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %} - -- Project: {{ project.name }} -- Issue #{{ snapshot.ref }}: {{ snapshot.subject }} -- Updated by {{ changer.get_full_name() }} -{% for entry in history_entries%} - {% if entry.comment %} - Comment: {{ entry.comment|linebreaksbr }} - {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-text.jinja" %} - {% endif %} -{% endfor %} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name(), + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("issue", project.slug, snapshot.ref) %} +Issue updated +Hello {{ user }}, {{ changer }} has updated an issue on {{ project }} +See issue #{{ ref }} {{ subject }} at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja b/taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja index e6f72cb8..ec535a22 100644 --- a/taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja +++ b/taiga/projects/notifications/templates/emails/issues/issue-change-subject.jinja @@ -1 +1,5 @@ -[{{ project.name|safe }}] Updated the issue #{{ snapshot.ref|safe }} "{{ snapshot.subject|safe }}" +{% trans project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +[{{ project }}] Updated the issue #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja b/taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja index 5f6538b8..63ae6018 100644 --- a/taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/issues/issue-create-body-html.jinja @@ -1,21 +1,16 @@ -{% extends "emails/base.jinja" %} - -{% set final_url = resolve_front_url("issue", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

Project: {{ project.name }}

-

Issue #{{ snapshot.ref }}: {{ snapshot.subject }}

-

Created by {{ changer.get_full_name() }}.

-
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

+ {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("issue", project.slug, snapshot.ref) %} +

New issue created

+

Hello {{ user }},
{{ changer }} has created a new issue on {{ project }}

+

Issue #{{ ref }} {{ subject }}

+ See issue +

The Taiga Team

+ {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja b/taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja index f64a3176..0a64b921 100644 --- a/taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/issues/issue-create-body-text.jinja @@ -1,8 +1,13 @@ -{% set final_url = resolve_front_url("issue", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("issue", project.slug, snapshot.ref) %} +New issue created +Hello {{ user }}, {{ changer }} has created a new issue on {{ project }} +See issue #{{ ref }} {{ subject }} at {{ url }} -- Project: {{ project.name }} -- US #{{ snapshot.ref }}: {{ snapshot.subject }} -- Created by {{ changer.get_full_name() }} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja b/taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja index 93ea5b7d..315ecf1f 100644 --- a/taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja +++ b/taiga/projects/notifications/templates/emails/issues/issue-create-subject.jinja @@ -1 +1,5 @@ -[{{ project.name|safe }}] Created the issue #{{ snapshot.ref|safe }} "{{ snapshot.subject|safe }}" +{% trans project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +[{{ project }}] Created the issue #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja index d9be80bc..1c832091 100644 --- a/taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/issues/issue-delete-body-html.jinja @@ -1,13 +1,14 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

{{ project.name }}

-

Issue #{{ snapshot.ref }}: {{ snapshot.subject }}

-

Deleted by {{ changer.get_full_name() }}

-
+ {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +

Issue deleted

+

Hello {{ user }},
{{ changer }} has deleted an issue on {{ project }}

+

Issue #{{ ref }} {{ subject }}

+

The Taiga Team

+ {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja index e71c0580..dd4d6cda 100644 --- a/taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/issues/issue-delete-body-text.jinja @@ -1,3 +1,12 @@ -- Project: {{ project.name }} -- Issue #{{ snapshot.ref }}: {{ snapshot.subject }} -- Deleted by {{ changer.get_full_name() }} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +Issue deleted +Hello {{ user }}, {{ changer }} has deleted an issue on {{ project }} +Issue #{{ ref }} {{ subject }} + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja b/taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja index 87d1c86a..b736414e 100644 --- a/taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja +++ b/taiga/projects/notifications/templates/emails/issues/issue-delete-subject.jinja @@ -1 +1,5 @@ -[{{ project.name|safe }}] Deleted the issue #{{ snapshot.ref|safe }} "{{ snapshot.subject|safe }}" +{% trans project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +[{{ project }}] Deleted the issue #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja index 898c8e78..2d96fa61 100644 --- a/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-html.jinja @@ -1,30 +1,14 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates-body-html.jinja" %} -{% set final_url = resolve_front_url("taskboard", project.slug, snapshot.slug) %} -{% set final_url_name = "Taiga - View milestone #{0}".format(snapshot.slug) %} - -{% block body %} - - - - -
-

Project: {{ project.name }}

-

Milestone #{{ snapshot.slug }}: {{ snapshot.name }}

-

Updated by {{ changer.get_full_name() }}.

- {% for entry in history_entries%} - {% if entry.comment %} -

Comment {{ entry.comment|linebreaksbr }}

- {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-html.jinja" %} - {% endif %} - {% endfor %} -
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

+{% block head %} + {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + name=snapshot.name|safe, + url=resolve_front_url("taskboard", project.slug, snapshot.slug) %} +

Sprint updated

+

Hello {{ user }},
{{ changer }} has updated an sprint on {{ project }}

+

Sprint {{ name }}

+ See sprint + {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja index ff4f4ea2..26dc3849 100644 --- a/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-change-body-text.jinja @@ -1,17 +1,12 @@ -{% set final_url = resolve_front_url("taskboard", project.slug, snapshot.slug) %} -{% set final_url_name = "Taiga - View milestone #{0}".format(snapshot.slug) %} - -- Project: {{ project.name }} -- Milestone #{{ snapshot.slug }}: {{ snapshot.name }} -- Updated by {{ changer.get_full_name() }} -{% for entry in history_entries%} - {% if entry.comment %} - Comment: {{ entry.comment|linebreaksbr }} - {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-text.jinja" %} - {% endif %} -{% endfor %} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + name=snapshot.name|safe, + url=resolve_front_url("task", project.slug, snapshot.slug) %} +Sprint updated +Hello {{ user }}, {{ changer }} has updated a sprint on {{ project }} +See sprint {{ subject }} at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja index 1249eb83..6a2d85e8 100644 --- a/taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-change-subject.jinja @@ -1 +1,4 @@ -[{{ project.name|safe }}] Updated the milestone #{{ snapshot.slug|safe }} "{{ snapshot.name|safe }}" +{% trans project=project.name|safe, + milestone=snapshot.name|safe %} +[{{ project }}] Updated the sprint "{{ milestone }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja index 284e98b0..4f9b81b8 100644 --- a/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-html.jinja @@ -1,21 +1,15 @@ -{% extends "emails/base.jinja" %} - -{% set final_url = resolve_front_url("taskboard", project.slug, snapshot.slug) %} -{% set final_url_name = "Taiga - View milestone #{0}".format(snapshot.slug) %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

Project: {{ project.name }}

-

Milestone #{{ snapshot.slug }}: {{ snapshot.name }}

-

Created by {{ changer.get_full_name() }}.

-
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

+ {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + name=snapshot.name|safe, + url=resolve_front_url("taskboard", project.slug, snapshot.slug) %} +

New sprint created

+

Hello {{ user }},
{{ changer }} has created a new sprint on {{ project }}

+

Sprint {{ name }}

+ See sprint +

The Taiga Team

+ {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja index 300c8059..98cb5d73 100644 --- a/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-create-body-text.jinja @@ -1,8 +1,12 @@ -{% set final_url = resolve_front_url("taskboard", project.slug, snapshot.slug) %} -{% set final_url_name = "Taiga - View milestone #{0}".format(snapshot.slug) %} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + name=snapshot.name|safe, + url=resolve_front_url("taskboard", project.slug, snapshot.slug) %} +New sprint created +Hello {{ user }}, {{ changer }} has created a new sprint on {{ project }} +See sprint {{ subject }} at {{ url }} -- Project: {{ project.name }} -- Milestone #{{ snapshot.slug }}: {{ snapshot.name }} -- Created by {{ changer.get_full_name() }} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja index 28704d3e..33c1aac2 100644 --- a/taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-create-subject.jinja @@ -1 +1,4 @@ -[{{ project.name|safe }}] Created the milestone #{{ snapshot.slug|safe }} "{{ snapshot.name|safe }}" +{% trans project=project.name|safe, + milestone=snapshot.name|safe %} +[{{ project }}] Created the sprint "{{ milestone }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja index 20ac2a5d..10dbfacc 100644 --- a/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja @@ -1,14 +1,14 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

{{ project.name }}

-

Milestone #{{ snapshot.slug }}: {{ snapshot.name }}

-

Deleted by {{ changer.get_full_name() }}

-
+ {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + name=snapshot.name|safe %} +

Sprint deleted

+

Hello {{ user }},
{{ changer }} has deleted an sprint on {{ project }}

+

Sprint {{ name }}

+

The Taiga Team

+ {% endtrans %} {% endblock %} - diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja index 2916ae80..41c3b22b 100644 --- a/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-text.jinja @@ -1,3 +1,11 @@ -- Project: {{ project.name }} -- Milestone #{{ snapshot.slug }}: {{ snapshot.name }} -- Deleted by {{ changer.get_full_name() }} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + name=snapshot.name|safe %} +Sprint deleted +Hello {{ user }}, {{ changer }} has deleted an sprint on {{ project }} +Sprint {{ name }} + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja index 54221a8c..496f517a 100644 --- a/taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja +++ b/taiga/projects/notifications/templates/emails/milestones/milestone-delete-subject.jinja @@ -1 +1,4 @@ -[{{ project.name|safe }}] Deleted the milestone #{{ snapshot.slug|safe }} "{{ snapshot.name|safe }}" +{% trans project=project.name|safe, + milestone=snapshot.name|safe %} +[{{ project }}] Deleted the Sprint "{{ milestone }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/projects/project-change-body-html.jinja b/taiga/projects/notifications/templates/emails/projects/project-change-body-html.jinja deleted file mode 100644 index 0c65ef64..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-change-body-html.jinja +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "emails/base.jinja" %} - -{% set final_url = resolve_front_url("project-admin", snapshot.slug) %} -{% set final_url_name = "Taiga - View Project #{0}".format(snapshot.slug) %} - -{% block body %} - - - - -
-

Project #{{ snapshot.slug }}: {{ snapshot.name }}

-

Updated by {{ changer.get_full_name() }}.

- {% for entry in history_entries%} - {% if entry.comment %} -

Comment {{ entry.comment|linebreaksbr }}

- {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-html.jinja" %} - {% endif %} - {% endfor %} -
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

-{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/projects/project-change-body-text.jinja b/taiga/projects/notifications/templates/emails/projects/project-change-body-text.jinja deleted file mode 100644 index 14fda13f..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-change-body-text.jinja +++ /dev/null @@ -1,17 +0,0 @@ -{% set final_url = resolve_front_url("project-admin", snapshot.slug) %} -{% set final_url_name = "Taiga - View Project #{0}".format(snapshot.slug) %} - -- Project #{{ snapshot.slug }}: {{ snapshot.name }} -- Updated by {{ changer.get_full_name() }} -{% for entry in history_entries%} - {% if entry.comment %} - Comment: {{ entry.comment|linebreaksbr }} - {% endif %} - {% set changed_fields = entry.values_diff %} - {% for field_name, values in changed_fields.items() %} - * {{ verbose_name(object, field_name) }}: from '{{ values.0 }}' to '{{ values.1 }}'. - {% endfor %} - {% endif %} -{% endfor %} - -** More info at {{ final_url_name }} ({{ final_url }}) ** diff --git a/taiga/projects/notifications/templates/emails/projects/project-change-subject.jinja b/taiga/projects/notifications/templates/emails/projects/project-change-subject.jinja deleted file mode 100644 index 25d9943b..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-change-subject.jinja +++ /dev/null @@ -1 +0,0 @@ -[{{ snapshot.name|safe }}] Updated the project #{{ snapshot.slug|safe }} diff --git a/taiga/projects/notifications/templates/emails/projects/project-create-body-html.jinja b/taiga/projects/notifications/templates/emails/projects/project-create-body-html.jinja deleted file mode 100644 index a37f59c1..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-create-body-html.jinja +++ /dev/null @@ -1,19 +0,0 @@ -{% set final_url = resolve_front_url("project-admin", snapshot.slug) %} -{% set final_url_name = "Taiga - View Project #{0}".format(snapshot.slug) %} - -{% block body %} - - - - -
-

Project: {{ project.name }}

-

Project #{{ snapshot.slug }}: {{ snapshot.name }}

-

Created by {{ changer.get_full_name() }}.

-
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

-{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/projects/project-create-body-text.jinja b/taiga/projects/notifications/templates/emails/projects/project-create-body-text.jinja deleted file mode 100644 index c66430fc..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-create-body-text.jinja +++ /dev/null @@ -1,7 +0,0 @@ -{% set final_url = resolve_front_url("project-admin", snapshot.slug) %} -{% set final_url_name = "Taiga - View Project #{0}".format(snapshot.slug) %} - -- Project #{{ snapshot.slug }}: {{ snapshot.name }} -- Created by {{ changer.get_full_name() }} - -** More info at {{ final_url_name }} ({{ final_url }}) ** diff --git a/taiga/projects/notifications/templates/emails/projects/project-create-subject.jinja b/taiga/projects/notifications/templates/emails/projects/project-create-subject.jinja deleted file mode 100644 index 06ed271b..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-create-subject.jinja +++ /dev/null @@ -1 +0,0 @@ -[{{ snapshot.name|safe }}] Created the project #{{ snapshot.slug|safe }} diff --git a/taiga/projects/notifications/templates/emails/projects/project-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/projects/project-delete-body-html.jinja deleted file mode 100644 index 2b8fa57d..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-delete-body-html.jinja +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "emails/base.jinja" %} - -{% block body %} - - - - -
-

Project #{{ snapshot.slug }}: {{ snapshot.name }}

-

Deleted by {{ changer.get_full_name() }}

-
-{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/projects/project-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/projects/project-delete-body-text.jinja deleted file mode 100644 index 39935515..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-delete-body-text.jinja +++ /dev/null @@ -1,2 +0,0 @@ -- Project #{{ snapshot.slug }}: {{ snapshot.name }} -- Deleted by {{ changer.get_full_name() }} diff --git a/taiga/projects/notifications/templates/emails/projects/project-delete-subject.jinja b/taiga/projects/notifications/templates/emails/projects/project-delete-subject.jinja deleted file mode 100644 index cf0adf31..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-delete-subject.jinja +++ /dev/null @@ -1 +0,0 @@ -[{{ snapshot.name|safe }}] Deleted the project #{{ snapshot.slug|safe }} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja b/taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja index 02bfee38..790cd752 100644 --- a/taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja @@ -1,30 +1,15 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates-body-html.jinja" %} -{% set final_url = resolve_front_url("task", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View task #{0}".format(snapshot.ref) %} - -{% block body %} - - - - -
-

Project: {{ project.name }}

-

Task #{{ snapshot.ref }}: {{ snapshot.subject }}

-

Updated by {{ changer.get_full_name() }}.

- {% for entry in history_entries%} - {% if entry.comment %} -

Comment {{ mdrender(project, entry.comment) }}

- {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-html.jinja" %} - {% endif %} - {% endfor %} -
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

+{% block head %} + {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("task", project.slug, snapshot.ref) %} +

Task updated

+

Hello {{ user }},
{{ changer }} has updated a task on {{ project }}

+

Task #{{ ref }} {{ subject }}

+ See task + {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja b/taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja index 925fbacc..52d7d258 100644 --- a/taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/tasks/task-change-body-text.jinja @@ -1,17 +1,13 @@ -{% set final_url = resolve_front_url("task", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View task #{0}".format(snapshot.ref) %} - -- Project: {{ project.name }} -- Task #{{ snapshot.ref }}: {{ snapshot.subject }} -- Updated by {{ changer.get_full_name() }} -{% for entry in history_entries%} - {% if entry.comment %} - Comment: {{ entry.comment|linebreaksbr }} - {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-text.jinja" %} - {% endif %} -{% endfor %} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("task", project.slug, snapshot.ref) %} +Task updated +Hello {{ user }}, {{ changer }} has updated a task on {{ project }} +See task #{{ ref }} {{ subject }} at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja b/taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja index ddd26e48..ced8c417 100644 --- a/taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja +++ b/taiga/projects/notifications/templates/emails/tasks/task-change-subject.jinja @@ -1 +1,5 @@ -[{{ project.name|safe }}] Updated the task #{{ snapshot.ref }} "{{ snapshot.subject|safe }}" +{% trans project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +[{{ project }}] Updated the task #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja b/taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja index 6d2a216e..969ee9e0 100644 --- a/taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja @@ -1,22 +1,16 @@ -{% extends "emails/base.jinja" %} - -{% set final_url = resolve_front_url("task", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View task #{0}".format(snapshot.ref) %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

Project: {{ project.name }}

-

Task #{{ snapshot.ref }}: {{ snapshot.subject }}

-

Created by {{ changer.get_full_name() }}.

-
+ {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("task", project.slug, snapshot.ref) %} +

New task created

+

Hello {{ user }},
{{ changer }} has created a new task on {{ project }}

+

Task #{{ ref }} {{ subject }}

+ See task +

The Taiga Team

+ {% endtrans %} {% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

-{% endblock %} - diff --git a/taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja b/taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja index 7ceca7fe..88ed278f 100644 --- a/taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/tasks/task-create-body-text.jinja @@ -1,8 +1,13 @@ -{% set final_url = resolve_front_url("task", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View task #{0}".format(snapshot.ref) %} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("task", project.slug, snapshot.ref) %} +New created +Hello {{ user }}, {{ changer }} has created a new task on {{ project }} +See task #{{ ref }} {{ subject }} at {{ url }} -- Project: {{ project.name }} -- Task #{{ snapshot.ref }}: {{ snapshot.subject }} -- Created by {{ changer.get_full_name() }} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja b/taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja index c83009d4..6931764f 100644 --- a/taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja +++ b/taiga/projects/notifications/templates/emails/tasks/task-create-subject.jinja @@ -1 +1,5 @@ -[{{ project.name|safe }}] Created the task #{{ snapshot.ref }} "{{ snapshot.subject|safe }}" +{% trans project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +[{{ project }}] Created the task #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja index 0186fa98..05d1f493 100644 --- a/taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja @@ -1,14 +1,15 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

{{ project.name }}

-

Task #{{ snapshot.ref }}: {{ snapshot.subject }}

-

Deleted by {{ changer.get_full_name() }}

-
+ {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +

Task deleted

+

Hello {{ user }},
{{ changer }} has deleted a task on {{ project }}

+

Task #{{ ref }} {{ subject }}

+

The Taiga Team

+ {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja index 8aa8d6ba..acf9bfc3 100644 --- a/taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/tasks/task-delete-body-text.jinja @@ -1,4 +1,12 @@ -- Project: {{ project.name }} -- Task #{{ snapshot.ref }}: {{ snapshot.subject }} -- Deleted by {{ changer.get_full_name() }} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +Task deleted +Hello {{ user }}, {{ changer }} has deleted a task on {{ project }} +Task #{{ ref }} {{ subject }} +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja b/taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja index 5a411013..1807e6a5 100644 --- a/taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja +++ b/taiga/projects/notifications/templates/emails/tasks/task-delete-subject.jinja @@ -1 +1,5 @@ -[{{ project.name|safe }}] Deleted the task #{{ snapshot.ref }} "{{ snapshot.subject|safe }}" +{% trans project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +[{{ project }}] Deleted the task #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja index 770ae1a0..756a8260 100644 --- a/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja @@ -1,30 +1,15 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates-body-html.jinja" %} -{% set final_url = resolve_front_url("userstory", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View US #{0}".format(snapshot.ref) %} - -{% block body %} - - - - -
-

Project: {{ project.name }}

-

US #{{ snapshot.ref }}: {{ snapshot.subject }}

-

Updated by {{ changer.get_full_name() }}.

- {% for entry in history_entries%} - {% if entry.comment %} -

Comment {{ mdrender(project, entry.comment) }}

- {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-html.jinja" %} - {% endif %} - {% endfor %} -
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

+{% block head %} + {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("userstory", project.slug, snapshot.ref) %} +

User Story updated

+

Hello {{ user }},
{{ changer }} has updated a user story on {{ project }}

+

User Story #{{ ref }} {{ subject }}

+ See user story + {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja index 95195b79..79eaa5fa 100644 --- a/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-change-body-text.jinja @@ -1,17 +1,13 @@ -{% set final_url = resolve_front_url("userstory", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View US #{0}".format(snapshot.ref) %} - -- Project: {{ project.name }} -- US #{{ snapshot.ref }}: {{ snapshot.subject }} -- Updated by {{ changer.get_full_name() }} -{% for entry in history_entries%} - {% if entry.comment %} - Comment: {{ entry.comment|linebreaksbr }} - {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-text.jinja" %} - {% endif %} -{% endfor %} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("userstory", project.slug, snapshot.ref) %} +User story updated +Hello {{ user }}, {{ changer }} has updated a user story on {{ project }} +See user story #{{ ref }} {{ subject }} at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja index 5c66b14f..a362b076 100644 --- a/taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-change-subject.jinja @@ -1 +1,5 @@ -[{{ project.name|safe }}] Updated the US #{{ snapshot.ref }} "{{ snapshot.subject|safe }}" +{% trans project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +[{{ project }}] Updated the US #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja index 922aa86f..1735950e 100644 --- a/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja @@ -1,21 +1,16 @@ -{% extends "emails/base.jinja" %} - -{% set final_url = resolve_front_url("userstory", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View US #{0}".format(snapshot.ref) %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

Project: {{ project.name }}

-

US #{{ snapshot.ref }}: {{ snapshot.subject }}

-

Created by {{ changer.get_full_name() }}.

-
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

+ {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("userstory", project.slug, snapshot.ref) %} +

New user story created

+

Hello {{ user }},
{{ changer }} has created a new user story on {{ project }}

+

User Story #{{ ref }} {{ subject }}

+ See user story +

The Taiga Team

+ {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja index 850d1d3a..3d5aa92f 100644 --- a/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-create-body-text.jinja @@ -1,8 +1,13 @@ -{% set final_url = resolve_front_url("userstory", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View US #{0}".format(snapshot.ref) %} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe, + url=resolve_front_url("userstory", project.slug, snapshot.ref) %} +New user story created +Hello {{ user }}, {{ changer }} has created a new user story on {{ project }} +See user story #{{ ref }} {{ subject }} at {{ url }} -- Project: {{ project.name }} -- US #{{ snapshot.ref }}: {{ snapshot.subject }} -- Created by {{ changer.get_full_name() }} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja index 59f4b8db..549a6129 100644 --- a/taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-create-subject.jinja @@ -1 +1,5 @@ -[{{ project.name|safe }}] Created the US #{{ snapshot.ref }} "{{ snapshot.subject|safe }}" +{% trans project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +[{{ project }}] Created the US #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja index 75d3c14d..24aadddc 100644 --- a/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-html.jinja @@ -1,13 +1,15 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

{{ project.name }}

-

US #{{ snapshot.ref }}: {{ snapshot.subject }}

-

Deleted by {{ changer.get_full_name() }}

-
+ {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +

User Story deleted

+

Hello {{ user }},
{{ changer }} has deleted a user story on {{ project }}

+

User Story #{{ ref }} {{ subject }}

+

The Taiga Team

+ {% endtrans %} {% endblock %} + diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja index 7ea708ce..22a67c97 100644 --- a/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-body-text.jinja @@ -1,3 +1,12 @@ -- Project: {{ project.name }} -- US #{{ snapshot.ref }}: {{ snapshot.subject }} -- Deleted by {{ changer.get_full_name() }} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +User Story deleted +Hello {{ user }}, {{ changer }} has deleted a user story on {{ project }} +User Story #{{ ref }} {{ subject }} + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja index 1b315744..3c2d6ed9 100644 --- a/taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja +++ b/taiga/projects/notifications/templates/emails/userstories/userstory-delete-subject.jinja @@ -1 +1,5 @@ -[{{ project.name|safe }}] Deleted the US #{{ snapshot.ref }} "{{ snapshot.subject|safe }}" +{% trans project=project.name|safe, + ref=snapshot.ref, + subject=snapshot.subject|safe %} +[{{ project }}] Deleted the US #{{ ref }} "{{ subject }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja index c7694c4a..65f4aa1c 100644 --- a/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-html.jinja @@ -1,30 +1,14 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates-body-html.jinja" %} -{% set final_url = resolve_front_url("wiki", project.slug, snapshot.slug) %} -{% set final_url_name = "Taiga - View Wiki Page '{0}'".format(snapshot.slug) %} - -{% block body %} - - - - -
-

Project: {{ project.name }}

-

Wiki Page: {{ snapshot.slug }}

-

Updated by {{ changer.get_full_name() }}.

- {% for entry in history_entries%} - {% if entry.comment %} -

Comment {{ mdrender(project, entry.comment) }}

- {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-html.jinja" %} - {% endif %} - {% endfor %} -
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

+{% block head %} + {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + page=snapshot.slug, + url=resolve_front_url("wiki", project.slug, snapshot.slug) %} +

Wiki Page updated

+

Hello {{ user }},
{{ changer }} has updated a wiki page on {{ project }}

+

Wiki page {{ page }}

+ See Wiki Page + {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja index 50bc8d74..9d284cc8 100644 --- a/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-body-text.jinja @@ -1,17 +1,14 @@ -{% set final_url = resolve_front_url("wiki", project.slug, snapshot.slug) %} -{% set final_url_name = "Taiga - View Wiki Page '{0}'".format(snapshot.slug) %} +{% extends "emails/updates-body-text.jinja" %} +{% block head %} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + page=snapshot.slug, + url=resolve_front_url("wiki", project.slug, snapshot.slug) %} +Wiki Page updated -- Project: {{ project.name }} -- Wiki Page: {{ snapshot.slug }} -- Updated by {{ changer.get_full_name() }} -{% for entry in history_entries%} - {% if entry.comment %} - Comment: {{ entry.comment|linebreaksbr }} - {% endif %} - {% set changed_fields = entry.values_diff %} - {% if changed_fields %} - {% include "emails/includes/fields_diff-text.jinja" %} - {% endif %} -{% endfor %} +Hello {{ user }}, {{ changer }} has updated a wiki page on {{ project }} -** More info at {{ final_url_name }} ({{ final_url }}) ** +See wiki page {{ page }} at {{ url }} +{% endtrans %} +{% endblock %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja index 024d566e..251a5adb 100644 --- a/taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-change-subject.jinja @@ -1 +1,3 @@ -[{{ project.name|safe }}] Updated the Wiki Page "{{ snapshot.slug }}" +{% trans project=project.name|safe, page=snapshot.slug %} +[{{ project }}] Updated the Wiki Page "{{ page }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja index 6112a0c5..f9642cb9 100644 --- a/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-html.jinja @@ -1,21 +1,15 @@ -{% extends "emails/base.jinja" %} - -{% set final_url = resolve_front_url("wiki", project.slug, snapshot.slug) %} -{% set final_url_name = "Taiga - View Wiki Page '{0}'".format(snapshot.slug) %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

Project: {{ project.name }}

-

Wiki Page: {{ snapshot.slug }}

-

Created by {{ changer.get_full_name() }}.

-
-{% endblock %} -{% block footer %} -

- More info at: {{ final_url_name }} -

+ {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + page=snapshot.slug, + url=resolve_front_url("wiki", project.slug, snapshot.slug) %} +

New wiki page created

+

Hello {{ user }},
{{ changer }} has created a new wiki page on {{ project }}

+

Wiki page {{ page }}

+ See wiki page +

The Taiga Team

+ {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja index 70bb34e9..89177038 100644 --- a/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-body-text.jinja @@ -1,8 +1,14 @@ -{% set final_url = resolve_front_url("wiki", project.slug, snapshot.slug) %} -{% set final_url_name = "Taiga - View Wiki Page '{0}'".format(snapshot.slug) %} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + page=snapshot.slug, + url=resolve_front_url("wiki", project.slug, snapshot.slug) %} +New wiki page created -- Project: {{ project.name }} -- Wiki Page: {{ snapshot.slug }} -- Created by {{ changer.get_full_name() }} +Hello {{ user }}, {{ changer }} has created a new wiki page on {{ project }} -** More info at {{ final_url_name }} ({{ final_url }}) ** +See wiki page {{ page }} at {{ url }} + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja index af4ca48d..ac5a6ddf 100644 --- a/taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-create-subject.jinja @@ -1 +1,3 @@ -[{{ project.name|safe }}] Created the Wiki Page "{{ snapshot.slug }}" +{% trans project=project.name|safe, page=snapshot.slug %} +[{{ project }}] Created the Wiki Page "{{ page }}" +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja index 4e98c508..b8f07630 100644 --- a/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-html.jinja @@ -1,13 +1,13 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

{{ project.name }}

-

Wiki Page: {{ snapshot.slug }}

-

Deleted by {{ changer.get_full_name() }}

-
+ {% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + page=snapshot.slug %} +

Wiki page deleted

+

Hello {{ user }},
{{ changer }} has deleted a wiki page on {{ project }}

+

Wiki page {{ page }}

+

The Taiga Team

+ {% endtrans %} {% endblock %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja index d896f42f..46f5fb2e 100644 --- a/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-body-text.jinja @@ -1,3 +1,13 @@ -- Project: {{ project.name }} -- Wiki Page: {{ snapshot.slug }} -- Deleted by {{ changer.get_full_name() }} +{% trans user=user.get_full_name()|safe, + changer=changer.get_full_name()|safe, + project=project.name|safe, + page=snapshot.slug %} +Wiki page deleted + +Hello {{ user }}, {{ changer }} has deleted a wiki page on {{ project }} + +Wiki page {{ page }} + +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja index a496edc2..d73de78f 100644 --- a/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja +++ b/taiga/projects/notifications/templates/emails/wiki/wikipage-delete-subject.jinja @@ -1 +1,3 @@ -[{{ project.name|safe }}] Deleted the Wiki Page "{{ snapshot.slug }}" +{% trans project=project.name|safe, page=snapshot.slug %} +[{{ project }}] Deleted the Wiki Page "{{ page }}" +{% endtrans %} diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 71068bff..5c89484f 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -148,6 +148,7 @@ class MembershipSerializer(ModelSerializer): role_name = serializers.CharField(source='role.name', required=False, read_only=True) full_name = serializers.CharField(source='user.get_full_name', required=False, read_only=True) user_email = serializers.EmailField(source='user.email', required=False, read_only=True) + is_user_active = serializers.BooleanField(source='user.is_active', required=False, read_only=True) email = serializers.EmailField(required=True) color = serializers.CharField(source='user.color', required=False, read_only=True) photo = serializers.SerializerMethodField("get_photo") @@ -237,6 +238,7 @@ class ProjectSerializer(ModelSerializer): i_am_owner = serializers.SerializerMethodField("get_i_am_owner") tags_colors = TagsColorsField(required=False) users = serializers.SerializerMethodField("get_users") + total_closed_milestones = serializers.SerializerMethodField("get_total_closed_milestones") class Meta: model = models.Project @@ -260,6 +262,9 @@ class ProjectSerializer(ModelSerializer): def get_users(self, obj): return UserSerializer(obj.members.all(), many=True).data + def get_total_closed_milestones(self, obj): + return obj.milestones.filter(closed=True).count() + def validate_total_milestones(self, attrs, source): """ Check that total_milestones is not null, it's an optional parameter but diff --git a/taiga/projects/services/invitations.py b/taiga/projects/services/invitations.py index 59da4b80..1d79e58d 100644 --- a/taiga/projects/services/invitations.py +++ b/taiga/projects/services/invitations.py @@ -1,12 +1,12 @@ from django.apps import apps from django.conf import settings -from djmail.template_mail import MagicMailBuilder +from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail def send_invitation(invitation): """Send an invitation email""" - mbuilder = MagicMailBuilder() + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) if invitation.user: template = mbuilder.membership_notification else: diff --git a/taiga/projects/services/stats.py b/taiga/projects/services/stats.py index a770f95b..c852459f 100644 --- a/taiga/projects/services/stats.py +++ b/taiga/projects/services/stats.py @@ -15,12 +15,12 @@ # along with this program. If not, see . from django.db.models import Q, Count +from django.apps import apps import datetime import copy from taiga.projects.history.models import HistoryEntry - def _get_milestones_stats_for_backlog(project): """ Get collection of stats for each millestone of project. @@ -37,20 +37,27 @@ def _get_milestones_stats_for_backlog(project): future_team_increment = sum(project.future_team_increment.values()) future_client_increment = sum(project.future_client_increment.values()) - milestones = project.milestones.order_by('estimated_start') + milestones = project.milestones.order_by('estimated_start').\ + prefetch_related("user_stories", + "user_stories__role_points", + "user_stories__role_points__points") + milestones = list(milestones) + milestones_count = len(milestones) optimal_points = 0 team_increment = 0 client_increment = 0 - for current_milestone in range(0, max(milestones.count(), project.total_milestones)): + + for current_milestone in range(0, max(milestones_count, project.total_milestones)): optimal_points = (project.total_story_points - (optimal_points_per_sprint * current_milestone)) evolution = (project.total_story_points - current_evolution if current_evolution is not None else None) - if current_milestone < milestones.count(): + if current_milestone < milestones_count: ml = milestones[current_milestone] + milestone_name = ml.name team_increment = current_team_increment client_increment = current_client_increment @@ -58,6 +65,7 @@ def _get_milestones_stats_for_backlog(project): current_evolution += sum(ml.closed_points.values()) current_team_increment += sum(ml.team_increment_points.values()) current_client_increment += sum(ml.client_increment_points.values()) + else: milestone_name = "Future sprint" team_increment = current_team_increment + future_team_increment, @@ -194,7 +202,13 @@ def get_stats_for_project_issues(project): def get_stats_for_project(project): - closed_points = sum(project.closed_points.values()) + project = apps.get_model("projects", "Project").objects.\ + prefetch_related("milestones", + "user_stories").\ + get(id=project.id) + + points = project.calculated_points + closed_points = sum(points["closed"].values()) closed_milestones = project.milestones.filter(closed=True).count() speed = 0 if closed_milestones != 0: @@ -205,11 +219,11 @@ def get_stats_for_project(project): 'total_milestones': project.total_milestones, 'total_points': project.total_story_points, 'closed_points': closed_points, - 'closed_points_per_role': project.closed_points, - 'defined_points': sum(project.defined_points.values()), - 'defined_points_per_role': project.defined_points, - 'assigned_points': sum(project.assigned_points.values()), - 'assigned_points_per_role': project.assigned_points, + 'closed_points_per_role': points["closed"], + 'defined_points': sum(points["defined"].values()), + 'defined_points_per_role': points["defined"], + 'assigned_points': sum(points["assigned"].values()), + 'assigned_points_per_role': points["assigned"], 'milestones': _get_milestones_stats_for_backlog(project), 'speed': speed, } diff --git a/taiga/projects/tasks/migrations/0004_auto_20141210_1107.py b/taiga/projects/tasks/migrations/0004_auto_20141210_1107.py new file mode 100644 index 00000000..b4c093f3 --- /dev/null +++ b/taiga/projects/tasks/migrations/0004_auto_20141210_1107.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db import connection +from taiga.projects.userstories.models import * +from taiga.projects.tasks.models import * +from taiga.projects.issues.models import * +from taiga.projects.models import * + +def _fix_tags_model(tags_model): + table_name = tags_model._meta.db_table + query = "select id from (select id, unnest(tags) tag from %s) x where tag LIKE '%%,%%'"%(table_name) + cursor = connection.cursor() + cursor.execute(query) + for row in cursor.fetchall(): + id = row[0] + instance = tags_model.objects.get(id=id) + instance.tags = [tag.replace(",", "") for tag in instance.tags] + instance.save() + + +def fix_tags(apps, schema_editor): + print("Fixing user task tags") + _fix_tags_model(Task) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0003_task_external_reference'), + ] + + operations = [ + migrations.RunPython(fix_tags), + ] diff --git a/taiga/projects/tasks/migrations/0005_auto_20150114_0954.py b/taiga/projects/tasks/migrations/0005_auto_20150114_0954.py new file mode 100644 index 00000000..6e01461b --- /dev/null +++ b/taiga/projects/tasks/migrations/0005_auto_20150114_0954.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0004_auto_20141210_1107'), + ] + + operations = [ + migrations.AlterModelOptions( + name='task', + options={'ordering': ['project', 'created_date', 'ref'], 'permissions': (('view_task', 'Can view task'),), 'verbose_name_plural': 'tasks', 'verbose_name': 'task'}, + ), + ] diff --git a/taiga/projects/tasks/models.py b/taiga/projects/tasks/models.py index 4f652ae8..c35de144 100644 --- a/taiga/projects/tasks/models.py +++ b/taiga/projects/tasks/models.py @@ -70,7 +70,7 @@ class Task(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.M class Meta: verbose_name = "task" verbose_name_plural = "tasks" - ordering = ["project", "created_date"] + ordering = ["project", "created_date", "ref"] # unique_together = ("ref", "project") permissions = ( ("view_task", "Can view task"), diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index fe71186b..8c132c5b 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers -from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin, PgArrayField +from taiga.base.serializers import Serializer, TagsField, NeighborsSerializerMixin, PgArrayField from taiga.mdrender.service import render as mdrender from taiga.projects.validators import ProjectExistsValidator from taiga.projects.milestones.validators import SprintExistsValidator @@ -27,7 +27,7 @@ from . import models class TaskSerializer(WatchersValidator, serializers.ModelSerializer): - tags = PickleField(required=False, default=[]) + tags = TagsField(required=False, default=[]) external_reference = PgArrayField(required=False) comment = serializers.SerializerMethodField("get_comment") milestone_slug = serializers.SerializerMethodField("get_milestone_slug") diff --git a/taiga/projects/templates/emails/membership_invitation-body-html.jinja b/taiga/projects/templates/emails/membership_invitation-body-html.jinja index 55bc69eb..a3efbc1f 100644 --- a/taiga/projects/templates/emails/membership_invitation-body-html.jinja +++ b/taiga/projects/templates/emails/membership_invitation-body-html.jinja @@ -1,31 +1,27 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/hero-body-html.jinja" %} + +{% if membership.invited_by %} + {% set sender_full_name=membership.invited_by.get_full_name() %} +{% else %} + {% set sender_full_name=_("someone") %} +{% endif %} -{% set final_url = resolve_front_url("invitation", membership.token) %} -{% set final_url_name = "Taiga - Invitation to join on {0} project.".format(membership.project) %} {% block body %} - - - - -
-

The Taiga.io Deputy System Admin is commanded by His Highness The Chief Lord Oompa Loompa to extend a membership invitation to {{ membership.email}} and delights in the pleasure of welcoming you to join {{ membership.invited_by.full_name }} and others in the team as a new and right honorable member in good standing of the project titled: '{{ membership.project }}'.

-

You may indicate your humble desire to accept this invitation by gently clicking here: {{ final_url }}

+ {% trans full_name=sender_full_name, + project=membership.project %} +

You have been invited to Taiga!

+

Hi! {{ full_name }} has sent you an invitation to join project {{ project }} in Taiga.
Taiga is a Free, open Source Agile Project Management Tool.

+ {% endtrans %} - {% if membership.invitation_extra_text %} -

- And now some words from the jolly good fellow or sistren who thought so kindly as to invite you:
- {{ membership.invitation_extra_text }} -

- {% endif %} + {% if membership.invitation_extra_text %} + {% trans extra=membership.invitation_extra_text|linebreaksbr %} +

And now a few words from the jolly good fellow or sistren
who thought so kindly as to invite you

+

{{ extra }}

+ {% endtrans %} + {% endif %} -

Dress: Morning Suit, Uniform, Lounge Suit, Birthday Suit or hoodie.

- -
-{% endblock %} - -{% block footer %} -

- Further details: {{ final_url_name }} -

+ {{ _("Accept your invitation") }} +

{{ _("The Taiga Team") }}

{% endblock %} diff --git a/taiga/projects/templates/emails/membership_invitation-body-text.jinja b/taiga/projects/templates/emails/membership_invitation-body-text.jinja index b96f4025..f8ed38e9 100644 --- a/taiga/projects/templates/emails/membership_invitation-body-text.jinja +++ b/taiga/projects/templates/emails/membership_invitation-body-text.jinja @@ -1,18 +1,24 @@ -{% set final_url = resolve_front_url("invitation", membership.token) %} -{% set final_url_name = "Taiga - Invitation to join on {0} project.".format(membership.project) %} - -The Taiga.io Deputy System Admin is commanded by His Highness The Chief Lord Oompa Loompa to extend a membership invitation to {{ membership.email}} and delights in the pleasure of welcoming you to join {{ membership.invited_by.full_name }} and others in the team as a new and right honorable member in good standing of the project titled: '{{ membership.project }}'. - -You may indicate your humble desire to accept this invitation by gently clicking here: {{ final_url }} - -{% if membership.invitation_extra_text %} - -And now some words from the jolly good fellow or sistren who thought so kindly as to invite you: - -{{ membership.invitation_extra_text }} - +{% if membership.invited_by %} + {% set sender_full_name=membership.invited_by.get_full_name() %} +{% else %} + {% set sender_full_name=_("someone") %} {% endif %} +{% trans full_name=sender_full_name, + project=membership.project %} +You, or someone you know, has invited you to Taiga -Dress: Morning Suit, Uniform, Lounge Suit, Birthday Suit or hoodie. +Hi! {{ full_name }} has sent you an invitation to join a project called {{ project }} which is being managed on Taiga, a Free, open Source Agile Project Management Tool. +{% endtrans %} +{% if membership.invitation_extra_text %} + {% trans extra=membership.invitation_extra_text %} +And now a few words from the jolly good fellow or sistren who thought so kindly as to invite you: -Further details: ({{ final_url }}) +{{ extra }} + {% endtrans %} +{% endif %} +{{ _("Accept your invitation to Taiga following whis link:") }} +{{ resolve_front_url("invitation", membership.token) }} +{% trans %} +--- +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/templates/emails/membership_invitation-subject.jinja b/taiga/projects/templates/emails/membership_invitation-subject.jinja index 9eebc2a9..0b5206ef 100644 --- a/taiga/projects/templates/emails/membership_invitation-subject.jinja +++ b/taiga/projects/templates/emails/membership_invitation-subject.jinja @@ -1 +1,3 @@ -[Taiga] Invitation to join to the project '{{ membership.project|safe }}' +{% trans project=membership.project|safe %} +[Taiga] Invitation to join to the project '{{ project }}' +{% endtrans %} diff --git a/taiga/projects/templates/emails/membership_notification-body-html.jinja b/taiga/projects/templates/emails/membership_notification-body-html.jinja index 5558f08b..db777193 100644 --- a/taiga/projects/templates/emails/membership_notification-body-html.jinja +++ b/taiga/projects/templates/emails/membership_notification-body-html.jinja @@ -1,22 +1,12 @@ -{% extends "emails/base.jinja" %} - -{% set final_url = resolve_front_url("project", membership.project.slug) %} -{% set final_url_name = "Taiga - Project '{0}'.".format(membership.project) %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

Hi {{ membership.user.get_full_name() }},

-

you have been added to the project - '{{ membership.project }}'.

-
-{% endblock %} - -{% block footer %} -

- More info at: {{ final_url_name }} -

+ {% trans url=resolve_front_url("project", membership.project.slug), + full_name=membership.user.get_full_name(), + project=membership.project %} +

You have been added to a project

+

Hello {{ full_name }},
you have been added to the project {{ project }}

+ Go to project +

The Taiga Team

+ {% endtrans %} {% endblock %} diff --git a/taiga/projects/templates/emails/membership_notification-body-text.jinja b/taiga/projects/templates/emails/membership_notification-body-text.jinja index 61d4b3ec..c9d94d67 100644 --- a/taiga/projects/templates/emails/membership_notification-body-text.jinja +++ b/taiga/projects/templates/emails/membership_notification-body-text.jinja @@ -1,8 +1,8 @@ -{% set final_url = resolve_front_url("project", membership.project.slug) %} +{% trans url=resolve_front_url("project", membership.project.slug), + full_name=membership.user.get_full_name(), + project=membership.project %} +You have been added to a project +Hello {{ full_name }},you have been added to the project {{ project }} -Hi {{ membership.user.get_full_name() }}, - -you have been added to the project '{{ membership.project }}' ({{ final_url }}). - - -** More info at ({{ final_url }}) ** +See project at {{ url }} +{% endtrans %} diff --git a/taiga/projects/templates/emails/membership_notification-subject.jinja b/taiga/projects/templates/emails/membership_notification-subject.jinja index 1e45591b..57d60ac6 100644 --- a/taiga/projects/templates/emails/membership_notification-subject.jinja +++ b/taiga/projects/templates/emails/membership_notification-subject.jinja @@ -1 +1,3 @@ -[Taiga] Added to the project '{{ membership.project|safe }}' +{% trans project=membership.project|safe %} +[Taiga] Added to the project '{{ project }}' +{% endtrans %} diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 796159e6..e667355d 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -52,17 +52,17 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi filter_backends = (filters.CanViewUsFilterBackend, filters.TagsFilter, filters.QFilter) retrieve_exclude_filters = (filters.TagsFilter,) - filter_fields = ['project', 'milestone', 'milestone__isnull', 'status', 'is_archived'] + filter_fields = ['project', 'milestone', 'milestone__isnull', 'status', 'is_archived', 'status__is_archived'] # Specific filter used for filtering neighbor user stories _neighbor_tags_filter = filters.TagsFilter('neighbor_tags') def get_queryset(self): qs = self.model.objects.all() - qs = qs.prefetch_related("points", - "role_points", + qs = qs.prefetch_related("role_points", "role_points__points", - "role_points__role") + "role_points__role", + "watchers") qs = qs.select_related("milestone", "project") return qs diff --git a/taiga/projects/userstories/migrations/0008_auto_20141210_1107.py b/taiga/projects/userstories/migrations/0008_auto_20141210_1107.py new file mode 100644 index 00000000..8c4b6c8d --- /dev/null +++ b/taiga/projects/userstories/migrations/0008_auto_20141210_1107.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.db import connection +from taiga.projects.userstories.models import * +from taiga.projects.tasks.models import * +from taiga.projects.issues.models import * +from taiga.projects.models import * + +def _fix_tags_model(tags_model): + table_name = tags_model._meta.db_table + query = "select id from (select id, unnest(tags) tag from %s) x where tag LIKE '%%,%%'"%(table_name) + cursor = connection.cursor() + cursor.execute(query) + for row in cursor.fetchall(): + id = row[0] + instance = tags_model.objects.get(id=id) + instance.tags = [tag.replace(",", "") for tag in instance.tags] + instance.save() + + +def fix_tags(apps, schema_editor): + print("Fixing user story tags") + _fix_tags_model(UserStory) + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0007_userstory_external_reference'), + ] + + operations = [ + migrations.RunPython(fix_tags), + ] diff --git a/taiga/projects/userstories/migrations/0009_remove_userstory_is_archived.py b/taiga/projects/userstories/migrations/0009_remove_userstory_is_archived.py new file mode 100644 index 00000000..7b5c72fd --- /dev/null +++ b/taiga/projects/userstories/migrations/0009_remove_userstory_is_archived.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstories', '0008_auto_20141210_1107'), + ] + + operations = [ + migrations.RemoveField( + model_name='userstory', + name='is_archived', + ), + ] diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index 54458b3c..52772ca6 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -64,8 +64,6 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod related_name="user_stories", verbose_name=_("status"), on_delete=models.SET_NULL) is_closed = models.BooleanField(default=False) - is_archived = models.BooleanField(default=False, null=False, blank=True, - verbose_name=_("archived")) points = models.ManyToManyField("projects.Points", null=False, blank=False, related_name="userstories", through="RolePoints", verbose_name=_("points")) @@ -126,8 +124,7 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod return self.role_points def get_total_points(self): - not_null_role_points = self.role_points.select_related("points").\ - exclude(points__value__isnull=True) + not_null_role_points = [rp for rp in self.role_points.all() if rp.points.value is not None] #If we only have None values the sum should be None if not not_null_role_points: diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index c768f909..7d3017f9 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -18,7 +18,7 @@ import json from django.apps import apps from rest_framework import serializers -from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin, PgArrayField +from taiga.base.serializers import Serializer, TagsField, NeighborsSerializerMixin, PgArrayField from taiga.mdrender.service import render as mdrender from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator from taiga.projects.userstories.validators import UserStoryExistsValidator @@ -38,7 +38,7 @@ class RolePointsField(serializers.WritableField): class UserStorySerializer(WatchersValidator, serializers.ModelSerializer): - tags = PickleField(default=[], required=False) + tags = TagsField(default=[], required=False) external_reference = PgArrayField(required=False) points = RolePointsField(source="role_points", required=False) total_points = serializers.SerializerMethodField("get_total_points") diff --git a/taiga/projects/userstories/signals.py b/taiga/projects/userstories/signals.py index f728b372..8764b60d 100644 --- a/taiga/projects/userstories/signals.py +++ b/taiga/projects/userstories/signals.py @@ -35,6 +35,7 @@ def cached_prev_us(sender, instance, **kwargs): def update_role_points_when_create_or_edit_us(sender, instance, **kwargs): if instance._importing: return + instance.project.update_role_points(user_stories=[instance]) @@ -52,15 +53,24 @@ def update_milestone_of_tasks_when_edit_us(sender, instance, created, **kwargs): #################################### def try_to_close_or_open_us_and_milestone_when_create_or_edit_us(sender, instance, created, **kwargs): + if instance._importing: + return + _try_to_close_or_open_us_when_create_or_edit_us(instance) _try_to_close_or_open_milestone_when_create_or_edit_us(instance) def try_to_close_milestone_when_delete_us(sender, instance, **kwargs): + if instance._importing: + return + _try_to_close_milestone_when_delete_us(instance) # US def _try_to_close_or_open_us_when_create_or_edit_us(instance): + if instance._importing: + return + from . import services as us_service if us_service.calculate_userstory_is_closed(instance): @@ -71,6 +81,9 @@ def _try_to_close_or_open_us_when_create_or_edit_us(instance): # Milestone def _try_to_close_or_open_milestone_when_create_or_edit_us(instance): + if instance._importing: + return + from taiga.projects.milestones import services as milestone_service if instance.milestone_id: @@ -87,6 +100,9 @@ def _try_to_close_or_open_milestone_when_create_or_edit_us(instance): def _try_to_close_milestone_when_delete_us(instance): + if instance._importing: + return + from taiga.projects.milestones import services as milestone_service with suppress(ObjectDoesNotExist): diff --git a/taiga/routers.py b/taiga/routers.py index 0b6ffba2..7ca20aa6 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -45,9 +45,10 @@ router.register(r"search", SearchViewSet, base_name="search") # Importer -from taiga.export_import.api import ProjectImporterViewSet +from taiga.export_import.api import ProjectImporterViewSet, ProjectExporterViewSet router.register(r"importer", ProjectImporterViewSet, base_name="importer") +router.register(r"exporter", ProjectExporterViewSet, base_name="exporter") # Projects & Types @@ -89,6 +90,10 @@ router.register(r"tasks/attachments", TaskAttachmentViewSet, base_name="task-att router.register(r"issues/attachments", IssueAttachmentViewSet, base_name="issue-attachments") router.register(r"wiki/attachments", WikiAttachmentViewSet, base_name="wiki-attachments") +# Webhooks +from taiga.webhooks.api import WebhookViewSet, WebhookLogViewSet +router.register(r"webhooks", WebhookViewSet, base_name="webhooks") +router.register(r"webhooklogs", WebhookLogViewSet, base_name="webhooklogs") # History & Components from taiga.projects.history.api import UserStoryHistory diff --git a/taiga/urls.py b/taiga/urls.py index f2de3105..1c4baac0 100644 --- a/taiga/urls.py +++ b/taiga/urls.py @@ -20,9 +20,11 @@ from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.contrib import admin from .routers import router +from .contrib_routers import router as contrib_router urlpatterns = [ url(r'^api/v1/', include(router.urls)), + url(r'^api/v1/', include(contrib_router.urls)), url(r'^api/v1/api-auth/', include('rest_framework.urls', namespace='rest_framework')), url(r'^admin/', include(admin.site.urls)), ] diff --git a/taiga/users/api.py b/taiga/users/api.py index 139c1f11..6407b8b9 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -31,7 +31,7 @@ from rest_framework.response import Response from rest_framework.filters import BaseFilterBackend from rest_framework import status -from djmail.template_mail import MagicMailBuilder +from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail from taiga.auth.tokens import get_user_for_token from taiga.base.decorators import list_route, detail_route @@ -103,7 +103,7 @@ class UsersViewSet(ModelCrudViewSet): user.token = str(uuid.uuid1()) user.save(update_fields=["token"]) - mbuilder = MagicMailBuilder() + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) email = mbuilder.password_recovery(user.email, {"user": user}) email.send() @@ -142,7 +142,10 @@ class UsersViewSet(ModelCrudViewSet): current_password = request.DATA.get("current_password") password = request.DATA.get("password") - if not current_password: + + # NOTE: GitHub users have no password yet (request.user.passwor == '') so + # current_password can be None + if not current_password and request.user.password: raise exc.WrongArguments(_("Current password parameter needed")) if not password: @@ -151,7 +154,7 @@ class UsersViewSet(ModelCrudViewSet): if len(password) < 6: raise exc.WrongArguments(_("Invalid password length at least 6 charaters needed")) - if not request.user.check_password(current_password): + if current_password and not request.user.check_password(current_password): raise exc.WrongArguments(_("Invalid current password")) request.user.set_password(password) @@ -234,7 +237,7 @@ class UsersViewSet(ModelCrudViewSet): request.user.email_token = str(uuid.uuid1()) request.user.new_email = new_email request.user.save(update_fields=["email_token", "new_email"]) - mbuilder = MagicMailBuilder() + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) email = mbuilder.change_email(request.user.new_email, {"user": request.user}) email.send() diff --git a/taiga/users/templates/emails/change_email-body-html.jinja b/taiga/users/templates/emails/change_email-body-html.jinja index f47f9b29..c1cfd438 100644 --- a/taiga/users/templates/emails/change_email-body-html.jinja +++ b/taiga/users/templates/emails/change_email-body-html.jinja @@ -1,27 +1,11 @@ -{% extends "emails/base.jinja" %} - -{% set final_url = resolve_front_url("change-email", user.email_token) %} -{% set final_url_name = "Taiga - Change email" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

Change your email:

-

Hello {{ user.get_full_name() }},

-

you can confirm your change of email by going to the following url:

-

{{ final_url }} -

You can ignore this message if you did not request.

-

Regards

-

--
The Taiga Team

-
-{% endblock %} - -{% block footer %} -

- More info at: - {{ final_url_name }} -

+ {% trans full_name=user.get_full_name(), url=resolve_front_url("change-email", user.email_token) %} +

Change your email

+

Hello {{ full_name }},
please confirm your email

+ Confirm email +

You can ignore this message if you did not request.

+

The Taiga Team

+ {% endtrans %} {% endblock %} diff --git a/taiga/users/templates/emails/change_email-body-text.jinja b/taiga/users/templates/emails/change_email-body-text.jinja index bd133d43..46aba26a 100644 --- a/taiga/users/templates/emails/change_email-body-text.jinja +++ b/taiga/users/templates/emails/change_email-body-text.jinja @@ -1,12 +1,10 @@ -Hello {{ user.get_full_name() }}, +{% trans full_name=user.get_full_name(), url=resolve_front_url('change-email', user.email_token) %} +Hello {{ full_name }}, please confirm your email -you can confirm your change of email by going to the following url: - -{{ resolve_front_url('change-email', user.email_token) }} +{{ url }} You can ignore this message if you did not request. -Regards - --- +--- The Taiga Team +{% endtrans %} diff --git a/taiga/users/templates/emails/change_email-subject.jinja b/taiga/users/templates/emails/change_email-subject.jinja index e00ea7a5..52199560 100644 --- a/taiga/users/templates/emails/change_email-subject.jinja +++ b/taiga/users/templates/emails/change_email-subject.jinja @@ -1 +1 @@ -[Taiga] Change email +{{ _("[Taiga] Change email") }} diff --git a/taiga/users/templates/emails/password_recovery-body-html.jinja b/taiga/users/templates/emails/password_recovery-body-html.jinja index 0d55d679..46450731 100644 --- a/taiga/users/templates/emails/password_recovery-body-html.jinja +++ b/taiga/users/templates/emails/password_recovery-body-html.jinja @@ -1,27 +1,12 @@ -{% extends "emails/base.jinja" %} - -{% set final_url = resolve_front_url("change-password", user.token) %} -{% set final_url_name = "Taiga - Change password" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} - - - - -
-

Recover your password:

-

Hello {{ user.get_full_name() }},

-

you can recover your password by going to the following url:

-

{{ final_url }} -

You can ignore this message if you did not request.

-

Regards

-

--
The Taiga Team

-
-{% endblock %} - -{% block footer %} -

- More info at: - {{ final_url_name }} -

+ {% trans full_name=user.get_full_name(), + url=resolve_front_url("change-password", user.token) %} +

Recover your password

+

Hello {{ full_name }},
you asked to recover your password

+ Recover your password +

You can ignore this message if you did not request.

+

The Taiga Team

+ {% endtrans %} {% endblock %} diff --git a/taiga/users/templates/emails/password_recovery-body-text.jinja b/taiga/users/templates/emails/password_recovery-body-text.jinja index 6fe87554..75bd1b95 100644 --- a/taiga/users/templates/emails/password_recovery-body-text.jinja +++ b/taiga/users/templates/emails/password_recovery-body-text.jinja @@ -1,12 +1,10 @@ -Hello {{ user.get_full_name() }}, +{% trans full_name=user.get_full_name(), url=resolve_front_url('change-password', user.token) %} +Hello {{ full_name }}, you asked to recover your password -you can recover your password by going to the following url: - -{{ resolve_front_url('change-password', user.token) }} +{{ url }} You can ignore this message if you did not request. -Regards - --- +--- The Taiga Team +{% endtrans %} diff --git a/taiga/users/templates/emails/password_recovery-subject.jinja b/taiga/users/templates/emails/password_recovery-subject.jinja index 86957e55..6193320d 100644 --- a/taiga/users/templates/emails/password_recovery-subject.jinja +++ b/taiga/users/templates/emails/password_recovery-subject.jinja @@ -1 +1 @@ -[Taiga] Password recovery +{{ _("[Taiga] Password recovery") }} diff --git a/taiga/users/templates/emails/registered_user-body-html.jinja b/taiga/users/templates/emails/registered_user-body-html.jinja index e82d0606..efe77ace 100644 --- a/taiga/users/templates/emails/registered_user-body-html.jinja +++ b/taiga/users/templates/emails/registered_user-body-html.jinja @@ -1,24 +1,26 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/hero-body-html.jinja" %} {% block body %} + {% trans %} +

Thank you for registering in Taiga

+

We hope you enjoy it

+

We built Taiga because we wanted the project management tool that sits open on our computers all day long, to serve as a continued reminder of why we love to collaborate, code and design.

+

We built it to be beautiful, elegant, simple to use and fun - without forsaking flexibility and power.

+ The taiga Team +
-

Welcome to Taiga, an Open Source, Agile Project Management Tool

- -

You, or someone you know has invited you to Taiga. You may remove your account from this service by clicking here:

- {{ resolve_front_url('cancel-account', cancel_token) }} - -

We built Taiga because we wanted the project management tool that sits open on our computers all day long, to serve as a continued reminder of why we love to collaborate, code and design. We built it to be beautiful, elegant, simple to use and fun - without forsaking flexibility and power. We named it Taiga after the forest biome, because like its namesake, our tool is at the center of an ecosystem - your team, your projects. A great project management tool also helps you see the forest for the trees. We couldn't think of a more appropriate name for what we've done.

- -

We hope you enjoy it.

-
{% endblock %} {% block footer %} -

- The Taiga development team. -

+ {{ super() }} +
+
+ {% trans url=resolve_front_url('cancel-account', cancel_token) %} + You may remove your account from this service clicking here + {% endtrans %} {% endblock %} diff --git a/taiga/users/templates/emails/registered_user-body-text.jinja b/taiga/users/templates/emails/registered_user-body-text.jinja index dde7e8b0..9ef756ce 100644 --- a/taiga/users/templates/emails/registered_user-body-text.jinja +++ b/taiga/users/templates/emails/registered_user-body-text.jinja @@ -1,12 +1,15 @@ -Welcome to Taiga, an Open Source, Agile Project Management Tool +{% trans %} +Thank you for registering in Taiga -You, or someone you know has invited you to Taiga. You may remove your account from this service by clicking here: +We hope you enjoy it -{{ resolve_front_url('cancel-account', cancel_token) }} +We built Taiga because we wanted the project management tool that sits open on our computers all day long, to serve as a continued reminder of why we love to collaborate, code and design. -We built Taiga because we wanted the project management tool that sits open on our computers all day long, to serve as a continued reminder of why we love to collaborate, code and design. We built it to be beautiful, elegant, simple to use and fun - without forsaking flexibility and power. We named it Taiga after the forest biome, because like its namesake, our tool is at the center of an ecosystem - your team, your projects. A great project management tool also helps you see the forest for the trees. We couldn't think of a more appropriate name for what we've done. - -We hope you enjoy it. +We built it to be beautiful, elegant, simple to use and fun - without forsaking flexibility and power. -- -The Taiga development team. +The taiga Team +{% endtrans %} +{% trans url=resolve_front_url('cancel-account', cancel_token) %} +You may remove your account from this service: {{ url }} +{% endtrans %} diff --git a/taiga/users/templates/emails/registered_user-subject.jinja b/taiga/users/templates/emails/registered_user-subject.jinja index 527a27a8..45fe8698 100644 --- a/taiga/users/templates/emails/registered_user-subject.jinja +++ b/taiga/users/templates/emails/registered_user-subject.jinja @@ -1 +1 @@ -You've been Taigatized! +{{ _("You've been Taigatized!") }} diff --git a/taiga/webhooks/__init__.py b/taiga/webhooks/__init__.py new file mode 100644 index 00000000..4f9173d3 --- /dev/null +++ b/taiga/webhooks/__init__.py @@ -0,0 +1,17 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +default_app_config = "taiga.webhooks.apps.WebhooksAppConfig" diff --git a/taiga/webhooks/api.py b/taiga/webhooks/api.py new file mode 100644 index 00000000..cd219574 --- /dev/null +++ b/taiga/webhooks/api.py @@ -0,0 +1,67 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import json + +from django.shortcuts import get_object_or_404 + +from rest_framework.response import Response + +from taiga.base import filters +from taiga.base.api import ModelCrudViewSet, ModelListViewSet +from taiga.base.decorators import detail_route + +from . import models +from . import serializers +from . import permissions +from . import tasks + + +class WebhookViewSet(ModelCrudViewSet): + model = models.Webhook + serializer_class = serializers.WebhookSerializer + permission_classes = (permissions.WebhookPermission,) + filter_backends = (filters.IsProjectAdminFilterBackend,) + filter_fields = ("project",) + + @detail_route(methods=["POST"]) + def test(self, request, pk=None): + webhook = self.get_object() + self.check_permissions(request, 'test', webhook) + + webhooklog = tasks.test_webhook(webhook.id, webhook.url, webhook.key) + log = serializers.WebhookLogSerializer(webhooklog) + + return Response(log.data) + +class WebhookLogViewSet(ModelListViewSet): + model = models.WebhookLog + serializer_class = serializers.WebhookLogSerializer + permission_classes = (permissions.WebhookLogPermission,) + filter_backends = (filters.IsProjectAdminFromWebhookLogFilterBackend,) + filter_fields = ("webhook",) + + @detail_route(methods=["POST"]) + def resend(self, request, pk=None): + webhooklog = self.get_object() + self.check_permissions(request, 'resend', webhooklog) + + webhook = webhooklog.webhook + + webhooklog = tasks.resend_webhook(webhook.id, webhook.url, webhook.key, webhooklog.request_data) + log = serializers.WebhookLogSerializer(webhooklog) + + return Response(log.data) diff --git a/taiga/webhooks/apps.py b/taiga/webhooks/apps.py new file mode 100644 index 00000000..5ae2ac20 --- /dev/null +++ b/taiga/webhooks/apps.py @@ -0,0 +1,37 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +from django.apps import AppConfig +from django.db.models import signals + +from . import signal_handlers as handlers +from taiga.projects.history.models import HistoryEntry + + +def connect_webhooks_signals(): + signals.post_save.connect(handlers.on_new_history_entry, sender=HistoryEntry, dispatch_uid="webhooks") + + +def disconnect_webhooks_signals(): + signals.post_save.disconnect(dispatch_uid="webhooks") + + +class WebhooksAppConfig(AppConfig): + name = "taiga.webhooks" + verbose_name = "Webhooks App Config" + + def ready(self): + connect_webhooks_signals() diff --git a/taiga/webhooks/migrations/0001_initial.py b/taiga/webhooks/migrations/0001_initial.py new file mode 100644 index 00000000..79ec8ba9 --- /dev/null +++ b/taiga/webhooks/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django_pgjson.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0015_auto_20141230_1212'), + ] + + operations = [ + migrations.CreateModel( + name='Webhook', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), + ('url', models.URLField(verbose_name='URL')), + ('key', models.TextField(verbose_name='secret key')), + ('project', models.ForeignKey(related_name='webhooks', to='projects.Project')), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='WebhookLog', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)), + ('url', models.URLField(verbose_name='URL')), + ('status', models.IntegerField(verbose_name='Status code')), + ('request_data', django_pgjson.fields.JsonField(verbose_name='Request data')), + ('response_data', models.TextField(verbose_name='Response data')), + ('webhook', models.ForeignKey(related_name='logs', to='webhooks.Webhook')), + ], + options={ + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/webhooks/migrations/0002_webhook_name.py b/taiga/webhooks/migrations/0002_webhook_name.py new file mode 100644 index 00000000..9def6115 --- /dev/null +++ b/taiga/webhooks/migrations/0002_webhook_name.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='name', + field=models.CharField(max_length=250, default='webhook', verbose_name='name'), + preserve_default=False, + ), + ] diff --git a/taiga/webhooks/migrations/0003_auto_20150122_1021.py b/taiga/webhooks/migrations/0003_auto_20150122_1021.py new file mode 100644 index 00000000..fa565ba3 --- /dev/null +++ b/taiga/webhooks/migrations/0003_auto_20150122_1021.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import datetime +import django_pgjson.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0002_webhook_name'), + ] + + operations = [ + migrations.AddField( + model_name='webhooklog', + name='created', + field=models.DateTimeField(default=datetime.datetime(2015, 1, 22, 10, 21, 17, 188643), auto_now_add=True), + preserve_default=False, + ), + migrations.AddField( + model_name='webhooklog', + name='duration', + field=models.FloatField(default=0, verbose_name='Duration'), + preserve_default=True, + ), + migrations.AddField( + model_name='webhooklog', + name='request_headers', + field=django_pgjson.fields.JsonField(default={}, verbose_name='Request headers'), + preserve_default=True, + ), + migrations.AddField( + model_name='webhooklog', + name='response_headers', + field=django_pgjson.fields.JsonField(default={}, verbose_name='Response headers'), + preserve_default=True, + ), + ] diff --git a/taiga/webhooks/migrations/__init__.py b/taiga/webhooks/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/webhooks/models.py b/taiga/webhooks/models.py new file mode 100644 index 00000000..6154b523 --- /dev/null +++ b/taiga/webhooks/models.py @@ -0,0 +1,48 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from django_pgjson.fields import JsonField + + +class Webhook(models.Model): + project = models.ForeignKey("projects.Project", null=False, blank=False, + related_name="webhooks") + name = models.CharField(max_length=250, null=False, blank=False, + verbose_name=_("name")) + url = models.URLField(null=False, blank=False, verbose_name=_("URL")) + key = models.TextField(null=False, blank=False, verbose_name=_("secret key")) + + class Meta: + ordering = ['name', '-id'] + + +class WebhookLog(models.Model): + webhook = models.ForeignKey(Webhook, null=False, blank=False, + related_name="logs") + url = models.URLField(null=False, blank=False, verbose_name=_("URL")) + status = models.IntegerField(null=False, blank=False, verbose_name=_("Status code")) + request_data = JsonField(null=False, blank=False, verbose_name=_("Request data")) + request_headers = JsonField(null=False, blank=False, verbose_name=_("Request headers"), default={}) + response_data = models.TextField(null=False, blank=False, verbose_name=_("Response data")) + response_headers = JsonField(null=False, blank=False, verbose_name=_("Response headers"), default={}) + duration = models.FloatField(null=False, blank=False, verbose_name=_("Duration"), default=0) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created', '-id'] diff --git a/taiga/webhooks/permissions.py b/taiga/webhooks/permissions.py new file mode 100644 index 00000000..7bc58ef6 --- /dev/null +++ b/taiga/webhooks/permissions.py @@ -0,0 +1,40 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +from taiga.base.api.permissions import (TaigaResourcePermission, IsProjectOwner, + AllowAny, PermissionComponent) + +from taiga.permissions.service import is_project_owner + + +class IsWebhookProjectOwner(PermissionComponent): + def check_permissions(self, request, view, obj=None): + return is_project_owner(request.user, obj.webhook.project) + + +class WebhookPermission(TaigaResourcePermission): + retrieve_perms = IsProjectOwner() + create_perms = IsProjectOwner() + update_perms = IsProjectOwner() + destroy_perms = IsProjectOwner() + list_perms = AllowAny() + test_perms = IsProjectOwner() + + +class WebhookLogPermission(TaigaResourcePermission): + retrieve_perms = IsWebhookProjectOwner() + list_perms = AllowAny() + resend_perms = IsWebhookProjectOwner() diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py new file mode 100644 index 00000000..ec087484 --- /dev/null +++ b/taiga/webhooks/serializers.py @@ -0,0 +1,138 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +from rest_framework import serializers + +from taiga.base.serializers import TagsField, PgArrayField, JsonField + +from taiga.projects.userstories import models as us_models +from taiga.projects.tasks import models as task_models +from taiga.projects.issues import models as issue_models +from taiga.projects.milestones import models as milestone_models +from taiga.projects.history import models as history_models +from taiga.projects.wiki import models as wiki_models + +from .models import Webhook, WebhookLog + + +class HistoryDiffField(serializers.Field): + def to_native(self, obj): + return {key: {"from": value[0], "to": value[1]} for key, value in obj.items()} + + +class WebhookSerializer(serializers.ModelSerializer): + logs_counter = serializers.SerializerMethodField("get_logs_counter") + class Meta: + model = Webhook + + def get_logs_counter(self, obj): + return obj.logs.count() + + +class WebhookLogSerializer(serializers.ModelSerializer): + request_data = JsonField() + request_headers = JsonField() + response_headers = JsonField() + + class Meta: + model = WebhookLog + + +class UserSerializer(serializers.Serializer): + id = serializers.SerializerMethodField("get_pk") + name = serializers.SerializerMethodField("get_name") + + def get_pk(self, obj): + return obj.pk + + def get_name(self, obj): + return obj.full_name + +class PointSerializer(serializers.Serializer): + id = serializers.SerializerMethodField("get_pk") + name = serializers.SerializerMethodField("get_name") + value = serializers.SerializerMethodField("get_value") + + def get_pk(self, obj): + return obj.pk + + def get_name(self, obj): + return obj.name + + def get_value(self, obj): + return obj.value + + +class UserStorySerializer(serializers.ModelSerializer): + tags = TagsField(default=[], required=False) + external_reference = PgArrayField(required=False) + owner = UserSerializer() + assigned_to = UserSerializer() + watchers = UserSerializer(many=True) + points = PointSerializer(many=True) + + class Meta: + model = us_models.UserStory + exclude = ("backlog_order", "sprint_order", "kanban_order", "version") + + +class TaskSerializer(serializers.ModelSerializer): + tags = TagsField(default=[], required=False) + owner = UserSerializer() + assigned_to = UserSerializer() + watchers = UserSerializer(many=True) + + class Meta: + model = task_models.Task + + +class IssueSerializer(serializers.ModelSerializer): + tags = TagsField(default=[], required=False) + owner = UserSerializer() + assigned_to = UserSerializer() + watchers = UserSerializer(many=True) + + class Meta: + model = issue_models.Issue + + +class WikiPageSerializer(serializers.ModelSerializer): + owner = UserSerializer() + last_modifier = UserSerializer() + watchers = UserSerializer(many=True) + + class Meta: + model = wiki_models.WikiPage + exclude = ("watchers", "version") + + +class MilestoneSerializer(serializers.ModelSerializer): + owner = UserSerializer() + + class Meta: + model = milestone_models.Milestone + exclude = ("order", "watchers") + + +class HistoryEntrySerializer(serializers.ModelSerializer): + diff = HistoryDiffField() + snapshot = JsonField() + values = JsonField() + user = JsonField() + delete_comment_user = JsonField() + + class Meta: + model = history_models.HistoryEntry diff --git a/taiga/webhooks/signal_handlers.py b/taiga/webhooks/signal_handlers.py new file mode 100644 index 00000000..0483b145 --- /dev/null +++ b/taiga/webhooks/signal_handlers.py @@ -0,0 +1,65 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +from django.conf import settings + +from taiga.projects.history import services as history_service +from taiga.projects.history.choices import HistoryType + +from . import tasks + + +def _get_project_webhooks(project): + webhooks = [] + for webhook in project.webhooks.all(): + webhooks.append({ + "id": webhook.pk, + "url": webhook.url, + "key": webhook.key, + }) + return webhooks + + +def on_new_history_entry(sender, instance, created, **kwargs): + if not settings.WEBHOOKS_ENABLED: + return None + + if instance.is_hidden: + return None + + model = history_service.get_model_from_key(instance.key) + pk = history_service.get_pk_from_key(instance.key) + obj = model.objects.get(pk=pk) + + webhooks = _get_project_webhooks(obj.project) + + if instance.type == HistoryType.create: + task = tasks.create_webhook + extra_args = [] + elif instance.type == HistoryType.change: + task = tasks.change_webhook + extra_args = [instance] + elif instance.type == HistoryType.delete: + task = tasks.delete_webhook + extra_args = [] + + for webhook in webhooks: + args = [webhook["id"], webhook["url"], webhook["key"], obj] + extra_args + + if settings.CELERY_ENABLED: + task.delay(*args) + else: + task(*args) diff --git a/taiga/webhooks/tasks.py b/taiga/webhooks/tasks.py new file mode 100644 index 00000000..7a0f6dcd --- /dev/null +++ b/taiga/webhooks/tasks.py @@ -0,0 +1,138 @@ +# Copyright (C) 2013 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import hmac +import hashlib +import requests +from requests.exceptions import RequestException + +from rest_framework.renderers import UnicodeJSONRenderer + +from taiga.base.utils.db import get_typename_for_model_instance +from taiga.celery import app + +from .serializers import (UserStorySerializer, IssueSerializer, TaskSerializer, + WikiPageSerializer, MilestoneSerializer, + HistoryEntrySerializer) +from .models import WebhookLog + + +def _serialize(obj): + content_type = get_typename_for_model_instance(obj) + + if content_type == "userstories.userstory": + return UserStorySerializer(obj).data + elif content_type == "issues.issue": + return IssueSerializer(obj).data + elif content_type == "tasks.task": + return TaskSerializer(obj).data + elif content_type == "wiki.wikipage": + return WikiPageSerializer(obj).data + elif content_type == "milestones.milestone": + return MilestoneSerializer(obj).data + elif content_type == "history.historyentry": + return HistoryEntrySerializer(obj).data + + +def _get_type(obj): + content_type = get_typename_for_model_instance(obj) + return content_type.split(".")[1] + + +def _generate_signature(data, key): + mac = hmac.new(key.encode("utf-8"), msg=data, digestmod=hashlib.sha1) + return mac.hexdigest() + + +def _send_request(webhook_id, url, key, data): + serialized_data = UnicodeJSONRenderer().render(data) + signature = _generate_signature(serialized_data, key) + headers = { + "X-TAIGA-WEBHOOK-SIGNATURE": signature, + "Content-Type": "application/json" + } + request = requests.Request('POST', url, data=serialized_data, headers=headers) + prepared_request = request.prepare() + + session = requests.Session() + try: + response = session.send(prepared_request) + webhook_log = WebhookLog.objects.create(webhook_id=webhook_id, url=url, + status=response.status_code, + request_data=data, + request_headers=dict(prepared_request.headers), + response_data=response.content, + response_headers=dict(response.headers), + duration=response.elapsed.total_seconds()) + except RequestException as e: + webhook_log = WebhookLog.objects.create(webhook_id=webhook_id, url=url, status=0, + request_data=data, + request_headers=dict(prepared_request.headers), + response_data="error-in-request: {}".format(str(e)), + response_headers={}, + duration=0) + session.close() + + ids = [webhook_log.id for webhook_log in WebhookLog.objects.filter(webhook_id=webhook_id).order_by("-id")[10:]] + WebhookLog.objects.filter(id__in=ids).delete() + return webhook_log + + +@app.task +def change_webhook(webhook_id, url, key, obj, change): + data = {} + data['data'] = _serialize(obj) + data['action'] = "change" + data['type'] = _get_type(obj) + data['change'] = _serialize(change) + + return _send_request(webhook_id, url, key, data) + + +@app.task +def create_webhook(webhook_id, url, key, obj): + data = {} + data['data'] = _serialize(obj) + data['action'] = "create" + data['type'] = _get_type(obj) + + return _send_request(webhook_id, url, key, data) + + +@app.task +def delete_webhook(webhook_id, url, key, obj): + data = {} + data['data'] = _serialize(obj) + data['action'] = "delete" + data['type'] = _get_type(obj) + + return _send_request(webhook_id, url, key, data) + + +@app.task +def resend_webhook(webhook_id, url, key, data): + return _send_request(webhook_id, url, key, data) + + +@app.task +def test_webhook(webhook_id, url, key): + data = {} + data['data'] = {"test": "test"} + data['action'] = "test" + data['type'] = "test" + + return _send_request(webhook_id, url, key, data) + diff --git a/tests/factories.py b/tests/factories.py index f49e4d49..14f120a8 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -197,6 +197,29 @@ class InvitationFactory(Factory): email = factory.Sequence(lambda n: "user{}@email.com".format(n)) +class WebhookFactory(Factory): + class Meta: + model = "webhooks.Webhook" + strategy = factory.CREATE_STRATEGY + + project = factory.SubFactory("tests.factories.ProjectFactory") + url = "http://localhost:8080/test" + key = "factory-key" + name = "Factory-name" + + +class WebhookLogFactory(Factory): + class Meta: + model = "webhooks.WebhookLog" + strategy = factory.CREATE_STRATEGY + + webhook = factory.SubFactory("tests.factories.WebhookFactory") + url = "http://localhost:8080/test" + status = "200" + request_data = "test-request" + response_data = "test-response" + + class StorageEntryFactory(Factory): class Meta: model = "userstorage.StorageEntry" @@ -280,6 +303,7 @@ class TaskFactory(Factory): status = factory.SubFactory("tests.factories.TaskStatusFactory") milestone = factory.SubFactory("tests.factories.MilestoneFactory") user_story = factory.SubFactory("tests.factories.UserStoryFactory") + tags = [] class WikiPageFactory(Factory): diff --git a/tests/integration/resources_permissions/test_webhooks_resources.py b/tests/integration/resources_permissions/test_webhooks_resources.py new file mode 100644 index 00000000..9514fc88 --- /dev/null +++ b/tests/integration/resources_permissions/test_webhooks_resources.py @@ -0,0 +1,309 @@ +from django.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.webhooks.serializers import WebhookSerializer +from taiga.webhooks.models import Webhook +from taiga.webhooks import tasks + +from tests import factories as f +from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals + +from unittest import mock + +import pytest +pytestmark = pytest.mark.django_db + + +def setup_module(module): + disconnect_signals() + + +def teardown_module(module): + reconnect_signals() + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + + m.registered_user = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + + m.project1 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + m.project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + f.MembershipFactory(project=m.project1, + user=m.project_owner, + is_owner=True) + + m.webhook1 = f.WebhookFactory(project=m.project1) + m.webhooklog1 = f.WebhookLogFactory(webhook=m.webhook1) + m.webhook2 = f.WebhookFactory(project=m.project2) + m.webhooklog2 = f.WebhookLogFactory(webhook=m.webhook2) + + return m + + +def test_webhook_retrieve(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', url1, None, users) + assert results == [401, 403, 200] + results = helper_test_http_method(client, 'get', url2, None, users) + assert results == [401, 403, 403] + + +def test_webhook_update(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + webhook_data = WebhookSerializer(data.webhook1).data + webhook_data["key"] = "test" + webhook_data = json.dumps(webhook_data) + results = helper_test_http_method(client, 'put', url1, webhook_data, users) + assert results == [401, 403, 200] + + webhook_data = WebhookSerializer(data.webhook2).data + webhook_data["key"] = "test" + webhook_data = json.dumps(webhook_data) + results = helper_test_http_method(client, 'put', url2, webhook_data, users) + assert results == [401, 403, 403] + + +def test_webhook_delete(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + results = helper_test_http_method(client, 'delete', url1, None, users) + assert results == [401, 403, 204] + results = helper_test_http_method(client, 'delete', url2, None, users) + assert results == [401, 403, 403] + + +def test_webhook_list(client, data): + url = reverse('webhooks-list') + + response = client.get(url) + webhooks_data = json.loads(response.content.decode('utf-8')) + assert len(webhooks_data) == 0 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + webhooks_data = json.loads(response.content.decode('utf-8')) + assert len(webhooks_data) == 0 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + webhooks_data = json.loads(response.content.decode('utf-8')) + assert len(webhooks_data) == 1 + assert response.status_code == 200 + + +def test_webhook_create(client, data): + url = reverse('webhooks-list') + + users = [ + None, + data.registered_user, + data.project_owner + ] + + create_data = json.dumps({ + "name": "Test", + "url": "http://test.com", + "key": "test", + "project": data.project1.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Webhook.objects.all().delete()) + assert results == [401, 403, 201] + + create_data = json.dumps({ + "name": "Test", + "url": "http://test.com", + "key": "test", + "project": data.project2.pk, + }) + results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Webhook.objects.all().delete()) + assert results == [401, 403, 403] + + +def test_webhook_patch(client, data): + url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + patch_data = json.dumps({"key": "test"}) + results = helper_test_http_method(client, 'patch', url1, patch_data, users) + assert results == [401, 403, 200] + + patch_data = json.dumps({"key": "test"}) + results = helper_test_http_method(client, 'patch', url2, patch_data, users) + assert results == [401, 403, 403] + + +def test_webhook_action_test(client, data): + url1 = reverse('webhooks-test', kwargs={"pk": data.webhook1.pk}) + url2 = reverse('webhooks-test', kwargs={"pk": data.webhook2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + results = helper_test_http_method(client, 'post', url1, None, users) + assert results == [404, 404, 200] + assert _send_request_mock.called == True + + with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + results = helper_test_http_method(client, 'post', url2, None, users) + assert results == [404, 404, 404] + assert _send_request_mock.called == False + + +def test_webhooklogs_list(client, data): + url = reverse('webhooklogs-list') + + response = client.get(url) + webhooklogs_data = json.loads(response.content.decode('utf-8')) + assert len(webhooklogs_data) == 0 + assert response.status_code == 200 + + client.login(data.registered_user) + + response = client.get(url) + webhooklogs_data = json.loads(response.content.decode('utf-8')) + assert len(webhooklogs_data) == 0 + assert response.status_code == 200 + + client.login(data.project_owner) + + response = client.get(url) + webhooklogs_data = json.loads(response.content.decode('utf-8')) + assert len(webhooklogs_data) == 1 + assert response.status_code == 200 + + +def test_webhooklogs_retrieve(client, data): + url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', url1, None, users) + assert results == [401, 403, 200] + + results = helper_test_http_method(client, 'get', url2, None, users) + assert results == [401, 403, 403] + + +def test_webhooklogs_create(client, data): + url1 = reverse('webhooklogs-list') + url2 = reverse('webhooklogs-list') + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'post', url2, None, users) + assert results == [405, 405, 405] + + +def test_webhooklogs_delete(client, data): + url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'delete', url2, None, users) + assert results == [405, 405, 405] + + +def test_webhooklogs_update(client, data): + url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'put', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'put', url2, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'patch', url1, None, users) + assert results == [405, 405, 405] + + results = helper_test_http_method(client, 'patch', url2, None, users) + assert results == [405, 405, 405] + + +def test_webhooklogs_action_resend(client, data): + url1 = reverse('webhooklogs-resend', kwargs={"pk": data.webhooklog1.pk}) + url2 = reverse('webhooklogs-resend', kwargs={"pk": data.webhooklog2.pk}) + + users = [ + None, + data.registered_user, + data.project_owner + ] + + results = helper_test_http_method(client, 'post', url1, None, users) + assert results == [404, 404, 200] + + results = helper_test_http_method(client, 'post', url2, None, users) + assert results == [404, 404, 404] diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py index d322fa32..793d73d1 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -89,10 +89,6 @@ def test_response_200_in_public_registration(client, settings): assert response.data["full_name"] == "martin seamus mcfly" assert len(mail.outbox) == 1 assert mail.outbox[0].subject == "You've been Taigatized!" - user = models.User.objects.get(username="mmcfly") - cancel_token = get_token_for_user(user, "cancel_account") - cancel_url = resolve_front_url("cancel-account", cancel_token) - assert mail.outbox[0].body.index(cancel_url) > 0 def test_response_200_in_registration_with_github_account(client, settings): settings.PUBLIC_REGISTER_ENABLED = False diff --git a/tests/integration/test_exporter_api.py b/tests/integration/test_exporter_api.py new file mode 100644 index 00000000..7758fdf6 --- /dev/null +++ b/tests/integration/test_exporter_api.py @@ -0,0 +1,84 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import pytest + +from django.core.urlresolvers import reverse + +from .. import factories as f +from taiga.base.utils import json + + +pytestmark = pytest.mark.django_db + + +def test_invalid_project_export(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("exporter-detail", args=[1000000]) + + response = client.get(url, content_type="application/json") + assert response.status_code == 404 + + +def test_valid_project_export_with_celery_disabled(client, settings): + settings.CELERY_ENABLED = False + + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_owner=True) + client.login(user) + + url = reverse("exporter-detail", args=[project.pk]) + + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert "url" in response_data + + +def test_valid_project_export_with_celery_enabled(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_owner=True) + client.login(user) + + url = reverse("exporter-detail", args=[project.pk]) + + response = client.get(url, content_type="application/json") + assert response.status_code == 202 + response_data = json.loads(response.content.decode("utf-8")) + assert "export_id" in response_data + + +def test_valid_project_with_throttling(client, settings): + settings.CELERY_ENABLED = False + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["import-dump-mode"] = "1/minute" + + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_owner=True) + client.login(user) + + url = reverse("exporter-detail", args=[project.pk]) + + response = client.get(url, content_type="application/json") + assert response.status_code == 200 + response = client.get(url, content_type="application/json") + assert response.status_code == 429 diff --git a/tests/integration/test_history.py b/tests/integration/test_history.py index 690b724e..f8f30998 100644 --- a/tests/integration/test_history.py +++ b/tests/integration/test_history.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json import pytest from unittest.mock import MagicMock from unittest.mock import patch @@ -23,6 +22,7 @@ from unittest.mock import patch from django.core.urlresolvers import reverse from .. import factories as f +from taiga.base.utils import json from taiga.projects.history import services from taiga.projects.history.models import HistoryEntry from taiga.projects.history.choices import HistoryType diff --git a/tests/integration/test_hooks_bitbucket.py b/tests/integration/test_hooks_bitbucket.py index 2fd53059..f071400e 100644 --- a/tests/integration/test_hooks_bitbucket.py +++ b/tests/integration/test_hooks_bitbucket.py @@ -1,5 +1,4 @@ import pytest -import json import urllib from unittest import mock @@ -8,6 +7,7 @@ from django.core.urlresolvers import reverse from django.core import mail from django.conf import settings +from taiga.base.utils import json from taiga.hooks.bitbucket import event_hooks from taiga.hooks.bitbucket.api import BitBucketViewSet from taiga.hooks.exceptions import ActionSyntaxException diff --git a/tests/integration/test_hooks_github.py b/tests/integration/test_hooks_github.py index bd5103ae..08a33bbd 100644 --- a/tests/integration/test_hooks_github.py +++ b/tests/integration/test_hooks_github.py @@ -1,11 +1,11 @@ import pytest -import json from unittest import mock from django.core.urlresolvers import reverse from django.core import mail +from taiga.base.utils import json from taiga.hooks.github import event_hooks from taiga.hooks.github.api import GitHubViewSet from taiga.hooks.exceptions import ActionSyntaxException diff --git a/tests/integration/test_hooks_gitlab.py b/tests/integration/test_hooks_gitlab.py index adf3970c..2bb4305a 100644 --- a/tests/integration/test_hooks_gitlab.py +++ b/tests/integration/test_hooks_gitlab.py @@ -1,11 +1,11 @@ import pytest -import json from unittest import mock from django.core.urlresolvers import reverse from django.core import mail +from taiga.base.utils import json from taiga.hooks.gitlab import event_hooks from taiga.hooks.gitlab.api import GitLabViewSet from taiga.hooks.exceptions import ActionSyntaxException diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 00480c0d..14fd299e 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -15,14 +15,15 @@ # along with this program. If not, see . import pytest -import json import base64 import datetime from django.core.urlresolvers import reverse +from django.core.files.base import ContentFile from .. import factories as f +from taiga.base.utils import json from taiga.projects.models import Project from taiga.projects.issues.models import Issue from taiga.projects.userstories.models import UserStory @@ -703,3 +704,99 @@ def test_milestone_import_duplicated_milestone(client): assert response.status_code == 400 response_data = json.loads(response.content.decode("utf-8")) assert response_data["milestones"][0]["name"][0] == "Name duplicated for the project" + +def test_invalid_dump_import(client): + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(b"test") + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["_error_message"] == "Invalid dump format" + +def test_valid_dump_import_with_celery_disabled(client, settings): + settings.CELERY_ENABLED = False + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc" + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + response_data = json.loads(response.content.decode("utf-8")) + assert "id" in response_data + assert response_data["name"] == "Valid project" + +def test_valid_dump_import_with_celery_enabled(client, settings): + settings.CELERY_ENABLED = True + + user = f.UserFactory.create() + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc" + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 202 + response_data = json.loads(response.content.decode("utf-8")) + assert "import_id" in response_data + +def test_dump_import_duplicated_project(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": project.slug, + "name": "Test import", + "description": "Valid project desc" + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["name"] == "Test import" + assert response_data["slug"] == "{}-test-import".format(user.username) + +def test_dump_import_throttling(client, settings): + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["import-dump-mode"] = "1/minute" + + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": project.slug, + "name": "Test import", + "description": "Valid project desc" + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + response = client.post(url, {'dump': data}) + assert response.status_code == 429 diff --git a/tests/integration/test_issues.py b/tests/integration/test_issues.py index 5a3b4ac7..c415b492 100644 --- a/tests/integration/test_issues.py +++ b/tests/integration/test_issues.py @@ -1,5 +1,4 @@ from unittest import mock -import json from django.core.urlresolvers import reverse diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py index 13e641c3..f100af22 100644 --- a/tests/integration/test_notifications.py +++ b/tests/integration/test_notifications.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json import pytest import time from unittest.mock import MagicMock, patch @@ -24,6 +23,7 @@ from django.core.urlresolvers import reverse from django.apps import apps from .. import factories as f +from taiga.base.utils import json from taiga.projects.notifications import services from taiga.projects.notifications import models from taiga.projects.notifications.choices import NotifyLevel diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py index 924e8e99..06639294 100644 --- a/tests/integration/test_tasks.py +++ b/tests/integration/test_tasks.py @@ -1,5 +1,4 @@ from unittest import mock -import json from django.core.urlresolvers import reverse diff --git a/tests/integration/test_timeline.py b/tests/integration/test_timeline.py index db510ec4..d3f84a3b 100644 --- a/tests/integration/test_timeline.py +++ b/tests/integration/test_timeline.py @@ -15,11 +15,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json import pytest from .. import factories +from taiga.base.utils import json from taiga.timeline import service from taiga.timeline.models import Timeline diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index 7c749cdb..38b03ba1 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -1,11 +1,11 @@ import pytest -import json from tempfile import NamedTemporaryFile from django.core.urlresolvers import reverse from .. import factories as f +from taiga.base.utils import json from taiga.users import models from taiga.auth.tokens import get_token_for_user diff --git a/tests/integration/test_userstorage_api.py b/tests/integration/test_userstorage_api.py index ad0fe680..fb2eef3f 100644 --- a/tests/integration/test_userstorage_api.py +++ b/tests/integration/test_userstorage_api.py @@ -15,12 +15,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json import pytest from django.core.urlresolvers import reverse from .. import factories +from taiga.base.utils import json pytestmark = pytest.mark.django_db diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index caad1435..a9070e50 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -190,7 +190,8 @@ def test_archived_filter(client): project = f.ProjectFactory.create(owner=user) f.MembershipFactory.create(project=project, user=user, is_owner=True) f.UserStoryFactory.create(project=project) - f.UserStoryFactory.create(is_archived=True, project=project) + archived_status = f.UserStoryStatusFactory.create(is_archived=True) + f.UserStoryFactory.create(status=archived_status, project=project) client.login(user) @@ -200,11 +201,11 @@ def test_archived_filter(client): response = client.get(url, data) assert len(json.loads(response.content)) == 2 - data = {"is_archived": 0} + data = {"status__is_archived": 0} response = client.get(url, data) assert len(json.loads(response.content)) == 1 - data = {"is_archived": 1} + data = {"status__is_archived": 1} response = client.get(url, data) assert len(json.loads(response.content)) == 1 diff --git a/tests/integration/test_webhooks.py b/tests/integration/test_webhooks.py new file mode 100644 index 00000000..0b3b32f0 --- /dev/null +++ b/tests/integration/test_webhooks.py @@ -0,0 +1,92 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# Copyright (C) 2014 Anler Hernández +# 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 . + +import pytest +from unittest.mock import patch + +from .. import factories as f + +from taiga.projects.history import services + +pytestmark = pytest.mark.django_db + + +def test_new_object_with_one_webhook(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + + objects = [ + f.IssueFactory.create(project=project), + f.TaskFactory.create(project=project), + f.UserStoryFactory.create(project=project), + f.WikiPageFactory.create(project=project) + ] + + for obj in objects: + with patch('taiga.webhooks.tasks.create_webhook') as create_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test") + assert create_webhook_mock.call_count == 1 + + for obj in objects: + with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock: + services.take_snapshot(obj, user=obj.owner) + assert change_webhook_mock.call_count == 0 + + for obj in objects: + with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test") + assert change_webhook_mock.call_count == 1 + + for obj in objects: + with patch('taiga.webhooks.tasks.delete_webhook') as delete_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) + assert delete_webhook_mock.call_count == 1 + + +def test_new_object_with_two_webhook(settings): + settings.WEBHOOKS_ENABLED = True + project = f.ProjectFactory() + f.WebhookFactory.create(project=project) + f.WebhookFactory.create(project=project) + + objects = [ + f.IssueFactory.create(project=project), + f.TaskFactory.create(project=project), + f.UserStoryFactory.create(project=project), + f.WikiPageFactory.create(project=project) + ] + + for obj in objects: + with patch('taiga.webhooks.tasks.create_webhook') as create_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test") + assert create_webhook_mock.call_count == 2 + + for obj in objects: + with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test") + assert change_webhook_mock.call_count == 2 + + for obj in objects: + with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock: + services.take_snapshot(obj, user=obj.owner) + assert change_webhook_mock.call_count == 0 + + for obj in objects: + with patch('taiga.webhooks.tasks.delete_webhook') as delete_webhook_mock: + services.take_snapshot(obj, user=obj.owner, comment="test", delete=True) + assert delete_webhook_mock.call_count == 2 diff --git a/tests/utils.py b/tests/utils.py index 6a437129..e30c90d0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -15,9 +15,9 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import functools -import json from django.db.models import signals +from taiga.base.utils import json def signals_switch():