From a7ed9107c3a535fbe214162cb9eaa46ca6b95d21 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 10 Dec 2014 11:15:02 +0100 Subject: [PATCH 01/54] Adding migration for fixing slugs in ProjectTemplates --- .../migrations/0012_auto_20141210_1009.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 taiga/projects/migrations/0012_auto_20141210_1009.py diff --git a/taiga/projects/migrations/0012_auto_20141210_1009.py b/taiga/projects/migrations/0012_auto_20141210_1009.py new file mode 100644 index 00000000..0a6cdec7 --- /dev/null +++ b/taiga/projects/migrations/0012_auto_20141210_1009.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.template.defaultfilters import slugify + + +def fix_project_template_slugs(apps, schema_editor): + ProjectTemplate = apps.get_model("projects", "ProjectTemplate") + for pt in ProjectTemplate.objects.all(): + for us_status in pt.us_statuses: + us_status["slug"] = slugify(us_status["name"]) + for task_status in pt.task_statuses: + task_status["slug"] = slugify(task_status["name"]) + for issue_status in pt.issue_statuses: + issue_status["slug"] = slugify(issue_status["name"]) + pt.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0011_auto_20141028_2057'), + ] + + operations = [ + migrations.RunPython(fix_project_template_slugs), + ] From f37de859617a8f9cf8f4edb50809430e0c443844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 10 Dec 2014 20:39:12 +0100 Subject: [PATCH 02/54] Remove unnecesary code --- taiga/mdrender/service.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/taiga/mdrender/service.py b/taiga/mdrender/service.py index 9c7990b5..70d236a5 100644 --- a/taiga/mdrender/service.py +++ b/taiga/mdrender/service.py @@ -36,7 +36,6 @@ bleach._serialize = _serialize from django.core.cache import cache from django.utils.encoding import force_bytes -from django.template.defaultfilters import slugify from markdown import Markdown @@ -101,9 +100,6 @@ def cache_by_sha(func): def _get_markdown(project): - def build_url(*args, **kwargs): - return args[1] + slugify(args[0]) - extensions = _make_extensions_list(project=project) md = Markdown(extensions=extensions) md.extracted_data = {"mentions": [], "references": []} From ce88a6007355008973d81cd5da52dfa30d53730c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 10 Dec 2014 21:02:43 +0100 Subject: [PATCH 03/54] tg-1783 #ready-for-test - Fix wiki links compatibility --- taiga/base/utils/slug.py | 13 ++++++++++--- taiga/mdrender/extensions/wikilinks.py | 3 ++- tests/unit/test_mdrender.py | 11 +++++++++++ tests/unit/test_slug.py | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/taiga/base/utils/slug.py b/taiga/base/utils/slug.py index 576ecfa3..d017664a 100644 --- a/taiga/base/utils/slug.py +++ b/taiga/base/utils/slug.py @@ -15,20 +15,27 @@ # along with this program. If not, see . from django.utils import baseconv -from django.template.defaultfilters import slugify +from django.template.defaultfilters import slugify as django_slugify import time from unidecode import unidecode +def slugify(value): + """ + Return a slug + """ + return django_slugify(unidecode(value or "")) + + def slugify_uniquely(value, model, slugfield="slug"): """ Returns a slug on a name which is unique within a model's table """ suffix = 0 - potential = base = slugify(unidecode(value)) + potential = base = django_slugify(unidecode(value)) if len(potential) == 0: potential = 'null' while True: @@ -45,7 +52,7 @@ def slugify_uniquely_for_queryset(value, queryset, slugfield="slug"): """ suffix = 0 - potential = base = slugify(unidecode(value)) + potential = base = django_slugify(unidecode(value)) if len(potential) == 0: potential = 'null' while True: diff --git a/taiga/mdrender/extensions/wikilinks.py b/taiga/mdrender/extensions/wikilinks.py index 9eedbbcf..b603e1cf 100644 --- a/taiga/mdrender/extensions/wikilinks.py +++ b/taiga/mdrender/extensions/wikilinks.py @@ -22,6 +22,7 @@ from markdown.treeprocessors import Treeprocessor from markdown.util import etree from taiga.front import resolve +from taiga.base.utils.slug import slugify import re @@ -48,7 +49,7 @@ class WikiLinksPattern(Pattern): def handleMatch(self, m): label = m.group(2).strip() - url = resolve("wiki", self.project.slug, label) + url = resolve("wiki", self.project.slug, slugify(label)) if m.group(3): title = m.group(3).strip()[1:] diff --git a/tests/unit/test_mdrender.py b/tests/unit/test_mdrender.py index a31b749c..6e7002e0 100644 --- a/tests/unit/test_mdrender.py +++ b/tests/unit/test_mdrender.py @@ -101,6 +101,17 @@ def test_render_wikilink(): expected_result = "

test

" assert render(dummy_project, "[[test]]") == expected_result +def test_render_wikilink_1(): + expected_result = "

test

" + assert render(dummy_project, "[[test]]") == expected_result + +def test_render_wikilink_2(): + expected_result = "

test page

" + assert render(dummy_project, "[[test page]]") == expected_result + +def test_render_wikilink_3(): + expected_result = "

TestPage

" + assert render(dummy_project, "[[TestPage]]") == expected_result def test_render_wikilink_with_custom_title(): expected_result = "

custom

