Merge branch 'master' into stable

Conflicts:
	taiga/projects/serializers.py
remotes/origin/enhancement/email-actions 1.5.0
Jesús Espino 2015-01-29 11:50:03 +01:00
commit be710a4545
187 changed files with 4508 additions and 1159 deletions

1
.gitignore vendored
View File

@ -11,3 +11,4 @@ media
.coverage
.cache
.\#*
.project

View File

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

View File

@ -1,5 +1,19 @@
# Changelog #
## 1.5.0 Betula Pendula - FOSDEM 2015 (2015-01-29)
### Features
- Improving SQL queries and performance.
- Now you can export and import projects between Taiga instances.
- Email redesign.
- Support for archived status (not shown by default in Kanban).
- Removing files from filesystem when deleting attachments.
- Support for contrib plugins (existing yet: slack, hall and gogs).
- Webhooks added (crazy integrations are welcome).
### Misc
- Lots of small and not so small bugfixes.
## 1.4.0 Abies veitchii (2014-12-10)

View File

@ -4,7 +4,7 @@
[![Travis Badge](https://img.shields.io/travis/taigaio/taiga-back.svg?style=flat)](https://travis-ci.org/taigaio/taiga-back "Travis Badge")
[![Coveralls](http://img.shields.io/coveralls/taigaio/taiga-back.svg?style=flat)](https://travis-ci.org/taigaio/taiga-back "Coveralls")
[![Coveralls](http://img.shields.io/coveralls/taigaio/taiga-back.svg?style=flat)](https://coveralls.io/r/taigaio/taiga-back?branch=master "Coveralls")
## Setup development environment ##
@ -24,7 +24,7 @@ Taiga only runs with python 3.4+
Initial auth data: admin/123123
If you want a complete environment for production usage, you can try the taiga bootstrapping
scripts https://github.com/taigaio/taiga-scripts (warning: alpha state)
scripts https://github.com/taigaio/taiga-scripts (warning: alpha state). All the information about the different installation methods (production, development, vagrant, docker...) can be found here http://taigaio.github.io/taiga-doc/dist/#_installation_guide.
## Community ##

View File

@ -19,14 +19,16 @@ Markdown==2.4.1
fn==0.2.13
diff-match-patch==20121119
requests==2.4.1
django-sr==0.0.4
easy-thumbnails==2.1
celery==3.1.12
celery==3.1.17
redis==2.10.3
Unidecode==0.04.16
raven==5.1.1
bleach==1.4
django-ipware==0.1.0
premailer==2.8.1
django-transactional-cleanup==0.1.12
# Comment it if you are using python >= 3.4
enum34==1.0

View File

@ -33,7 +33,7 @@ LANGUAGES = (
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"ENGINE": "transaction_hooks.backends.postgresql_psycopg2",
"NAME": "taiga",
}
}
@ -197,12 +197,16 @@ INSTALLED_APPS = [
"taiga.hooks.github",
"taiga.hooks.gitlab",
"taiga.hooks.bitbucket",
"taiga.webhooks",
"rest_framework",
"djmail",
"django_jinja",
"django_jinja.contrib._humanize",
"sr",
"easy_thumbnails",
"raven.contrib.django.raven_compat",
"django_transactional_cleanup",
]
WSGI_APPLICATION = "taiga.wsgi.application"
@ -300,7 +304,8 @@ REST_FRAMEWORK = {
"DEFAULT_THROTTLE_RATES": {
"anon": None,
"user": None,
"import-mode": None
"import-mode": None,
"import-dump-mode": "1/minute",
},
"FILTER_BACKEND": "taiga.base.filters.FilterBackend",
"EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler",
@ -362,6 +367,13 @@ PROJECT_MODULES_CONFIGURATORS = {
BITBUCKET_VALID_ORIGIN_IPS = ["131.103.20.165", "131.103.20.166"]
GITLAB_VALID_ORIGIN_IPS = []
EXPORTS_TTL = 60 * 60 * 24 # 24 hours
CELERY_ENABLED = False
WEBHOOKS_ENABLED = False
from .sr import *
# NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE
TEST_RUNNER="django.test.runner.DiscoverRunner"

29
settings/sr.py Normal file
View File

@ -0,0 +1,29 @@
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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",
}
}

View File

@ -19,6 +19,7 @@ from .development import *
SKIP_SOUTH_TESTS = True
SOUTH_TESTS_MIGRATE = False
CELERY_ALWAYS_EAGER = True
CELERY_ENABLED = False
MEDIA_ROOT = "/tmp"
@ -28,5 +29,6 @@ INSTALLED_APPS = INSTALLED_APPS + ["tests"]
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
"anon": None,
"user": None,
"import-mode": None
"import-mode": None,
"import-dump-mode": None,
}

View File

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

View File

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

View File

View File

@ -0,0 +1,172 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
from django.db.models.loading import get_model
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail
from taiga.projects.models import Project, Membership
from taiga.projects.history.models import HistoryEntry
from taiga.projects.history.services import get_history_queryset_by_model_instance
from taiga.users.models import User
class Command(BaseCommand):
args = '<email>'
help = 'Send an example of all emails'
def handle(self, *args, **options):
if len(args) != 1:
print("Usage: ./manage.py test_emails <email-address>")
return
test_email = args[0]
mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail)
# Register email
context = {"user": User.objects.all().order_by("?").first(), "cancel_token": "cancel-token"}
email = mbuilder.registered_user(test_email, context)
email.send()
# Membership invitation
membership = Membership.objects.order_by("?").filter(user__isnull=True).first()
membership.invited_by = User.objects.all().order_by("?").first()
membership.invitation_extra_text = "Text example, Text example,\nText example,\n\nText example"
context = {"membership": membership}
email = mbuilder.membership_invitation(test_email, context)
email.send()
# Membership notification
context = {"membership": Membership.objects.order_by("?").filter(user__isnull=False).first()}
email = mbuilder.membership_notification(test_email, context)
email.send()
# Feedback
context = {
"feedback_entry": {
"full_name": "Test full name",
"email": "test@email.com",
"comment": "Test comment",
},
"extra": {
"key1": "value1",
"key2": "value2",
},
}
email = mbuilder.feedback_notification(test_email, context)
email.send()
# Password recovery
context = {"user": User.objects.all().order_by("?").first()}
email = mbuilder.password_recovery(test_email, context)
email.send()
# Change email
context = {"user": User.objects.all().order_by("?").first()}
email = mbuilder.change_email(test_email, context)
email.send()
# Export/Import emails
context = {
"user": User.objects.all().order_by("?").first(),
"project": Project.objects.all().order_by("?").first(),
"error_subject": "Error generating project dump",
"error_message": "Error generating project dump",
}
email = mbuilder.export_error(test_email, context)
email.send()
context = {
"user": User.objects.all().order_by("?").first(),
"error_subject": "Error importing project dump",
"error_message": "Error importing project dump",
}
email = mbuilder.import_error(test_email, context)
email.send()
deletion_date = timezone.now() + datetime.timedelta(seconds=60*60*24)
context = {
"url": "http://dummyurl.com",
"user": User.objects.all().order_by("?").first(),
"project": Project.objects.all().order_by("?").first(),
"deletion_date": deletion_date,
}
email = mbuilder.dump_project(test_email, context)
email.send()
context = {
"user": User.objects.all().order_by("?").first(),
"project": Project.objects.all().order_by("?").first(),
}
email = mbuilder.load_dump(test_email, context)
email.send()
# Notification emails
notification_emails = [
("issues.Issue", "issues/issue-change"),
("issues.Issue", "issues/issue-create"),
("issues.Issue", "issues/issue-delete"),
("tasks.Task", "tasks/task-change"),
("tasks.Task", "tasks/task-create"),
("tasks.Task", "tasks/task-delete"),
("userstories.UserStory", "userstories/userstory-change"),
("userstories.UserStory", "userstories/userstory-create"),
("userstories.UserStory", "userstories/userstory-delete"),
("milestones.Milestone", "milestones/milestone-change"),
("milestones.Milestone", "milestones/milestone-create"),
("milestones.Milestone", "milestones/milestone-delete"),
("wiki.WikiPage", "wiki/wikipage-change"),
("wiki.WikiPage", "wiki/wikipage-create"),
("wiki.WikiPage", "wiki/wikipage-delete"),
]
context = {
"project": Project.objects.all().order_by("?").first(),
"changer": User.objects.all().order_by("?").first(),
"history_entries": HistoryEntry.objects.all().order_by("?")[0:5],
"user": User.objects.all().order_by("?").first(),
}
for notification_email in notification_emails:
model = get_model(*notification_email[0].split("."))
snapshot = {
"subject": "Tests subject",
"ref": 123123,
"name": "Tests name",
"slug": "test-slug"
}
queryset = model.objects.all().order_by("?")
for obj in queryset:
end = False
entries = get_history_queryset_by_model_instance(obj).filter(is_snapshot=True).order_by("?")
for entry in entries:
if entry.snapshot:
snapshot = entry.snapshot
end = True
break
if end:
break
context["snapshot"] = snapshot
cls = type("InlineCSSTemplateMail", (InlineCSSTemplateMail,), {"name": notification_email[1]})
email = cls()
email.send(test_email, context)

View File

@ -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,8 +29,12 @@ class PickleField(serializers.WritableField):
return obj
def from_native(self, data):
if not data:
return data
ret = sum([tag.split(",") for tag in data], [])
return ret
class JsonField(serializers.WritableField):
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -0,0 +1,453 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{ _("Taiga") }}</title>
<style type="text/css">
/* /\/\/\/\/\/\/\/\/ CLIENT-SPECIFIC STYLES /\/\/\/\/\/\/\/\/ */
#outlook a{padding:0;} /* Force Outlook to provide a "view in browser" message */
.ReadMsgBody{width:100%;} .ExternalClass{width:100%;} /* Force Hotmail to display emails at full width */
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing */
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;} /* Prevent WebKit and Windows mobile changing default text sizes */
table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;} /* Remove spacing between tables in Outlook 2007 and up */
img{-ms-interpolation-mode:bicubic;} /* Allow smoother rendering of resized image in Internet Explorer */
/* /\/\/\/\/\/\/\/\/ RESET STYLES /\/\/\/\/\/\/\/\/ */
body{margin:0; padding:0;}
img{border:0; height:auto; line-height:100%; outline:none; text-decoration:none;}
table{border-collapse:collapse !important;}
body, #bodyTable, #bodyCell{height:100% !important; margin:0; padding:0; width:100% !important;}
/* /\/\/\/\/\/\/\/\/ TEMPLATE STYLES /\/\/\/\/\/\/\/\/ */
#bodyCell{padding:20px;}
#templateContainer{width:600px;}
/* ========== Page Styles ========== */
body, #bodyTable{
background-color:#f5f5f5;
}
/**
* @section email border
*/
#templateContainer{
background-color:#FFF;
border:1px solid #CDCDCD;
}
/**
* @section heading 1
*/
h1{
color: #6e6e6e !important;
display:block;
font-family: 'Open Sans', Arial;
font-size:25px;
font-style:normal;
font-weight:bold;
line-height:100%;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:center;
}
/**
* @style heading 2
*/
h2{
color: #6e6e6e !important;
display:block;
font-family: 'Open Sans', Arial;
font-size:20px;
font-style:normal;
font-weight:bold;
line-height:100%;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:center;
}
/**
* @tab Page
* @section heading 3
*/
h3{
color:#6e6e6e !important;
display:block;
font-family: 'Open Sans', Arial, Helvetica;
font-size:16px;
font-weight:normal;
line-height:100%;
font-weight:bold;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:center;
}
/**
* @tab Page
* @section heading 4
*/
h4{
color:#808080 !important;
display:block;
font-family: 'Open Sans', Arial, Helvetica;
font-size:14px;
font-weight:bold;
line-height:100%;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:left;
}
/* ========== Header Styles ========== */
.headerContent {
text-align: center;
color:#b8b8b8 !important;
font-family: 'Open Sans', Arial, Helvetica;
font-size:14px;
margin-bottom:16px;
text-align:center;
}
#headerImage{
height:auto;
width:80px;
margin: 20px auto;
}
/* ========== Body Styles ========== */
/**
* @tab Body
* @section body style
* @tip Set the background color and borders for your email's body area.
*/
#templateBody{
background-color:#FFF;
}
/**
* @section body text
*/
.bodyContent{
color:#505050;
font-family: 'Open Sans', Arial, Helvetica;
font-size:16px;
line-height:150%;
padding-right:20px;
padding-bottom:20px;
padding-left:20px;
padding-top:20px;
text-align:center;
}
/**
* @section body link
*/
.bodyContent a:link, .bodyContent a:visited, /* Yahoo! Mail Override */ .bodyContent a .yshortcuts /* Yahoo! Mail Override */{
color:#699b05;
font-weight:normal;
text-decoration:underline;
}
/**
* @section body link button class
*/
a.button {
background: #699b05;
color: #fff;
display: block;
width: 50%;
text-decoration: none;
text-align: center;
margin: 0 auto 16px;
text-transform: uppercase;
padding: .8rem 3rem;
}
a.button:hover {
background: #aad400;
}
.bodyContent img{
display:inline;
height:auto;
max-width:560px;
}
.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 */
}
</style>
</head>
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0">
<center>
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable">
<tr>
<td align="center" valign="top" id="bodyCell">
<!-- BEGIN TEMPLATE // -->
<table border="0" cellpadding="0" cellspacing="0" id="templateContainer">
<tr>
<td align="center" valign="top">
<!-- BEGIN HEADER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateHeader">
<tr>
<td valign="top" class="headerContent">
<img src="{{ static("emails/top-bg-update.png") }}" />
<a href="{{ resolve_front_url("home") }}" title="Taiga">
<img src="{{ static("emails/logo-color.png") }}" id="headerImage" alt="Taiga logo" />
</a>
{% block body %}
{% endblock %}
</td>
</tr>
{% block social %}
<tr>
<td valign="top" class="social-links">
<a href="{{ sr("social.twitter_url") }}" title="{{ _("Follow us on Twitter") }}" style="color: #9dce0a">{{ _("Twitter") }}</a>
<a href="{{ sr("social.github_url") }}" title="{{ _("Get the code on GitHub") }}" style="color: #9dce0a">{{ _("GitHub") }}</a>
<a href="{{ sr("taigaio_url") }}" title="{{ _("Visit our website") }}" style="color: #9dce0a">{{ _("Taiga.io") }}</a>
</td>
</tr>
{% endblock %}
</table>
<!-- // END HEADER -->
</td>
</tr>
<tr>
<td align="center" valign="top">
<!-- BEGIN FOOTER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateFooter">
<tr>
<td valign="top" class="footerContent">
{% block footer %}
{% trans support_url=sr("support.url"),
support_email=sr("support.email"),
mailing_list_url=sr("support.mailing_list") %}
<strong>Taiga Support:</strong>
<a href="{{ support_url }}" title="Support page" style="color: #9dce0a">{{ support_url}}</a>
<br>
<strong>Contact us:</strong>
<a href="mailto:{{ support_email }}" title="Supporti email" style="color: #9dce0a">
{{ support_email }}
</a>
<br>
<strong>Mailing list:</strong>
<a href="{{ mailing_list_url }}" title="Mailing list" style="color: #9dce0a">
{{ mailing_list_url }}
</a>
{% endtrans %}
{% endblock %}
</td>
</tr>
</table>
<!-- // END FOOTER -->
</td>
</tr>
</table>
<!-- // END TEMPLATE -->
</td>
</tr>
</table>
</center>
</body>
</html>

