diff --git a/CHANGELOG.md b/CHANGELOG.md
index 108833e9..e206529d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog #
+## 3.1.0 ¿¿?? (¿¿??)
+
+### Features
+- Contact with the project: if the projects have this module enabled Taiga users can contact them.
## 3.0.0 Stellaria Borealis (2016-10-02)
diff --git a/settings/common.py b/settings/common.py
index 641d888f..747141ec 100644
--- a/settings/common.py
+++ b/settings/common.py
@@ -306,6 +306,7 @@ INSTALLED_APPS = [
"taiga.projects.tasks",
"taiga.projects.issues",
"taiga.projects.wiki",
+ "taiga.projects.contact",
"taiga.searches",
"taiga.timeline",
"taiga.mdrender",
diff --git a/taiga/base/templates/emails/base-body-html.jinja b/taiga/base/templates/emails/base-body-html.jinja
index ba857bb7..c566f25a 100644
--- a/taiga/base/templates/emails/base-body-html.jinja
+++ b/taiga/base/templates/emails/base-body-html.jinja
@@ -157,7 +157,6 @@
padding-bottom:20px;
padding-left:20px;
padding-top:20px;
- text-align:center;
}
/**
@@ -188,6 +187,13 @@
background: #aad400;
}
+ hr {
+ width: 90%;
+ margin: 0 auto;
+ border-bottom: 1px solid #e1e1e1;
+ border-top: 0;
+ }
+
.bodyContent img{
display:inline;
height:auto;
@@ -392,20 +398,29 @@
+
+
+
+
+
+
+
+ {% block body %}{% endblock %}
|
{% block social %}
- {{ _("Twitter") }}
- {{ _("GitHub") }}
- {{ _("Taiga.io") }}
+ {{ _("Twitter") }}
+ {{ _("GitHub") }}
+ {{ _("Taiga.io") }}
|
{% endblock %}
diff --git a/taiga/projects/contact/admin.py b/taiga/projects/contact/admin.py
new file mode 100644
index 00000000..89450c82
--- /dev/null
+++ b/taiga/projects/contact/admin.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from django.contrib import admin
+
+from . import models
+
+
+class ContactEntryAdmin(admin.ModelAdmin):
+ list_display = ["created_date", "project", "user"]
+ list_display_links = list_display
+ list_filter = ["created_date", ]
+ date_hierarchy = "created_date"
+ ordering = ("-created_date", "id")
+ search_fields = ("project__name", "project__slug", "user__username", "user__email", "user__full_name")
+
+admin.site.register(models.ContactEntry, ContactEntryAdmin)
diff --git a/taiga/projects/contact/api.py b/taiga/projects/contact/api.py
new file mode 100644
index 00000000..9f5c532a
--- /dev/null
+++ b/taiga/projects/contact/api.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from taiga.base import status
+from taiga.base.api.mixins import CreateModelMixin, BlockedByProjectMixin
+from taiga.base.api.viewsets import GenericViewSet
+
+from . import models
+from . import permissions
+from . import services
+from . import validators
+
+from django.conf import settings
+
+
+class ContactViewSet(BlockedByProjectMixin, CreateModelMixin, GenericViewSet):
+ permission_classes = (permissions.ContactPermission,)
+ validator_class = validators.ContactEntryValidator
+ model = models.ContactEntry
+
+ def create(self, *args, **kwargs):
+ response = super().create(*args, **kwargs)
+
+ if response.status_code == status.HTTP_201_CREATED:
+ if settings.CELERY_ENABLED:
+ services.send_contact_email.delay(self.object.id)
+ else:
+ services.send_contact_email(self.object.id)
+
+ return response
+
+ def pre_save(self, obj):
+ obj.user = self.request.user
+ super().pre_save(obj)
diff --git a/taiga/projects/contact/migrations/0001_initial.py b/taiga/projects/contact/migrations/0001_initial.py
new file mode 100644
index 00000000..f06eb96b
--- /dev/null
+++ b/taiga/projects/contact/migrations/0001_initial.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.2 on 2016-11-10 15:18
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('projects', '0056_auto_20161110_1518'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ContactEntry',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('comment', models.TextField(verbose_name='comment')),
+ ('created_date', models.DateTimeField(auto_now_add=True, verbose_name='created date')),
+ ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_entries', to='projects.Project', verbose_name='project')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_entries', to=settings.AUTH_USER_MODEL, verbose_name='user')),
+ ],
+ options={
+ 'verbose_name': 'contact entry',
+ 'ordering': ['-created_date', 'id'],
+ 'verbose_name_plural': 'contact entries',
+ },
+ ),
+ ]
diff --git a/taiga/projects/contact/migrations/__init__.py b/taiga/projects/contact/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/taiga/projects/contact/models.py b/taiga/projects/contact/models.py
new file mode 100644
index 00000000..c9a210cf
--- /dev/null
+++ b/taiga/projects/contact/models.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from django.conf import settings
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+
+class ContactEntry(models.Model):
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="contact_entries",
+ verbose_name=_("user"))
+
+ project = models.ForeignKey("projects.Project", null=False, blank=False,
+ related_name="contact_entries", verbose_name=_("project"))
+
+ comment = models.TextField(null=False, blank=False, verbose_name=_("comment"))
+
+ created_date = models.DateTimeField(null=False, blank=False, auto_now_add=True,
+ verbose_name=_("created date"))
+
+ class Meta:
+ verbose_name = "contact entry"
+ verbose_name_plural = "contact entries"
+ ordering = ["-created_date", "id"]
diff --git a/taiga/projects/contact/permissions.py b/taiga/projects/contact/permissions.py
new file mode 100644
index 00000000..d8111b1d
--- /dev/null
+++ b/taiga/projects/contact/permissions.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from taiga.base.api.permissions import PermissionComponent
+from taiga.base.api.permissions import TaigaResourcePermission
+
+
+class IsContactActivated(PermissionComponent):
+ def check_permissions(self, request, view, obj=None):
+ return request.user.is_authenticated() and obj.project.is_contact_activated
+
+
+class ContactPermission(TaigaResourcePermission):
+ create_perms = IsContactActivated()
diff --git a/taiga/projects/contact/services.py b/taiga/projects/contact/services.py
new file mode 100644
index 00000000..0fe6ed7e
--- /dev/null
+++ b/taiga/projects/contact/services.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from taiga.base.mails import mail_builder
+from taiga.celery import app
+from taiga.front.templatetags.functions import resolve as resolve_front_url
+from taiga.users.services import get_user_photo_url
+
+from . import models
+
+
+@app.task
+def send_contact_email(contact_entry_id):
+ contact_entry = models.ContactEntry.objects.filter(id=contact_entry_id).first()
+ if contact_entry is None:
+ return
+
+ ctx = {
+ "comment": contact_entry.comment,
+ "full_name": contact_entry.user.get_full_name(),
+ "project_name": contact_entry.project.name,
+ "photo_url": get_user_photo_url(contact_entry.user),
+ "user_profile_url": resolve_front_url("user", contact_entry.user.username),
+ "project_settings_url": resolve_front_url("project-admin", contact_entry.project.slug),
+ }
+ users = contact_entry.project.get_users().exclude(id=contact_entry.user_id)
+ addresses = ", ".join([u.email for u in users])
+ email = mail_builder.contact_notification(addresses, ctx)
+ email.extra_headers["Reply-To"] = ", ".join([contact_entry.user.email])
+ email.send()
diff --git a/taiga/projects/contact/templates/emails/contact_notification-body-html.jinja b/taiga/projects/contact/templates/emails/contact_notification-body-html.jinja
new file mode 100644
index 00000000..257a5905
--- /dev/null
+++ b/taiga/projects/contact/templates/emails/contact_notification-body-html.jinja
@@ -0,0 +1,24 @@
+{% extends "emails/base-body-html.jinja" %}
+
+{% block body %}
+
+ {% if photo_url %}
+
+
+
+ {% endif %}
+ {% trans full_name=full_name, comment=comment, project_name=project_name %}
+ {{ full_name }} has written to {{ project_name }}
+ {% endtrans %}
+
+
+ {{ comment }}
+
+
+
+
+ {% trans project_name=project_name %}
+ You are receiving this message because you are listed as administrator of the project titled {{ project_name }}. If you don't want members of the Taiga community contacting your project, please update your project settings to prevent such contacts. Regular communications amongst members of the project will not be affected.
+ {% endtrans %}
+
+{% endblock %}
diff --git a/taiga/projects/contact/templates/emails/contact_notification-body-text.jinja b/taiga/projects/contact/templates/emails/contact_notification-body-text.jinja
new file mode 100644
index 00000000..63392957
--- /dev/null
+++ b/taiga/projects/contact/templates/emails/contact_notification-body-text.jinja
@@ -0,0 +1,9 @@
+{% trans full_name=full_name, comment=comment, project_name=project_name %}
+{{ full_name }} has written to {{ project_name }}
+{% endtrans %}
+---------
+{{ comment }}
+---------
+{% trans project_name=project_name %}
+You are receiving this message because you are listed as administrator of the project titled {{ project_name }}. If you don't want members of the Taiga community contacting your project, please update your project settings
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from taiga.base.api import validators
+
+from . import models
+
+
+class ContactEntryValidator(validators.ModelValidator):
+
+ class Meta:
+ model = models.ContactEntry
+ read_only_fields = ("user", "created_date", )
diff --git a/taiga/projects/migrations/0056_auto_20161110_1518.py b/taiga/projects/migrations/0056_auto_20161110_1518.py
new file mode 100644
index 00000000..1c03c79c
--- /dev/null
+++ b/taiga/projects/migrations/0056_auto_20161110_1518.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.2 on 2016-11-10 15:18
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0055_json_to_jsonb'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='project',
+ name='is_contact_activated',
+ field=models.BooleanField(default=True, verbose_name='active contact'),
+ ),
+ migrations.AddField(
+ model_name='projecttemplate',
+ name='is_contact_activated',
+ field=models.BooleanField(default=True, verbose_name='active contact'),
+ ),
+ ]
diff --git a/taiga/projects/models.py b/taiga/projects/models.py
index 67f9e50f..46a8a694 100644
--- a/taiga/projects/models.py
+++ b/taiga/projects/models.py
@@ -164,7 +164,8 @@ class Project(ProjectDefaults, TaggedMixin, TagsColorsdMixin, models.Model):
total_milestones = models.IntegerField(null=True, blank=True,
verbose_name=_("total of milestones"))
total_story_points = models.FloatField(null=True, blank=True, verbose_name=_("total story points"))
-
+ is_contact_activated = models.BooleanField(default=True, null=False, blank=True,
+ verbose_name=_("active contact"))
is_epics_activated = models.BooleanField(default=False, null=False, blank=True,
verbose_name=_("active epics panel"))
is_backlog_activated = models.BooleanField(default=True, null=False, blank=True,
@@ -734,7 +735,8 @@ class ProjectTemplate(models.Model):
default_owner_role = models.CharField(max_length=50, null=False,
blank=False,
verbose_name=_("default owner's role"))
-
+ is_contact_activated = models.BooleanField(default=True, null=False, blank=True,
+ verbose_name=_("active contact"))
is_epics_activated = models.BooleanField(default=False, null=False, blank=True,
verbose_name=_("active epics panel"))
is_backlog_activated = models.BooleanField(default=True, null=False, blank=True,
@@ -780,6 +782,7 @@ class ProjectTemplate(models.Model):
super().save(*args, **kwargs)
def load_data_from_project(self, project):
+ self.is_contact_activated = project.is_contact_activated
self.is_epics_activated = project.is_epics_activated
self.is_backlog_activated = project.is_backlog_activated
self.is_kanban_activated = project.is_kanban_activated
@@ -896,6 +899,7 @@ class ProjectTemplate(models.Model):
raise Exception("Project need an id (must be a saved project)")
project.creation_template = self
+ project.is_contact_activated = self.is_contact_activated
project.is_epics_activated = self.is_epics_activated
project.is_backlog_activated = self.is_backlog_activated
project.is_kanban_activated = self.is_kanban_activated
diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py
index eb7b2e54..6bdb05e5 100644
--- a/taiga/projects/serializers.py
+++ b/taiga/projects/serializers.py
@@ -213,6 +213,7 @@ class ProjectSerializer(serializers.LightSerializer):
members = MethodField()
total_milestones = Field()
total_story_points = Field()
+ is_contact_activated = Field()
is_epics_activated = Field()
is_backlog_activated = Field()
is_kanban_activated = Field()
@@ -474,6 +475,7 @@ class ProjectTemplateSerializer(serializers.LightSerializer):
created_date = Field()
modified_date = Field()
default_owner_role = Field()
+ is_contact_activated = Field()
is_epics_activated = Field()
is_backlog_activated = Field()
is_kanban_activated = Field()
diff --git a/taiga/routers.py b/taiga/routers.py
index 92f2d58c..f59bea7e 100644
--- a/taiga/routers.py
+++ b/taiga/routers.py
@@ -223,6 +223,10 @@ router.register(r"history/task", TaskHistory, base_name="task-history")
router.register(r"history/issue", IssueHistory, base_name="issue-history")
router.register(r"history/wiki", WikiHistory, base_name="wiki-history")
+# Contact
+from taiga.projects.contact.api import ContactViewSet
+router.register(r"contact", ContactViewSet, base_name="contact")
+
# Timelines
from taiga.timeline.api import ProfileTimeline
diff --git a/tests/integration/resources_permissions/test_contact.py b/tests/integration/resources_permissions/test_contact.py
new file mode 100644
index 00000000..dca0081e
--- /dev/null
+++ b/tests/integration/resources_permissions/test_contact.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# Copyright (C) 2014-2016 Anler Hernández
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from django.core.urlresolvers import reverse
+
+from tests import factories as f
+from tests.utils import helper_test_http_method
+
+from taiga.base.utils import json
+
+import pytest
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture
+def data():
+ m = type("Models", (object,), {})
+ m.user = f.UserFactory.create()
+ m.project = f.ProjectFactory.create()
+ f.MembershipFactory(user=m.project.owner, project=m.project, is_admin=True)
+
+ return m
+
+
+def test_contact_create(client, data):
+ url = reverse("contact-list")
+ users = [None, data.user]
+
+ contact_data = json.dumps({
+ "project": data.project.id,
+ "comment": "Testing comment"
+ })
+ results = helper_test_http_method(client, 'post', url, contact_data, users)
+ assert results == [401, 201]
diff --git a/tests/integration/test_contact.py b/tests/integration/test_contact.py
new file mode 100644
index 00000000..4720ee37
--- /dev/null
+++ b/tests/integration/test_contact.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2014-2016 Andrey Antukh
+# Copyright (C) 2014-2016 Jesús Espino
+# Copyright (C) 2014-2016 David Barragán
+# Copyright (C) 2014-2016 Alejandro Alonso
+# Copyright (C) 2014-2016 Anler Hernández
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from django.core import mail
+from django.core.urlresolvers import reverse
+
+from tests import factories as f
+
+from taiga.base.utils import json
+
+import pytest
+pytestmark = pytest.mark.django_db
+
+
+def test_create_comment(client):
+ user = f.UserFactory.create()
+ project = f.ProjectFactory.create()
+ f.MembershipFactory(user=project.owner, project=project, is_admin=True)
+
+ url = reverse("contact-list")
+
+ contact_data = json.dumps({
+ "project": project.id,
+ "comment": "Testing comment"
+ })
+
+ client.login(user)
+
+ assert len(mail.outbox) == 0
+ response = client.post(url, contact_data, content_type="application/json")
+ assert response.status_code == 201
+ assert len(mail.outbox) == 1
+ assert mail.outbox[0].to == [project.owner.email]
+
+
+
+def test_create_comment_disabled(client):
+ user = f.UserFactory.create()
+ project = f.ProjectFactory.create()
+ project.is_contact_activated = False
+ project.save()
+ f.MembershipFactory(user=project.owner, project=project, is_admin=True)
+
+ url = reverse("contact-list")
+
+ contact_data = json.dumps({
+ "project": project.id,
+ "comment": "Testing comment"
+ })
+
+ client.login(user)
+
+ response = client.post(url, contact_data, content_type="application/json")
+ assert response.status_code == 403
|