" diff --git a/tests/unit/test_slug.py b/tests/unit/test_slug.py index ea7dd17d..deb1961b 100644 --- a/tests/unit/test_slug.py +++ b/tests/unit/test_slug.py @@ -18,10 +18,24 @@ from taiga.projects.models import Project from taiga.users.models import User +from taiga.base.utils.slug import slugify + import pytest pytestmark = pytest.mark.django_db +def test_slugify_1(): + assert slugify("漢字") == "han-zi" + + +def test_slugify_2(): + assert slugify("TestExamplePage") == "testexamplepage" + + +def test_slugify_3(): + assert slugify(None) == "" + + def test_project_slug_with_special_chars(): user = User.objects.create(username="test") project = Project.objects.create(name="漢字", description="漢字", owner=user) From 78e703ad534b3b7101d218d44a4a410255ea16f8 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 11 Dec 2014 10:07:45 +0100 Subject: [PATCH 04/54] Adding user_active info to membership serializer --- taiga/projects/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 5dbbba4c..153d99a9 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") From 75e42efda2536196e2fc8317696373e63f46a728 Mon Sep 17 00:00:00 2001 From: Hector Colina - e1th0r Date: Fri, 19 Dec 2014 19:52:23 -0430 Subject: [PATCH 05/54] Modifications to django.po file for to include new translations and fixed some gramatical errors --- taiga/locale/es/LC_MESSAGES/django.po | 85 ++++++++++++++------------- 1 file changed, 44 insertions(+), 41 deletions(-) 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" From 6d80927d9636e9f5f49413ff7325c25b0156873c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Sat, 20 Dec 2014 11:06:24 +0100 Subject: [PATCH 06/54] Try to fix tests --- .travis.yml | 1 + 1 file changed, 1 insertion(+) 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: From 88a1cd33b223f796a84321f51895c64f4d650cb1 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 22 Dec 2014 09:59:49 +0100 Subject: [PATCH 07/54] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5e23fc97..8374ecae 100644 --- a/README.md +++ b/README.md @@ -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 ## From bb40c528d7b4d67035bf07beb0c06d2c46065d99 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 23 Dec 2014 12:30:51 +0100 Subject: [PATCH 08/54] Adding end points for getting projects by slug, tasks, userstories and issues by ref --- taiga/projects/api.py | 6 ++++++ taiga/projects/issues/api.py | 7 +++++++ taiga/projects/permissions.py | 1 + taiga/projects/serializers.py | 4 ++++ taiga/projects/tasks/api.py | 7 +++++++ taiga/projects/userstories/api.py | 7 +++++++ taiga/projects/wiki/api.py | 7 +++++++ 7 files changed, 39 insertions(+) diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 5f5532f8..d92806d7 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -60,6 +60,12 @@ class ProjectViewSet(ModelCrudViewSet): qs = models.Project.objects.all() return attach_votescount_to_queryset(qs, as_field="stars_count") + @list_route(methods=["GET"]) + def by_slug(self, request): + slug = request.QUERY_PARAMS.get("slug", None) + project = get_object_or_404(models.Project, slug=slug) + return self.retrieve(request, pk=project.pk) + @detail_route(methods=["GET", "PATCH"]) def modules(self, request, pk=None): project = self.get_object() diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index 94dff493..cdae587f 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -153,6 +153,13 @@ class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, if obj.type and obj.type.project != obj.project: raise exc.PermissionDenied(_("You don't have permissions to set this type to this issue.")) + @list_route(methods=["GET"]) + def by_ref(self, request): + ref = request.QUERY_PARAMS.get("ref", None) + project_id = request.QUERY_PARAMS.get("project", None) + issue = get_object_or_404(models.Issue, ref=ref, project_id=project_id) + return self.retrieve(request, pk=issue.pk) + @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): serializer = serializers.IssuesBulkSerializer(data=request.DATA) diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index 126ac9c0..aaea97f4 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -40,6 +40,7 @@ class CanLeaveProject(PermissionComponent): class ProjectPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') + by_slug_perms = HasProjectPerm('view_project') create_perms = IsAuthenticated() update_perms = IsProjectOwner() partial_update_perms = IsProjectOwner() diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 153d99a9..861ba937 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -237,6 +237,7 @@ class ProjectSerializer(ModelSerializer): my_permissions = serializers.SerializerMethodField("get_my_permissions") i_am_owner = serializers.SerializerMethodField("get_i_am_owner") tags_colors = TagsColorsField(required=False) + users = serializers.SerializerMethodField("get_users") class Meta: model = models.Project @@ -257,6 +258,9 @@ class ProjectSerializer(ModelSerializer): return is_project_owner(self.context["request"].user, obj) return False + def get_users(self, obj): + return UserSerializer(obj.members.all(), many=True).data + 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/tasks/api.py b/taiga/projects/tasks/api.py index ed7cd208..a4e6361d 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -64,6 +64,13 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, if obj.milestone and obj.user_story and obj.milestone != obj.user_story.milestone: raise exc.WrongArguments(_("You don't have permissions for add/modify this task.")) + @list_route(methods=["GET"]) + def by_ref(self, request): + ref = request.QUERY_PARAMS.get("ref", None) + project_id = request.QUERY_PARAMS.get("project", None) + task = get_object_or_404(models.Task, ref=ref, project_id=project_id) + return self.retrieve(request, pk=task.pk) + @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): serializer = serializers.TasksBulkSerializer(data=request.DATA) diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 166ddd26..796159e6 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -95,6 +95,13 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi super().post_save(obj, created) + @list_route(methods=["GET"]) + def by_ref(self, request): + ref = request.QUERY_PARAMS.get("ref", None) + project_id = request.QUERY_PARAMS.get("project", None) + userstory = get_object_or_404(models.UserStory, ref=ref, project_id=project_id) + return self.retrieve(request, pk=userstory.pk) + @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): serializer = serializers.UserStoriesBulkSerializer(data=request.DATA) diff --git a/taiga/projects/wiki/api.py b/taiga/projects/wiki/api.py index 9970502d..8ce56eac 100644 --- a/taiga/projects/wiki/api.py +++ b/taiga/projects/wiki/api.py @@ -45,6 +45,13 @@ class WikiViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, filter_backends = (filters.CanViewWikiPagesFilterBackend,) filter_fields = ("project", "slug") + @list_route(methods=["GET"]) + def by_slug(self, request): + slug = request.QUERY_PARAMS.get("slug", None) + project_id = request.QUERY_PARAMS.get("project", None) + wiki_page = get_object_or_404(models.WikiPage, slug=slug, project_id=project_id) + return self.retrieve(request, pk=wiki_page.pk) + @list_route(methods=["POST"]) def render(self, request, **kwargs): content = request.DATA.get("content", None) From d985e93be0176b09f10f9b092bd61fed85065818 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 10 Dec 2014 12:55:25 +0100 Subject: [PATCH 09/54] Splitting tags if containing , --- taiga/base/serializers.py | 8 +++-- .../migrations/0003_auto_20141210_1108.py | 36 +++++++++++++++++++ taiga/projects/issues/serializers.py | 4 +-- .../migrations/0013_auto_20141210_1040.py | 36 +++++++++++++++++++ .../migrations/0004_auto_20141210_1107.py | 36 +++++++++++++++++++ taiga/projects/tasks/serializers.py | 4 +-- .../migrations/0008_auto_20141210_1107.py | 36 +++++++++++++++++++ taiga/projects/userstories/serializers.py | 4 +-- 8 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 taiga/projects/issues/migrations/0003_auto_20141210_1108.py create mode 100644 taiga/projects/migrations/0013_auto_20141210_1040.py create mode 100644 taiga/projects/tasks/migrations/0004_auto_20141210_1107.py create mode 100644 taiga/projects/userstories/migrations/0008_auto_20141210_1107.py 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/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/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/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/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/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/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/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") From 731aba097649774e06f1bfda58bc92799043dd0c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 29 Dec 2014 13:02:17 +0100 Subject: [PATCH 10/54] Filtering milestones by closed attribute --- taiga/projects/milestones/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py index e5c58b76..ea2ef418 100644 --- a/taiga/projects/milestones/api.py +++ b/taiga/projects/milestones/api.py @@ -40,7 +40,7 @@ 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() From e03672d7e0db8a608fd826f79abd9bbfb8bcfa5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Mon, 29 Dec 2014 16:13:56 +0100 Subject: [PATCH 11/54] Add h4, h5 and h6 to mdrender allowed tags --- taiga/mdrender/service.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/taiga/mdrender/service.py b/taiga/mdrender/service.py index 70d236a5..19715404 100644 --- a/taiga/mdrender/service.py +++ b/taiga/mdrender/service.py @@ -51,9 +51,10 @@ from .extensions.references import TaigaReferencesExtension # Bleach configuration -bleach.ALLOWED_TAGS += ["p", "table", "thead", "tbody", "th", "tr", "td", "h1", "h2", "h3", - "div", "pre", "span", "hr", "dl", "dt", "dd", "sup", - "img", "del", "br", "ins"] +bleach.ALLOWED_TAGS += ["p", "table", "thead", "tbody", "th", "tr", "td", "h1", + "h2", "h3", "h4", "h5", "h6", "div", "pre", "span", + "hr", "dl", "dt", "dd", "sup", "img", "del", "br", + "ins"] bleach.ALLOWED_STYLES.append("background") From aee57d61109f172fdf9ac3d161d98e197d08dcbf Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 29 Dec 2014 15:39:51 +0100 Subject: [PATCH 12/54] Moving archived status from userstory to status --- .../fixtures/initial_project_templates.json | 4 +-- .../migrations/0008_auto_20141024_1012.py | 6 ++-- .../0014_userstorystatus_is_archived.py | 20 ++++++++++++ .../migrations/0015_auto_20141230_1212.py | 31 +++++++++++++++++++ taiga/projects/models.py | 3 ++ taiga/projects/userstories/api.py | 2 +- .../0009_remove_userstory_is_archived.py | 18 +++++++++++ taiga/projects/userstories/models.py | 2 -- tests/integration/test_userstories.py | 7 +++-- 9 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 taiga/projects/migrations/0014_userstorystatus_is_archived.py create mode 100644 taiga/projects/migrations/0015_auto_20141230_1212.py create mode 100644 taiga/projects/userstories/migrations/0009_remove_userstory_is_archived.py diff --git a/taiga/projects/fixtures/initial_project_templates.json b/taiga/projects/fixtures/initial_project_templates.json index 42d8118f..fe28fe42 100644 --- a/taiga/projects/fixtures/initial_project_templates.json +++ b/taiga/projects/fixtures/initial_project_templates.json @@ -6,7 +6,7 @@ "task_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#ff9900\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#ffcc00\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#669900\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#999999\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}]", "is_backlog_activated": true, "modified_date": "2014-07-25T10:02:46.479Z", - "us_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"wip_limit\": null, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#ff8a84\", \"order\": 2, \"is_closed\": false, \"wip_limit\": null, \"name\": \"Ready\", \"slug\": \"ready\"}, {\"color\": \"#ff9900\", \"order\": 3, \"is_closed\": false, \"wip_limit\": null, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#fcc000\", \"order\": 4, \"is_closed\": false, \"wip_limit\": null, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#669900\", \"order\": 5, \"is_closed\": true, \"wip_limit\": null, \"name\": \"Done\", \"slug\": \"done\"}]", + "us_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"is_archived\": false, \"wip_limit\": null, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#ff8a84\", \"order\": 2, \"is_closed\": false, \"is_archived\": false, \"wip_limit\": null, \"name\": \"Ready\", \"slug\": \"ready\"}, {\"color\": \"#ff9900\", \"order\": 3, \"is_closed\": false, \"is_archived\": false, \"wip_limit\": null, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#fcc000\", \"order\": 4, \"is_closed\": false, \"is_archived\": false, \"wip_limit\": null, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#669900\", \"order\": 5, \"is_closed\": true, \"is_archived\": false, \"wip_limit\": null, \"name\": \"Done\", \"slug\": \"done\"}, {\"color\": \"#5c3566\", \"order\": 6, \"is_closed\": true, \"is_archived\": true, \"wip_limit\": null, \"name\": \"Archived\", \"slug\": \"archived\"}]", "is_wiki_activated": true, "roles": "[{\"order\": 10, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"UX\", \"computable\": true}, {\"order\": 20, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Design\", \"computable\": true}, {\"order\": 30, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Front\", \"computable\": true}, {\"order\": 40, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Back\", \"computable\": true}, {\"order\": 50, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Product Owner\", \"computable\": false}, {\"order\": 60, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Stakeholder\", \"computable\": false}]", "points": "[{\"value\": null, \"order\": 1, \"name\": \"?\"}, {\"value\": 0.0, \"order\": 2, \"name\": \"0\"}, {\"value\": 0.5, \"order\": 3, \"name\": \"1/2\"}, {\"value\": 1.0, \"order\": 4, \"name\": \"1\"}, {\"value\": 2.0, \"order\": 5, \"name\": \"2\"}, {\"value\": 3.0, \"order\": 6, \"name\": \"3\"}, {\"value\": 5.0, \"order\": 7, \"name\": \"5\"}, {\"value\": 8.0, \"order\": 8, \"name\": \"8\"}, {\"value\": 10.0, \"order\": 9, \"name\": \"10\"}, {\"value\": 15.0, \"order\": 10, \"name\": \"15\"}, {\"value\": 20.0, \"order\": 11, \"name\": \"20\"}, {\"value\": 40.0, \"order\": 12, \"name\": \"40\"}]", @@ -33,7 +33,7 @@ "task_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#729fcf\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#f57900\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#4e9a06\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#cc0000\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}]", "is_backlog_activated": false, "modified_date": "2014-07-25T13:11:42.754Z", - "us_statuses": "[{\"wip_limit\": null, \"order\": 1, \"is_closed\": false, \"color\": \"#999999\", \"name\": \"New\", \"slug\": \"new\"}, {\"wip_limit\": null, \"order\": 2, \"is_closed\": false, \"color\": \"#f57900\", \"name\": \"Ready\", \"slug\": \"ready\"}, {\"wip_limit\": null, \"order\": 3, \"is_closed\": false, \"color\": \"#729fcf\", \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"wip_limit\": null, \"order\": 4, \"is_closed\": false, \"color\": \"#4e9a06\", \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"wip_limit\": null, \"order\": 5, \"is_closed\": true, \"color\": \"#cc0000\", \"name\": \"Done\", \"slug\": \"done\"}]", + "us_statuses": "[{\"wip_limit\": null, \"order\": 1, \"is_closed\": false, \"is_archived\": false, \"color\": \"#999999\", \"name\": \"New\", \"slug\": \"new\"}, {\"wip_limit\": null, \"order\": 2, \"is_closed\": false, \"is_archived\": false, \"color\": \"#f57900\", \"name\": \"Ready\", \"slug\": \"ready\"}, {\"wip_limit\": null, \"order\": 3, \"is_closed\": false, \"is_archived\": false, \"color\": \"#729fcf\", \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"wip_limit\": null, \"order\": 4, \"is_closed\": false, \"is_archived\": false, \"color\": \"#4e9a06\", \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"wip_limit\": null, \"order\": 5, \"is_closed\": true, \"is_archived\": false, \"color\": \"#cc0000\", \"name\": \"Done\", \"slug\": \"done\"}, {\"wip_limit\": null, \"order\": 6, \"is_closed\": true, \"is_archived\": true, \"color\": \"#5c3566\", \"name\": \"Archived\", \"slug\": \"archived\"}]", "is_wiki_activated": false, "roles": "[{\"order\": 10, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"UX\", \"computable\": true}, {\"order\": 20, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Design\", \"computable\": true}, {\"order\": 30, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Front\", \"computable\": true}, {\"order\": 40, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Back\", \"computable\": true}, {\"order\": 50, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Product Owner\", \"computable\": false}, {\"order\": 60, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Stakeholder\", \"computable\": false}]", "points": "[{\"value\": null, \"name\": \"?\", \"order\": 1}, {\"value\": 0.0, \"name\": \"0\", \"order\": 2}, {\"value\": 0.5, \"name\": \"1/2\", \"order\": 3}, {\"value\": 1.0, \"name\": \"1\", \"order\": 4}, {\"value\": 2.0, \"name\": \"2\", \"order\": 5}, {\"value\": 3.0, \"name\": \"3\", \"order\": 6}, {\"value\": 5.0, \"name\": \"5\", \"order\": 7}, {\"value\": 8.0, \"name\": \"8\", \"order\": 8}, {\"value\": 10.0, \"name\": \"10\", \"order\": 9}, {\"value\": 15.0, \"name\": \"15\", \"order\": 10}, {\"value\": 20.0, \"name\": \"20\", \"order\": 11}, {\"value\": 40.0, \"name\": \"40\", \"order\": 12}]", 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/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/models.py b/taiga/projects/models.py index 4c615d05..a90a70fe 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -323,6 +323,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 +692,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/userstories/api.py b/taiga/projects/userstories/api.py index 796159e6..4ce739ec 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -52,7 +52,7 @@ 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') 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..a9f64a68 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")) 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 From 3afcb0e6b600f2c2ce168bdfeef4afefdc11a12c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 7 Jan 2015 15:18:56 +0100 Subject: [PATCH 13/54] Fixing problem whith parens in links --- taiga/mdrender/extensions/autolink.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/taiga/mdrender/extensions/autolink.py b/taiga/mdrender/extensions/autolink.py index 69762109..7676bd18 100644 --- a/taiga/mdrender/extensions/autolink.py +++ b/taiga/mdrender/extensions/autolink.py @@ -38,8 +38,6 @@ class AutolinkExtension(markdown.Extension): """ def extendMarkdown(self, md, md_globals): - url_re = r'(?i)\b((?:(?:ftp|https?)://|www\d{0,3}[.])(?:[^\s()<>]+|' + \ - r'\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()' + \ - r'<>]+\)))*\)|[^\s`!()\[\]{};:' + r"'" + r'".,<>?«»“”‘’]))' + url_re = r'(?i)\b((?:(?:ftp|https?)://|www\d{0,3}[.])([^\s<>]+))' autolink = AutolinkPattern(url_re, md) md.inlinePatterns.add('gfm-autolink', autolink, '_end') From a4ddac510cb8a7782dfec01b1d301249a0c2b228 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 8 Jan 2015 11:55:00 +0100 Subject: [PATCH 14/54] Adding closed milestones counter to projects API --- taiga/projects/serializers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 861ba937..5c89484f 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -238,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 @@ -261,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 From 42e891e5bc09cc39bea22af22b187b0b859cac01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Thu, 8 Jan 2015 12:23:56 +0100 Subject: [PATCH 15/54] Bug #1849: Fix problem with especial caracter in wikilinks titles --- taiga/mdrender/extensions/wikilinks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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), " Date: Fri, 9 Jan 2015 12:50:48 +0100 Subject: [PATCH 16/54] Email images --- taiga/base/static/emails/logo-color.png | Bin 0 -> 3903 bytes taiga/base/static/emails/logo.png | Bin 0 -> 3045 bytes taiga/base/static/emails/top-bg-hero.png | Bin 0 -> 47116 bytes taiga/base/static/emails/top-bg-update.png | Bin 0 -> 5770 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 taiga/base/static/emails/logo-color.png create mode 100644 taiga/base/static/emails/logo.png create mode 100644 taiga/base/static/emails/top-bg-hero.png create mode 100644 taiga/base/static/emails/top-bg-update.png diff --git a/taiga/base/static/emails/logo-color.png b/taiga/base/static/emails/logo-color.png new file mode 100644 index 0000000000000000000000000000000000000000..49888fa4ae8ca3b90504c75210acc618da94be1d GIT binary patch literal 3903 zcmV-F55Vw=P) z2&n05g090fGg$nt%><@1v#C-Er%3;O~`m z3n>WP)7E7Ej^#0yTZU0$7wiKlA^}hBv?caK-|DQ6I-u(8%zXnEu6mS4|9oVsM`JHc;k?^&^Ccv$6hybt| zc&4q%ECwZjOVn{0Fb}8)0&QRW!e?v65S*k&^r?YP2j&C62WXq$epmo;U_?zwtgb%% zzVzkoLK6wVQ<5z=0(Ae};})>e)IXI@ z>p-<<>YE#9g(BhR8hwT=09)k3Xkd|?S%b~LIVFJCEx$7lI9NftenWHP8AvH70XR^0 z&`-#DRotou?3_xKt_WBNp(RJ-y;Z#b2lqU}72Cchda9_k2D~w4fY&X*Ggj@HbUZbj zO=m_bXhGxnAD4y-mKo=esOFH;ZD0FBYN{ix364wpQY(_580#PE@jbt!SCzhODgazx zBDRJME-z^ZOFXNcjKexn!0(OoJL)wjb|;rQBhHKom^Dos=^xxDggCB$U-}INDqUZ4 zgcr2k1SYEb=x}`Yk)C7A;-iVv3oZ{#8{p)D)WURc`fNaEj%Md2_odFAAPvv=#s)_Y z9%rMHa9M|?A=n`f7YnxUaR1`?XmXAa!XF(@oEhsKJXZ)IryTI@U%eZ5%-tJ*FR{!| z`xB(&XZ=WgS7MoT<+xMJrqZLCL~5X<!sc=ywM#hdx-=7dmYe7v) zUrJ=b_(`(7A`T%y2;|66d`{ny{>4&CQ7*uV^$y%FrM%;m0$#s$Q~vxYaF3MIt(J6T zW7#v4`%??ia-c_!4ek}12mojSjVG>o^5Q4|=&6g{tSiP<`9*C%TCU%|BLj;PW63$y z5+CWy6tHIF+OFAjB^az=%&%Qvp=dTHa@uJZsk zuI-u}-~y@Mq>}d;1q}Ge+R{Dg%Rq8}YEgE;X-UUZsV$#>y532Buv%r4S+?HtFOkgN zY+__MJ~!4oc%GE9CNq;rM|pyL4Bi0z$Hui?w{mjx{x`_=p-OKu|n_7Wqfl&2H&H)sf2%`}}5D}UP13?5K1VK0BieGfST<^Jl z#m+*(5LbW7NQHu-JSbO6=^)8SDbv31r;%h8$D)Sfg$u0i=xpD;acvhJo$bdUH* z*8$%Jk|$X@l7MtlPT-f1zjBf9)uyHaE#O}D$Ihd=MBu z1z-h|U?>s_gc|B(tq<1Kg$dV%11dogH8`$nGywc>RU2Nvb(0Ouu3ZIHYos_%ONxMtjw7b}Ky!pQC&y=}(#dzIGO3CFn zUzQ5i1X zv;Br*uvM#;>$h%Nq#RgbJKssU%O{~$BDQyAUuG&770xNf7sF4eyq?@)f{+ixar9z!jCi)>N0y2O+>+ zz%A03(#?41SF$4^5b^%>z|i4%AAp;2LVfQXI-gvILpGTW15P*`$*aAXKkuA<^^y7% zKvSf-r76;UMin2Tg|yHv6?{C{(b-;8cv*G1*l^Rm`SU}-O$GJg(qJ{A9osv)fACQJ zs0si@->$=p@?aIc3A~k`G$DlSJ$kq`e>0vQ8yJZ9?=6*2PY5lMA>hW2&i3v~;p7Rw zc>q}R*MD`xtk%<>3WWpV5)oDA*!v>~eAo9Bct7@D@4`$X-8dn+NM_#!{BSsY0f=$) z2-k&E3(i@%*Y|xbUn85{Jf}6F1tZ0LyPlICN(`=l^atOaRDL+=0)UhD&g*-3XivO9 zH6S#h0i9}L)fMcoASk}!XXEdBXe>gsPDC1_5 zLI|N^g()xbEE$gqMze{Yo&9g8Ml$h&%q3S{+jF$F7_6#e#mB+>e#wgCk)wxNi&Z$| zWRiWuM|Nf1Y$`8ppk+D$pEIF@fCv;hEg9S0+vT~4C(ClI-RRlOuAmOq}itD;{{R92gsvePB+txKqrG#56 z0oDSVACHa9E|yHOvnl|bskFomj||Q!SJN6VM!-cM8ere^wU%?wI9P?rLMNphS81Ae z*4g_jOdhH@anY@9>!uWN#Y`pK6iGF{>se<4}An|?IFMj7FeBT>m;tl}<+u#U$L^joslvXR zrxNfIU^}^YvOCGWSQ`Vzwmj68C~mxJrLh|LA-QcyH5EOil(#+p^n)?rr|JOU*0yzx z3S5)Yw2RcSbTR;Hj-^-M)h@O?)Fq3qvE`x4LHnweMw`mCWDVx^!)@E``ztlNwQU{6 zHe2f2DN7zEL#tLAY};~{&N1AsiG<0dbnz(0l6JMFISMVR5kFi zsRTPMfD3>xP<686ibe$Z%C_zHYtz9;V7dSwJiKRQ!TiM;;FCoY{!iPs+s{uIAKElZ zm~GqbCspk`PlvW`w;xezKP@z^p8ym%v0swgC;4e0KGk;nq0_3B#++3vjfMQK&~&Qs z$Mi9MOdr$lE*ZM{TgQ_ybn}uLBeFN_sO6OyU;)qrNIPm(Vq^|*wjH(JsKl%twJ>yZ z7H~1SGOSDP<@T#~)bjIfD)bQnX4z5eNVx*Q>orbZ3ouyXx()ouj#}TW^8B9w-!B!; z7Jyi0=;ouqXVfjPFy1$G^BLgJ$$eE*j-FN7*XPSgZb`whh}<5JOwskFwuFGDx~*w9VS z(9KapH@|u+YFdSZ)&U*B-M|-t7l1h@(9fm7I^YRAYTW{qw9)*vQukCp0_X;wQkwKQ z@Wd%e57nTT00}#4bpi&^V(8|)6X@r0U@Q;zB>0)3K;LCYt#&{fy7}o-nGbJ8ePCfn ztpqRx{LLgKTu-j@xQ>&je+7mWF917$^(PHjIjcp$xxfwA*FKRb!;R;Y7NrM0yN zey#_ScGP+`@Ad7d^`ct8Z#gNaW$0!I_y+I~hHkDXxFvzzz?0;@K|JZ&nxUJws{o|1 z-Tijd+FjK22f#~tew2hI@S@VZ`%cD7e^H%pQ%5OAF9CMcAhrBwbc+n#e5*wJ6$Kh2 zfyK%T?=Ap#p_+)R$?Xg(#m{PBiygI22=KSn%w1o?B(iGIwRY56T|-}6l$XBFj#_V( z2uN-(x+aU&+0f1JliQTGtPDT@3AEc$Ys;i0tY-6K6?uIH7%i5FYSRteXXxf%p!o^= zXU|o#zpSB~JAn;~@pPs2w!~O-)sncT0zX~AzZtr@5ZJFx(mC7(?6;%VugV1ncGUVd zxi@{cRRUYU&>E$ZKUWf)3_o91K%c6>&%cxVnEN|+)S9%H{!^uf)#%460;|fj%AURf zTw&{5uTaE3mpR7vf7x8b^Ahiv^Yld!K1H4d8J?*IVfT5fFltbEQ=;k2s zDDW;YQ?0MpsOGL3=Q_pzni|-D4EUCzn_n*_;W(u~Gyf9slpVEX4N`j@coq1|05^Mnj9l zs)br&w35_T3N{*zi5iWGrZK*!AB?Rg#V=@s0Ue?L9VV}E)B{290q%6<-)YVj zic-NwdI9hwU~J6!Az(GI>x$JVf+7iO1%3+5N|8GZ+y|Vy>@{+r$R_qM>EvK!9=^$xC7`hsoVS>HOJ>;uDb#F0oi@9VPGw=J?8vG zUU(5oRLe}?#`5e|985!6Mc z-Dw1_1*V%a<^fa9Ip3Z(7I_I0;5aY`b>70|O+>XR`bG}{ryOOaY0xPujeV=RM{6jr zgbqDAQ8KIU;vqjlvr&J<JfUAMI4q1z<%)RHwocBT= zf|j7O5J|la=KKuwjoKzR4D6uX9yOvwPNDpv#mEku|4k?{S9{!h1o#y2P7hgk<*NvH zj0f%qZZ-dBp&<+(3;_p$KJ$MD`j$bp&5p?X37W<}0z=z|YOU`Wawl-W9KV8wch7oW z3uM=z4&d_y%xxK&-iTBJC33?kkaTj&=szn0@ zvq1}~EjgfX^(^YVbyjhGr@8hVwIc`A7QG#HI4e&kXiz8c5bkkR7K}k{(|Cd~>lEO1 z`1eS|z)Pq%J%BbZoAS0;_E0bMVp-KVM-{0)p)7{z%9g2+4KP~Qa;wof`8&BX%L+q`FmOc zzAFAl)T4cf${uYT*~t;q=QP>gre?xgwC|^}JLZF>DQ|@9MM#;69gAS0>TEGe5Z)h)dEJfCdJmHQ*=sf$8 z6uEx1l3{Y1snohYf;s$XQ<_P+p*{#~B7M!!0pROYNe<)>m<<5`Hp$gn90mSL`HV&b zI{1vz4>+*+JPNpej=Tfb>H?)bGg@)Fd5j*t&hZbV1W_{$b5D>#SBaT%0r zgUc9?rP2ex2>i<=JN`O@64#B|wW`+!LH>2plL$!eMJu)deXFiXy{QBX7@{~Ia~#G* zG%ghFg$|=yIuqDKpv@(7zrvbW^kIil&2f>_Lh_P3AA&%mkZq3qbEr=hZR&9^#0z-| z@*Ste*_|+a39|Ocx9Q{>OV(P0;?PO5LpegZ$2&j}q<6sv?tM_G2Kn6lD&nVwEc&P% zm8W^lz5%zoZ(_Zqgf(&dGid~!BYi;x6xhTX@L%P%pX3=%SIjwypkY&{&oCfYSD%NV za2KD3vkT!t)6B?b& z8YS!po<&^(Yxoa11f^h{lLB^s5ojXe_GmKVr z8ha)hK|F!RoV!S#aE#(Qba6#|*hNH!sz|>Q5n|GO5g9ac3lrpQ`lylJ;34~N5qVNX z&d1d2xQMJ1k+*u3^#*fIzldBDC)bF3Sz0Ul!IBH;O3^5GC%lyh2^Mv(0Y2^VCett# z_&)G1^S=++hJG!t7uBTs=t9r;1HbVobFKMp06qwO%_$Yb+?x!$T^?^DoSH=Bu!!ss zk%J=ABO>E+YEZj~>@hN@MdafmGA2g0Q$#+JaNedi5qVWaUJ#K}ru>piw$?`aZZshO zG_V1kkGdyEc_nnSNe4QEaVzi`@v{^MfbS%nw^)VRt_|okQU|ciC0J{MtlDk_er=A| zOXdzTZb+>Uqg)v3U`q!sr)Xh{WQfgBkTKFo0FmA zYjIBj?F2p+`ad1r`t8C^=(Y(^;~)Jlqg~E-59*$rgxV0an&55Ul^|C#vS08l_b9q{ zXad?py5`R8Zb6Idi6>}n=y+wqeaE5OvmS-~1T~-!4Qk-xzBIKM_@8K<=T&3M02QMQ)6U>@%{aA$DRLtOhB1pz^b2&CPzn9isP8_pD17TVK+yf` zH90hBmWT|9NUw-Y_ISIqjeNg|%=FYCMC2yXLF$l*oD`7{i%4UP>>`H--C**L84XFR z=Z!}GjEGFiqe0dGm1mN?n`;E*JkGEpeN^{LV2|^OS0ka7r@<|c-hXet+Xxb+lFniEk n(<~Z98#0>VThF!Tnyvo_%z^!ybqR?x00000NkvXXu0mjfzOSjR literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9bb905bfb2e4d662030a2c2847e12093a4bb0ec6 GIT binary patch literal 47116 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 IM6N<$f?#ag0RR91 literal 0 HcmV?d00001 From 99691372a52e951e360236d048cf9e1163ed8caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 12 Jan 2015 16:38:48 +0100 Subject: [PATCH 17/54] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8374ecae..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 ## From ca09cda87cb3af787b44d4b94390025617df40e4 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 22 Dec 2014 10:02:31 +0100 Subject: [PATCH 18/54] Some extra api optimizations --- taiga/projects/milestones/api.py | 7 +++- taiga/projects/milestones/models.py | 48 ++++++++++++++++++---------- taiga/projects/models.py | 30 ++++++++++------- taiga/projects/services/stats.py | 34 ++++++++++++++------ taiga/projects/userstories/api.py | 6 ++-- taiga/projects/userstories/models.py | 3 +- 6 files changed, 85 insertions(+), 43 deletions(-) diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py index ea2ef418..d669a5bf 100644 --- a/taiga/projects/milestones/api.py +++ b/taiga/projects/milestones/api.py @@ -47,7 +47,12 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView 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 a90a70fe..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): 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/userstories/api.py b/taiga/projects/userstories/api.py index 4ce739ec..e667355d 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -59,10 +59,10 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi 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/models.py b/taiga/projects/userstories/models.py index a9f64a68..52772ca6 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -124,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: From e2d071f61ced94ae71e3ed14c0254f7c94486dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 12 Jan 2015 17:01:05 +0100 Subject: [PATCH 19/54] Update CHANGELOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f63d14b..12a22b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog # +## 1.5.0 ??? (unreleased) + +### Features +- Improving some SQL queries + +### Misc +- Lots of small and not so small bugfixes. + ## 1.4.0 Abies veitchii (2014-12-10) From 4fe73aef52affd36fcbfb2533ba0afaf6e7e3f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Juli=C3=A1n?= Date: Tue, 2 Dec 2014 18:38:14 +0100 Subject: [PATCH 20/54] US #1680: Emails redesign --- requirements.txt | 1 + taiga/auth/services.py | 4 +- taiga/base/templates/emails/base.jinja | 544 +++++++++++++----- taiga/base/templates/emails/hero.jinja | 409 +++++++++++++ taiga/base/templates/emails/updates.jinja | 454 +++++++++++++++ taiga/feedback/services.py | 4 +- .../feedback_notification-body-html.jinja | 486 ++++++++++++++-- taiga/projects/history/services.py | 2 +- .../emails/includes/fields_diff-html.jinja | 223 +++---- taiga/projects/notifications/services.py | 8 +- .../issues/issue-change-body-html.jinja | 56 +- .../issues/issue-change-body-text.jinja | 9 +- .../issues/issue-create-body-html.jinja | 27 +- .../issues/issue-create-body-text.jinja | 9 +- .../issues/issue-delete-body-html.jinja | 25 +- .../issues/issue-delete-body-text.jinja | 7 +- .../milestone-change-body-html.jinja | 15 +- .../milestone-create-body-html.jinja | 13 +- .../milestone-delete-body-html.jinja | 11 + .../projects/project-change-body-html.jinja | 15 +- .../projects/project-change-body-text.jinja | 8 +- .../projects/project-create-body-html.jinja | 14 +- .../projects/project-delete-body-html.jinja | 12 + .../emails/tasks/task-change-body-html.jinja | 56 +- .../emails/tasks/task-change-body-text.jinja | 10 +- .../emails/tasks/task-create-body-html.jinja | 31 +- .../emails/tasks/task-create-body-text.jinja | 9 +- .../emails/tasks/task-delete-body-html.jinja | 25 +- .../emails/tasks/task-delete-body-text.jinja | 7 +- .../userstory-change-body-html.jinja | 56 +- .../userstory-change-body-text.jinja | 9 +- .../userstory-create-body-html.jinja | 27 +- .../userstory-create-body-text.jinja | 10 +- .../userstory-delete-body-html.jinja | 25 +- .../userstory-delete-body-text.jinja | 7 +- .../wiki/wikipage-change-body-html.jinja | 57 +- .../wiki/wikipage-change-body-text.jinja | 9 +- .../wiki/wikipage-create-body-html.jinja | 27 +- .../wiki/wikipage-create-body-text.jinja | 9 +- .../wiki/wikipage-delete-body-html.jinja | 13 +- .../wiki/wikipage-delete-body-text.jinja | 7 +- taiga/projects/services/invitations.py | 4 +- .../membership_invitation-body-html.jinja | 41 +- .../membership_notification-body-html.jinja | 27 +- .../membership_notification-body-text.jinja | 8 +- taiga/users/api.py | 6 +- .../emails/change_email-body-html.jinja | 31 +- .../emails/change_email-body-text.jinja | 9 +- .../emails/password_recovery-body-html.jinja | 31 +- .../emails/password_recovery-body-text.jinja | 7 +- .../emails/registered_user-body-html.jinja | 30 +- tests/factories.py | 1 + 52 files changed, 2314 insertions(+), 631 deletions(-) create mode 100644 taiga/base/templates/emails/hero.jinja create mode 100644 taiga/base/templates/emails/updates.jinja diff --git a/requirements.txt b/requirements.txt index b741b4f9..10375718 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,7 @@ Unidecode==0.04.16 raven==5.1.1 bleach==1.4 django-ipware==0.1.0 +premailer==2.8.1 # Comment it if you are using python >= 3.4 enum34==1.0 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/templates/emails/base.jinja b/taiga/base/templates/emails/base.jinja index 0138a1b7..ac8439df 100644 --- a/taiga/base/templates/emails/base.jinja +++ b/taiga/base/templates/emails/base.jinja @@ -1,159 +1,431 @@ - + {% set home_url = resolve_front_url("home") %} {% set home_url_name = "Taiga" %} + + + + Taiga + - - - - - - - -
+ .update-row h1, + .update-row h2, + .update-row h3 { + text-align: left; + } - - - - - - - - - - - - - - - - - + .update-row tr { + border-bottom: 1px solid #cdcdcd; + padding: 10px; + } + + .update-row tr:first-child, + .update-row tr:last-child { + border-bottom: 3px solid #cdcdcd; + } + + .update-row td { + padding: 15px; + text-align: left; + } + + .update-row td.update-row-name { + width: 40%; + text-align: center; + } + + .update-row td.update-row-from { + border-bottom: 1px solid #f5f5f5; + } + + .social-links { + font-family: 'Open Sans', Arial, Helvetica; + font-size:13px; + padding-right:20px; + padding-bottom:20px; + padding-left:20px; + text-align:center; + } + + .social-links a:link, .social-links a:visited{ + color:#699b05; + font-weight:normal; + text-decoration:underline; + text-align: center; + margin: 0 5px; + } + + /* ========== Footer Styles ========== */ + + /** + * @section footer style + */ + #templateFooter{ + background-color:#555555; + } + + /** + * @section footer text + */ + .footerContent{ + color:#f5f5f5; + font-family: 'Open Sans', Arial, Helvetica; + font-size:10px; + line-height:150%; + padding-top:20px; + padding-right:20px; + padding-bottom:20px; + padding-left:20px; + text-align:left; + } + + /** + * @tab Footer + * @section footer link + * @tip Set the styling for your email's footer links. Choose a color that helps them stand out from your text. + */ + .footerContent a:link, .footerContent a:visited, /* Yahoo! Mail Override */ .footerContent a .yshortcuts, .footerContent a span /* Yahoo! Mail Override */{ + /*@editable*/ color:#699b05; + /*@editable*/ font-weight:normal; + /*@editable*/ text-decoration:underline; + } + + /* /\/\/\/\/\/\/\/\/ MOBILE STYLES /\/\/\/\/\/\/\/\/ */ + + @media only screen and (max-width: 480px){ + /* /\/\/\/\/\/\/ CLIENT-SPECIFIC MOBILE STYLES /\/\/\/\/\/\/ */ + body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:none !important;} /* Prevent Webkit platforms from changing default text sizes */ + body{width:100% !important; min-width:100% !important;} /* Prevent iOS Mail from adding padding to the body */ + + /* /\/\/\/\/\/\/ MOBILE RESET STYLES /\/\/\/\/\/\/ */ + #bodyCell{padding:10px !important;} + + /* /\/\/\/\/\/\/ MOBILE TEMPLATE STYLES /\/\/\/\/\/\/ */ + + /* ======== Page Styles ======== */ + + /** + * @section template width + */ + #templateContainer{ + max-width:600px !important; + width:100% !important; + } + + /** + * @section heading 1 + */ + h1{ + font-size:18px !important; + line-height:100% !important; + } + + /** + * @section heading 2 + */ + h2{ + font-size:16px !important; + line-height:100% !important; + } + + /** + * @section heading 3 + */ + h3{ + font-size:14px !important; + line-height:100% !important; + } + + + /* ======== Header Styles ======== */ + + #templatePreheader{display:none !important;} /* Hide the template preheader to save space */ + + /** + * @section header image + */ + #headerImage{ + height:auto !important; + max-width:600px !important; + width:20% !important; + } + + /* ======== Body Styles ======== */ + + /** + * @tab Mobile Styles + * @section body image + * @tip Make the main body image fluid for portrait or landscape view adaptability, and set the image's original width as the max-width. If a fluid setting doesn't work, set the image width to half its original size instead. + */ + #bodyImage{ + height:auto !important; + max-width:560px !important; + width:100% !important; + } + + /** + * @section body text + */ + .bodyContent{ + font-size:16px !important; + line-height:125% !important; + } + + /** + * @section body link button class + */ + .bodyContent a.button { + font-size:14px !important; + width: auto; + } + + /* ======== Footer Styles ======== */ + + /** + * @section footer text + */ + .footerContent{ + font-size:14px !important; + line-height:115% !important; + } + + .footerContent a{display:block !important;} /* Place footer social and utility links on their own lines, for easier access */ + } + + + +
+
- - - - -
- - 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 %} -
+ + +
+ + + + + + + + +
+ + + + + +
+ + + Taiga logo + + {% block body %} + {% endblock %} +
+ +
+ + + + + +
+ {% block footer %} + {% endblock %} +
+ +
+ +
-
- + + diff --git a/taiga/base/templates/emails/hero.jinja b/taiga/base/templates/emails/hero.jinja new file mode 100644 index 00000000..cf952bcb --- /dev/null +++ b/taiga/base/templates/emails/hero.jinja @@ -0,0 +1,409 @@ + +{% set home_url = resolve_front_url("home") %} +{% set home_url_name = "Taiga" %} + + + + + You have been Taigatized + + + +
+ + + + +
+ + + + + + + + + + + +
+ + + + + +
+ + Taiga + +