View File

@ -1,159 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">
{% set home_url = resolve_front_url("home") %}
{% set home_url_name = "Taiga" %}
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<!-- So that mobile webkit will display zoomed in -->
<meta name="viewport" content="initial-scale=1.0">
<!-- disable auto telephone linking in iOS -->
<meta name="format-detection" content="telephone=no">
<title>Taiga</title>
<style type="text/css">
/* Resets: see reset.css for details */
body {-webkit-text-size-adjust:none; -ms-text-size-adjust:none;}
body {margin:0; padding:0;}
table {border-spacing:0;}
table td {border-collapse:collapse;}
/* Constrain email width for small screens */
@media screen and (max-width: 600px) {
table[class="container"] {
width: 95% !important;
}
}
/* Give content more room on mobile */
@media screen and (max-width: 480px) {
td[class="container-padding"] {
padding-left: 12px !important;
padding-right: 12px !important;
}
}
/* Styles for forcing columns to rows */
@media only screen and (max-width : 600px) {
/* force container columns to (horizontal) blocks */
td[class="force-col"] {
display: block;
padding-right: 0 !important;
}
table[class="col-3"] {
/* unset table align="left/right" */
float: none !important;
width: 100% !important;
/* change left/right padding and margins to top/bottom ones */
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #eee;
}
/* remove bottom border for last column/row */
table[id="last-col-3"] {
border-bottom: none !important;
margin-bottom: 0;
}
/* align images right and shrink them a bit */
img[class="col-3-img"] {
float: right;
margin-left: 6px;
max-width: 130px;
}
}
</style>
</head>
<body style="margin:20px auto; padding:10px 0;" bgcolor="#eee" leftmargin="0" topmargin="0"
marginwidth="0" marginheight="0">
<!-- background -->
<table border="0" width="100%" height="100%" cellpadding="0" cellspacing="0" bgcolor="#eee">
<tr>
<td align="center" valign="top" bgcolor="#eee" style="background-color: #eee;">
<!-- container -->
<table border="0" width="600" cellpadding="0" cellspacing="0" class="container"
bgcolor="#f1f1f1">
<thead>
<tr>
<th border="0" class="container-padding" bgcolor="#669933"
style="background-color: #669900; margin-top: 20px;
padding: 20px; font-size: 13px; line-height: 20px;
font-family: Arial, Helvetica, sans-serif;
color: #FFF; border-bottom: 5px solid #333;" align="left">
<table border="0" width="100%" cellpadding="0" cellspacing="0"
class="table-header">
<tr>
<td>
<a href="{{ home_url }}"
title="{{ home_url_name }}">
<img src="{{ static("emails/email-logo.png") }}" alt="Taiga" height="32" />
</a>
</td>
</tr>
</table>
</th>
</tr>
</thead>
<tbody>
<tr>
<td border="0" style="background: #FFFFFF; padding: 20px;
font-size: 14px; line-height: 20px;
margin-bottom: 180px;
font-family: Arial, Helvetica, sans-serif;
color: #6c6c6c;" align="left">
<!-- BODY -->
{% block body %}{% endblock %}
{#
<table border="0" width="100%" cellpadding="0" cellspacing="0"
class="table-body">
<tr>
<td>
<h1>{{ project_name }}</h1>
<h2>{{ type }}: {{ subject }}</h2>
<p>Updated fields by <b>{{ user.get_full_name() }}</b></p>
{% block body_changes %}
<ul>
<li><b>severity</b>: from "10" to "project 2 - Normal".</li>
</ul>
{% endblock %}
</td>
</tr>
</table>
#}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td border="0" style="background-color: #fff; padding: 0 20px;
font-size: 14px; line-height: 20px;
font-family: Arial, Helvetica, sans-serif;
color: #CCC;" align="left">
{% block footer %}
{#
<p style="padding: 10px; border-top: 1px solid #eee;">
More info at:
<a href="{{ final_url }}" style="color: #666;">
{{ final_url_name }}
</a>
</p>
#}
{% endblock %}
</td>
</tr>
</tfoot>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,427 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{ _("You have been Taigatized") }}</title>
<style type="text/css">
/* /\/\/\/\/\/\/\/\/ CLIENT-SPECIFIC STYLES /\/\/\/\/\/\/\/\/ */
#outlook a{padding:0;} /* Force Outlook to provide a "view in browser" message */
.ReadMsgBody{width:100%;} .ExternalClass{width:100%;} /* Force Hotmail to display emails at full width */
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing */
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;} /* Prevent WebKit and Windows mobile changing default text sizes */
table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;} /* Remove spacing between tables in Outlook 2007 and up */
img{-ms-interpolation-mode:bicubic;} /* Allow smoother rendering of resized image in Internet Explorer */
/* /\/\/\/\/\/\/\/\/ RESET STYLES /\/\/\/\/\/\/\/\/ */
body{margin:0; padding:0;}
img{border:0; height:auto; line-height:100%; outline:none; text-decoration:none;}
table{border-collapse:collapse !important;}
body, #bodyTable, #bodyCell{height:100% !important; margin:0; padding:0; width:100% !important;}
/* /\/\/\/\/\/\/\/\/ TEMPLATE STYLES /\/\/\/\/\/\/\/\/ */
#bodyCell{padding:20px;}
#templateContainer{width:600px;}
/* ========== Page Styles ========== */
body, #bodyTable{
background-color:#f5f5f5;
}
/**
* @section email border
*/
#templateContainer{
border:1px solid #CDCDCD;
}
/**
* @section heading 1
*/
h1{
color: #fff !important;
display:block;
font-family: 'Open Sans', Arial;
font-size:25px;
font-style:normal;
font-weight:bold;
line-height:100%;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:center;
}
/**
* @style heading 2
*/
h2{
color: #b8b8b8 !important;
display:block;
font-family: 'Open Sans', Arial;
font-size:20px;
font-style:normal;
font-weight:bold;
line-height:100%;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:center;
}
/**
* @section heading 3
*/
h3{
color:#6e6e6e !important;
display:block;
font-family: 'Open Sans', Arial, Helvetica;
font-size:16px;
font-weight:normal;
line-height:100%;
font-weight:bold;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:center;
}
/**
* @section heading 4
*/
h4{
color:#808080 !important;
display:block;
font-family: 'Open Sans', Arial, Helvetica;
font-size:14px;
font-weight:bold;
line-height:100%;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:left;
}
/* ========== Header Styles ========== */
.headerContent {
text-align: center;
color:#fff !important;
font-family: 'Open Sans', Arial, Helvetica;
font-size:14px;
margin-bottom:16px;
text-align:center;
}
#headerImage{
height:auto;
width:80px;
margin: 20px auto;
}
/* ========== Body Styles ========== */
/**
* @section body style
*/
#templateBody{
background-color:#FFF;
}
/**
* @section body text
*/
.bodyContent{
color:#505050;
font-family: 'Open Sans', Arial, Helvetica;
font-size:16px;
line-height:150%;
padding-right:20px;
padding-bottom:20px;
padding-left:20px;
padding-top:20px;
text-align:center;
}
/**
* @section body link
*/
.bodyContent a:link, .bodyContent a:visited, /* Yahoo! Mail Override */ .bodyContent a .yshortcuts /* Yahoo! Mail Override */{
color:#699b05;
font-weight:normal;
text-decoration:underline;
}
/**
* @section body link button class
*/
.bodyContent a.button {
background: #699b05;
color: #fff;
display: block;
width: 50%;
text-decoration: none;
text-align: center;
margin: 0 auto 16px;
text-transform: uppercase;
padding: .8rem 3rem;
}
.bodyContent a.button:hover {
background: #aad400;
}
.bodyContent img{
display:inline;
height:auto;
max-width:560px;
}
.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;
}
/**
* @section footer link
*/
.footerContent a:link, .footerContent a:visited, /* Yahoo! Mail Override */ .footerContent a .yshortcuts, .footerContent a span /* Yahoo! Mail Override */{
color:#699b05;
font-weight:normal;
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;
/*@editable*/ 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 ======== */
/**
* @section body image
*/
#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 */
}
</style>
</head>
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0">
<center>
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable">
<tr>
<td align="center" valign="top" id="bodyCell">
<!-- BEGIN TEMPLATE // -->
<table border="0" cellpadding="0" cellspacing="0" id="templateContainer">
<tr>
<td align="center" valign="top">
<!-- BEGIN HEADER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateHeader">
<tr>
<td valign="top" class="headerContent" background="{{ static("emails/top-bg-hero.png") }}" style="background-position: center center">
<a href="{{ resolve_front_url("home") }}" title="Taiga">
<img src="{{ static("emails/logo.png") }}" alt="Taiga logo" id="headerImage" />
</a>
{% trans %}
<h1>You have been Taigatized!</h1>
<p>Welcome to Taiga, an Open Source, Agile Project Management Tool</p>
{% endtrans %}
</td>
</tr>
</table>
<!-- // END HEADER -->
</td>
</tr>
<tr>
<td align="center" valign="top">
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateBody">
<tr>
<td valign="top" class="bodyContent">
{% block body %}{% endblock %}
</td>
</tr>
{% block social %}
<tr>
<td valign="top" class="social-links">
<a href="{{ sr("social.twitter_url") }}" title="{{ _("Follow us on Twitter") }}" style="color: #9dce0a">{{ _("Twitter") }}</a>
<a href="{{ sr("social.github_url") }}" title="{{ _("Get the code on GitHub") }}" style="color: #9dce0a">{{ _("GitHub") }}</a>
<a href="{{ sr("taigaio_url") }}" title="{{ _("Visit our website") }}" style="color: #9dce0a">{{ _("Taiga.io") }}</a>
</td>
</tr>
{% endblock %}
</table>
</td>
</tr>
<tr>
<td align="center" valign="top">
<!-- BEGIN FOOTER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateFooter">
<tr>
<td valign="top" class="footerContent">
{% block footer %}
{% trans support_url=sr("support.url"),
support_email=sr("support.email"),
mailing_list_url=sr("support.mailing_list") %}
<strong>Taiga Support:</strong>
<a href="{{ support_url }}" title="Support page" style="color: #9dce0a">{{ support_url}}</a>
<br>
<strong>Contact us:</strong>
<a href="mailto:{{ support_email }}" title="Supporti email" style="color: #9dce0a">
{{ support_email }}
</a>
<br>
<strong>Mailing list:</strong>
<a href="{{ mailing_list_url }}" title="Mailing list" style="color: #9dce0a">
{{ mailing_list_url }}
</a>
{% endtrans %}
{% endblock %}
</td>
</tr>
</table>
<!-- // END FOOTER -->
</td>
</tr>
</table>
<!-- // END TEMPLATE -->
</td>
</tr>
</table>
</center>
</body>
</html>