You have been Taigatized!

+

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

+
+ +
+ + + + + + + +
+ {% block body %}{% endblock %} +
+
+ + + + + + +
+ {% block footer %} + {% endblock %} +
+ +
+ +
+
+ + diff --git a/taiga/base/templates/emails/updates.jinja b/taiga/base/templates/emails/updates.jinja new file mode 100644 index 00000000..65a16fa2 --- /dev/null +++ b/taiga/base/templates/emails/updates.jinja @@ -0,0 +1,454 @@ + + +{% set home_url = resolve_front_url("home") %} +{% set home_url_name = "Taiga" %} + + + + [Taiga] Jesús Espino updated the US #1680 "Rediseño de emails" + + + +
+ + + + +
+ + + + + + + + + + + +
+ + + + + +
+ + + Taiga logo + + {% block head %} + {% endblock %} +
+ +
+ + + + + + + + +
+ + {% block body %} + {% endblock %} +
+
+ +
+ + + + + +
+ {% block footer %} + {% endblock %} +
+ +
+ +
+
+ + diff --git a/taiga/feedback/services.py b/taiga/feedback/services.py index 10362208..01d24cd9 100644 --- a/taiga/feedback/services.py +++ b/taiga/feedback/services.py @@ -16,14 +16,14 @@ from django.conf import settings -from djmail.template_mail import MagicMailBuilder +from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail def send_feedback(feedback_entry, extra): support_email = settings.FEEDBACK_EMAIL if support_email: - mbuilder = MagicMailBuilder() + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) email = mbuilder.feedback_notification(support_email, {"feedback_entry": feedback_entry, "extra": extra}) 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..9687ed51 100644 --- a/taiga/feedback/templates/emails/feedback_notification-body-html.jinja +++ b/taiga/feedback/templates/emails/feedback_notification-body-html.jinja @@ -1,37 +1,451 @@ -{% extends "emails/base.jinja" %} + + + + Taiga + + + +
+ + + + +
+ + + + + + + + +
+ + + + + +
+ + + Taiga logo + + +

Feedback

+

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

+ + + + + + {% if extra %} + + + + {% endif %} +
+

Comment

+

{{ feedback_entry.comment }}

+
+

Extra:

+
+ {% for k, v in extra.items() %} +
{{ k }}
+
{{ v }}
+ {% endfor %} +
+
+ +
+ +
+ + + + + +
+ + + +
+ +
+ +
+
+ + diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py index f22574a2..c47fab2a 100644 --- a/taiga/projects/history/services.py +++ b/taiga/projects/history/services.py @@ -173,7 +173,7 @@ def is_hidden_snapshot(obj:FrozenDiff) -> 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..91484315 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 }} -
+ + +

{{ role }} role points

+ + + 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|linebreaksbr }} + +

+ {% if att.description %} +

{{ att.description|linebreaksbr }}

+ {% 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)|linebreaksbr }} + + + + + to
+ {{ ', '.join(values.1)|linebreaksbr }} + + {# 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|linebreaksbr }} + {% else %} + from
+ {{ _("Unassigned") }} + {% endif %} + + + + + {% if values.1 != None and values.1 != "" %} + to
+ {{ values.1|linebreaksbr }} + {% 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/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..15d72016 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,42 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates.jinja" %} {% set final_url = resolve_front_url("issue", project.slug, snapshot.ref) %} {% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %} +{% block head %} +

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

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has updated an issue on {{ project.name }}

+ See Issue +{% endblock %} + {% 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 %} -
+ +

Updates

+ + {% 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 }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..8c2ddbff 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,9 +1,10 @@ {% 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() }} +Issue #{{ snapshot.ref }} {{ snapshot.subject }} updated +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has updated an issue on {{ project.name }} +See issue in Taiga {{ final_url_name }} ({{ final_url }}) + {% for entry in history_entries%} {% if entry.comment %} Comment: {{ entry.comment|linebreaksbr }} @@ -13,5 +14,3 @@ {% include "emails/includes/fields_diff-text.jinja" %} {% endif %} {% endfor %} - -** More info at {{ final_url_name }} ({{ final_url }}) ** 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..182d31de 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 @@ -4,18 +4,21 @@ {% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %} {% block body %} - - - - -
-

Project: {{ project.name }}

-

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

-

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

-
+

New issue created

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has created a new issue on {{ project.name }}

+

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

+ See issue +

The Taiga Team

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

- More info at: {{ final_url_name }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..1f67040e 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,7 @@ {% 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 }} -- US #{{ snapshot.ref }}: {{ snapshot.subject }} -- Created by {{ changer.get_full_name() }} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +New issue created +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has created a new issue on {{ project.name }} +See issue #{{ snapshot.ref }} {{ snapshot.subject }} at {{ final_url_name }} ({{ final_url }}) +The Taiga Team 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..d50874be 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,20 @@ {% extends "emails/base.jinja" %} {% block body %} - - - - -
-

{{ project.name }}

-

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

-

Deleted by {{ changer.get_full_name() }}

-
+

Issue deleted

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has deleted an issue on {{ project.name }}

+

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

+

The Taiga Team

+{% endblock %} + +{% block footer %} + Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..26dd8ef8 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,4 @@ -- Project: {{ project.name }} -- Issue #{{ snapshot.ref }}: {{ snapshot.subject }} -- Deleted by {{ changer.get_full_name() }} +Issue deleted +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has deleted an issue on {{ project.name }} +Issue #{{ snapshot.ref }} {{ snapshot.subject }} +The Taiga Team 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..f0e3f152 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,4 +1,4 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates.jinja" %} {% set final_url = resolve_front_url("taskboard", project.slug, snapshot.slug) %} {% set final_url_name = "Taiga - View milestone #{0}".format(snapshot.slug) %} @@ -23,8 +23,15 @@ {% endblock %} + {% block footer %} -

- More info at: {{ final_url_name }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% endblock %} 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..fc9accae 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 @@ -14,8 +14,15 @@ {% endblock %} + {% block footer %} -

- More info at: {{ final_url_name }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% endblock %} 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..dae36cfa 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 @@ -12,3 +12,14 @@ {% endblock %} +{% block footer %} + Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio +{% endblock %} 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 index 0c65ef64..41ae8ea0 100644 --- a/taiga/projects/notifications/templates/emails/projects/project-change-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/projects/project-change-body-html.jinja @@ -1,4 +1,4 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates.jinja" %} {% set final_url = resolve_front_url("project-admin", snapshot.slug) %} {% set final_url_name = "Taiga - View Project #{0}".format(snapshot.slug) %} @@ -22,8 +22,15 @@ {% endblock %} + {% block footer %} -

- More info at: {{ final_url_name }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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 index 14fda13f..0d14754d 100644 --- a/taiga/projects/notifications/templates/emails/projects/project-change-body-text.jinja +++ b/taiga/projects/notifications/templates/emails/projects/project-change-body-text.jinja @@ -7,11 +7,11 @@ {% 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 %} + {% for field_name, values in changed_fields.items() %} + * {{ verbose_name(object, field_name) }}: from '{{ values.0 }}' to '{{ values.1 }}'. + {% endfor %} {% endfor %} ** More info at {{ final_url_name }} ({{ final_url }}) ** 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 index a37f59c1..4207a4cb 100644 --- a/taiga/projects/notifications/templates/emails/projects/project-create-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/projects/project-create-body-html.jinja @@ -1,3 +1,5 @@ +{% extends "emails/hero.jinja" %} + {% set final_url = resolve_front_url("project-admin", snapshot.slug) %} {% set final_url_name = "Taiga - View Project #{0}".format(snapshot.slug) %} @@ -13,7 +15,13 @@ {% endblock %} {% block footer %} -

- More info at: {{ final_url_name }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% endblock %} 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 index 2b8fa57d..53dfc6e8 100644 --- a/taiga/projects/notifications/templates/emails/projects/project-delete-body-html.jinja +++ b/taiga/projects/notifications/templates/emails/projects/project-delete-body-html.jinja @@ -10,3 +10,15 @@ {% endblock %} + +{% block footer %} + Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio +{% endblock %} 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..ada43d22 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,42 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates.jinja" %} {% set final_url = resolve_front_url("task", project.slug, snapshot.ref) %} {% set final_url_name = "Taiga - View task #{0}".format(snapshot.ref) %} +{% block head %} +

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

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has updated a task on {{ project.name }}

+ See task +{% endblock %} + {% 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 %} -
+ +

Updates

+ + {% 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 }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..90e38aa6 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,9 +1,11 @@ {% 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() }} +Task #{{ snapshot.ref }} {{ snapshot.subject }} updated +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has updated a task on {{ project.name }} + +See task at {{ final_url_name }} ({{ final_url }}) + {% for entry in history_entries%} {% if entry.comment %} Comment: {{ entry.comment|linebreaksbr }} @@ -13,5 +15,3 @@ {% include "emails/includes/fields_diff-text.jinja" %} {% endif %} {% endfor %} - -** More info at {{ final_url_name }} ({{ final_url }}) ** 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..d153199a 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 @@ -4,19 +4,22 @@ {% set final_url_name = "Taiga - View task #{0}".format(snapshot.ref) %} {% block body %} - - - - -
-

Project: {{ project.name }}

-

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

-

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

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

- More info at: {{ final_url_name }} -

+

New task created

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has created a new task on {{ project.name }}

+

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

+ See task +

The Taiga Team

+{% endblock %} + +{% block footer %} + Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..abe81ecc 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,7 @@ {% 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 }} -- Created by {{ changer.get_full_name() }} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +New task created +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has created a new task on {{ project.name }} +See task #{{ snapshot.ref }} {{ snapshot.subject }} at {{ final_url_name }} ({{ final_url }}) +The Taiga Team 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..ba2804bc 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,21 @@ {% extends "emails/base.jinja" %} {% block body %} - - - - -
-