View File

@ -0,0 +1,489 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{ _("[Taiga] Updates") }}</title>
<style type="text/css">
/* /\/\/\/\/\/\/\/\/ CLIENT-SPECIFIC STYLES /\/\/\/\/\/\/\/\/ */
#outlook a{padding:0;} /* Force Outlook to provide a "view in browser" message */
.ReadMsgBody{width:100%;} .ExternalClass{width:100%;} /* Force Hotmail to display emails at full width */
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing */
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;} /* Prevent WebKit and Windows mobile changing default text sizes */
table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;} /* Remove spacing between tables in Outlook 2007 and up */
img{-ms-interpolation-mode:bicubic;} /* Allow smoother rendering of resized image in Internet Explorer */
/* /\/\/\/\/\/\/\/\/ RESET STYLES /\/\/\/\/\/\/\/\/ */
body{margin:0; padding:0;}
img{border:0; height:auto; line-height:100%; outline:none; text-decoration:none;}
table{border-collapse:collapse !important;}
body, #bodyTable, #bodyCell{height:100% !important; margin:0; padding:0; width:100% !important;}
/* /\/\/\/\/\/\/\/\/ TEMPLATE STYLES /\/\/\/\/\/\/\/\/ */
#bodyCell{padding:20px;}
#templateContainer{width:600px;}
/* ========== Page Styles ========== */
body, #bodyTable{
background-color:#f5f5f5;
}
/**
* @section email border
*/
#templateContainer{
background-color:#FFF;
border:1px solid #CDCDCD;
}
/**
* @section heading 1
*/
h1{
color: #6e6e6e !important;
display:block;
font-family: 'Open Sans', Arial;
font-size:25px;
font-style:normal;
font-weight:bold;
line-height:100%;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:center;
}
/**
* @style heading 2
*/
h2{
color: #6e6e6e !important;
display:block;
font-family: 'Open Sans', Arial;
font-size:20px;
font-style:normal;
font-weight:bold;
line-height:100%;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:center;
}
/**
* @tab Page
* @section heading 3
*/
h3{
color:#6e6e6e !important;
display:block;
font-family: 'Open Sans', Arial, Helvetica;
font-size:16px;
font-weight:normal;
line-height:100%;
font-weight:bold;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:center;
}
/**
* @tab Page
* @section heading 4
*/
h4{
color:#808080 !important;
display:block;
font-family: 'Open Sans', Arial, Helvetica;
font-size:14px;
font-weight:bold;
line-height:100%;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:left;
}
/* ========== Header Styles ========== */
.headerContent {
text-align: center;
color:#b8b8b8 !important;
font-family: 'Open Sans', Arial, Helvetica;
font-size:14px;
margin-bottom:16px;
text-align:center;
}
#headerImage{
height:auto;
width:80px;
margin: 20px auto;
}
/* ========== Body Styles ========== */
/**
* @tab Body
* @section body style
* @tip Set the background color and borders for your email's body area.
*/
#templateBody{
background-color:#FFF;
}
/**
* @section body text
*/
.bodyContent{
color:#505050;
font-family: 'Open Sans', Arial, Helvetica;
font-size:16px;
line-height:150%;
padding-right:20px;
padding-bottom:20px;
padding-left:20px;
padding-top:20px;
text-align:center;
}
/**
* @section body link
*/
.bodyContent a:link, .bodyContent a:visited, /* Yahoo! Mail Override */ .bodyContent a .yshortcuts /* Yahoo! Mail Override */{
color:#699b05;
font-weight:normal;
text-decoration:underline;
}
/**
* @section body link button class
*/
a.button {
background: #699b05;
color: #fff;
display: block;
width: 50%;
text-decoration: none;
text-align: center;
margin: 0 auto 16px;
text-transform: uppercase;
padding: .8rem 3rem;
}
a.button:hover {
background: #aad400;
}
.bodyContent img{
display:inline;
height:auto;
max-width:560px;
}
.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 */
}
</style>
</head>
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0">
<center>
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable">
<tr>
<td align="center" valign="top" id="bodyCell">
<!-- BEGIN TEMPLATE // -->
<table border="0" cellpadding="0" cellspacing="0" id="templateContainer">
<tr>
<td align="center" valign="top">
<!-- BEGIN HEADER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateHeader">
<tr>
<td valign="top" class="headerContent">
<img src="{{ static("emails/top-bg-update.png") }}" />
<a href="{{ resolve_front_url("home") }}"
title="Taiga">
<img src="{{ static("emails/logo-color.png") }}" id="headerImage" alt="Taiga logo" />
</a>
{% block head %}
{% endblock %}
</td>
</tr>
</table>
<!-- // END HEADER -->
</td>
</tr>
<tr>
<td align="center" valign="top">
<!-- BEGIN BODY // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateBody">
<tr>
<td valign="top" class="bodyContent">
<table class="update-row" border="0" cellpadding="0" cellspacing="0" width="100%">
{% block body %}
<tr>
<th colspan="2"><h2>{{ _("Updates") }}</h2></th>
</tr>
{% for entry in history_entries%}
{% if entry.comment %}
<tr>
<td colspan="2">
{% trans comment=mdrender(project, entry.comment) %}
<h3>comment:</h3>
<p>{{ comment }}</p>
{% endtrans %}
</td>
</tr>
{% endif %}
{% set changed_fields = entry.values_diff %}
{% if changed_fields %}
{% include "emails/includes/fields_diff-html.jinja" %}
{% endif %}
{% endfor %}
{% endblock %}
</table>
</td>
</tr>
{% block social %}
<tr>
<td valign="top" class="social-links">
<a href="{{ sr("social.twitter_url") }}" title="{{ _("Follow us on Twitter") }}" style="color: #9dce0a">{{ _("Twitter") }}</a>
<a href="{{ sr("social.github_url") }}" title="{{ _("Get the code on GitHub") }}" style="color: #9dce0a">{{ _("GitHub") }}</a>
<a href="{{ sr("taigaio_url") }}" title="{{ _("Visit our website") }}" style="color: #9dce0a">{{ _("Taiga.io") }}</a>
</td>
</tr>
{% endblock %}
</table>
<!-- // END BODY -->
</td>
</tr>
<tr>
<td align="center" valign="top">
<!-- BEGIN FOOTER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateFooter">
<tr>
<td valign="top" class="footerContent">
{% block footer %}
{% trans support_url=sr("support.url"),
support_email=sr("support.email"),
mailing_list_url=sr("support.mailing_list") %}
<strong>Taiga Support:</strong>
<a href="{{ support_url }}" title="Support page" style="color: #9dce0a">{{ support_url}}</a>
<br>
<strong>Contact us:</strong>
<a href="mailto:{{ support_email }}" title="Supporti email" style="color: #9dce0a">
{{ support_email }}
</a>
<br>
<strong>Mailing list:</strong>
<a href="{{ mailing_list_url }}" title="Mailing list" style="color: #9dce0a">
{{ mailing_list_url }}
</a>
{% endtrans %}
{% endblock %}
</td>
</tr>
</table>
<!-- // END FOOTER -->
</td>
</tr>
</table>
<!-- // END TEMPLATE -->
</td>
</tr>
</table>
</center>
</body>
</html>