{{ project.name }}

-

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

-

Deleted by {{ changer.get_full_name() }}

-
+

Task deleted

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has deleted a task on {{ project.name }}

+

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

+

The Taiga Team

+{% endblock %} + +{% block footer %} + Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..22e8c043 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,3 @@ -- Project: {{ project.name }} -- Task #{{ snapshot.ref }}: {{ snapshot.subject }} -- Deleted by {{ changer.get_full_name() }} - +Task deleted +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has deleted a task on {{ project.name }} +Task #{{ snapshot.ref }} {{ snapshot.subject }} 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..37777be0 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,42 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates.jinja" %} {% set final_url = resolve_front_url("userstory", project.slug, snapshot.ref) %} {% set final_url_name = "Taiga - View US #{0}".format(snapshot.ref) %} +{% block head %} +

User story #{{ snapshot.ref }} {{ snapshot.subject }} updated

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has updated a user story on {{ project.name }}

+ See Issue +{% endblock %} + {% 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 %} -
+ +

Updates

+ + {% 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 }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..c6138a12 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,9 +1,10 @@ {% 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() }} +User story #{{ snapshot.ref }} {{ snapshot.subject }} updated +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has updated a user story on {{ project.name }} +See user story in Taiga {{ final_url_name }} ({{ final_url }}) + {% for entry in history_entries%} {% if entry.comment %} Comment: {{ entry.comment|linebreaksbr }} @@ -13,5 +14,3 @@ {% include "emails/includes/fields_diff-text.jinja" %} {% endif %} {% endfor %} - -** More info at {{ final_url_name }} ({{ final_url }}) ** 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..bd95b4ca 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 @@ -4,18 +4,21 @@ {% set final_url_name = "Taiga - View US #{0}".format(snapshot.ref) %} {% block body %} - - - - -
-

Project: {{ project.name }}

-

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

-

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

-
+

New user story created

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has created a new user story on {{ project.name }}

+

User story #{{ snapshot.ref }} {{ snapshot.subject }}

+ See user story +

The Taiga Team

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

- More info at: {{ final_url_name }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..9d161906 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,8 @@ {% 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 }} -- Created by {{ changer.get_full_name() }} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +New user story created +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has created a new user story on {{ project.name }} +User story #{{ snapshot.ref }} {{ snapshot.subject }} +More info at {{ final_url_name }} ({{ final_url }}) +The Taiga Team 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..2e98392f 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,20 @@ {% extends "emails/base.jinja" %} {% block body %} - - - - -
-

{{ project.name }}

-

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

-

Deleted by {{ changer.get_full_name() }}

-
+

User story deleted

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has deleted a user story on {{ project.name }}

+

User story #{{ snapshot.ref }} {{ snapshot.subject }}

+

The Taiga Team

+{% endblock %} + +{% block footer %} + Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..d4af94d3 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,4 @@ -- Project: {{ project.name }} -- US #{{ snapshot.ref }}: {{ snapshot.subject }} -- Deleted by {{ changer.get_full_name() }} +User story deleted +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} hasdeleted a user story on {{ project.name }} +User story #{{ snapshot.ref }} {{ snapshot.subject }} +The Taiga Team 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..4d4bd4b7 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,43 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/updates.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 head %} +

Wiki Page {{ snapshot.slug }} updated

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has updated a wiki page on {{ project.name }}

+ See Wiki +{% endblock %} + + {% 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 %} -
+ +

Updates

+ + {% 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 }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..b9eb44f2 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,9 +1,10 @@ {% set final_url = resolve_front_url("wiki", project.slug, snapshot.slug) %} {% set final_url_name = "Taiga - View Wiki Page '{0}'".format(snapshot.slug) %} -- Project: {{ project.name }} -- Wiki Page: {{ snapshot.slug }} -- Updated by {{ changer.get_full_name() }} +Wiki Page {{ snapshot.slug }} updated +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has updated a wiki page on {{ project.name }} +See wiki page in Taiga {{ final_url_name }} ({{ final_url }}) + {% for entry in history_entries%} {% if entry.comment %} Comment: {{ entry.comment|linebreaksbr }} @@ -13,5 +14,3 @@ {% include "emails/includes/fields_diff-text.jinja" %} {% endif %} {% endfor %} - -** More info at {{ final_url_name }} ({{ final_url }}) ** 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..cba2b5b0 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 @@ -4,18 +4,21 @@ {% set final_url_name = "Taiga - View Wiki Page '{0}'".format(snapshot.slug) %} {% block body %} - - - - -
-

Project: {{ project.name }}

-

Wiki Page: {{ snapshot.slug }}

-

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

-
+

New wiki page created

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has created a new wiki page on {{ project.name }}

+

Wiki page {{ snapshot.slug }}

+ See wiki page +

The Taiga Team

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

- More info at: {{ final_url_name }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..8f6f4020 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,7 @@ {% set final_url = resolve_front_url("wiki", project.slug, snapshot.slug) %} {% set final_url_name = "Taiga - View Wiki Page '{0}'".format(snapshot.slug) %} -- Project: {{ project.name }} -- Wiki Page: {{ snapshot.slug }} -- Created by {{ changer.get_full_name() }} - -** More info at {{ final_url_name }} ({{ final_url }}) ** +New wiki page created +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has created a new wiki page on {{ project.name }} +See wiki page {{ snapshot.slug }} at {{ final_url_name }} ({{ final_url }}) +The Taiga Team 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..46bdc60c 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,8 @@ {% extends "emails/base.jinja" %} {% block body %} - - - - -
-

{{ project.name }}

-

Wiki Page: {{ snapshot.slug }}

-

Deleted by {{ changer.get_full_name() }}

-
+

Wiki page deleted

+

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has deleted a wiki page on {{ project.name }}

+

Wiki page {{ snapshot.slug }}

+

The Taiga Team

{% 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..caecd953 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,4 @@ -- Project: {{ project.name }} -- Wiki Page: {{ snapshot.slug }} -- Deleted by {{ changer.get_full_name() }} +Wiki page deleted +Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has deleted a wiki page on {{ project.name }} +Wiki page {{ snapshot.slug }} +The Taiga Team 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/templates/emails/membership_invitation-body-html.jinja b/taiga/projects/templates/emails/membership_invitation-body-html.jinja index 55bc69eb..7c880832 100644 --- a/taiga/projects/templates/emails/membership_invitation-body-html.jinja +++ b/taiga/projects/templates/emails/membership_invitation-body-html.jinja @@ -1,31 +1,28 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/hero.jinja" %} {% 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 }}

- - {% 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 %} - -

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

- -
+

You, or someone you know, has invited you to Taiga

+

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 +Accept your invitation +{% 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 %} +

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

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

- Further details: {{ final_url_name }} -

+Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+Contact us: +
+Support: +support@taiga.io +
+Our mailing address is: +https://groups.google.com/forum/#!forum/taigaio {% endblock %} diff --git a/taiga/projects/templates/emails/membership_notification-body-html.jinja b/taiga/projects/templates/emails/membership_notification-body-html.jinja index 5558f08b..fe5e0630 100644 --- a/taiga/projects/templates/emails/membership_notification-body-html.jinja +++ b/taiga/projects/templates/emails/membership_notification-body-html.jinja @@ -1,22 +1,23 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/hero.jinja" %} {% set final_url = resolve_front_url("project", membership.project.slug) %} {% set final_url_name = "Taiga - Project '{0}'.".format(membership.project) %} {% block body %} - - - - -
-

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

-

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

-
+

You have been added to a project

+

Hello {{ membership.user.get_full_name() }},
you have been added to the project {{ membership.project }}

+ Go to project +

The Taiga Team

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

- More info at: {{ final_url_name }} -

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..b54a23d2 100644 --- a/taiga/projects/templates/emails/membership_notification-body-text.jinja +++ b/taiga/projects/templates/emails/membership_notification-body-text.jinja @@ -1,8 +1,6 @@ {% set final_url = resolve_front_url("project", membership.project.slug) %} -Hi {{ membership.user.get_full_name() }}, +You have been added to a project +Hello {{ membership.user.get_full_name() }},you have been added to the project {{ membership.project }} -you have been added to the project '{{ membership.project }}' ({{ final_url }}). - - -** More info at ({{ final_url }}) ** +See project at ({{ final_url }}) diff --git a/taiga/users/api.py b/taiga/users/api.py index 139c1f11..856f99a1 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() @@ -234,7 +234,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..6eadbdf0 100644 --- a/taiga/users/templates/emails/change_email-body-html.jinja +++ b/taiga/users/templates/emails/change_email-body-html.jinja @@ -4,24 +4,21 @@ {% set final_url_name = "Taiga - Change email" %} {% 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

-
+

Change your email

+

Hello {{ user.get_full_name() }},
please confirm your email

+ Confirm email +

You can ignore this message if you did not request.

+

The Taiga Team

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

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

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..ffb6e9d9 100644 --- a/taiga/users/templates/emails/change_email-body-text.jinja +++ b/taiga/users/templates/emails/change_email-body-text.jinja @@ -1,12 +1,5 @@ -Hello {{ user.get_full_name() }}, - -you can confirm your change of email by going to the following url: - -{{ resolve_front_url('change-email', user.email_token) }} +Hello {{ user.get_full_name() }}, please confirm your email {{ resolve_front_url('change-email', user.email_token) }} You can ignore this message if you did not request. -Regards - --- The Taiga Team diff --git a/taiga/users/templates/emails/password_recovery-body-html.jinja b/taiga/users/templates/emails/password_recovery-body-html.jinja index 0d55d679..f74ebedb 100644 --- a/taiga/users/templates/emails/password_recovery-body-html.jinja +++ b/taiga/users/templates/emails/password_recovery-body-html.jinja @@ -4,24 +4,21 @@ {% set final_url_name = "Taiga - Change password" %} {% 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

-
+

Recover your password

+

Hello {{ user.get_full_name() }},
you asked to recover your password

+ Recover your password +

You can ignore this message if you did not request.

+

The Taiga Team

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

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

+ Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio {% 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..d5e25baf 100644 --- a/taiga/users/templates/emails/password_recovery-body-text.jinja +++ b/taiga/users/templates/emails/password_recovery-body-text.jinja @@ -1,12 +1,7 @@ -Hello {{ user.get_full_name() }}, - -you can recover your password by going to the following url: +Hello {{ user.get_full_name() }}, you asked to recover your password {{ resolve_front_url('change-password', user.token) }} You can ignore this message if you did not request. -Regards - --- The Taiga Team diff --git a/taiga/users/templates/emails/registered_user-body-html.jinja b/taiga/users/templates/emails/registered_user-body-html.jinja index e82d0606..f9ba1519 100644 --- a/taiga/users/templates/emails/registered_user-body-html.jinja +++ b/taiga/users/templates/emails/registered_user-body-html.jinja @@ -1,24 +1,30 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/hero.jinja" %} {% block body %}
-

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.

+

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
{% endblock %} {% block footer %} -

- The Taiga development team. -

+Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+Contact us: +
+Support: +support@taiga.io +
+Our mailing address is: +https://groups.google.com/forum/#!forum/taigaio +
+
+You may remove your account from this service clicking here {% endblock %} diff --git a/tests/factories.py b/tests/factories.py index f49e4d49..e54a3ccb 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -280,6 +280,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): From 042d2871d65d4f2ec94a9d3c483aec47cb2b56a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Mon, 12 Jan 2015 18:07:01 +0100 Subject: [PATCH 21/54] Add test_emails script to send all emails to test it --- taiga/base/management/__init__.py | 0 taiga/base/management/commands/__init__.py | 0 taiga/base/management/commands/test_emails.py | 112 ++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 taiga/base/management/__init__.py create mode 100644 taiga/base/management/commands/__init__.py create mode 100644 taiga/base/management/commands/test_emails.py 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..e566c51e --- /dev/null +++ b/taiga/base/management/commands/test_emails.py @@ -0,0 +1,112 @@ +# 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 djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail + +from taiga.projects.models import Project, Membership +from taiga.projects.history.models import HistoryEntry +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 + context = {"membership": Membership.objects.order_by("?").filter(user__isnull=True).first()} + 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() + + # Notification emails + notification_emails = [ + "issues/issue-change", + "issues/issue-create", + "issues/issue-delete", + "milestones/milestone-change", + "milestones/milestone-create", + "milestones/milestone-delete", + "projects/project-change", + "projects/project-create", + "projects/project-delete", + "tasks/task-change", + "tasks/task-create", + "tasks/task-delete", + "userstories/userstory-change", + "userstories/userstory-create", + "userstories/userstory-delete", + "wiki/wikipage-change", + "wiki/wikipage-create", + "wiki/wikipage-delete", + ] + + context = { + "snapshot": HistoryEntry.objects.filter(is_snapshot=True).order_by("?")[0].snapshot, + "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: + cls = type("InlineCSSTemplateMail", (InlineCSSTemplateMail,), {"name": notification_email}) + email = cls() + email.send(test_email, context) From df282a0c94614a4232c68b55eb5565c75b758e51 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 13 Jan 2015 11:24:37 +0100 Subject: [PATCH 22/54] Fixing register test --- .../emails/registered_user-body-html.jinja | 2 +- .../emails/registered_user-body-text.jinja | 20 ++++++++++++------- tests/integration/test_auth_api.py | 4 ---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/taiga/users/templates/emails/registered_user-body-html.jinja b/taiga/users/templates/emails/registered_user-body-html.jinja index f9ba1519..d939b1b1 100644 --- a/taiga/users/templates/emails/registered_user-body-html.jinja +++ b/taiga/users/templates/emails/registered_user-body-html.jinja @@ -22,7 +22,7 @@ Support: support@taiga.io
-Our mailing address is: +Our mailing list is: https://groups.google.com/forum/#!forum/taigaio

diff --git a/taiga/users/templates/emails/registered_user-body-text.jinja b/taiga/users/templates/emails/registered_user-body-text.jinja index dde7e8b0..d01e0ceb 100644 --- a/taiga/users/templates/emails/registered_user-body-text.jinja +++ b/taiga/users/templates/emails/registered_user-body-text.jinja @@ -1,12 +1,18 @@ -Welcome to Taiga, an Open Source, Agile Project Management Tool +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 + +Copyright © 2014 Taiga Agile, LLC, All rights reserved. + +Contact us: + +Support: mailto:support@taiga.io +Our mailing list is: https://groups.google.com/forum/#!forum/taigaio +You may remove your account from this service: {{ resolve_front_url('cancel-account', cancel_token) }} 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 From e81cafb3e36df46e1d49704245d8e27b4b032dd7 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 13 Jan 2015 13:33:17 +0100 Subject: [PATCH 23/54] Fixing invitation email --- .../templates/emails/membership_invitation-body-html.jinja | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/taiga/projects/templates/emails/membership_invitation-body-html.jinja b/taiga/projects/templates/emails/membership_invitation-body-html.jinja index 7c880832..3b426178 100644 --- a/taiga/projects/templates/emails/membership_invitation-body-html.jinja +++ b/taiga/projects/templates/emails/membership_invitation-body-html.jinja @@ -5,9 +5,9 @@ {% block body %}

You, or someone you know, has invited you to Taiga

-

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 }}'.

+

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 -Accept your invitation +Accept your invitation {% 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 }}

From a8afd77f89cd7ecac5fee368a53e8d0c4b7c387e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Mon, 29 Dec 2014 18:40:17 +0100 Subject: [PATCH 24/54] Add import/export functionality to the API --- requirements.txt | 2 +- settings/common.py | 7 +- settings/testing.py | 3 +- taiga/base/management/commands/test_emails.py | 29 ++++++ taiga/export_import/api.py | 75 ++++++++++++++- taiga/export_import/dump_service.py | 10 ++ taiga/export_import/permissions.py | 4 +- taiga/export_import/serializers.py | 8 +- taiga/export_import/service.py | 4 +- taiga/export_import/tasks.py | 82 ++++++++++++++++ .../emails/dump_project-body-html.jinja | 28 ++++++ .../emails/dump_project-body-text.jinja | 9 ++ .../emails/dump_project-subject.jinja | 1 + .../export_import_error-body-html.jinja | 14 +++ .../export_import_error-body-text.jinja | 7 ++ .../emails/export_import_error-subject.jinja | 1 + .../emails/load_dump-body-html.jinja | 29 ++++++ .../emails/load_dump-body-text.jinja | 7 ++ .../templates/emails/load_dump-subject.jinja | 1 + taiga/export_import/throttling.py | 3 + taiga/projects/attachments/models.py | 2 +- taiga/projects/issues/models.py | 2 +- taiga/projects/tasks/models.py | 2 +- taiga/projects/userstories/signals.py | 16 ++++ taiga/routers.py | 3 +- tests/integration/test_exporter_api.py | 84 +++++++++++++++++ tests/integration/test_importer_api.py | 94 +++++++++++++++++++ 27 files changed, 512 insertions(+), 15 deletions(-) create mode 100644 taiga/export_import/tasks.py create mode 100644 taiga/export_import/templates/emails/dump_project-body-html.jinja create mode 100644 taiga/export_import/templates/emails/dump_project-body-text.jinja create mode 100644 taiga/export_import/templates/emails/dump_project-subject.jinja create mode 100644 taiga/export_import/templates/emails/export_import_error-body-html.jinja create mode 100644 taiga/export_import/templates/emails/export_import_error-body-text.jinja create mode 100644 taiga/export_import/templates/emails/export_import_error-subject.jinja create mode 100644 taiga/export_import/templates/emails/load_dump-body-html.jinja create mode 100644 taiga/export_import/templates/emails/load_dump-body-text.jinja create mode 100644 taiga/export_import/templates/emails/load_dump-subject.jinja create mode 100644 tests/integration/test_exporter_api.py diff --git a/requirements.txt b/requirements.txt index 10375718..2810c5f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ diff-match-patch==20121119 requests==2.4.1 easy-thumbnails==2.1 -celery==3.1.12 +celery==3.1.17 redis==2.10.3 Unidecode==0.04.16 raven==5.1.1 diff --git a/settings/common.py b/settings/common.py index 81a91568..e27df665 100644 --- a/settings/common.py +++ b/settings/common.py @@ -201,6 +201,7 @@ INSTALLED_APPS = [ "rest_framework", "djmail", "django_jinja", + "django_jinja.contrib._humanize", "easy_thumbnails", "raven.contrib.django.raven_compat", ] @@ -300,7 +301,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 +364,9 @@ 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 + # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE TEST_RUNNER="django.test.runner.DiscoverRunner" diff --git a/settings/testing.py b/settings/testing.py index 2df79576..c3fd878d 100644 --- a/settings/testing.py +++ b/settings/testing.py @@ -28,5 +28,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/base/management/commands/test_emails.py b/taiga/base/management/commands/test_emails.py index e566c51e..d8e021cd 100644 --- a/taiga/base/management/commands/test_emails.py +++ b/taiga/base/management/commands/test_emails.py @@ -14,7 +14,10 @@ # 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.management.base import BaseCommand, CommandError +from django.utils import timezone from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail @@ -76,6 +79,32 @@ class Command(BaseCommand): email = mbuilder.change_email(test_email, context) email.send() + # Export/Import emails + context = { + "user": User.objects.all().order_by("?").first(), + "error_subject": "Error generating project dump", + "error_message": "Error generating project dump", + } + email = mbuilder.export_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-change", diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index 8277be63..15fda9ca 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -14,17 +14,24 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import json +import codecs + from rest_framework.exceptions import APIException 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 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 @@ -32,15 +39,46 @@ from . import mixins from . import serializers from . import service from . import permissions +from . import tasks +from . import dump_service +from . import throttling + +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,), countdown=settings.EXPORTS_TTL) + return Response({"export-id": task.id}, status=status.HTTP_202_ACCEPTED) + + return Response( + service.project_to_dict(project), + status=status.HTTP_200_OK, + headers={ + "Content-Disposition": "attachment; filename={}.json".format(project.slug) + } + ) + 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): @@ -113,6 +151,39 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi 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) + + dump_service.dict_to_project(dump, request.user.email) + return Response(None, status=status.HTTP_204_NO_CONTENT) + + @detail_route(methods=['post']) @method_decorator(atomic) def issue(self, request, *args, **kwargs): 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/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..253f8f9f 100644 --- a/taiga/export_import/service.py +++ b/taiga/export_import/service.py @@ -84,7 +84,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 +102,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..97b08a8a --- /dev/null +++ b/taiga/export_import/tasks.py @@ -0,0 +1,82 @@ +# 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 + +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() + + path = "exports/{}/{}.json".format(project.pk, self.request.id) + + try: + content = ContentFile(ExportRenderer().render(project_to_dict(project), renderer_context={"indent": 4}).decode('utf-8')) + default_storage.save(path, content) + url = default_storage.url(path) + except Exception: + email = mbuilder.export_import_error( + user.email, + { + "user": user, + "error_subject": "Error generating project dump", + "error_message": "Error generating project dump", + } + ) + email.send() + return + + deletion_date = timezone.now() + datetime.timedelta(seconds=settings.EXPORTS_TTL) + email = mbuilder.dump_project(user.email, {"url": url, "project": project, "user": user, "deletion_date": deletion_date}) + email.send() + +@app.task +def delete_project_dump(project_id, task_id): + default_storage.delete("exports/{}/{}.json".format(project_id, task_id)) + +@app.task +def load_project_dump(user, dump): + mbuilder = MagicMailBuilder() + + try: + project = dict_to_project(dump, user.email) + except Exception: + email = mbuilder.export_import_error( + user.email, + { + "user": user, + "error_subject": "Error loading project dump", + "error_message": "Error loading project dump", + } + ) + email.send() + return + + email = mbuilder.load_dump(user.email, {"user": user, "project": project}) + 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..f1109ccb --- /dev/null +++ b/taiga/export_import/templates/emails/dump_project-body-html.jinja @@ -0,0 +1,28 @@ +{% extends "emails/base.jinja" %} + +{% block body %} + + + + +
+

Project dump generated

+

Hello {{ user.get_full_name() }},

+

Your project dump has been correctly generated.

+

You can download it from here: {{ url }}

+

This file will be deleted on {{ deletion_date|date("r") }}.

+

The Taiga Team

+
+{% endblock %} + +{% block footer %} + Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio +{% 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..4874d4b5 --- /dev/null +++ b/taiga/export_import/templates/emails/dump_project-body-text.jinja @@ -0,0 +1,9 @@ +Hello {{ user.get_full_name() }}, + +Your project dump has been correctly generated. You can download it from here: + +{{ url }} + +This file will be deleted on {{ deletion_date|date("r") }}. + +The Taiga Team 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..cdd31e44 --- /dev/null +++ b/taiga/export_import/templates/emails/dump_project-subject.jinja @@ -0,0 +1 @@ +[Taiga] Your project dump has been generated diff --git a/taiga/export_import/templates/emails/export_import_error-body-html.jinja b/taiga/export_import/templates/emails/export_import_error-body-html.jinja new file mode 100644 index 00000000..c3fb40ae --- /dev/null +++ b/taiga/export_import/templates/emails/export_import_error-body-html.jinja @@ -0,0 +1,14 @@ +{% extends "emails/base.jinja" %} + +{% block body %} + + + + +
+

{{ error_message }}

+

Hello {{ user.get_full_name() }},

+

Please, contact with the support team at support@taiga.io

+

The Taiga Team

+
+{% endblock %} diff --git a/taiga/export_import/templates/emails/export_import_error-body-text.jinja b/taiga/export_import/templates/emails/export_import_error-body-text.jinja new file mode 100644 index 00000000..6ef22223 --- /dev/null +++ b/taiga/export_import/templates/emails/export_import_error-body-text.jinja @@ -0,0 +1,7 @@ +Hello {{ user.get_full_name() }}, + +{{ error_message }} + +Please, contact with the support team at support@taiga.io + +The Taiga Team diff --git a/taiga/export_import/templates/emails/export_import_error-subject.jinja b/taiga/export_import/templates/emails/export_import_error-subject.jinja new file mode 100644 index 00000000..67eaf97f --- /dev/null +++ b/taiga/export_import/templates/emails/export_import_error-subject.jinja @@ -0,0 +1 @@ +[Taiga] {{ error_subject }} 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..a253fe00 --- /dev/null +++ b/taiga/export_import/templates/emails/load_dump-body-html.jinja @@ -0,0 +1,29 @@ +{% extends "emails/base.jinja" %} + +{% set final_url = resolve_front_url("project", project.slug) %} + +{% block body %} + + + + +
+

Project dump imported

+

Hello {{ user.get_full_name() }},

+

Your project dump has been correctly imported.

+

You can see the project here: {{ final_url }}

+

The Taiga Team

+
+{% endblock %} + +{% block footer %} + Copyright © 2014 Taiga Agile, LLC, All rights reserved. +
+ Contact us: +
+ Support: + support@taiga.io +
+ Our mailing address is: + https://groups.google.com/forum/#!forum/taigaio +{% 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..30064644 --- /dev/null +++ b/taiga/export_import/templates/emails/load_dump-body-text.jinja @@ -0,0 +1,7 @@ +Hello {{ user.get_full_name() }}, + +Your project dump has been correctly imported. You can see the project here: + +{{ resolve_front_url('project', project.slug) }} + +The Taiga Team 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..d18f37c4 --- /dev/null +++ b/taiga/export_import/templates/emails/load_dump-subject.jinja @@ -0,0 +1 @@ +[Taiga] Your project dump has been imported 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/projects/attachments/models.py b/taiga/projects/attachments/models.py index eb444a7d..df466c18 100644 --- a/taiga/projects/attachments/models.py +++ b/taiga/projects/attachments/models.py @@ -80,7 +80,7 @@ class Attachment(models.Model): class Meta: verbose_name = "attachment" verbose_name_plural = "attachments" - ordering = ["project", "created_date"] + ordering = ["project", "created_date", "id"] permissions = ( ("view_attachment", "Can view attachment"), ) 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/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/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..98f6a86c 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 diff --git a/tests/integration/test_exporter_api.py b/tests/integration/test_exporter_api.py new file mode 100644 index 00000000..060b1c59 --- /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 +import json + +from django.core.urlresolvers import reverse + +from .. import factories as f + + +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 response_data["slug"] == project.slug + + +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_importer_api.py b/tests/integration/test_importer_api.py index 00480c0d..d458a1a6 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -20,6 +20,7 @@ import base64 import datetime from django.core.urlresolvers import reverse +from django.core.files.base import ContentFile from .. import factories as f @@ -703,3 +704,96 @@ 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 == 204 + +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 == 204 + new_project = Project.objects.all().order_by("-id").first() + assert new_project.name == "Test import" + assert new_project.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 == 204 + response = client.post(url, {'dump': data}) + assert response.status_code == 429 From 64dde7870dad9e30ecf3599654c751a0c2ca4e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 13 Jan 2015 19:10:03 +0100 Subject: [PATCH 25/54] Remove unnecessary import --- tests/integration/test_tasks.py | 1 - 1 file changed, 1 deletion(-) 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 From e81a73e24b65f1ee1478864cd2393aa313959cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 13 Jan 2015 14:25:01 +0100 Subject: [PATCH 26/54] Issue #1734: currentPassword is not needed for github users --- taiga/users/api.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/taiga/users/api.py b/taiga/users/api.py index 856f99a1..6407b8b9 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -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) From b04c22045196b116d7a3d592eee11baba6bf82e9 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 14 Jan 2015 10:54:16 +0100 Subject: [PATCH 27/54] Adding lost migrations --- .../migrations/0003_auto_20150114_0954.py | 18 ++++++++++++++++++ .../migrations/0004_auto_20150114_0954.py | 18 ++++++++++++++++++ .../migrations/0005_auto_20150114_0954.py | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 taiga/projects/attachments/migrations/0003_auto_20150114_0954.py create mode 100644 taiga/projects/issues/migrations/0004_auto_20150114_0954.py create mode 100644 taiga/projects/tasks/migrations/0005_auto_20150114_0954.py diff --git a/taiga/projects/attachments/migrations/0003_auto_20150114_0954.py b/taiga/projects/attachments/migrations/0003_auto_20150114_0954.py new file mode 100644 index 00000000..ab9b2185 --- /dev/null +++ b/taiga/projects/attachments/migrations/0003_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 = [ + ('attachments', '0002_add_size_and_name_fields'), + ] + + operations = [ + migrations.AlterModelOptions( + name='attachment', + options={'ordering': ['project', 'created_date', 'id'], 'permissions': (('view_attachment', 'Can view attachment'),), 'verbose_name_plural': 'attachments', 'verbose_name': 'attachment'}, + ), + ] 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/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'}, + ), + ] From 834d8dfe3f04a93732258327e3eca585c3a26274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 14 Jan 2015 10:54:04 +0100 Subject: [PATCH 28/54] Use taiga.base.utils.json instead json --- tests/integration/test_exporter_api.py | 2 +- tests/integration/test_history.py | 2 +- tests/integration/test_hooks_bitbucket.py | 2 +- tests/integration/test_hooks_github.py | 2 +- tests/integration/test_hooks_gitlab.py | 2 +- tests/integration/test_importer_api.py | 2 +- tests/integration/test_issues.py | 1 - tests/integration/test_notifications.py | 2 +- tests/integration/test_timeline.py | 2 +- tests/integration/test_users.py | 2 +- tests/integration/test_userstorage_api.py | 2 +- tests/utils.py | 2 +- 12 files changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/integration/test_exporter_api.py b/tests/integration/test_exporter_api.py index 060b1c59..edf333e4 100644 --- a/tests/integration/test_exporter_api.py +++ b/tests/integration/test_exporter_api.py @@ -15,11 +15,11 @@ # along with this program. If not, see . import pytest -import json from django.core.urlresolvers import reverse from .. import factories as f +from taiga.base.utils import json pytestmark = pytest.mark.django_db 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 d458a1a6..2b2ad9fa 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -15,7 +15,6 @@ # along with this program. If not, see . import pytest -import json import base64 import datetime @@ -24,6 +23,7 @@ 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 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_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/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(): From 6e39a8f7a884366d9dbd6c79b165bab6e2ce7a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Mon, 29 Dec 2014 15:58:58 +0100 Subject: [PATCH 29/54] Add django-transactional-cleanup and configure it --- requirements.txt | 1 + settings/common.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2810c5f9..3b91231c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,7 @@ 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 e27df665..46e9b343 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", } } @@ -204,6 +204,7 @@ INSTALLED_APPS = [ "django_jinja.contrib._humanize", "easy_thumbnails", "raven.contrib.django.raven_compat", + "django_transactional_cleanup", ] WSGI_APPLICATION = "taiga.wsgi.application" From 47107eb079c8d6dfa8274d1ef9f7cebd4ec6f4f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Fri, 2 Jan 2015 14:15:29 +0100 Subject: [PATCH 30/54] US #1678: Add webhooks to the backend --- settings/common.py | 2 + settings/testing.py | 1 + taiga/base/filters.py | 46 +++ taiga/base/utils/db.py | 7 + taiga/events/events.py | 11 +- taiga/events/signal_handlers.py | 6 +- taiga/routers.py | 4 + taiga/webhooks/__init__.py | 17 + taiga/webhooks/api.py | 65 ++++ taiga/webhooks/apps.py | 37 +++ taiga/webhooks/migrations/0001_initial.py | 41 +++ taiga/webhooks/migrations/__init__.py | 0 taiga/webhooks/models.py | 36 ++ taiga/webhooks/permissions.py | 40 +++ taiga/webhooks/serializers.py | 131 ++++++++ taiga/webhooks/signal_handlers.py | 65 ++++ taiga/webhooks/tasks.py | 125 +++++++ tests/factories.py | 22 ++ .../test_webhooks_resources.py | 307 ++++++++++++++++++ tests/integration/test_webhooks.py | 92 ++++++ 20 files changed, 1044 insertions(+), 11 deletions(-) create mode 100644 taiga/webhooks/__init__.py create mode 100644 taiga/webhooks/api.py create mode 100644 taiga/webhooks/apps.py create mode 100644 taiga/webhooks/migrations/0001_initial.py create mode 100644 taiga/webhooks/migrations/__init__.py create mode 100644 taiga/webhooks/models.py create mode 100644 taiga/webhooks/permissions.py create mode 100644 taiga/webhooks/serializers.py create mode 100644 taiga/webhooks/signal_handlers.py create mode 100644 taiga/webhooks/tasks.py create mode 100644 tests/integration/resources_permissions/test_webhooks_resources.py create mode 100644 tests/integration/test_webhooks.py diff --git a/settings/common.py b/settings/common.py index e27df665..12e2275c 100644 --- a/settings/common.py +++ b/settings/common.py @@ -197,6 +197,7 @@ INSTALLED_APPS = [ "taiga.hooks.github", "taiga.hooks.gitlab", "taiga.hooks.bitbucket", + "taiga.webhooks", "rest_framework", "djmail", @@ -366,6 +367,7 @@ GITLAB_VALID_ORIGIN_IPS = [] EXPORTS_TTL = 60 * 60 * 24 # 24 hours CELERY_ENABLED = False +WEBHOOKS_ENABLED = False # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE TEST_RUNNER="django.test.runner.DiscoverRunner" diff --git a/settings/testing.py b/settings/testing.py index c3fd878d..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" 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/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/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/routers.py b/taiga/routers.py index 98f6a86c..7ca20aa6 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -90,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/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..8fb952eb --- /dev/null +++ b/taiga/webhooks/api.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 . + +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) + + tasks.test_webhook(webhook.id, webhook.url, webhook.key) + + return Response() + +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 + + tasks.resend_webhook(webhook.id, webhook.url, webhook.key, webhooklog.request_data) + + return Response() 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/__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..af802535 --- /dev/null +++ b/taiga/webhooks/models.py @@ -0,0 +1,36 @@ +# 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") + url = models.URLField(null=False, blank=False, verbose_name=_("URL")) + key = models.TextField(null=False, blank=False, verbose_name=_("secret key")) + + +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")) + response_data = models.TextField(null=False, blank=False, verbose_name=_("Response data")) 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..388e4d5f --- /dev/null +++ b/taiga/webhooks/serializers.py @@ -0,0 +1,131 @@ +# 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): + class Meta: + model = Webhook + +class WebhookLogSerializer(serializers.ModelSerializer): + request_data = 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..652b9f56 --- /dev/null +++ b/taiga/webhooks/tasks.py @@ -0,0 +1,125 @@ +# 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, + } + try: + response = requests.post(url, data=serialized_data, headers=headers) + WebhookLog.objects.create(webhook_id=webhook_id, url=url, + status=response.status_code, + request_data=data, + response_data=response.content) + except RequestException: + WebhookLog.objects.create(webhook_id=webhook_id, url=url, status=0, + request_data=data, + response_data="error-in-request") + + 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() + + +@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) + + _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) + + _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) + + _send_request(webhook_id, url, key, data) + + +@app.task +def resend_webhook(webhook_id, url, key, data): + _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" + + _send_request(webhook_id, url, key, data) + diff --git a/tests/factories.py b/tests/factories.py index e54a3ccb..9c96224e 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -197,6 +197,28 @@ 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" + + +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" 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..ab5cad17 --- /dev/null +++ b/tests/integration/resources_permissions/test_webhooks_resources.py @@ -0,0 +1,307 @@ +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({ + "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({ + "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_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 From c0af3a1acf6c5230af612a9b3ce55276164bff33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 15 Jan 2015 18:20:26 +0100 Subject: [PATCH 31/54] Add license --- .../management/commands/load_dump.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 From e77d2ba783456338618ec7d45001a8dabd8e1094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 19 Jan 2015 12:26:57 +0100 Subject: [PATCH 32/54] Ignore .project --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 12ecaa9a..c6e43b78 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ media .coverage .cache .\#* +.project From 5a53e8f78a158c20a329794929bbb593a31cbaa5 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 20 Jan 2015 10:52:39 +0100 Subject: [PATCH 33/54] Refactoring project export --- taiga/export_import/api.py | 41 +++++++++++++------------- taiga/export_import/service.py | 1 + tests/integration/test_exporter_api.py | 2 +- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index 15fda9ca..3e1cfbae 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -16,8 +16,8 @@ import json import codecs +import uuid -from rest_framework.exceptions import APIException from rest_framework.response import Response from rest_framework.decorators import throttle_classes from rest_framework import status @@ -27,6 +27,8 @@ 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 @@ -42,14 +44,11 @@ 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, ) @@ -68,13 +67,15 @@ class ProjectExporterViewSet(mixins.ImportThrottlingPolicyMixin, GenericViewSet) tasks.delete_project_dump.apply_async((project.pk,), countdown=settings.EXPORTS_TTL) return Response({"export-id": task.id}, status=status.HTTP_202_ACCEPTED) - return Response( - service.project_to_dict(project), - status=status.HTTP_200_OK, - headers={ - "Content-Disposition": "attachment; filename={}.json".format(project.slug) - } - ) + path = "exports/{}/{}.json".format(project.pk, 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 @@ -90,7 +91,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, @@ -144,7 +145,7 @@ 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 @@ -197,7 +198,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) @@ -212,7 +213,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) @@ -227,7 +228,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) @@ -242,7 +243,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) @@ -257,7 +258,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) @@ -272,7 +273,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/service.py b/taiga/export_import/service.py index 253f8f9f..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 diff --git a/tests/integration/test_exporter_api.py b/tests/integration/test_exporter_api.py index edf333e4..9e25ccce 100644 --- a/tests/integration/test_exporter_api.py +++ b/tests/integration/test_exporter_api.py @@ -48,7 +48,7 @@ def test_valid_project_export_with_celery_disabled(client, settings): response = client.get(url, content_type="application/json") assert response.status_code == 200 response_data = json.loads(response.content.decode("utf-8")) - assert response_data["slug"] == project.slug + assert "url" in response_data def test_valid_project_export_with_celery_enabled(client, settings): From 1afe78da8f206f52081b888cf1af6b38e7dcd2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Mon, 19 Jan 2015 16:38:05 +0100 Subject: [PATCH 34/54] Adding initial contrib modules system --- taiga/contrib_routers.py | 19 +++++++++++++++++++ taiga/urls.py | 2 ++ 2 files changed, 21 insertions(+) create mode 100644 taiga/contrib_routers.py 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/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)), ] From 395c35628eb6581e6d4ccad2322dc16beab4f548 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 21 Jan 2015 11:39:21 +0100 Subject: [PATCH 35/54] Import API in synchronous mode now returns the new project serialized --- taiga/export_import/api.py | 6 ++++-- tests/integration/test_importer_api.py | 15 +++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index 3e1cfbae..d198de7d 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -36,6 +36,7 @@ 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 @@ -181,8 +182,9 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi task = tasks.load_project_dump.delay(request.user, dump) return Response({"import-id": task.id}, status=status.HTTP_202_ACCEPTED) - dump_service.dict_to_project(dump, request.user.email) - return Response(None, status=status.HTTP_204_NO_CONTENT) + 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']) diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 2b2ad9fa..29b552c0 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -735,7 +735,10 @@ def test_valid_dump_import_with_celery_disabled(client, settings): data.name = "test" response = client.post(url, {'dump': data}) - assert response.status_code == 204 + 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 @@ -772,10 +775,10 @@ def test_dump_import_duplicated_project(client): data.name = "test" response = client.post(url, {'dump': data}) - assert response.status_code == 204 - new_project = Project.objects.all().order_by("-id").first() - assert new_project.name == "Test import" - assert new_project.slug == "{}-test-import".format(user.username) + 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" @@ -794,6 +797,6 @@ def test_dump_import_throttling(client, settings): data.name = "test" response = client.post(url, {'dump': data}) - assert response.status_code == 204 + assert response.status_code == 201 response = client.post(url, {'dump': data}) assert response.status_code == 429 From df20fc7f4532aefd66d0925cf299cafc322bb458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 21 Jan 2015 15:07:20 +0100 Subject: [PATCH 36/54] Update changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12a22b44..b8782573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog # -## 1.5.0 ??? (unreleased) +## 1.5.0 Betula Pendula - FOSDEM 2015 (unreleased) ### Features - Improving some SQL queries +- Now you can export and import projects between Taiga instances. ### Misc - Lots of small and not so small bugfixes. From 8c6588b17a50b2e20ce72750d9c37461cc8f809c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 21 Jan 2015 15:28:00 +0100 Subject: [PATCH 37/54] Make human-friendly the name of the dump files and xut some long lines --- taiga/export_import/api.py | 5 +++-- taiga/export_import/tasks.py | 37 ++++++++++++++++++++++-------------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index d198de7d..fff76806 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -65,10 +65,10 @@ class ProjectExporterViewSet(mixins.ImportThrottlingPolicyMixin, GenericViewSet) if settings.CELERY_ENABLED: task = tasks.dump_project.delay(request.user, project) - tasks.delete_project_dump.apply_async((project.pk,), countdown=settings.EXPORTS_TTL) + 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, uuid.uuid4().hex) + 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')) @@ -78,6 +78,7 @@ class ProjectExporterViewSet(mixins.ImportThrottlingPolicyMixin, GenericViewSet) } return Response(response_data, status=status.HTTP_200_OK) + class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixin, GenericViewSet): model = Project permission_classes = (permissions.ImportExportPermission, ) diff --git a/taiga/export_import/tasks.py b/taiga/export_import/tasks.py index 97b08a8a..108a0417 100644 --- a/taiga/export_import/tasks.py +++ b/taiga/export_import/tasks.py @@ -33,32 +33,41 @@ from .renderers import ExportRenderer @app.task(bind=True) def dump_project(self, user, project): mbuilder = MagicMailBuilder() - - path = "exports/{}/{}.json".format(project.pk, self.request.id) + path = "exports/{}/{}-{}.json".format(project.pk, project.slug, self.request.id) try: - content = ContentFile(ExportRenderer().render(project_to_dict(project), renderer_context={"indent": 4}).decode('utf-8')) + 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: - email = mbuilder.export_import_error( - user.email, - { - "user": user, - "error_subject": "Error generating project dump", - "error_message": "Error generating project dump", - } - ) + ctx = { + "user": user, + "error_subject": "Error generating project dump", + "error_message": "Error generating project dump", + } + email = mbuilder.export_import_error(user.email, ctx) email.send() return + deletion_date = timezone.now() + datetime.timedelta(seconds=settings.EXPORTS_TTL) - email = mbuilder.dump_project(user.email, {"url": url, "project": project, "user": user, "deletion_date": deletion_date}) + ctx = { + "url": url, + "project": project, + "user": user, + "deletion_date": deletion_datei + } + email = mbuilder.dump_project(user.email, ctx) email.send() + @app.task -def delete_project_dump(project_id, task_id): - default_storage.delete("exports/{}/{}.json".format(project_id, task_id)) +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): From 6032ed6c34c12627a9edc051eef82325ecb24bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 22 Jan 2015 09:31:23 +0100 Subject: [PATCH 38/54] Remove an i --- taiga/export_import/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taiga/export_import/tasks.py b/taiga/export_import/tasks.py index 108a0417..2110a7b0 100644 --- a/taiga/export_import/tasks.py +++ b/taiga/export_import/tasks.py @@ -58,7 +58,7 @@ def dump_project(self, user, project): "url": url, "project": project, "user": user, - "deletion_date": deletion_datei + "deletion_date": deletion_date } email = mbuilder.dump_project(user.email, ctx) email.send() From da156401b01204499a381d4de60ef6040a00b09f Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 21 Jan 2015 15:06:32 +0100 Subject: [PATCH 39/54] Adding name to webhooks and logs_counter to webhooks API --- .../webhooks/migrations/0002_webhook_name.py | 20 +++++++++++++++++++ taiga/webhooks/models.py | 2 ++ taiga/webhooks/serializers.py | 5 +++++ tests/factories.py | 1 + .../test_webhooks_resources.py | 2 ++ 5 files changed, 30 insertions(+) create mode 100644 taiga/webhooks/migrations/0002_webhook_name.py 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/models.py b/taiga/webhooks/models.py index af802535..e4a418c1 100644 --- a/taiga/webhooks/models.py +++ b/taiga/webhooks/models.py @@ -23,6 +23,8 @@ 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")) diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py index 388e4d5f..7bc08e0f 100644 --- a/taiga/webhooks/serializers.py +++ b/taiga/webhooks/serializers.py @@ -34,9 +34,14 @@ class HistoryDiffField(serializers.Field): 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() diff --git a/tests/factories.py b/tests/factories.py index 9c96224e..14f120a8 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -205,6 +205,7 @@ class WebhookFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") url = "http://localhost:8080/test" key = "factory-key" + name = "Factory-name" class WebhookLogFactory(Factory): diff --git a/tests/integration/resources_permissions/test_webhooks_resources.py b/tests/integration/resources_permissions/test_webhooks_resources.py index ab5cad17..9514fc88 100644 --- a/tests/integration/resources_permissions/test_webhooks_resources.py +++ b/tests/integration/resources_permissions/test_webhooks_resources.py @@ -137,6 +137,7 @@ def test_webhook_create(client, data): ] create_data = json.dumps({ + "name": "Test", "url": "http://test.com", "key": "test", "project": data.project1.pk, @@ -145,6 +146,7 @@ def test_webhook_create(client, data): assert results == [401, 403, 201] create_data = json.dumps({ + "name": "Test", "url": "http://test.com", "key": "test", "project": data.project2.pk, From 5b7cb4b13bbe83d86a7acd7eb02012d57b713ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Thu, 22 Jan 2015 11:48:12 +0100 Subject: [PATCH 40/54] Add extra data to the WebhookLog model (headers, duration, and creation date) --- .../migrations/0003_auto_20150122_1021.py | 40 +++++++++++++++++++ taiga/webhooks/models.py | 4 ++ taiga/webhooks/tasks.py | 20 ++++++++-- 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 taiga/webhooks/migrations/0003_auto_20150122_1021.py 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/models.py b/taiga/webhooks/models.py index e4a418c1..0fd212dd 100644 --- a/taiga/webhooks/models.py +++ b/taiga/webhooks/models.py @@ -35,4 +35,8 @@ class WebhookLog(models.Model): 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) diff --git a/taiga/webhooks/tasks.py b/taiga/webhooks/tasks.py index 652b9f56..c65543e6 100644 --- a/taiga/webhooks/tasks.py +++ b/taiga/webhooks/tasks.py @@ -62,17 +62,29 @@ def _send_request(webhook_id, url, key, 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 = requests.post(url, data=serialized_data, headers=headers) + response = session.send(prepared_request) WebhookLog.objects.create(webhook_id=webhook_id, url=url, status=response.status_code, request_data=data, - response_data=response.content) - except RequestException: + request_headers=dict(prepared_request.headers), + response_data=response.content, + response_headers=dict(response.headers), + duration=response.elapsed.total_seconds()) + except RequestException as e: WebhookLog.objects.create(webhook_id=webhook_id, url=url, status=0, request_data=data, - response_data="error-in-request") + 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() From 415f8b35d270ea3637d6cc61f7e58b6542eb214a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 22 Jan 2015 15:50:32 +0100 Subject: [PATCH 41/54] Set reply-to to feedback emails --- taiga/feedback/api.py | 2 +- taiga/feedback/services.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) 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 01d24cd9..e5f92c3c 100644 --- a/taiga/feedback/services.py +++ b/taiga/feedback/services.py @@ -19,11 +19,18 @@ from django.conf import settings 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: + reply_to.append(support_email) + + ctx = { + "feedback_entry": feedback_entry, + "extra": extra + } + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) - email = mbuilder.feedback_notification(support_email, {"feedback_entry": feedback_entry, - "extra": extra}) + email = mbuilder.feedback_notification(support_email, ctx) + email.extra_headers["Reply-To"] = ", ".join(reply_to) email.send() From 97cec70d558c54160ef1790e09ab09b8d0ff60d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Thu, 22 Jan 2015 13:37:41 +0100 Subject: [PATCH 42/54] Now webhook test and webhooklog resend endpoints return the generated webhooklog object --- taiga/webhooks/api.py | 10 ++++++---- taiga/webhooks/serializers.py | 2 ++ taiga/webhooks/tasks.py | 37 ++++++++++++++++++----------------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/taiga/webhooks/api.py b/taiga/webhooks/api.py index 8fb952eb..cd219574 100644 --- a/taiga/webhooks/api.py +++ b/taiga/webhooks/api.py @@ -42,9 +42,10 @@ class WebhookViewSet(ModelCrudViewSet): webhook = self.get_object() self.check_permissions(request, 'test', webhook) - tasks.test_webhook(webhook.id, webhook.url, webhook.key) + webhooklog = tasks.test_webhook(webhook.id, webhook.url, webhook.key) + log = serializers.WebhookLogSerializer(webhooklog) - return Response() + return Response(log.data) class WebhookLogViewSet(ModelListViewSet): model = models.WebhookLog @@ -60,6 +61,7 @@ class WebhookLogViewSet(ModelListViewSet): webhook = webhooklog.webhook - tasks.resend_webhook(webhook.id, webhook.url, webhook.key, webhooklog.request_data) + webhooklog = tasks.resend_webhook(webhook.id, webhook.url, webhook.key, webhooklog.request_data) + log = serializers.WebhookLogSerializer(webhooklog) - return Response() + return Response(log.data) diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py index 7bc08e0f..ec087484 100644 --- a/taiga/webhooks/serializers.py +++ b/taiga/webhooks/serializers.py @@ -44,6 +44,8 @@ class WebhookSerializer(serializers.ModelSerializer): class WebhookLogSerializer(serializers.ModelSerializer): request_data = JsonField() + request_headers = JsonField() + response_headers = JsonField() class Meta: model = WebhookLog diff --git a/taiga/webhooks/tasks.py b/taiga/webhooks/tasks.py index c65543e6..7a0f6dcd 100644 --- a/taiga/webhooks/tasks.py +++ b/taiga/webhooks/tasks.py @@ -70,24 +70,25 @@ def _send_request(webhook_id, url, key, data): session = requests.Session() try: response = session.send(prepared_request) - 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()) + 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: - 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) + 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 @@ -98,7 +99,7 @@ def change_webhook(webhook_id, url, key, obj, change): data['type'] = _get_type(obj) data['change'] = _serialize(change) - _send_request(webhook_id, url, key, data) + return _send_request(webhook_id, url, key, data) @app.task @@ -108,7 +109,7 @@ def create_webhook(webhook_id, url, key, obj): data['action'] = "create" data['type'] = _get_type(obj) - _send_request(webhook_id, url, key, data) + return _send_request(webhook_id, url, key, data) @app.task @@ -118,12 +119,12 @@ def delete_webhook(webhook_id, url, key, obj): data['action'] = "delete" data['type'] = _get_type(obj) - _send_request(webhook_id, url, key, data) + return _send_request(webhook_id, url, key, data) @app.task def resend_webhook(webhook_id, url, key, data): - _send_request(webhook_id, url, key, data) + return _send_request(webhook_id, url, key, data) @app.task @@ -133,5 +134,5 @@ def test_webhook(webhook_id, url, key): data['action'] = "test" data['type'] = "test" - _send_request(webhook_id, url, key, data) + return _send_request(webhook_id, url, key, data) From 62d627beb47b99b3b6570c52156cbc2aee10782e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 22 Jan 2015 18:43:24 +0100 Subject: [PATCH 43/54] Use _ instead - in response.data --- taiga/export_import/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index fff76806..a993b6af 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -66,7 +66,7 @@ class ProjectExporterViewSet(mixins.ImportThrottlingPolicyMixin, GenericViewSet) 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) + 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), @@ -181,7 +181,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi if settings.CELERY_ENABLED: task = tasks.load_project_dump.delay(request.user, dump) - return Response({"import-id": task.id}, status=status.HTTP_202_ACCEPTED) + 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 From 779a2542d3ba8fc072bba0ba0ebaa70227e09f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 22 Jan 2015 21:15:59 +0100 Subject: [PATCH 44/54] Update test_exporter_api.py --- tests/integration/test_exporter_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_exporter_api.py b/tests/integration/test_exporter_api.py index 9e25ccce..7758fdf6 100644 --- a/tests/integration/test_exporter_api.py +++ b/tests/integration/test_exporter_api.py @@ -64,7 +64,7 @@ def test_valid_project_export_with_celery_enabled(client, settings): 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 + assert "export_id" in response_data def test_valid_project_with_throttling(client, settings): From 90d5eb88e973058f90de8310665b19481da753da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 22 Jan 2015 21:18:12 +0100 Subject: [PATCH 45/54] Update test_importer_api.py --- tests/integration/test_importer_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 29b552c0..14fd299e 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -758,7 +758,7 @@ def test_valid_dump_import_with_celery_enabled(client, settings): 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 + assert "import_id" in response_data def test_dump_import_duplicated_project(client): user = f.UserFactory.create() From 0d591dcadc7db41a127100d28c3556e539469692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 14 Jan 2015 21:45:15 +0100 Subject: [PATCH 46/54] Translate and fix all emails --- requirements.txt | 2 +- settings/common.py | 4 + settings/sr.py | 29 ++ taiga/base/management/commands/test_emails.py | 70 ++- .../{updates.jinja => base-body-html.jinja} | 51 +- .../{hero.jinja => hero-body-html.jinja} | 34 +- .../{base.jinja => updates-body-html.jinja} | 70 ++- .../templates/emails/updates-body-text.jinja | 15 + taiga/export_import/tasks.py | 20 +- .../emails/dump_project-body-html.jinja | 38 +- .../emails/dump_project-body-text.jinja | 12 +- .../emails/dump_project-subject.jinja | 2 +- .../emails/export_error-body-html.jinja | 14 + .../emails/export_error-body-text.jinja | 13 + .../emails/export_error-subject.jinja | 1 + .../export_import_error-body-html.jinja | 14 - .../export_import_error-body-text.jinja | 7 - .../emails/export_import_error-subject.jinja | 1 - .../emails/import_error-body-html.jinja | 13 + .../emails/import_error-body-text.jinja | 12 + .../emails/import_error-subject.jinja | 1 + .../emails/load_dump-body-html.jinja | 36 +- .../emails/load_dump-body-text.jinja | 13 +- .../templates/emails/load_dump-subject.jinja | 2 +- .../feedback_notification-body-html.jinja | 452 +----------------- .../feedback_notification-body-text.jinja | 11 +- .../feedback_notification-subject.jinja | 4 +- .../emails/includes/fields_diff-html.jinja | 42 +- .../emails/includes/fields_diff-text.jinja | 16 +- .../issues/issue-change-body-html.jinja | 51 +- .../issues/issue-change-body-text.jinja | 29 +- .../emails/issues/issue-change-subject.jinja | 6 +- .../issues/issue-create-body-html.jinja | 30 +- .../issues/issue-create-body-text.jinja | 16 +- .../emails/issues/issue-create-subject.jinja | 6 +- .../issues/issue-delete-body-html.jinja | 24 +- .../issues/issue-delete-body-text.jinja | 12 +- .../emails/issues/issue-delete-subject.jinja | 6 +- .../milestone-change-body-html.jinja | 47 +- .../milestone-change-body-text.jinja | 29 +- .../milestones/milestone-change-subject.jinja | 5 +- .../milestone-create-body-html.jinja | 37 +- .../milestone-create-body-text.jinja | 18 +- .../milestones/milestone-create-subject.jinja | 5 +- .../milestone-delete-body-html.jinja | 33 +- .../milestone-delete-body-text.jinja | 14 +- .../milestones/milestone-delete-subject.jinja | 5 +- .../projects/project-change-body-html.jinja | 36 -- .../projects/project-change-body-text.jinja | 17 - .../projects/project-change-subject.jinja | 1 - .../projects/project-create-body-html.jinja | 27 -- .../projects/project-create-body-text.jinja | 7 - .../projects/project-create-subject.jinja | 1 - .../projects/project-delete-body-html.jinja | 24 - .../projects/project-delete-body-text.jinja | 2 - .../projects/project-delete-subject.jinja | 1 - .../emails/tasks/task-change-body-html.jinja | 51 +- .../emails/tasks/task-change-body-text.jinja | 30 +- .../emails/tasks/task-change-subject.jinja | 6 +- .../emails/tasks/task-create-body-html.jinja | 31 +- .../emails/tasks/task-create-body-text.jinja | 16 +- .../emails/tasks/task-create-subject.jinja | 6 +- .../emails/tasks/task-delete-body-html.jinja | 24 +- .../emails/tasks/task-delete-body-text.jinja | 13 +- .../emails/tasks/task-delete-subject.jinja | 6 +- .../userstory-change-body-html.jinja | 51 +- .../userstory-change-body-text.jinja | 29 +- .../userstory-change-subject.jinja | 6 +- .../userstory-create-body-html.jinja | 30 +- .../userstory-create-body-text.jinja | 17 +- .../userstory-create-subject.jinja | 6 +- .../userstory-delete-body-html.jinja | 25 +- .../userstory-delete-body-text.jinja | 14 +- .../userstory-delete-subject.jinja | 6 +- .../wiki/wikipage-change-body-html.jinja | 51 +- .../wiki/wikipage-change-body-text.jinja | 26 +- .../emails/wiki/wikipage-change-subject.jinja | 4 +- .../wiki/wikipage-create-body-html.jinja | 29 +- .../wiki/wikipage-create-body-text.jinja | 17 +- .../emails/wiki/wikipage-create-subject.jinja | 4 +- .../wiki/wikipage-delete-body-html.jinja | 11 +- .../wiki/wikipage-delete-body-text.jinja | 13 +- .../emails/wiki/wikipage-delete-subject.jinja | 4 +- .../membership_invitation-body-html.jinja | 40 +- .../membership_invitation-body-text.jinja | 29 +- .../membership_invitation-subject.jinja | 4 +- .../membership_notification-body-html.jinja | 25 +- .../membership_notification-body-text.jinja | 10 +- .../membership_notification-subject.jinja | 4 +- .../emails/change_email-body-html.jinja | 23 +- .../emails/change_email-body-text.jinja | 7 +- .../emails/change_email-subject.jinja | 2 +- .../emails/password_recovery-body-html.jinja | 24 +- .../emails/password_recovery-body-text.jinja | 7 +- .../emails/password_recovery-subject.jinja | 2 +- .../emails/registered_user-body-html.jinja | 24 +- .../emails/registered_user-body-text.jinja | 13 +- .../emails/registered_user-subject.jinja | 2 +- 98 files changed, 863 insertions(+), 1367 deletions(-) create mode 100644 settings/sr.py rename taiga/base/templates/emails/{updates.jinja => base-body-html.jinja} (87%) rename taiga/base/templates/emails/{hero.jinja => hero-body-html.jinja} (85%) rename taiga/base/templates/emails/{base.jinja => updates-body-html.jinja} (74%) create mode 100644 taiga/base/templates/emails/updates-body-text.jinja create mode 100644 taiga/export_import/templates/emails/export_error-body-html.jinja create mode 100644 taiga/export_import/templates/emails/export_error-body-text.jinja create mode 100644 taiga/export_import/templates/emails/export_error-subject.jinja delete mode 100644 taiga/export_import/templates/emails/export_import_error-body-html.jinja delete mode 100644 taiga/export_import/templates/emails/export_import_error-body-text.jinja delete mode 100644 taiga/export_import/templates/emails/export_import_error-subject.jinja create mode 100644 taiga/export_import/templates/emails/import_error-body-html.jinja create mode 100644 taiga/export_import/templates/emails/import_error-body-text.jinja create mode 100644 taiga/export_import/templates/emails/import_error-subject.jinja delete mode 100644 taiga/projects/notifications/templates/emails/projects/project-change-body-html.jinja delete mode 100644 taiga/projects/notifications/templates/emails/projects/project-change-body-text.jinja delete mode 100644 taiga/projects/notifications/templates/emails/projects/project-change-subject.jinja delete mode 100644 taiga/projects/notifications/templates/emails/projects/project-create-body-html.jinja delete mode 100644 taiga/projects/notifications/templates/emails/projects/project-create-body-text.jinja delete mode 100644 taiga/projects/notifications/templates/emails/projects/project-create-subject.jinja delete mode 100644 taiga/projects/notifications/templates/emails/projects/project-delete-body-html.jinja delete mode 100644 taiga/projects/notifications/templates/emails/projects/project-delete-body-text.jinja delete mode 100644 taiga/projects/notifications/templates/emails/projects/project-delete-subject.jinja diff --git a/requirements.txt b/requirements.txt index 3b91231c..71fb578a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ 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.17 redis==2.10.3 diff --git a/settings/common.py b/settings/common.py index 76c6b14b..f6ec3a8a 100644 --- a/settings/common.py +++ b/settings/common.py @@ -203,6 +203,7 @@ INSTALLED_APPS = [ "djmail", "django_jinja", "django_jinja.contrib._humanize", + "sr", "easy_thumbnails", "raven.contrib.django.raven_compat", "django_transactional_cleanup", @@ -370,6 +371,9 @@ 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/taiga/base/management/commands/test_emails.py b/taiga/base/management/commands/test_emails.py index d8e021cd..fbe7a7dd 100644 --- a/taiga/base/management/commands/test_emails.py +++ b/taiga/base/management/commands/test_emails.py @@ -16,6 +16,7 @@ import datetime +from django.db.models.loading import get_model from django.core.management.base import BaseCommand, CommandError from django.utils import timezone @@ -23,6 +24,7 @@ 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 @@ -45,7 +47,11 @@ class Command(BaseCommand): email.send() # Membership invitation - context = {"membership": Membership.objects.order_by("?").filter(user__isnull=True).first()} + 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() @@ -82,10 +88,13 @@ class Command(BaseCommand): # 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_import_error(test_email, context) + email = mbuilder.export_error(test_email, context) + email.send() + email = mbuilder.import_error(test_email, context) email.send() deletion_date = timezone.now() + datetime.timedelta(seconds=60*60*24) @@ -107,28 +116,24 @@ class Command(BaseCommand): # Notification emails notification_emails = [ - "issues/issue-change", - "issues/issue-create", - "issues/issue-delete", - "milestones/milestone-change", - "milestones/milestone-create", - "milestones/milestone-delete", - "projects/project-change", - "projects/project-create", - "projects/project-delete", - "tasks/task-change", - "tasks/task-create", - "tasks/task-delete", - "userstories/userstory-change", - "userstories/userstory-create", - "userstories/userstory-delete", - "wiki/wikipage-change", - "wiki/wikipage-create", - "wiki/wikipage-delete", + ("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 = { - "snapshot": HistoryEntry.objects.filter(is_snapshot=True).order_by("?")[0].snapshot, "project": Project.objects.all().order_by("?").first(), "changer": User.objects.all().order_by("?").first(), "history_entries": HistoryEntry.objects.all().order_by("?")[0:5], @@ -136,6 +141,27 @@ class Command(BaseCommand): } for notification_email in notification_emails: - cls = type("InlineCSSTemplateMail", (InlineCSSTemplateMail,), {"name": notification_email}) + 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/templates/emails/updates.jinja b/taiga/base/templates/emails/base-body-html.jinja similarity index 87% rename from taiga/base/templates/emails/updates.jinja rename to taiga/base/templates/emails/base-body-html.jinja index 65a16fa2..1f6c23a1 100644 --- a/taiga/base/templates/emails/updates.jinja +++ b/taiga/base/templates/emails/base-body-html.jinja @@ -1,11 +1,9 @@ -{% set home_url = resolve_front_url("home") %} -{% set home_url_name = "Taiga" %} - [Taiga] Jesús Espino updated the US #1680 "Rediseño de emails" + {{ _("Taiga") }} - - -
- - - - -
- - - - - - - - -
- - - - - -
- - - Taiga logo - +{% 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 {{ feedback_entry.full_name }} <{{ feedback_entry.email }}>

+

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

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

Comment

+

{{ comment }}

+ {% endtrans %} + + {% if extra %} - - - - - {% if extra %} - {% endif %}
-

Comment

-

{{ feedback_entry.comment }}

-
-

Extra:

+

{{ _("Extra info") }}

{% for k, v in extra.items() %}
{{ k }}
@@ -417,35 +24,6 @@
- -
- -
- - - - - -
- - - -
- -
- -
-
- - + {% 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/projects/history/templates/emails/includes/fields_diff-html.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja index 91484315..6d3728e4 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja @@ -16,16 +16,16 @@ {% for role, points in values.items() %} -

{{ role }} role points

+

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

- from
+ {{ _("from") }}
{{ points.1 }} - to
+ {{ _("to") }}
{{ points.0 }} @@ -40,11 +40,11 @@

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

- {{ att.filename|linebreaksbr }} + {{ att.filename }}

{% if att.description %} -

{{ att.description|linebreaksbr }}

+

{{ att.description }}

{% endif %} @@ -61,9 +61,9 @@ {{ att.filename|linebreaksbr }} {% if att.changes.is_deprecated %} {% if att.changes.is_deprecated.1 %} - [deprecated] + [{{ _("deprecated") }}] {% else %} - [not deprecated] + [{{ _("not deprecated") }}] {% endif %} {% endif %} @@ -94,21 +94,21 @@

{{ field_name }}

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

Description diff

+

{{ _("Description diff") }}

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

@@ -116,7 +116,7 @@ {% elif field_name in ["content_diff"] %} -

Content diff

+

{{ _("Content diff") }}

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

@@ -128,10 +128,10 @@ {% if values.0 != None and values.0 != "" %} - from
- {{ values.0|linebreaksbr }} + {{ _("from") }}
+ {{ values.0 }} {% else %} - from
+ {{ _("from") }}
{{ _("Unassigned") }} {% endif %} @@ -139,10 +139,10 @@ {% if values.1 != None and values.1 != "" %} - to
- {{ values.1|linebreaksbr }} + {{ _("to") }}
+ {{ values.1 }} {% else %} - to
+ {{ _("to") }}
{{ _("Unassigned") }} {% endif %} @@ -155,13 +155,13 @@

{{ field_name }}

- from
+ {{ _("from") }}
{{ values.1|linebreaksbr }} - to
+ {{ _("to") }}
{{ values.0|linebreaksbr }} 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/notifications/templates/emails/issues/issue-change-body-html.jinja b/taiga/projects/notifications/templates/emails/issues/issue-change-body-html.jinja index 15d72016..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,42 +1,15 @@ -{% extends "emails/updates.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/updates-body-html.jinja" %} {% block head %} -

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

-

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has updated an issue on {{ project.name }}

- See Issue -{% endblock %} - -{% block body %} - -

Updates

- - {% 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 %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 8c2ddbff..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,16 +1,13 @@ -{% set final_url = resolve_front_url("issue", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %} - -Issue #{{ snapshot.ref }} {{ snapshot.subject }} updated -Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has updated an issue on {{ project.name }} -See issue in Taiga {{ final_url_name }} ({{ final_url }}) - -{% 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 %} +{% 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 182d31de..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,24 +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 %} + {% 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.get_full_name() }},
{{ changer.get_full_name() }} has created a new issue on {{ project.name }}

-

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

- See issue +

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

+

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

+ See issue

The Taiga Team

-{% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 1f67040e..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,7 +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.get_full_name() }}, {{ changer.get_full_name() }} has created a new issue on {{ project.name }} -See issue #{{ snapshot.ref }} {{ snapshot.subject }} at {{ final_url_name }} ({{ final_url }}) +Hello {{ user }}, {{ changer }} has created a new issue on {{ project }} +See issue #{{ ref }} {{ subject }} at {{ 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 d50874be..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,20 +1,14 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} + {% 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.get_full_name() }},
{{ changer.get_full_name() }} has deleted an issue on {{ project.name }}

-

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

+

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

+

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

The Taiga Team

-{% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 26dd8ef8..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,4 +1,12 @@ +{% 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.get_full_name() }}, {{ changer.get_full_name() }} has deleted an issue on {{ project.name }} -Issue #{{ snapshot.ref }} {{ snapshot.subject }} +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 f0e3f152..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,37 +1,14 @@ -{% extends "emails/updates.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 %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio +{% 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 fc9accae..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,28 +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 %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 dae36cfa..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,25 +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() }}

-
-{% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 41ae8ea0..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-change-body-html.jinja +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "emails/updates.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 %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio -{% 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 0d14754d..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 %} -{% 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 4207a4cb..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-create-body-html.jinja +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "emails/hero.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: {{ project.name }}

-

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

-

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

-
-{% endblock %} -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio -{% 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 53dfc6e8..00000000 --- a/taiga/projects/notifications/templates/emails/projects/project-delete-body-html.jinja +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "emails/base.jinja" %} - -{% block body %} - - - - -
-

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

-

Deleted by {{ changer.get_full_name() }}

-
-{% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio -{% 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 ada43d22..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,42 +1,15 @@ -{% extends "emails/updates.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/updates-body-html.jinja" %} {% block head %} -

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

-

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has updated a task on {{ project.name }}

- See task -{% endblock %} - -{% block body %} - -

Updates

- - {% 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 %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 90e38aa6..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) %} - -Task #{{ snapshot.ref }} {{ snapshot.subject }} updated -Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has updated a task on {{ project.name }} - -See task at {{ final_url_name }} ({{ final_url }}) - -{% 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 %} +{% 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 d153199a..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,25 +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 %} + {% 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.get_full_name() }},
{{ changer.get_full_name() }} has created a new task on {{ project.name }}

-

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

- See task +

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

+

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

+ See task

The Taiga Team

+ {% endtrans %} {% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio -{% 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 abe81ecc..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,7 +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 }} -New task created -Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has created a new task on {{ project.name }} -See task #{{ snapshot.ref }} {{ snapshot.subject }} 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 ba2804bc..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,21 +1,15 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} + {% 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.get_full_name() }},
{{ changer.get_full_name() }} has deleted a task on {{ project.name }}

-

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

+

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

+

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

The Taiga Team

-{% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 22e8c043..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,3 +1,12 @@ +{% 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.get_full_name() }}, {{ changer.get_full_name() }} has deleted a task on {{ project.name }} -Task #{{ snapshot.ref }} {{ snapshot.subject }} +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 37777be0..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,42 +1,15 @@ -{% extends "emails/updates.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/updates-body-html.jinja" %} {% block head %} -

User story #{{ snapshot.ref }} {{ snapshot.subject }} updated

-

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has updated a user story on {{ project.name }}

- See Issue -{% endblock %} - -{% block body %} - -

Updates

- - {% 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 %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 c6138a12..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,16 +1,13 @@ -{% set final_url = resolve_front_url("userstory", project.slug, snapshot.ref) %} -{% set final_url_name = "Taiga - View US #{0}".format(snapshot.ref) %} - -User story #{{ snapshot.ref }} {{ snapshot.subject }} updated -Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has updated a user story on {{ project.name }} -See user story in Taiga {{ final_url_name }} ({{ final_url }}) - -{% 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 %} +{% 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 bd95b4ca..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,24 +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 %} + {% 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.get_full_name() }},
{{ changer.get_full_name() }} has created a new user story on {{ project.name }}

-

User story #{{ snapshot.ref }} {{ snapshot.subject }}

- See user story +

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

+

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

+ See user story

The Taiga Team

-{% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 9d161906..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.get_full_name() }}, {{ changer.get_full_name() }} has created a new user story on {{ project.name }} -User story #{{ snapshot.ref }} {{ snapshot.subject }} -More info at {{ final_url_name }} ({{ final_url }}) +Hello {{ user }}, {{ changer }} has created a new user story on {{ project }} +See user story #{{ ref }} {{ subject }} at {{ 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 2e98392f..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,20 +1,15 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} -

User story deleted

-

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has deleted a user story on {{ project.name }}

-

User story #{{ snapshot.ref }} {{ snapshot.subject }}

+ {% 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 %} -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio -{% 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 d4af94d3..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,4 +1,12 @@ -User story deleted -Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} hasdeleted a user story on {{ project.name }} -User story #{{ snapshot.ref }} {{ snapshot.subject }} +{% 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 4d4bd4b7..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,43 +1,14 @@ -{% extends "emails/updates.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/updates-body-html.jinja" %} {% block head %} -

Wiki Page {{ snapshot.slug }} updated

-

Hello {{ user.get_full_name() }},
{{ changer.get_full_name() }} has updated a wiki page on {{ project.name }}

- See Wiki -{% endblock %} - - -{% block body %} - -

Updates

- - {% 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 %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 b9eb44f2..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,16 +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 -Wiki Page {{ snapshot.slug }} updated -Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has updated a wiki page on {{ project.name }} -See wiki page in Taiga {{ final_url_name }} ({{ final_url }}) +Hello {{ user }}, {{ changer }} has updated a wiki page on {{ project }} -{% 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 %} +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 cba2b5b0..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,24 +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 %} + {% 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.get_full_name() }},
{{ changer.get_full_name() }} has created a new wiki page on {{ project.name }}

-

Wiki page {{ snapshot.slug }}

- See wiki page +

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

+

Wiki page {{ page }}

+ See wiki page

The Taiga Team

-{% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 8f6f4020..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,7 +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 -Hello {{ user.get_full_name() }}, {{ changer.get_full_name() }} has created a new wiki page on {{ project.name }} -See wiki page {{ snapshot.slug }} at {{ final_url_name }} ({{ final_url }}) + +Hello {{ user }}, {{ changer }} has created a new wiki page on {{ project }} + +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 46bdc60c..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,8 +1,13 @@ -{% extends "emails/base.jinja" %} +{% extends "emails/base-body-html.jinja" %} {% block body %} + {% 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.get_full_name() }},
{{ changer.get_full_name() }} has deleted a wiki page on {{ project.name }}

-

Wiki page {{ snapshot.slug }}

+

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 caecd953..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,4 +1,13 @@ +{% 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.get_full_name() }}, {{ changer.get_full_name() }} has deleted a wiki page on {{ project.name }} -Wiki page {{ snapshot.slug }} + +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/templates/emails/membership_invitation-body-html.jinja b/taiga/projects/templates/emails/membership_invitation-body-html.jinja index 3b426178..ad8c019e 100644 --- a/taiga/projects/templates/emails/membership_invitation-body-html.jinja +++ b/taiga/projects/templates/emails/membership_invitation-body-html.jinja @@ -1,28 +1,20 @@ -{% extends "emails/hero.jinja" %} - -{% set final_url = resolve_front_url("invitation", membership.token) %} -{% set final_url_name = "Taiga - Invitation to join on {0} project.".format(membership.project) %} +{% extends "emails/hero-body-html.jinja" %} {% block body %} -

You, or someone you know, has invited you to Taiga

-

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 -Accept your invitation -{% 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 %} -

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

-{% endblock %} + {% trans full_name=membership.invited_by.get_full_name(), + project=membership.project %} +

You, or someone you know, has invited you to Taiga

+

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 %} -{% block footer %} -Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
-Contact us: -
-Support: -support@taiga.io -
-Our mailing address is: -https://groups.google.com/forum/#!forum/taigaio + {% 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 %} + + {{ _("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..d2763684 100644 --- a/taiga/projects/templates/emails/membership_invitation-body-text.jinja +++ b/taiga/projects/templates/emails/membership_invitation-body-text.jinja @@ -1,18 +1,19 @@ -{% 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 }} +{% trans full_name=membership.invited_by.get_full_name(), + project=membership.project %} +You, or someone you know, has invited you to Taiga +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: -And now some words from the jolly good fellow or sistren who thought so kindly as to invite you: - -{{ membership.invitation_extra_text }} - +{{ extra }} + {% endtrans %} {% endif %} - -Dress: Morning Suit, Uniform, Lounge Suit, Birthday Suit or hoodie. - -Further details: ({{ final_url }}) +{{ _("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 fe5e0630..db777193 100644 --- a/taiga/projects/templates/emails/membership_notification-body-html.jinja +++ b/taiga/projects/templates/emails/membership_notification-body-html.jinja @@ -1,23 +1,12 @@ -{% extends "emails/hero.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 %} + {% 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 {{ membership.user.get_full_name() }},
you have been added to the project {{ membership.project }}

- Go to project +

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

+ Go to project

The Taiga Team

-{% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 b54a23d2..c9d94d67 100644 --- a/taiga/projects/templates/emails/membership_notification-body-text.jinja +++ b/taiga/projects/templates/emails/membership_notification-body-text.jinja @@ -1,6 +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 {{ membership.user.get_full_name() }},you have been added to the project {{ membership.project }} +Hello {{ full_name }},you have been added to the project {{ project }} -See project 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/users/templates/emails/change_email-body-html.jinja b/taiga/users/templates/emails/change_email-body-html.jinja index 6eadbdf0..c1cfd438 100644 --- a/taiga/users/templates/emails/change_email-body-html.jinja +++ b/taiga/users/templates/emails/change_email-body-html.jinja @@ -1,24 +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 %} + {% trans full_name=user.get_full_name(), url=resolve_front_url("change-email", user.email_token) %}

Change your email

-

Hello {{ user.get_full_name() }},
please confirm your email

- Confirm email +

Hello {{ full_name }},
please confirm your email

+ Confirm email

You can ignore this message if you did not request.

The Taiga Team

-{% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 ffb6e9d9..46aba26a 100644 --- a/taiga/users/templates/emails/change_email-body-text.jinja +++ b/taiga/users/templates/emails/change_email-body-text.jinja @@ -1,5 +1,10 @@ -Hello {{ user.get_full_name() }}, please confirm your email {{ resolve_front_url('change-email', user.email_token) }} +{% trans full_name=user.get_full_name(), url=resolve_front_url('change-email', user.email_token) %} +Hello {{ full_name }}, please confirm your email + +{{ url }} You can ignore this message if you did not request. +--- 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 f74ebedb..46450731 100644 --- a/taiga/users/templates/emails/password_recovery-body-html.jinja +++ b/taiga/users/templates/emails/password_recovery-body-html.jinja @@ -1,24 +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 %} + {% trans full_name=user.get_full_name(), + url=resolve_front_url("change-password", user.token) %}

Recover your password

-

Hello {{ user.get_full_name() }},
you asked to recover your password

- 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

-{% endblock %} - -{% block footer %} - Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
- Contact us: -
- Support: - support@taiga.io -
- Our mailing address is: - https://groups.google.com/forum/#!forum/taigaio + {% 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 d5e25baf..75bd1b95 100644 --- a/taiga/users/templates/emails/password_recovery-body-text.jinja +++ b/taiga/users/templates/emails/password_recovery-body-text.jinja @@ -1,7 +1,10 @@ -Hello {{ user.get_full_name() }}, you asked to recover your password +{% trans full_name=user.get_full_name(), url=resolve_front_url('change-password', user.token) %} +Hello {{ full_name }}, you asked to recover your password -{{ resolve_front_url('change-password', user.token) }} +{{ url }} You can ignore this message if you did not request. +--- 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 d939b1b1..efe77ace 100644 --- a/taiga/users/templates/emails/registered_user-body-html.jinja +++ b/taiga/users/templates/emails/registered_user-body-html.jinja @@ -1,30 +1,26 @@ -{% extends "emails/hero.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 -
{% endblock %} {% block footer %} -Copyright © 2014 Taiga Agile, LLC, All rights reserved. -
-Contact us: -
-Support: -support@taiga.io -
-Our mailing list is: -https://groups.google.com/forum/#!forum/taigaio -
-
-You may remove your account from this service clicking here + {{ 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 d01e0ceb..9ef756ce 100644 --- a/taiga/users/templates/emails/registered_user-body-text.jinja +++ b/taiga/users/templates/emails/registered_user-body-text.jinja @@ -1,3 +1,4 @@ +{% trans %} Thank you for registering in Taiga We hope you enjoy it @@ -8,11 +9,7 @@ We built it to be beautiful, elegant, simple to use and fun - without forsaking -- The taiga Team - -Copyright © 2014 Taiga Agile, LLC, All rights reserved. - -Contact us: - -Support: mailto:support@taiga.io -Our mailing list is: https://groups.google.com/forum/#!forum/taigaio -You may remove your account from this service: {{ resolve_front_url('cancel-account', cancel_token) }} +{% 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!") }} From 97d8ee0bc53b1ae756e8688f1286699e3979cb6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Juli=C3=A1n?= Date: Tue, 20 Jan 2015 15:34:11 +0100 Subject: [PATCH 47/54] Minor fixes in emails --- .../templates/emails/export_error-body-html.jinja | 3 ++- .../emails/membership_invitation-body-html.jinja | 11 +++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/taiga/export_import/templates/emails/export_error-body-html.jinja b/taiga/export_import/templates/emails/export_error-body-html.jinja index 47eac827..670fcc13 100644 --- a/taiga/export_import/templates/emails/export_error-body-html.jinja +++ b/taiga/export_import/templates/emails/export_error-body-html.jinja @@ -7,7 +7,8 @@ project=project.name|safe %}

{{ error_message }}

Hello {{ user }},

-

Please, try it again or contact with the support team at +

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/projects/templates/emails/membership_invitation-body-html.jinja b/taiga/projects/templates/emails/membership_invitation-body-html.jinja index ad8c019e..c5a31754 100644 --- a/taiga/projects/templates/emails/membership_invitation-body-html.jinja +++ b/taiga/projects/templates/emails/membership_invitation-body-html.jinja @@ -1,16 +1,15 @@ {% extends "emails/hero-body-html.jinja" %} {% block body %} - {% trans full_name=membership.invited_by.get_full_name(), - project=membership.project %} -

You, or someone you know, has invited you to Taiga

-

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.

+ {% trans full_name=membership.invited_by.get_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 %} {% 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 }}

+

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

+

{{ extra }}

{% endtrans %} {% endif %} From 006dbd7278fc5b9db08d3ebf60cd52067866f069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 22 Jan 2015 11:50:06 +0100 Subject: [PATCH 48/54] Fix tests --- taiga/base/management/commands/test_emails.py | 5 +++++ .../templates/emails/export_error-body-html.jinja | 2 +- .../templates/emails/export_error-body-text.jinja | 3 +++ .../templates/emails/import_error-body-html.jinja | 3 ++- .../templates/emails/import_error-body-text.jinja | 4 ++++ .../emails/membership_invitation-body-html.jinja | 10 +++++++++- .../emails/membership_invitation-body-text.jinja | 7 ++++++- 7 files changed, 30 insertions(+), 4 deletions(-) diff --git a/taiga/base/management/commands/test_emails.py b/taiga/base/management/commands/test_emails.py index fbe7a7dd..6b7f8bf5 100644 --- a/taiga/base/management/commands/test_emails.py +++ b/taiga/base/management/commands/test_emails.py @@ -94,6 +94,11 @@ class Command(BaseCommand): } 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() diff --git a/taiga/export_import/templates/emails/export_error-body-html.jinja b/taiga/export_import/templates/emails/export_error-body-html.jinja index 670fcc13..aacc09c0 100644 --- a/taiga/export_import/templates/emails/export_error-body-html.jinja +++ b/taiga/export_import/templates/emails/export_error-body-html.jinja @@ -7,7 +7,7 @@ project=project.name|safe %}

{{ error_message }}

Hello {{ user }},

-

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

+

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

diff --git a/taiga/export_import/templates/emails/export_error-body-text.jinja b/taiga/export_import/templates/emails/export_error-body-text.jinja index bf84a194..018ced94 100644 --- a/taiga/export_import/templates/emails/export_error-body-text.jinja +++ b/taiga/export_import/templates/emails/export_error-body-text.jinja @@ -5,6 +5,9 @@ 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 }} diff --git a/taiga/export_import/templates/emails/import_error-body-html.jinja b/taiga/export_import/templates/emails/import_error-body-html.jinja index 00275f86..9f178f2a 100644 --- a/taiga/export_import/templates/emails/import_error-body-html.jinja +++ b/taiga/export_import/templates/emails/import_error-body-html.jinja @@ -6,7 +6,8 @@ support_email=sr("support.email") %}