View File

@ -0,0 +1,15 @@
{% block head %}{% endblock %}
{% block body %}
{% for entry in history_entries %}
{% if entry.comment %}
{% trans comment=entry.comment %}
Comment: {{ comment }}
{% endtrans %}
{% endif %}
{% set changed_fields = entry.values_diff %}
{% if changed_fields %}
{% include "emails/includes/fields_diff-text.jinja" %}
{% endif %}
{% endfor %}
{% endblock %}

View File

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

19
taiga/contrib_routers.py Normal file
View File

@ -0,0 +1,19 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base import routers
router = routers.DefaultRouter(trailing_slash=False)

View File

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

View File

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

View File

@ -14,33 +14,74 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from rest_framework.exceptions import APIException
import json
import codecs
import uuid
from rest_framework.response import Response
from rest_framework.decorators import throttle_classes
from rest_framework import status
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext_lazy as _
from django.db.transaction import atomic
from django.db.models import signals
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from taiga.base.api.mixins import CreateModelMixin
from taiga.base.api.viewsets import GenericViewSet
from taiga.base.decorators import detail_route
from taiga.base.decorators import detail_route, list_route
from taiga.base import exceptions as exc
from taiga.projects.models import Project, Membership
from taiga.projects.issues.models import Issue
from taiga.projects.serializers import ProjectSerializer
from . import mixins
from . import serializers
from . import service
from . import permissions
from . import tasks
from . import dump_service
from . import throttling
from .renderers import ExportRenderer
from taiga.base.api.utils import get_object_or_404
class Http400(APIException):
status_code = 400
class ProjectExporterViewSet(mixins.ImportThrottlingPolicyMixin, GenericViewSet):
model = Project
permission_classes = (permissions.ImportExportPermission, )
def retrieve(self, request, pk, *args, **kwargs):
throttle = throttling.ImportDumpModeRateThrottle()
if not throttle.allow_request(request, self):
self.throttled(request, throttle.wait())
project = get_object_or_404(self.get_queryset(), pk=pk)
self.check_permissions(request, 'export_project', project)
if settings.CELERY_ENABLED:
task = tasks.dump_project.delay(request.user, project)
tasks.delete_project_dump.apply_async((project.pk, project.slug), countdown=settings.EXPORTS_TTL)
return Response({"export_id": task.id}, status=status.HTTP_202_ACCEPTED)
path = "exports/{}/{}-{}.json".format(project.pk, project.slug, uuid.uuid4().hex)
content = ContentFile(ExportRenderer().render(service.project_to_dict(project),
renderer_context={"indent": 4}).decode('utf-8'))
default_storage.save(path, content)
response_data = {
"url": default_storage.url(path)
}
return Response(response_data, status=status.HTTP_200_OK)
class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixin, GenericViewSet):
model = Project
permission_classes = (permissions.ImportPermission, )
permission_classes = (permissions.ImportExportPermission, )
@method_decorator(atomic)
def create(self, request, *args, **kwargs):
@ -52,7 +93,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
project_serialized = service.store_project(data)
if project_serialized is None:
raise Http400(service.get_errors())
raise exc.BadRequest(service.get_errors())
if "points" in data:
service.store_choices(project_serialized.object, data,
@ -106,13 +147,47 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
errors = service.get_errors()
if errors:
raise Http400(errors)
raise exc.BadRequest(errors)
response_data = project_serialized.data
response_data['id'] = project_serialized.object.id
headers = self.get_success_headers(response_data)
return Response(response_data, status=status.HTTP_201_CREATED, headers=headers)
@list_route(methods=["POST"])
@method_decorator(atomic)
def load_dump(self, request):
throttle = throttling.ImportDumpModeRateThrottle()
if not throttle.allow_request(request, self):
self.throttled(request, throttle.wait())
self.check_permissions(request, "load_dump", None)
dump = request.FILES.get('dump', None)
if not dump:
raise exc.WrongArguments(_("Needed dump file"))
reader = codecs.getreader("utf-8")
try:
dump = json.load(reader(dump))
except Exception:
raise exc.WrongArguments(_("Invalid dump format"))
if Project.objects.filter(slug=dump['slug']).exists():
del dump['slug']
if settings.CELERY_ENABLED:
task = tasks.load_project_dump.delay(request.user, dump)
return Response({"import_id": task.id}, status=status.HTTP_202_ACCEPTED)
project = dump_service.dict_to_project(dump, request.user.email)
response_data = ProjectSerializer(project).data
return Response(response_data, status=status.HTTP_201_CREATED)
@detail_route(methods=['post'])
@method_decorator(atomic)
def issue(self, request, *args, **kwargs):
@ -126,7 +201,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
errors = service.get_errors()
if errors:
raise Http400(errors)
raise exc.BadRequest(errors)
headers = self.get_success_headers(issue.data)
return Response(issue.data, status=status.HTTP_201_CREATED, headers=headers)
@ -141,7 +216,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
errors = service.get_errors()
if errors:
raise Http400(errors)
raise exc.BadRequest(errors)
headers = self.get_success_headers(task.data)
return Response(task.data, status=status.HTTP_201_CREATED, headers=headers)
@ -156,7 +231,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
errors = service.get_errors()
if errors:
raise Http400(errors)
raise exc.BadRequest(errors)
headers = self.get_success_headers(us.data)
return Response(us.data, status=status.HTTP_201_CREATED, headers=headers)
@ -171,7 +246,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
errors = service.get_errors()
if errors:
raise Http400(errors)
raise exc.BadRequest(errors)
headers = self.get_success_headers(milestone.data)
return Response(milestone.data, status=status.HTTP_201_CREATED, headers=headers)
@ -186,7 +261,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
errors = service.get_errors()
if errors:
raise Http400(errors)
raise exc.BadRequest(errors)
headers = self.get_success_headers(wiki_page.data)
return Response(wiki_page.data, status=status.HTTP_201_CREATED, headers=headers)
@ -201,7 +276,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
errors = service.get_errors()
if errors:
raise Http400(errors)
raise exc.BadRequest(errors)
headers = self.get_success_headers(wiki_link.data)
return Response(wiki_link.data, status=status.HTTP_201_CREATED, headers=headers)

View File

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

View File

@ -1,3 +1,19 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import signals

View File

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

View File

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

View File

@ -44,6 +44,7 @@ def add_errors(section, errors):
else:
_errors_log[section] = [errors]
def project_to_dict(project):
return serializers.ProjectExportSerializer(project).data
@ -84,7 +85,7 @@ def store_choice(project, data, field, serializer):
def store_choices(project, data, field, serializer):
result = []
for choice_data in data[field]:
for choice_data in data.get(field, []):
result.append(store_choice(project, choice_data, field, serializer))
return result
@ -102,7 +103,7 @@ def store_role(project, role):
def store_roles(project, data):
results = []
for role in data['roles']:
for role in data.get('roles', []):
results.append(store_role(project, role))
return results

View File

@ -0,0 +1,91 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.utils import timezone
from django.conf import settings
from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail
from taiga.celery import app
from .service import project_to_dict
from .dump_service import dict_to_project
from .renderers import ExportRenderer
@app.task(bind=True)
def dump_project(self, user, project):
mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail)
path = "exports/{}/{}-{}.json".format(project.pk, project.slug, self.request.id)
try:
content = ExportRenderer().render(project_to_dict(project), renderer_context={"indent": 4})
content = content.decode('utf-8')
content = ContentFile(content)
default_storage.save(path, content)
url = default_storage.url(path)
except Exception:
ctx = {
"user": user,
"error_subject": "Error generating project dump",
"error_message": "Error generating project dump",
"project": project
}
email = mbuilder.export_error(user.email, ctx)
email.send()
return
deletion_date = timezone.now() + datetime.timedelta(seconds=settings.EXPORTS_TTL)
ctx = {
"url": url,
"project": project,
"user": user,
"deletion_date": deletion_date
}
email = mbuilder.dump_project(user.email, ctx)
email.send()
@app.task
def delete_project_dump(project_id, project_slug, task_id):
default_storage.delete("exports/{}/{}-{}.json".format(project_id, project_slug, task_id))
@app.task
def load_project_dump(user, dump):
mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail)
try:
project = dict_to_project(dump, user.email)
except Exception:
ctx = {
"user": user,
"error_subject": "Error loading project dump",
"error_message": "Error loading project dump",
}
email = mbuilder.import_error(user.email, ctx)
email.send()
return
ctx = {"user": user, "project": project}
email = mbuilder.load_dump(user.email, ctx)
email.send()

View File

@ -0,0 +1,16 @@
{% extends "emails/base-body-html.jinja" %}
{% block body %}
{% trans user=user.get_full_name()|safe,
project=project.name|safe,
url=url,
deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %}
<h1>Project dump generated</h1>
<p>Hello {{ user }},</p>
<h3>Your dump from project {{ project }} has been correctly generated.</h3>
<p>You can download it here:</p>
<a class="button" href="{{ url }}" title="Download the dump file">Download the dump file</a>
<p>This file will be deleted on {{ deletion_date }}.</p>
<p><small>The Taiga Team</small></p>
{% endtrans %}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% trans user=user.get_full_name()|safe,
project=project.name|safe,
url=url,
deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %}
Hello {{ user }},
Your dump from project {{ project }} has been correctly generated. You can download it here:
{{ url }}
This file will be deleted on {{ deletion_date }}.
---
The Taiga Team
{% endtrans %}

View File

@ -0,0 +1 @@
{% trans project=project.name|safe %}[{{ project }}] Your project dump has been generated{% endtrans %}

View File

@ -0,0 +1,15 @@
{% extends "emails/base-body-html.jinja" %}
{% block body %}
{% trans user=user.get_full_name()|safe,
error_message=error_message,
support_email=sr("support.email"),
project=project.name|safe %}
<h1>{{ error_message }}</h1>
<p>Hello {{ user }},</p>
<p>Your project {{ project }} has not been exported correctly.</p>
<p>The Taiga system administrators have been informed.<br/> Please, try it again or contact with the support team at
<a href="mailto:{{ support_email }}" title="Support email" style="color: #699b05">{{ support_email }}</a></p>
<p><small>The Taiga Team</small></p>
{% endtrans %}
{% endblock %}

View File

@ -0,0 +1,16 @@
{% trans user=user.get_full_name()|safe,
error_message=error_message,
support_email=sr("support.email"),
project=project.name|safe %}
Hello {{ user }},
{{ error_message }}
Your project {{ project }} has not been exported correctly.
The Taiga system administrators have been informed.
Please, try it again or contact with the support team at {{ support_email }}
---
The Taiga Team
{% endtrans %}

View File

@ -0,0 +1 @@
{% trans error_subject=error_subject, project=project.name|safe %}[{{ project }}] {{ error_subject }}{% endtrans %}

View File

@ -0,0 +1,14 @@
{% extends "emails/base-body-html.jinja" %}
{% block body %}
{% trans user=user.get_full_name()|safe,
error_message=error_message,
support_email=sr("support.email") %}
<h1>{{ error_message }}</h1>
<p>Hello {{ user }},</p>
<p>Your project has not been importer correctly.</p>
<p>The Taiga system administrators have been informed.<br/> Please, try it again or contact with the support team at
<a href="mailto:{{ support_email }}" title="Support email" style="color: #699b05">{{ support_email }}</a></p>
<p><small>The Taiga Team</small></p>
{% endtrans %}
{% endblock %}

View File

@ -0,0 +1,16 @@
{% trans user=user.get_full_name()|safe,
error_message=error_message,
support_email=sr("support.email") %}
Hello {{ user }},
{{ error_message }}
Your project has not been importer correctly.
The Taiga system administrators have been informed.
Please, try it again or contact with the support team at {{ support_email }}
---
The Taiga Team
{% endtrans %}

View File