{{ error_message }}

Hello {{ user }},

-

Please, try it again or contact with the support team at +

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-body-text.jinja b/taiga/export_import/templates/emails/import_error-body-text.jinja index bdaab4e2..affc8dc9 100644 --- a/taiga/export_import/templates/emails/import_error-body-text.jinja +++ b/taiga/export_import/templates/emails/import_error-body-text.jinja @@ -5,6 +5,10 @@ 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 }} --- diff --git a/taiga/projects/templates/emails/membership_invitation-body-html.jinja b/taiga/projects/templates/emails/membership_invitation-body-html.jinja index c5a31754..a3efbc1f 100644 --- a/taiga/projects/templates/emails/membership_invitation-body-html.jinja +++ b/taiga/projects/templates/emails/membership_invitation-body-html.jinja @@ -1,7 +1,15 @@ {% 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 %} + + {% block body %} - {% trans full_name=membership.invited_by.get_full_name(), project=membership.project %} + {% 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 %} diff --git a/taiga/projects/templates/emails/membership_invitation-body-text.jinja b/taiga/projects/templates/emails/membership_invitation-body-text.jinja index d2763684..f8ed38e9 100644 --- a/taiga/projects/templates/emails/membership_invitation-body-text.jinja +++ b/taiga/projects/templates/emails/membership_invitation-body-text.jinja @@ -1,4 +1,9 @@ -{% trans full_name=membership.invited_by.get_full_name(), +{% 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 From f106c5d6c02841c5ed8f41893a0db534cf791ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Thu, 22 Jan 2015 18:44:50 +0100 Subject: [PATCH 49/54] Show deletion_datetime in localized format (in the future will take the language) and with timezone --- .../export_import/templates/emails/dump_project-body-html.jinja | 2 +- .../export_import/templates/emails/dump_project-body-text.jinja | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/taiga/export_import/templates/emails/dump_project-body-html.jinja b/taiga/export_import/templates/emails/dump_project-body-html.jinja index 3c6ba602..bac303e7 100644 --- a/taiga/export_import/templates/emails/dump_project-body-html.jinja +++ b/taiga/export_import/templates/emails/dump_project-body-html.jinja @@ -4,7 +4,7 @@ {% trans user=user.get_full_name()|safe, project=project.name|safe, url=url, - deletion_date=deletion_date|date("d/M/Y H:i") %} + 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.

diff --git a/taiga/export_import/templates/emails/dump_project-body-text.jinja b/taiga/export_import/templates/emails/dump_project-body-text.jinja index 3770092d..f8b94d5d 100644 --- a/taiga/export_import/templates/emails/dump_project-body-text.jinja +++ b/taiga/export_import/templates/emails/dump_project-body-text.jinja @@ -1,7 +1,7 @@ {% trans user=user.get_full_name()|safe, project=project.name|safe, url=url, - deletion_date=deletion_date|date("d/M/Y H:i") %} + 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: From 7fc8d3202714f58ccd78a2f4eca2415653a1d320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Mon, 26 Jan 2015 18:06:39 +0100 Subject: [PATCH 50/54] Adding ordering to webhooklog and webhooks --- taiga/webhooks/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/taiga/webhooks/models.py b/taiga/webhooks/models.py index 0fd212dd..6154b523 100644 --- a/taiga/webhooks/models.py +++ b/taiga/webhooks/models.py @@ -28,6 +28,9 @@ class Webhook(models.Model): 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, @@ -40,3 +43,6 @@ class WebhookLog(models.Model): 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'] From 954cb2bb0f4bd8f8c3c65001697a1c1e39e6b70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 27 Jan 2015 20:02:46 +0100 Subject: [PATCH 51/54] Fix import and export emails --- taiga/export_import/tasks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/taiga/export_import/tasks.py b/taiga/export_import/tasks.py index eb0bcf86..3158f7ba 100644 --- a/taiga/export_import/tasks.py +++ b/taiga/export_import/tasks.py @@ -21,7 +21,7 @@ from django.core.files.base import ContentFile from django.utils import timezone from django.conf import settings -from djmail.template_mail import MagicMailBuilder +from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail from taiga.celery import app @@ -32,7 +32,7 @@ from .renderers import ExportRenderer @app.task(bind=True) def dump_project(self, user, project): - mbuilder = MagicMailBuilder() + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) path = "exports/{}/{}-{}.json".format(project.pk, project.slug, self.request.id) try: @@ -72,7 +72,7 @@ def delete_project_dump(project_id, project_slug, task_id): @app.task def load_project_dump(user, dump): - mbuilder = MagicMailBuilder() + mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail) try: project = dict_to_project(dump, user.email) From 4cb62f2022e482c8ead680b1d5986e5cc4000356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 28 Jan 2015 10:12:41 +0100 Subject: [PATCH 52/54] Fix indent --- .../templates/emails/export_error-body-text.jinja | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/taiga/export_import/templates/emails/export_error-body-text.jinja b/taiga/export_import/templates/emails/export_error-body-text.jinja index 018ced94..0b463d67 100644 --- a/taiga/export_import/templates/emails/export_error-body-text.jinja +++ b/taiga/export_import/templates/emails/export_error-body-text.jinja @@ -1,7 +1,7 @@ - {% trans user=user.get_full_name()|safe, - error_message=error_message, - support_email=sr("support.email"), - project=project.name|safe %} +{% trans user=user.get_full_name()|safe, + error_message=error_message, + support_email=sr("support.email"), + project=project.name|safe %} Hello {{ user }}, {{ error_message }} From 80661af35b8d7d96e9a018c63c9af9088c0c02b7 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 29 Jan 2015 11:25:43 +0100 Subject: [PATCH 53/54] Updating CHANGELOG --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8782573..29958ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,13 @@ ## 1.5.0 Betula Pendula - FOSDEM 2015 (unreleased) ### Features -- Improving some SQL queries +- 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. From d753f61a5773a76cddafbeec12a497e0d6b51d6b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 29 Jan 2015 11:28:30 +0100 Subject: [PATCH 54/54] Updating date in CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29958ff7..1ba4dbd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog # -## 1.5.0 Betula Pendula - FOSDEM 2015 (unreleased) +## 1.5.0 Betula Pendula - FOSDEM 2015 (2015-01-29) ### Features - Improving SQL queries and performance.