@ -0,0 +1 @@
{% trans error_subject=error_subject %}[Taiga] {{ error_subject }}{% endtrans %}

View File

@ -0,0 +1,13 @@
{% extends "emails/base-body-html.jinja" %}
{% block body %}
{% trans user=user.get_full_name()|safe,
url=resolve_front_url("project", project.slug),
project=project.name|safe %}
<h1>Project dump imported</h1>
<p>Hello {{ user }},</p>
<h3>Your project dump has been correctly imported.</h3>
<a class="button" href="{{ url }}" title="Go to the project {{ project }}">Go to {{ project }}</a>
<p><small>The Taiga Team</small></p>
{% endtrans %}
{% endblock %}

View File

@ -0,0 +1,14 @@
{% trans user=user.get_full_name()|safe,
url=resolve_front_url("project", project.slug),
project=project.name|safe %}
Hello {{ user }},
Your project dump has been correctly imported.
You can see the project {{ project }} here:
{{ url }}
---
The Taiga Team
{% endtrans %}

View File

@ -0,0 +1 @@
{% trans project=project.name|safe %}[{{ project }}] Your project dump has been imported{% endtrans %}

View File

@ -19,3 +19,6 @@ from taiga.base import throttling
class ImportModeRateThrottle(throttling.UserRateThrottle):
scope = "import-mode"
class ImportDumpModeRateThrottle(throttling.UserRateThrottle):
scope = "import-dump-mode"

View File

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

View File

@ -16,14 +16,21 @@
from django.conf import settings
from djmail.template_mail import MagicMailBuilder
from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail
def send_feedback(feedback_entry, extra):
def send_feedback(feedback_entry, extra, reply_to=[]):
support_email = settings.FEEDBACK_EMAIL
if support_email:
mbuilder = MagicMailBuilder()
email = mbuilder.feedback_notification(support_email, {"feedback_entry": feedback_entry,
"extra": extra})
reply_to.append(support_email)
ctx = {
"feedback_entry": feedback_entry,
"extra": extra
}
mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail)
email = mbuilder.feedback_notification(support_email, ctx)
email.extra_headers["Reply-To"] = ", ".join(reply_to)
email.send()

View File

@ -1,29 +1,21 @@
{% extends "emails/base.jinja" %}
{% extends "emails/base-body-html.jinja" %}
{% block body %}
<table border="0" width="100%" cellpadding="4" cellspacing="10" class="table-body" style="border-collapse: collapse;">
<tr>
<td valign="top" style="border-top: 1px solid gray; border-bottom: 1px solid gray;">
<strong>From:</strong>
</td>
<td style="border-top: 1px solid gray; border-bottom: 1px solid gray;">
{{ feedback_entry.full_name }} [{{ feedback_entry.email }}]
</td>
</tr>
<tr>
<td valign="top" style="border-top: 1px solid gray; border-bottom: 1px solid gray;">
<strong>Comment:</strong>
</td>
<td style="border-top: 1px solid gray; border-bottom: 1px solid gray;">
{{ feedback_entry.comment|linebreaks }}
</td>
</tr>
{% trans full_name=feedback_entry.full_name|safe, email=feedback_entry.email %}
<h1>Feedback</h1>
<p>Taiga has received feedback from {{ full_name }} <{{ email }}></p>
{% endtrans %}
{% trans comment=feedback_entry.comment|linebreaksbr %}
<h3>Comment</h3>
<p>{{ comment }}</p>
{% endtrans %}
{% if extra %}
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateBody">
<tr>
<td valign="top" style="border-top: 1px solid gray; border-bottom: 1px solid gray;">
<strong>Extra:</strong>
</td>
<td style="border-top: 1px solid gray; border-bottom: 1px solid gray;">
<td valign="top" valign="top" class="bodyContent">
<h3>{{ _("Extra info") }}</h3>
<dl>
{% for k, v in extra.items() %}
<dt>{{ k }}</dt>
@ -31,7 +23,7 @@
{% endfor %}
</dl>
</td>
</tr
{% endif %}>
</tr>
</table>
{% endif %}
{% endblock %}

View File

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

View File

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

View File

@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
"PO-Revision-Date: 2014-12-19 19:48-0430\n"
"Last-Translator: Hector Colina <hcolina@gmail.com>\n"
"Language-Team: LANGUAGE <LL@li.org>\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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,141 +9,162 @@
"us_order"
] %}
<dl>
{% for field_name, values in changed_fields.items() %}
{% if field_name not in excluded_fields %}
<dt style="background: #669933; padding: 5px 15px; color: #fff">
<b>{{ verbose_name(object, field_name) }}</b>
</dt>
{# POINTS #}
{% if field_name == "points" %}
{% for role, points in values.items() %}
<dd style="background: #b2cc99; padding: 5px 15px; color: #fff">
<b>{{ role }}</b>
</dd>
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>to:</b> <i>{{ points.1|linebreaksbr }}</i>
</dd>
<dd style="padding: 5px 15px; color: #bbb">
<b>from:</b> <i>{{ points.0|linebreaksbr }}</i>
</dd>
<tr>
<td valign="middle" rowspan="2" class="update-row-name">
<h3>{% trans role=role %}{{ role }} role points{% endtrans %}</h3>
</td>
<td valign="top" class="update-row-from">
<span>{{ _("from") }}</span><br>
<strong>{{ points.1 }}</strong>
</td>
</tr>
<tr>
<td valign="top">
<span>{{ _("to") }}</span><br>
<strong>{{ points.0 }}</strong>
</td>
</tr>
{% endfor %}
{# ATTACHMENTS #}
{% elif field_name == "attachments" %}
{% if values.new %}
<dd style="background: #b2cc99; padding: 5px 15px; color: #fff">
<b>{{ _("Added") }}</b>
</dd>
{% for att in values['new']%}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<a href="{{ att.url }}" target="_blank" style="font-weight: bold; color: #444">
{{ att.filename|linebreaksbr }}
<tr>
<td colspan="2">
<h3>{{ _("Added new attachment") }}</h3>
<p>
<a href="{{ att.url }}" target="_blank">
{{ att.filename }}
</a>
{% if att.description %}<i> {{ att.description|linebreaksbr }}</i>{% endif %}
</dd>
</p>
{% if att.description %}
<p>{{ att.description }}</p>
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
{% if values.changed %}
<dd style="background: #b2cc99; padding: 5px 15px; color: #fff">
<b>{{ _("Changed") }}</b>
</dd>
{% for att in values['changed'] %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<a href="{{ att.url }}" target="_blank" style="font-weight: bold; color: #444">
<tr>
<td colspan="2">
<h3>{{ _("Updated attachment") }}</h3>
<p>
<a href="{{ att.url }}" target="_blank">
{{ att.filename|linebreaksbr }}
</a>
<ul>
{% if att.changes.is_deprecated %}
{% if att.changes.is_deprecated.1 %}
<li>to <i>deprecated</i></li>
[<i>{{ _("deprecated") }}</i>]
{% else %}
<li>to <i>not deprecated</i></li>
[<i>{{ _("not deprecated") }}</i>]
{% endif %}
{% endif %}
</a>
</p>
{% if att.changes.description %}
<li>description to <i>{{ att.changes.description.1 }}</i></li>
<p>{{ att.changes.description.1 }}</p>
{% endif %}
</ul>
</dd>
</td>
</tr>
{% endfor %}
{% endif %}
{% if values.deleted %}
<dd style="background: #b2cc99; padding: 5px 15px; color: #fff">
<b>{{ _("Deleted") }}</b>
</dd>
{% for att in values['deleted']%}
<dd style="padding: 5px 15px; color: #bbb">
<i>{{ att.filename|linebreaksbr }}</i>
</dd>
<tr>
<td colspan="2">
<h3>{{ _("Deleted attachment") }}</h3>
{% if att.changes.description %}
<p>{{ att.filename|linebreaksbr }}</p>
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
{# TAGS AND WATCHERS #}
{% elif field_name in ["tags", "watchers"] %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>to:</b> <i>{{ ', '.join(values.1)|linebreaksbr }}</i>
</dd>
{% if values.0 %}
<dd style="padding: 5px 15px; color: #bbb">
<b>from:</b> <i>{{ ', '.join(values.0)|linebreaksbr }}</i>
</dd>
{% endif %}
<tr>
<td valign="middle" rowspan="2" class="update-row-name">
<h3>{{ field_name }}</h3>
</td>
<td valign="top" class="update-row-from">
<span>{{ _("from") }}</span><br>
<strong>{{ ', '.join(values.0) }}</strong>
</td>
</tr>
<tr>
<td valign="top">
<span>{{ _("to") }}</span><br>
<strong>{{ ', '.join(values.1) }}</strong>
</td>
</tr>
{# DESCRIPTIONS #}
{% elif field_name in ["description_diff"] %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>diff:</b> <i>{{ mdrender(project, values.1) }}</i>
</dd>
<tr>
<td colspan="2">
<h3>{{ _("Description diff") }}</h3>
<p>{{ mdrender(project, values.1) }}</p>
</td>
</tr>
{# CONTENT #}
{% elif field_name in ["content_diff"] %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>diff:</b> <i>{{ mdrender(project, values.1) }}</i>
</dd>
<tr>
<td colspan="2">
<h3>{{ _("Content diff") }}</h3>
<p>{{ mdrender(project, values.1) }}</p>
</td>
</tr>
{# ASSIGNED TO #}
{% elif field_name == "assigned_to" %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
{% if values.1 != None and values.1 != "" %}
<b>to:</b> <i>{{ values.1|linebreaksbr }}</i>
{% else %}
<b>to:</b> <i>{{ _("Unassigned") }}</i>
{% endif %}
</dd>
<dd style="padding: 5px 15px; color: #bbb">
<tr>
<td valign="middle" rowspan="2" class="update-row-name">
<h3>{{ field_name }}</h3>
</td>
<td valign="top" class="update-row-from">
{% if values.0 != None and values.0 != "" %}
<b>from:</b> <i>{{ values.0|linebreaksbr }}</i>
<span>{{ _("from") }}</span><br>
<strong>{{ values.0 }}</strong>
{% else %}
<b>from:</b> <i>{{ _("Unassigned") }}</i>
<span>{{ _("from") }}</span><br>
<strong>{{ _("Unassigned") }}</strong>
{% endif %}
</dd>
</td>
</tr>
<tr>
<td valign="top">
{% if values.1 != None and values.1 != "" %}
<span>{{ _("to") }}</span><br>
<strong>{{ values.1 }}</strong>
{% else %}
<span>{{ _("to") }}</span><br>
<strong>{{ _("Unassigned") }}</strong>
{% endif %}
</td>
</tr>
{# * #}
{% else %}
{% if values.1 != None and values.1 != "" %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>to:</b> <i>{{ values.1|linebreaksbr }}</i>
</dd>
{% endif %}
{% if values.0 != None and values.0 != "" %}
<dd style="padding: 5px 15px; color: #bbb">
<b>from:</b> <i>{{ values.0|linebreaksbr }}</i>
</dd>
<tr>
<td valign="middle" rowspan="2" class="update-row-name">
<h3>{{ field_name }}</h3>
</td>
<td valign="top" class="update-row-from">
<span>{{ _("from") }}</span><br>
<strong>{{ values.1|linebreaksbr }}</strong>
</td>
</tr>
<tr>
<td valign="top">
<span>{{ _("to") }}</span><br>
<strong>{{ values.0|linebreaksbr }}</strong>
</td>
</tr>
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
</dl>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('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,
),
]

View File

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

View File

@ -40,14 +40,19 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView
serializer_class = serializers.MilestoneSerializer
permission_classes = (permissions.MilestonePermission,)
filter_backends = (filters.CanViewMilestonesFilterBackend,)
filter_fields = ("project",)
filter_fields = ("project", "closed")
def get_queryset(self):
qs = models.Milestone.objects.all()
qs = qs.prefetch_related("user_stories",
"user_stories__role_points",
"user_stories__role_points__points",
"user_stories__role_points__role")
"user_stories__role_points__role",
"user_stories__generated_from_issue",
"user_stories__project",
"watchers",
"user_stories__watchers")
qs = qs.select_related("project")
qs = qs.order_by("-estimated_start")
return qs

View File

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

View File

@ -254,23 +254,20 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
return dict_sum(*flat_role_dicts)
def _get_points_increment(self, client_requirement, team_requirement):
userstory_model = apps.get_model("userstories", "UserStory")
user_stories = userstory_model.objects.none()
last_milestones = self.milestones.order_by('-estimated_finish')
last_milestone = last_milestones[0] if last_milestones else None
if last_milestone:
user_stories = userstory_model.objects.filter(
user_stories = self.user_stories.filter(
created_date__gte=last_milestone.estimated_finish,
project_id=self.id,
client_requirement=client_requirement,
team_requirement=team_requirement
).prefetch_related('role_points', 'role_points__points')
)
else:
user_stories = userstory_model.objects.filter(
project_id=self.id,
user_stories = self.user_stories.filter(
client_requirement=client_requirement,
team_requirement=team_requirement
).prefetch_related('role_points', 'role_points__points')
)
user_stories = user_stories.prefetch_related('role_points', 'role_points__points')
return self._get_user_stories_points(user_stories)
@property
@ -291,15 +288,26 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
@property
def closed_points(self):
return self._get_user_stories_points(self.user_stories.filter(is_closed=True).prefetch_related('role_points', 'role_points__points'))
return self.calculated_points["closed"]
@property
def defined_points(self):
return self._get_user_stories_points(self.user_stories.all().prefetch_related('role_points', 'role_points__points'))
return self.calculated_points["defined"]
@property
def assigned_points(self):
return self._get_user_stories_points(self.user_stories.filter(milestone__isnull=False).prefetch_related('role_points', 'role_points__points'))
return self.calculated_points["assigned"]
@property
def calculated_points(self):
user_stories = self.user_stories.all().prefetch_related('role_points', 'role_points__points')
closed_user_stories = user_stories.filter(is_closed=True)
assigned_user_stories = user_stories.filter(milestone__isnull=False)
return {
"defined": self._get_user_stories_points(user_stories),
"closed": self._get_user_stories_points(closed_user_stories),
"assigned": self._get_user_stories_points(assigned_user_stories),
}
class ProjectModulesConfig(models.Model):
@ -323,6 +331,8 @@ class UserStoryStatus(models.Model):
verbose_name=_("order"))
is_closed = models.BooleanField(default=False, null=False, blank=True,
verbose_name=_("is closed"))
is_archived = models.BooleanField(default=False, null=False, blank=True,
verbose_name=_("is archived"))
color = models.CharField(max_length=20, null=False, blank=False, default="#999999",
verbose_name=_("color"))
wip_limit = models.IntegerField(null=True, blank=True, default=None,
@ -690,6 +700,7 @@ class ProjectTemplate(models.Model):
name=us_status["name"],
slug=us_status["slug"],
is_closed=us_status["is_closed"],
is_archived=us_status["is_archived"],
color=us_status["color"],
wip_limit=us_status["wip_limit"],
order=us_status["order"],

View File

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

View File

@ -1,30 +1,15 @@
{% extends "emails/base.jinja" %}
{% extends "emails/updates-body-html.jinja" %}
{% set final_url = resolve_front_url("issue", project.slug, snapshot.ref) %}
{% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<h1>Project: {{ project.name }}</h1>
<h2>Issue #{{ snapshot.ref }}: {{ snapshot.subject }}</h2>
<p>Updated by <b>{{ changer.get_full_name() }}</b>.</p>
{% for entry in history_entries%}
{% if entry.comment %}
<p>Comment <b>{{ mdrender(project, entry.comment) }}</b></p>
{% endif %}
{% set changed_fields = entry.values_diff %}
{% if changed_fields %}
{% include "emails/includes/fields_diff-html.jinja" %}
{% endif %}
{% endfor %}
</td>
</tr>
</table>
{% endblock %}
{% block footer %}
<p style="padding: 10px; border-top: 1px solid #eee;">
More info at: <a href="{{ final_url }}" style="color: #666;">{{ final_url_name }}</a>
</p>
{% block head %}
{% trans user=user.get_full_name()|safe,
changer=changer.get_full_name()|safe,
project=project.name|safe,
ref=snapshot.ref,
subject=snapshot.subject|safe,
url=resolve_front_url("issue", project.slug, snapshot.ref) %}
<h1>Issue updated</h1>
<p>Hello {{ user }}, <br> {{ changer }} has updated an issue on {{ project }}</p>
<p>Issue #{{ ref }} {{ subject }}</p>
<a class="button" href="{{ url }}" title="See Issue #{{ ref }}: {{ subject }} in Taiga">See issue</a>
{% endtrans %}
{% endblock %}

View File

@ -1,17 +1,13 @@
{% set final_url = resolve_front_url("issue", project.slug, snapshot.ref) %}
{% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %}
- Project: {{ project.name }}
- Issue #{{ snapshot.ref }}: {{ snapshot.subject }}
- Updated by {{ changer.get_full_name() }}
{% for entry in history_entries%}
{% if entry.comment %}
Comment: {{ entry.comment|linebreaksbr }}
{% endif %}
{% set changed_fields = entry.values_diff %}
{% if changed_fields %}
{% include "emails/includes/fields_diff-text.jinja" %}
{% endif %}
{% endfor %}
** More info at {{ final_url_name }} ({{ final_url }}) **
{% extends "emails/updates-body-text.jinja" %}
{% block head %}
{% trans user=user.get_full_name(),
changer=changer.get_full_name()|safe,
project=project.name|safe,
ref=snapshot.ref,
subject=snapshot.subject|safe,
url=resolve_front_url("issue", project.slug, snapshot.ref) %}
Issue updated
Hello {{ user }}, {{ changer }} has updated an issue on {{ project }}
See issue #{{ ref }} {{ subject }} at {{ url }}
{% endtrans %}
{% endblock %}

View File

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

View File

@ -1,21 +1,16 @@
{% extends "emails/base.jinja" %}
{% set final_url = resolve_front_url("issue", project.slug, snapshot.ref) %}
{% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %}
{% extends "emails/base-body-html.jinja" %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<h1>Project: {{ project.name }}</h1>
<h2>Issue #{{ snapshot.ref }}: {{ snapshot.subject }}</h2>
<p>Created by <b>{{ changer.get_full_name() }}</b>.</p>
</td>
</tr>
</table>
{% endblock %}
{% block footer %}
<p style="padding: 10px; border-top: 1px solid #eee;">
More info at: <a href="{{ final_url }}" style="color: #666;">{{ final_url_name }}</a>
</p>
{% 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) %}
<h1>New issue created</h1>
<p>Hello {{ user }},<br />{{ changer }} has created a new issue on {{ project }}</p>
<p>Issue #{{ ref }} {{ subject }}</p>
<a class="button" href="{{ url }}" title="See Issue #{{ ref }} {{ subject }}">See issue</a>
<p><small>The Taiga Team</small></p>
{% endtrans %}
{% endblock %}

View File

@ -1,8 +1,13 @@
{% set final_url = resolve_front_url("issue", project.slug, snapshot.ref) %}
{% set final_url_name = "Taiga - View issue #{0}".format(snapshot.ref) %}
{% trans user=user.get_full_name()|safe,
changer=changer.get_full_name()|safe,
project=project.name|safe,
ref=snapshot.ref,
subject=snapshot.subject|safe,
url=resolve_front_url("issue", project.slug, snapshot.ref) %}
New issue created
Hello {{ user }}, {{ changer }} has created a new issue on {{ project }}
See issue #{{ ref }} {{ subject }} at {{ url }}
- Project: {{ project.name }}
- US #{{ snapshot.ref }}: {{ snapshot.subject }}
- Created by {{ changer.get_full_name() }}
** More info at {{ final_url_name }} ({{ final_url }}) **
---
The Taiga Team
{% endtrans %}

View File

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

View File

@ -1,13 +1,14 @@
{% extends "emails/base.jinja" %}
{% extends "emails/base-body-html.jinja" %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<h1>{{ project.name }}</h1>
<h2>Issue #{{ snapshot.ref }}: {{ snapshot.subject }}</h2>
<p>Deleted by <b>{{ changer.get_full_name() }}</b></p>
</td>
</tr>
</table>
{% trans user=user.get_full_name()|safe,
changer=changer.get_full_name()|safe,
project=project.name|safe,
ref=snapshot.ref,
subject=snapshot.subject|safe %}
<h1>Issue deleted</h1>
<p>Hello {{ user }},<br />{{ changer }} has deleted an issue on {{ project }}</p>
<p>Issue #{{ ref }} {{ subject }}</p>
<p><small>The Taiga Team</small></p>
{% endtrans %}
{% endblock %}

View File

@ -1,3 +1,12 @@
- Project: {{ project.name }}
- Issue #{{ snapshot.ref }}: {{ snapshot.subject }}
- Deleted by {{ changer.get_full_name() }}
{% trans user=user.get_full_name()|safe,
changer=changer.get_full_name()|safe,
project=project.name|safe,
ref=snapshot.ref,
subject=snapshot.subject|safe %}
Issue deleted
Hello {{ user }}, {{ changer }} has deleted an issue on {{ project }}
Issue #{{ ref }} {{ subject }}
---
The Taiga Team
{% endtrans %}

View File

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

View File

@ -1,30 +1,14 @@
{% extends "emails/base.jinja" %}
{% extends "emails/updates-body-html.jinja" %}
{% set final_url = resolve_front_url("taskboard", project.slug, snapshot.slug) %}
{% set final_url_name = "Taiga - View milestone #{0}".format(snapshot.slug) %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<h1>Project: {{ project.name }}</h1>
<h2>Milestone #{{ snapshot.slug }}: {{ snapshot.name }}</h2>
<p>Updated by <b>{{ changer.get_full_name() }}</b>.</p>
{% for entry in history_entries%}
{% if entry.comment %}
<p>Comment <b>{{ entry.comment|linebreaksbr }}</b></p>
{% endif %}
{% set changed_fields = entry.values_diff %}
{% if changed_fields %}
{% include "emails/includes/fields_diff-html.jinja" %}
{% endif %}
{% endfor %}
</td>
</tr>
</table>
{% endblock %}
{% block footer %}
<p style="padding: 10px; border-top: 1px solid #eee;">
More info at: <a href="{{ final_url }}" style="color: #666;">{{ final_url_name }}</a>
</p>
{% 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) %}
<h1>Sprint updated</h1>
<p>Hello {{ user }}, <br> {{ changer }} has updated an sprint on {{ project }}</p>
<p>Sprint {{ name }}</p>
<a class="button" href="{{ url }}" title="See Sprint: {{ name }} in Taiga">See sprint</a>
{% endtrans %}
{% endblock %}

View File

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

View File

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

View File

@ -1,21 +1,15 @@
{% extends "emails/base.jinja" %}
{% set final_url = resolve_front_url("taskboard", project.slug, snapshot.slug) %}
{% set final_url_name = "Taiga - View milestone #{0}".format(snapshot.slug) %}
{% extends "emails/base-body-html.jinja" %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<h1>Project: {{ project.name }}</h1>
<h2>Milestone #{{ snapshot.slug }}: {{ snapshot.name }}</h2>
<p>Created by <b>{{ changer.get_full_name() }}</b>.</p>
</td>
</tr>
</table>
{% endblock %}
{% block footer %}
<p style="padding: 10px; border-top: 1px solid #eee;">
More info at: <a href="{{ final_url }}" style="color: #666;">{{ final_url_name }}</a>
</p>
{% 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) %}
<h1>New sprint created</h1>
<p>Hello {{ user }},<br />{{ changer }} has created a new sprint on {{ project }}</p>
<p>Sprint {{ name }}</p>
<a class="button" href="{{ url }}" title="See Sprint {{ name }}">See sprint</a>
<p><small>The Taiga Team</small></p>
{% endtrans %}
{% endblock %}

View File

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

View File

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

View File

@ -1,14 +1,14 @@
{% extends "emails/base.jinja" %}
{% extends "emails/base-body-html.jinja" %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<h1>{{ project.name }}</h1>
<h2>Milestone #{{ snapshot.slug }}: {{ snapshot.name }}</h2>
<p>Deleted by <b>{{ changer.get_full_name() }}</b></p>
</td>
</tr>
</table>
{% trans user=user.get_full_name()|safe,
changer=changer.get_full_name()|safe,
project=project.name|safe,
ref=snapshot.ref,
name=snapshot.name|safe %}
<h1>Sprint deleted</h1>
<p>Hello {{ user }},<br />{{ changer }} has deleted an sprint on {{ project }}</p>
<p>Sprint {{ name }}</p>
<p><small>The Taiga Team</small></p>
{% endtrans %}
{% endblock %}

View File

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

View File

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

View File

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

View File

@ -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) }}</b>: from '{{ values.0 }}' to '{{ values.1 }}'.
{% endfor %}
{% endif %}
{% endfor %}
** More info at {{ final_url_name }} ({{ final_url }}) **

View File

@ -1 +0,0 @@
[{{ snapshot.name|safe }}] Updated the project #{{ snapshot.slug|safe }}

View File

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

View File

@ -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 }}) **

View File

@ -1 +0,0 @@
[{{ snapshot.name|safe }}] Created the project #{{ snapshot.slug|safe }}

View File

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

View File

@ -1,2 +0,0 @@
- Project #{{ snapshot.slug }}: {{ snapshot.name }}
- Deleted by {{ changer.get_full_name() }}

View File

@ -1 +0,0 @@
[{{ snapshot.name|safe }}] Deleted the project #{{ snapshot.slug|safe }}

View File

@ -1,30 +1,15 @@
{% extends "emails/base.jinja" %}
{% extends "emails/updates-body-html.jinja" %}
{% set final_url = resolve_front_url("task", project.slug, snapshot.ref) %}
{% set final_url_name = "Taiga - View task #{0}".format(snapshot.ref) %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<h1>Project: {{ project.name }}</h1>
<h2>Task #{{ snapshot.ref }}: {{ snapshot.subject }}</h2>
<p>Updated by <b>{{ changer.get_full_name() }}</b>.</p>
{% for entry in history_entries%}
{% if entry.comment %}
<p>Comment <b>{{ mdrender(project, entry.comment) }}</b></p>
{% endif %}
{% set changed_fields = entry.values_diff %}
{% if changed_fields %}
{% include "emails/includes/fields_diff-html.jinja" %}
{% endif %}
{% endfor %}
</td>
</tr>
</table>
{% endblock %}
{% block footer %}
<p style="padding: 10px; border-top: 1px solid #eee;">
More info at: <a href="{{ final_url }}" style="color: #666;">{{ final_url_name }}</a>
</p>
{% 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) %}
<h1>Task updated</h1>
<p>Hello {{ user }}, <br> {{ changer }} has updated a task on {{ project }}</p>
<p>Task #{{ ref }} {{ subject }}</p>
<a class="button" href="{{ url }}" title="See Task #{{ ref }}: {{ subject }} in Taiga">See task</a>
{% endtrans %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More