Merge branch 'master' into stable

Conflicts:
	requirements.txt
remotes/origin/enhancement/email-actions 1.0.0
Jesús Espino 2014-10-07 15:46:35 +02:00
commit d38a7a9e93
49 changed files with 898 additions and 262 deletions

View File

@ -1,6 +1,6 @@
language: python language: python
python: python:
- "3.3" - "3.4"
services: services:
- rabbitmq # will start rabbitmq-server - rabbitmq # will start rabbitmq-server
addons: addons:

10
CHANGELOG.md Normal file
View File

@ -0,0 +1,10 @@
<a name="1.0.0"></a>
# 1.0.0 taiga-back (2014-10-07)
### Misc
- Lots of small and not so small bugfixes
### Features
- New data exposed in the API for taskboard and backlog summaries
- Allow feedback for users from the platform
- Real time changes for backlog, taskboard, kanban and issues

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# Taiga Backend #
![Kaleidos Project](http://kaleidos.net/static/img/badge.png "Kaleidos Project")
[![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")
## Setup development environment ##
Just execute these commands in your virtualenv(wrapper):
```
pip install -r requirements.txt
python manage.py migrate --noinput
python manage.py loaddata initial_user
python manage.py loaddata initial_project_templates
python manage.py loaddata initial_role
python manage.py sample_data
```
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)
## Community ##
[Taiga has a mailing list](http://groups.google.com/d/forum/taigaio). Feel free to join it and ask any questions you may have.
To subscribe for announcements of releases, important changes and so on, please follow [@taigaio](https://twitter.com/taigaio) on Twitter.

View File

@ -1,42 +0,0 @@
Taiga Backend
=================
.. image:: http://kaleidos.net/static/img/badge.png
:target: http://kaleidos.net/community/taiga/
.. image:: https://travis-ci.org/taigaio/taiga-back.png?branch=master
:target: https://travis-ci.org/taigaio/taiga-back
.. image:: https://coveralls.io/repos/taigaio/taiga-back/badge.png?branch=master
:target: https://coveralls.io/r/taigaio/taiga-back?branch=master
Setup development environment
-----------------------------
Just execute these commands in your virtualenv(wrapper):
.. code-block:: console
pip install -r requirements.txt
python manage.py migrate --noinput
python manage.py loaddata initial_user
python manage.py loaddata initial_project_templates
python manage.py loaddata initial_role
python manage.py sample_data
Note: taiga only runs with python 3.3+.
Note: Initial auth data: admin/123123
Polyfills
---------
Django-Rest Framework by default returns 403 for not authenticated requests and permission denied
requests. The file ``taiga/base/monkey.py`` contains a temporary fix for this bug.
This patch is applied when the module ``base.models`` it's loaded. Once it's solved on django rest
framework, this patch can be removed.

View File

@ -7,6 +7,7 @@ psycopg2==2.5.4
pillow==2.5.3 pillow==2.5.3
pytz==2014.4 pytz==2014.4
six==1.8.0 six==1.8.0
amqp==1.4.6
djmail==0.9 djmail==0.9
django-pgjson==0.2.0 django-pgjson==0.2.0
djorm-pgarray==1.0.4 djorm-pgarray==1.0.4
@ -25,3 +26,7 @@ enum34==1.0
easy-thumbnails==2.1 easy-thumbnails==2.1
celery==3.1.12 celery==3.1.12
redis==2.10.3 redis==2.10.3
Unidecode==0.04.16
# Comment it if you are using python >= 3.4
enum34==1.0

View File

@ -88,6 +88,8 @@ DJMAIL_TEMPLATE_EXTENSION = "jinja"
# Events backend # Events backend
EVENTS_PUSH_BACKEND = "taiga.events.backends.postgresql.EventsPushBackend" EVENTS_PUSH_BACKEND = "taiga.events.backends.postgresql.EventsPushBackend"
# EVENTS_PUSH_BACKEND = "taiga.events.backends.rabbitmq.EventsPushBackend"
# EVENTS_PUSH_BACKEND_OPTIONS = {"url": "//guest:guest@127.0.0.1/"}
# Message System # Message System
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage" MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
@ -99,8 +101,8 @@ MEDIA_URL = "http://localhost:8000/media/"
# Static url is not widelly used by taiga (only # Static url is not widelly used by taiga (only
# if admin is activated). # if admin is activated).
STATIC_URL = "/static/" STATIC_URL = "http://localhost:8000/static/"
ADMIN_MEDIA_PREFIX = "/static/admin/" ADMIN_MEDIA_PREFIX = "http://localhost:8000/static/admin/"
# Static configuration. # Static configuration.
MEDIA_ROOT = os.path.join(BASE_DIR, "media") MEDIA_ROOT = os.path.join(BASE_DIR, "media")
@ -191,6 +193,7 @@ INSTALLED_APPS = [
"taiga.timeline", "taiga.timeline",
"taiga.mdrender", "taiga.mdrender",
"taiga.export_import", "taiga.export_import",
"taiga.feedback",
"rest_framework", "rest_framework",
"djmail", "djmail",
@ -250,12 +253,7 @@ LOGGING = {
"handlers": ["console"], "handlers": ["console"],
"level": "DEBUG", "level": "DEBUG",
"propagate": False, "propagate": False,
}, }
"taiga.domains": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
} }
} }
@ -274,7 +272,6 @@ AUTHENTICATION_BACKENDS = (
) )
ANONYMOUS_USER_ID = -1 ANONYMOUS_USER_ID = -1
GRAPPELLI_INDEX_DASHBOARD = "taiga.dashboard.CustomIndexDashboard"
MAX_SEARCH_RESULTS = 100 MAX_SEARCH_RESULTS = 100
@ -309,8 +306,6 @@ SOUTH_MIGRATION_MODULES = {
DEFAULT_AVATAR_SIZE = 80 # 80x80 pixels DEFAULT_AVATAR_SIZE = 80 # 80x80 pixels
DEFAULT_BIG_AVATAR_SIZE = 300 # 300x300 pixels DEFAULT_BIG_AVATAR_SIZE = 300 # 300x300 pixels
DEFAULT_AVATAR_URL = ''
THUMBNAIL_ALIASES = { THUMBNAIL_ALIASES = {
'': { '': {
'avatar': {'size': (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), 'crop': True}, 'avatar': {'size': (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), 'crop': True},
@ -318,17 +313,9 @@ THUMBNAIL_ALIASES = {
}, },
} }
GRAVATAR_DEFAULT_OPTIONS = { # GRAVATAR_DEFAULT_AVATAR = "img/user-noimage.png"
'default': DEFAULT_AVATAR_URL, # default avatar to show if there's no gravatar image GRAVATAR_DEFAULT_AVATAR = ""
'size': DEFAULT_AVATAR_SIZE GRAVATAR_AVATAR_SIZE = DEFAULT_AVATAR_SIZE
}
try:
IN_DEVELOPMENT_SERVER = sys.argv[1] == 'runserver'
except IndexError:
IN_DEVELOPMENT_SERVER = False
ATTACHMENTS_TOKEN_SALT = "ATTACHMENTS_TOKEN_SALT"
TAGS_PREDEFINED_COLORS = ["#fce94f", "#edd400", "#c4a000", "#8ae234", TAGS_PREDEFINED_COLORS = ["#fce94f", "#edd400", "#c4a000", "#8ae234",
"#73d216", "#4e9a06", "#d3d7cf", "#fcaf3e", "#73d216", "#4e9a06", "#d3d7cf", "#fcaf3e",
@ -337,12 +324,15 @@ TAGS_PREDEFINED_COLORS = ["#fce94f", "#edd400", "#c4a000", "#8ae234",
"#5c3566", "#ef2929", "#cc0000", "#a40000", "#5c3566", "#ef2929", "#cc0000", "#a40000",
"#2e3436",] "#2e3436",]
# NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE # Feedback module settings
FEEDBACK_ENABLED = True
FEEDBACK_EMAIL = "support@taiga.io"
# NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE
TEST_RUNNER="django.test.runner.DiscoverRunner" TEST_RUNNER="django.test.runner.DiscoverRunner"
if "test" in sys.argv: if "test" in sys.argv:
print ("\033[1;91mNo django tests.\033[0m") print ("\033[1;91mNo django tests.\033[0m")
print ("Try: \033[1;33mpy.test\033[0m") print ("Try: \033[1;33mpy.test\033[0m")
sys.exit(0) sys.exit(0)

View File

@ -47,7 +47,7 @@ from .development import *
#EMAIL_HOST_PASSWORD = 'yourpassword' #EMAIL_HOST_PASSWORD = 'yourpassword'
#EMAIL_PORT = 587 #EMAIL_PORT = 587
# GITHUP SETTINGS # GITHUB SETTINGS
#GITHUB_URL = "https://github.com/" #GITHUB_URL = "https://github.com/"
#GITHUB_API_URL = "https://api.github.com/" #GITHUB_API_URL = "https://api.github.com/"
#GITHUB_API_CLIENT_ID = "yourgithubclientid" #GITHUB_API_CLIENT_ID = "yourgithubclientid"

View File

@ -18,8 +18,6 @@
import re import re
from functools import partial from functools import partial
import six
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -106,7 +104,7 @@ def _tags_filter(**filters_map):
else: else:
qs = model_or_qs qs = model_or_qs
for filter_name, filter_value in six.iteritems(filters): for filter_name, filter_value in filters.items():
try: try:
filter = get_filter(filter_name) or get_filter_matching(filter_name) filter = get_filter(filter_name) or get_filter_matching(filter_name)
except (LookupError, AttributeError): except (LookupError, AttributeError):

View File

@ -0,0 +1,17 @@
# 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/>.
default_app_config = "taiga.events.apps.EventsAppConfig"

39
taiga/events/apps.py Normal file
View File

@ -0,0 +1,39 @@
# 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 sys
from django.apps import AppConfig
from django.db.models import signals
from . import signal_handlers as handlers
def connect_events_signals():
signals.post_save.connect(handlers.on_save_any_model, dispatch_uid="events_change")
signals.post_delete.connect(handlers.on_delete_any_model, dispatch_uid="events_delete")
def disconnect_events_signals():
signals.post_save.disconnect(dispatch_uid="events_change")
signals.post_delete.disconnect(dispatch_uid="events_delete")
class EventsAppConfig(AppConfig):
name = "taiga.events"
verbose_name = "Events App Config"
def ready(self):
connect_events_signals()

View File

@ -21,7 +21,7 @@ from django.conf import settings
class BaseEventsPushBackend(object, metaclass=abc.ABCMeta): class BaseEventsPushBackend(object, metaclass=abc.ABCMeta):
@abc.abstractmethod @abc.abstractmethod
def emit_event(self, message:str, *, channel:str="events"): def emit_event(self, message:str, *, routing_key:str, channel:str="events"):
pass pass

View File

@ -20,7 +20,10 @@ from . import base
class EventsPushBackend(base.BaseEventsPushBackend): class EventsPushBackend(base.BaseEventsPushBackend):
@transaction.atomic @transaction.atomic
def emit_event(self, message:str, *, channel:str="events"): def emit_event(self, message:str, *, routing_key:str, channel:str="events"):
routing_key = routing_key.replace(".", "__")
channel = "{channel}_{routing_key}".format(channel=channel,
routing_key=routing_key)
sql = "NOTIFY {channel}, %s".format(channel=channel) sql = "NOTIFY {channel}, %s".format(channel=channel)
cursor = connection.cursor() cursor = connection.cursor()
cursor.execute(sql, [message]) cursor.execute(sql, [message])

View File

@ -0,0 +1,65 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# 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 json
import logging
from amqp import Connection as AmqpConnection
from amqp.basic_message import Message as AmqpMessage
from urllib.parse import urlparse
from . import base
log = logging.getLogger("tagia.events")
def _make_rabbitmq_connection(url):
parse_result = urlparse(url)
# Parse host & user/password
try:
(authdata, host) = parse_result.netloc.split("@")
except Exception as e:
raise RuntimeError("Invalid url") from e
try:
(user, password) = authdata.split(":")
except Exception:
(user, password) = ("guest", "guest")
vhost = parse_result.path
return AmqpConnection(host=host, userid=user,
password=password, virtual_host=vhost)
class EventsPushBackend(base.BaseEventsPushBackend):
def __init__(self, url):
self.url = url
def emit_event(self, message:str, *, routing_key:str, channel:str="events"):
connection = _make_rabbitmq_connection(self.url)
try:
rchannel = connection.channel()
message = AmqpMessage(message)
rchannel.exchange_declare(exchange=channel, type="topic", auto_delete=True)
rchannel.basic_publish(message, routing_key=routing_key, exchange=channel)
rchannel.close()
except Exception:
log.error("Unhandled exception", exc_info=True)
finally:
connection.close()

View File

@ -1,61 +0,0 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# 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 json
from django.contrib.contenttypes.models import ContentType
from . import backends
# The complete list of content types
# of allowed models for change events
watched_types = (
("userstories", "userstory"),
("issues", "issue"),
)
def _get_type_for_model(model_instance):
"""
Get content type tuple from model instance.
"""
ct = ContentType.objects.get_for_model(model_instance)
return (ct.app_label, ct.model)
def emit_change_event_for_model(model_instance, sessionid:str, *,
type:str="change", channel:str="events"):
"""
Emit change event for notify of model change to
all connected frontends.
"""
content_type = _get_type_for_model(model_instance)
assert hasattr(model_instance, "project_id")
assert content_type in watched_types
assert type in ("create", "change", "delete")
project_id = model_instance.project_id
routing_key = "project.{0}".format(project_id)
data = {"type": "model-changes",
"routing_key": routing_key,
"session_id": sessionid,
"data": {
"type": type,
"matches": ".".join(content_type),
"pk": model_instance.pk}}
backend = backends.get_events_backend()
return backend.emit_event(json.dumps(data), channel="events")

101
taiga/events/events.py Normal file
View File

@ -0,0 +1,101 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# 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 json
import collections
from django.contrib.contenttypes.models import ContentType
from taiga.base.utils import json
from . import middleware as mw
from . import backends
# The complete list of content types
# of allowed models for change events
watched_types = set([
"userstories.userstory",
"issues.issue",
"tasks.task",
"wiki.wiki_page",
"milestones.milestone",
])
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:
sessionid = mw.get_current_session_id()
data = {"session_id": sessionid,
"data": data}
backend = backends.get_events_backend()
return backend.emit_event(message=json.dumps(data),
routing_key=routing_key,
channel=channel)
def emit_event_for_model(obj, *, type:str="change", channel:str="events",
content_type:str=None, sessionid:str=None):
"""
Sends a model change event.
"""
assert type in set(["create", "change", "delete"])
assert hasattr(obj, "project_id")
if not content_type:
content_type = _get_type_for_model(obj)
projectid = getattr(obj, "project_id")
pk = getattr(obj, "pk", None)
app_name, model_name = content_type.split(".", 1)
routing_key = "changes.project.{0}.{1}".format(projectid, app_name)
data = {"type": type,
"matches": content_type,
"pk": pk}
return emit_event(routing_key=routing_key,
channel=channel,
sessionid=sessionid,
data=data)
def emit_event_for_ids(ids, content_type:str, projectid:int, *,
type:str="change", channel:str="events", sessionid:str=None):
assert type in set(["create", "change", "delete"])
assert isinstance(ids, collections.Iterable)
assert content_type, "content_type parameter is mandatory"
app_name, model_name = content_type.split(".", 1)
routing_key = "changes.project.{0}.{1}".format(projectid, app_name)
data = {"type": type,
"matches": content_type,
"pk": ids}
return emit_event(routing_key=routing_key,
channel=channel,
sessionid=sessionid,
data=data)

View File

@ -1,53 +0,0 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db.models import signals
from django.dispatch import receiver
from . import middleware as mw
from . import changes
@receiver(signals.post_save, dispatch_uid="events_dispatcher_on_change")
def on_save_any_model(sender, instance, created, **kwargs):
# Ignore any object that can not have project_id
content_type = changes._get_type_for_model(instance)
# Ignore changes on import
if getattr(instance, '_importing', False):
return
# Ignore any other changes
if content_type not in changes.watched_types:
return
sesionid = mw.get_current_session_id()
if created:
changes.emit_change_event_for_model(instance, sesionid, type="create")
else:
changes.emit_change_event_for_model(instance, sesionid, type="change")
@receiver(signals.post_delete, dispatch_uid="events_dispatcher_on_delete")
def on_delete_any_model(sender, instance, **kwargs):
# Ignore any object that can not have project_id
content_type = changes._get_type_for_model(instance)
# Ignore any other changes
if content_type not in changes.watched_types:
return
sesionid = mw.get_current_session_id()
changes.emit_change_event_for_model(instance, sesionid, type="delete")

View File

@ -0,0 +1,34 @@
from django.db.models import signals
from django.dispatch import receiver
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)
# Ignore any other events
if content_type not in events.watched_types:
return
sesionid = mw.get_current_session_id()
if created:
events.emit_event_for_model(instance, sessionid=sesionid, type="create")
else:
events.emit_event_for_model(instance, sessionid=sesionid, type="change")
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)
# Ignore any other changes
if content_type not in events.watched_types:
return
sesionid = mw.get_current_session_id()
events.emit_event_for_model(instance, sessionid=sesionid, type="delete")

View File

@ -0,0 +1,17 @@
# 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/>.
default_app_config = "taiga.feedback.apps.FeedbackAppConfig"

31
taiga/feedback/admin.py Normal file
View File

@ -0,0 +1,31 @@
# 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.contrib import admin
from . import models
class FeedbackEntryAdmin(admin.ModelAdmin):
list_display = ['created_date', 'full_name', 'email' ]
list_display_links = list_display
list_filter = ['created_date',]
date_hierarchy = "created_date"
ordering = ("-created_date", "id")
search_fields = ("full_name", "email", "id")
admin.site.register(models.FeedbackEntry, FeedbackEntryAdmin)

51
taiga/feedback/api.py Normal file
View File

@ -0,0 +1,51 @@
# 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 response
from taiga.base.api import viewsets
from . import permissions
from . import serializers
from . import services
import copy
class FeedbackViewSet(viewsets.ViewSet):
permission_classes = (permissions.FeedbackPermission,)
serializer_class = serializers.FeedbackEntrySerializer
def create(self, request, **kwargs):
self.check_permissions(request, "create", None)
data = copy.deepcopy(request.DATA)
data.update({"full_name": request.user.get_full_name(),
"email": request.user.email})
serializer = self.serializer_class(data=data)
if not serializer.is_valid():
return response.BadRequest(serializer.errors)
self.object = serializer.save(force_insert=True)
extra = {
"HTTP_HOST": request.META.get("HTTP_HOST", None),
"HTTP_REFERER": request.META.get("HTTP_REFERER", None),
"HTTP_USER_AGENT": request.META.get("HTTP_USER_AGENT", None),
}
services.send_feedback(self.object, extra)
return response.Ok(serializer.data)

32
taiga/feedback/apps.py Normal file
View File

@ -0,0 +1,32 @@
# 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.apps import AppConfig
from django.apps import apps
from django.conf import settings
from django.conf.urls import include, url
from .routers import router
class FeedbackAppConfig(AppConfig):
name = "taiga.feedback"
verbose_name = "Feedback"
def ready(self):
if settings.FEEDBACK_ENABLED:
from taiga.urls import urlpatterns
urlpatterns.append(url(r'^api/v1/', include(router.urls)))

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
]
operations = [
migrations.CreateModel(
name='FeedbackEntry',
fields=[
('id', models.AutoField(primary_key=True, auto_created=True, verbose_name='ID', serialize=False)),
('full_name', models.CharField(verbose_name='full name', max_length=256)),
('email', models.EmailField(verbose_name='email address', max_length=255)),
('comment', models.TextField(verbose_name='comment')),
('created_date', models.DateTimeField(auto_now_add=True, verbose_name='created date')),
],
options={
'verbose_name': 'feedback entry',
'verbose_name_plural': 'feedback entries',
'ordering': ['-created_date', 'id'],
},
bases=(models.Model,),
),
]

View File

34
taiga/feedback/models.py Normal file
View File

@ -0,0 +1,34 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db import models
from django.utils.translation import ugettext_lazy as _
class FeedbackEntry(models.Model):
full_name = models.CharField(null=False, blank=False, max_length=256,
verbose_name=_('full name'))
email = models.EmailField(null=False, blank=False, max_length=255,
verbose_name=_('email address'))
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 = "feedback entry"
verbose_name_plural = "feedback entries"
ordering = ["-created_date", "id"]

View File

@ -0,0 +1,23 @@
# 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.api.permissions import TaigaResourcePermission
from taiga.base.api.permissions import IsAuthenticated
class FeedbackPermission(TaigaResourcePermission):
create_perms = IsAuthenticated()

22
taiga/feedback/routers.py Normal file
View File

@ -0,0 +1,22 @@
# 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
from . import api
router = routers.DefaultRouter(trailing_slash=False)
router.register(r"feedback", api.FeedbackViewSet, base_name="feedback")

View File

@ -0,0 +1,24 @@
# 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 rest_framework import serializers
from . import models
class FeedbackEntrySerializer(serializers.ModelSerializer):
class Meta:
model = models.FeedbackEntry

View File

@ -0,0 +1,29 @@
# 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.conf import settings
from djmail.template_mail import MagicMailBuilder
def send_feedback(feedback_entry, extra):
support_email = settings.FEEDBACK_EMAIL
if support_email:
mbuilder = MagicMailBuilder()
email = mbuilder.feedback_notification(support_email, {"feedback_entry": feedback_entry,
"extra": extra})
email.send()

View File

@ -0,0 +1,37 @@
{% extends "emails/base.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>
{% if extra %}
<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;">
<dl>
{% for k, v in extra.items() %}
<dt>{{ k }}</dt>
<dd>{{ v }}</dd>
{% endfor %}
</dl>
</td>
</tr
{% endif %}>
</table>
{% endblock %}

View File

@ -0,0 +1,11 @@
---------
- From: {{ feedback_entry.full_name }} [{{ feedback_entry.email }}]
---------
- Comment:
{{ feedback_entry.comment }}
---------{% if extra %}
- Extra:
{% for k, v in extra.items() %}
- {{ k }}: {{ v }}
{% endfor %}
{% endif %}----------

View File

@ -0,0 +1 @@
[Taiga] Feedback from {{ feedback_entry.full_name }} <{{ feedback_entry.email }}>

View File

@ -111,6 +111,15 @@ class HistoryEntry(models.Model):
if description_diff: if description_diff:
key = "description_diff" key = "description_diff"
value = (None, description_diff) value = (None, description_diff)
elif key == "content":
content_diff = get_diff_of_htmls(
self.diff[key][0],
self.diff[key][1]
)
if content_diff:
key = "content_diff"
value = (None, content_diff)
elif key in users_keys: elif key in users_keys:
value = [resolve_value("users", x) for x in self.diff[key]] value = [resolve_value("users", x) for x in self.diff[key]]
elif key == "watchers": elif key == "watchers":

View File

@ -1,6 +1,8 @@
{% set excluded_fields = [ {% set excluded_fields = [
"description", "description",
"description_html" "description_html",
"content",
"content_html"
] %} ] %}
<dl> <dl>
@ -94,7 +96,12 @@
{# DESCRIPTIONS #} {# DESCRIPTIONS #}
{% elif field_name in ["description_diff"] %} {% elif field_name in ["description_diff"] %}
<dd style="background: #eee; padding: 5px 15px; color: #444"> <dd style="background: #eee; padding: 5px 15px; color: #444">
<b>to:</b> <i>{{ mdrender(object.project, values.1) }}</i> <b>diff:</b> <i>{{ mdrender(object.project, values.1) }}</i>
</dd>
{# CONTENT #}
{% elif field_name in ["content_diff"] %}
<dd style="background: #eee; padding: 5px 15px; color: #444">
<b>diff:</b> <i>{{ mdrender(object.project, values.1) }}</i>
</dd> </dd>
{# ASSIGNED TO #} {# ASSIGNED TO #}
{% elif field_name == "assigned_to" %} {% elif field_name == "assigned_to" %}

View File

@ -1,6 +1,8 @@
{% set excluded_fields = [ {% set excluded_fields = [
"description_diff", "description_diff",
"description_html" "description_html",
"content_diff",
"content_html"
] %} ] %}
{% for field_name, values in changed_fields.items() %} {% for field_name, values in changed_fields.items() %}
{% if field_name not in excluded_fields %} {% if field_name not in excluded_fields %}

View File

@ -23,6 +23,7 @@ register = library.Library()
EXTRA_FIELD_VERBOSE_NAMES = { EXTRA_FIELD_VERBOSE_NAMES = {
"description_diff": _("description"), "description_diff": _("description"),
"content_diff": _("content")
} }

View File

@ -14,11 +14,13 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import random
import datetime
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from django.utils.timezone import now from django.utils.timezone import now
from django.conf import settings from django.conf import settings
from django.contrib.webdesign import lorem_ipsum from django.contrib.webdesign import lorem_ipsum
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -34,9 +36,8 @@ from taiga.projects.wiki.models import *
from taiga.projects.attachments.models import * from taiga.projects.attachments.models import *
from taiga.projects.history.services import take_snapshot from taiga.projects.history.services import take_snapshot
from taiga.events.apps import disconnect_events_signals
import random
import datetime
ATTACHMENT_SAMPLE_DATA = [ ATTACHMENT_SAMPLE_DATA = [
"taiga/projects/management/commands/sample_data", "taiga/projects/management/commands/sample_data",
@ -102,6 +103,9 @@ class Command(BaseCommand):
@transaction.atomic @transaction.atomic
def handle(self, *args, **options): def handle(self, *args, **options):
# Prevent events emission when sample data is running
disconnect_events_signals()
self.users = [User.objects.get(is_superuser=True)] self.users = [User.objects.get(is_superuser=True)]
# create users # create users
@ -190,19 +194,19 @@ class Command(BaseCommand):
project.save() project.save()
def create_attachment(self, object, order): def create_attachment(self, obj, order):
attachment = Attachment.objects.create(project=object.project, attached_file = self.sd.file_from_directory(*ATTACHMENT_SAMPLE_DATA)
content_type=ContentType.objects.get_for_model(object.__class__), membership = self.sd.db_object_from_queryset(obj.project.memberships
content_object=object, .filter(user__isnull=False))
attachment = Attachment.objects.create(project=obj.project,
name=path.basename(attached_file.name).lower(),
size=attached_file.size,
content_object=obj,
order=order, order=order,
owner=membership.user,
is_deprecated=self.sd.boolean(), is_deprecated=self.sd.boolean(),
description=self.sd.words(3, 12), description=self.sd.words(3, 12),
object_id=object.id, attached_file=attached_file)
owner=self.sd.db_object_from_queryset(
object.project.memberships.filter(user__isnull=False)).user,
attached_file=self.sd.file_from_directory(
*ATTACHMENT_SAMPLE_DATA))
return attachment return attachment
def create_wiki(self, project, slug): def create_wiki(self, project, slug):

View File

@ -192,16 +192,23 @@ def get_stats_for_project_issues(project):
def get_stats_for_project(project): def get_stats_for_project(project):
closed_points = sum(project.closed_points.values())
closed_milestones = project.milestones.filter(closed=True).count()
speed = 0
if closed_milestones != 0:
speed = closed_points / closed_milestones
project_stats = { project_stats = {
'name': project.name, 'name': project.name,
'total_milestones': project.total_milestones, 'total_milestones': project.total_milestones,
'total_points': project.total_story_points, 'total_points': project.total_story_points,
'closed_points': sum(project.closed_points.values()), 'closed_points': closed_points,
'closed_points_per_role': project.closed_points, 'closed_points_per_role': project.closed_points,
'defined_points': sum(project.defined_points.values()), 'defined_points': sum(project.defined_points.values()),
'defined_points_per_role': project.defined_points, 'defined_points_per_role': project.defined_points,
'assigned_points': sum(project.assigned_points.values()), 'assigned_points': sum(project.assigned_points.values()),
'assigned_points_per_role': project.assigned_points, 'assigned_points_per_role': project.assigned_points,
'milestones': _get_milestones_stats_for_backlog(project) 'milestones': _get_milestones_stats_for_backlog(project),
'speed': speed,
} }
return project_stats return project_stats

View File

@ -87,7 +87,9 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
project = get_object_or_404(Project, pk=data["project_id"]) project = get_object_or_404(Project, pk=data["project_id"])
self.check_permissions(request, "bulk_update_order", project) self.check_permissions(request, "bulk_update_order", project)
services.update_userstories_order_in_bulk(data["bulk_stories"], field="backlog_order") services.update_userstories_order_in_bulk(data["bulk_stories"],
project=project,
field="backlog_order")
services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user)
return response.NoContent() return response.NoContent()
@ -102,7 +104,9 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
project = get_object_or_404(Project, pk=data["project_id"]) project = get_object_or_404(Project, pk=data["project_id"])
self.check_permissions(request, "bulk_update_order", project) self.check_permissions(request, "bulk_update_order", project)
services.update_userstories_order_in_bulk(data["bulk_stories"], field="sprint_order") services.update_userstories_order_in_bulk(data["bulk_stories"],
project=project,
field="sprint_order")
services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user)
return response.NoContent() return response.NoContent()
@ -116,7 +120,9 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
project = get_object_or_404(Project, pk=data["project_id"]) project = get_object_or_404(Project, pk=data["project_id"])
self.check_permissions(request, "bulk_update_order", project) self.check_permissions(request, "bulk_update_order", project)
services.update_userstories_order_in_bulk(data["bulk_stories"], field="kanban_order") services.update_userstories_order_in_bulk(data["bulk_stories"],
project=project,
field="kanban_order")
services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user)
return response.NoContent() return response.NoContent()

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('userstories', '0003_userstory_order_fields'),
]
operations = [
migrations.AlterModelOptions(
name='rolepoints',
options={'verbose_name': 'role points', 'verbose_name_plural': 'role points', 'ordering': ['user_story', 'role']},
),
migrations.AlterModelOptions(
name='userstory',
options={'verbose_name': 'user story', 'verbose_name_plural': 'user stories', 'ordering': ['project', 'backlog_order', 'ref']},
),
]

View File

@ -42,9 +42,6 @@ class RolePoints(models.Model):
verbose_name_plural = "role points" verbose_name_plural = "role points"
unique_together = ("user_story", "role") unique_together = ("user_story", "role")
ordering = ["user_story", "role"] ordering = ["user_story", "role"]
permissions = (
("view_rolepoints", "Can view role points"),
)
def __str__(self): def __str__(self):
return "{}: {}".format(self.role.name, self.points.name) return "{}: {}".format(self.role.name, self.points.name)
@ -105,10 +102,6 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod
verbose_name = "user story" verbose_name = "user story"
verbose_name_plural = "user stories" verbose_name_plural = "user stories"
ordering = ["project", "backlog_order", "ref"] ordering = ["project", "backlog_order", "ref"]
#unique_together = ("ref", "project")
permissions = (
("view_userstory", "Can view user story"),
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self._importing or not self.modified_date: if not self._importing or not self.modified_date:

View File

@ -18,6 +18,7 @@ from django.utils import timezone
from taiga.base.utils import db, text from taiga.base.utils import db, text
from taiga.projects.history.services import take_snapshot from taiga.projects.history.services import take_snapshot
from taiga.events import events
from . import models from . import models
@ -48,7 +49,7 @@ def create_userstories_in_bulk(bulk_data, callback=None, precall=None, **additio
return userstories return userstories
def update_userstories_order_in_bulk(bulk_data:list, field:str): def update_userstories_order_in_bulk(bulk_data:list, field:str, project:object):
""" """
Update the order of some user stories. Update the order of some user stories.
`bulk_data` should be a list of tuples with the following format: `bulk_data` should be a list of tuples with the following format:
@ -61,6 +62,10 @@ def update_userstories_order_in_bulk(bulk_data:list, field:str):
user_story_ids.append(us_data["us_id"]) user_story_ids.append(us_data["us_id"])
new_order_values.append({field: us_data["order"]}) new_order_values.append({field: us_data["order"]})
events.emit_event_for_ids(ids=user_story_ids,
content_type="userstories.userstory",
projectid=project.pk)
db.update_in_bulk_with_ids(user_story_ids, new_order_values, model=models.UserStory) db.update_in_bulk_with_ids(user_story_ids, new_order_values, model=models.UserStory)

View File

@ -128,4 +128,11 @@ router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links")
# Notify policies # Notify policies
from taiga.projects.notifications.api import NotifyPolicyViewSet from taiga.projects.notifications.api import NotifyPolicyViewSet
router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications") router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications")
# feedback
# - see taiga.feedback.routers and taiga.feedback.apps

View File

@ -27,23 +27,21 @@ urlpatterns = [
url(r'^admin/', include(admin.site.urls)), url(r'^admin/', include(admin.site.urls)),
] ]
def mediafiles_urlpatterns(prefix):
def mediafiles_urlpatterns():
""" """
Method for serve media files with runserver. Method for serve media files with runserver.
""" """
import re
_media_url = settings.MEDIA_URL
if _media_url.startswith('/'):
_media_url = _media_url[1:]
from django.views.static import serve from django.views.static import serve
return [ return [
url(r'^%s(?P<path>.*)$' % 'media', serve, url(r'^%s(?P<path>.*)$' % re.escape(prefix.lstrip('/')), serve,
{'document_root': settings.MEDIA_ROOT}) {'document_root': settings.MEDIA_ROOT})
] ]
if settings.DEBUG:
# Hardcoded only for development server
urlpatterns += staticfiles_urlpatterns(prefix="/static/")
urlpatterns += mediafiles_urlpatterns(prefix="/media/")
urlpatterns += staticfiles_urlpatterns(prefix="/static/")
urlpatterns += mediafiles_urlpatterns()
handler500 = "taiga.base.api.views.api_server_error" handler500 = "taiga.base.api.views.api_server_error"

View File

@ -16,12 +16,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import hashlib import hashlib
import copy
from urllib.parse import urlencode from urllib.parse import urlencode
from django.conf import settings from django.conf import settings
from django.templatetags.static import static
from taiga.base.utils.urls import get_absolute_url
GRAVATAR_BASE_URL = "//www.gravatar.com/avatar/{}?{}" GRAVATAR_BASE_URL = "//www.gravatar.com/avatar/{}?{}"
@ -32,16 +32,22 @@ def get_gravatar_url(email: str, **options) -> str:
:param options: Additional options to gravatar. :param options: Additional options to gravatar.
- `default` defines what image url to show if no gravatar exists - `default` defines what image url to show if no gravatar exists
- `size` defines the size of the avatar. - `size` defines the size of the avatar.
By default the `settings.GRAVATAR_DEFAULT_OPTIONS` are used.
:return: Gravatar url. :return: Gravatar url.
""" """
defaults = settings.GRAVATAR_DEFAULT_OPTIONS.copy()
default = defaults.get("default", None) params = copy.copy(options)
if default:
defaults["default"] = get_absolute_url(default) default_avatar = getattr(settings, "GRAVATAR_DEFAULT_AVATAR", None)
defaults.update(options) default_size = getattr(settings, "GRAVATAR_AVATAR_SIZE", None)
if default_avatar:
params["default"] = static(default)
if default_size:
params["size"] = default_size
email_hash = hashlib.md5(email.lower().encode()).hexdigest() email_hash = hashlib.md5(email.lower().encode()).hexdigest()
url = GRAVATAR_BASE_URL.format(email_hash, urlencode(defaults)) url = GRAVATAR_BASE_URL.format(email_hash, urlencode(params))
return url return url

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -0,0 +1,27 @@
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()
return m
def test_feedback_create(client, data):
url = reverse("feedback-list")
users = [None, data.user]
feedback_data = {"comment": "One feedback comment"}
feedback_data = json.dumps(feedback_data)
results = helper_test_http_method(client, 'post', url, feedback_data, users)
assert results == [401, 200]

View File

@ -0,0 +1,47 @@
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 user():
return f.UserFactory.create()
def test_create_feedback(client, user):
url = reverse("feedback-list")
feedback_data = {"comment": "One feedback comment"}
feedback_data = json.dumps(feedback_data)
client.login(user)
response = client.post(url, feedback_data, content_type="application/json")
assert response.status_code == 200
assert response.data.get("id", None)
assert response.data.get("created_date", None)
assert response.data.get("full_name", user.full_name)
assert response.data.get("email", user.email)
client.logout()
def test_create_feedback_without_comments(client, user):
url = reverse("feedback-list")
feedback_data = json.dumps({})
client.login(user)
response = client.post(url, feedback_data, content_type="application/json")
assert response.status_code == 400
assert response.data.get("comment", None)
client.logout()

View File

@ -40,7 +40,7 @@ def data():
return m return m
def test_us_without_tasks_open_close_us_status(data): def test_auto_close_us_when_change_us_status_to_closed_without_tasks(data):
assert data.user_story2.is_closed is False assert data.user_story2.is_closed is False
data.user_story2.status = data.us_closed_status data.user_story2.status = data.us_closed_status
data.user_story2.save() data.user_story2.save()
@ -52,7 +52,7 @@ def test_us_without_tasks_open_close_us_status(data):
assert data.user_story2.is_closed is False assert data.user_story2.is_closed is False
def test_us_with_tasks_open_close_us_status(data): def test_noop_when_change_us_status_to_closed_with_open_tasks(data):
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
data.user_story1.status = data.us_closed_status data.user_story1.status = data.us_closed_status
data.user_story1.save() data.user_story1.save()
@ -64,101 +64,100 @@ def test_us_with_tasks_open_close_us_status(data):
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
def test_us_on_task_delete_empty_close(data): def test_auto_close_us_with_closed_state_when_all_tasks_are_deleted(data):
data.user_story1.status = data.us_closed_status data.user_story1.status = data.us_closed_status
data.user_story1.save() data.user_story1.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
data.task3.delete() data.task3.delete()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
data.task2.delete() data.task2.delete()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
data.task1.delete() data.task1.delete()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
def test_us_on_task_delete_empty_open(data): def test_auto_open_us_with_open_status_when_all_tasks_are_deleted(data):
data.task1.status = data.task_closed_status data.task1.status = data.task_closed_status
data.task1.save() data.task1.save()
data.task2.status = data.task_closed_status data.task2.status = data.task_closed_status
data.task2.save() data.task2.save()
data.task3.status = data.task_closed_status data.task3.status = data.task_closed_status
data.task3.save() data.task3.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
data.task3.delete() data.task3.delete()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
data.task2.delete() data.task2.delete()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
data.task1.delete() data.task1.delete()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
def test_us_with_tasks_on_move_empty_open(data): def test_auto_open_us_with_open_status_when_all_task_are_moved_to_another_us(data):
data.task1.status = data.task_closed_status data.task1.status = data.task_closed_status
data.task1.save() data.task1.save()
data.task2.status = data.task_closed_status data.task2.status = data.task_closed_status
data.task2.save() data.task2.save()
data.task3.status = data.task_closed_status data.task3.status = data.task_closed_status
data.task3.save() data.task3.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
data.task3.user_story = data.user_story2 data.task3.user_story = data.user_story2
data.task3.save() data.task3.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
data.task2.user_story = data.user_story2 data.task2.user_story = data.user_story2
data.task2.save() data.task2.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
data.task1.user_story = data.user_story2 data.task1.user_story = data.user_story2
data.task1.save() data.task1.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
def test_us_with_tasks_on_move_empty_close(data): def test_auto_close_us_closed_status_when_all_tasks_are_moved_to_another_us(data):
data.user_story1.status = data.us_closed_status data.user_story1.status = data.us_closed_status
data.user_story1.save() data.user_story1.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
data.task3.user_story = data.user_story2 data.task3.user_story = data.user_story2
data.task3.save() data.task3.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
data.task2.user_story = data.user_story2 data.task2.user_story = data.user_story2
data.task2.save() data.task2.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
data.task1.user_story = data.user_story2 data.task1.user_story = data.user_story2
data.task1.save() data.task1.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
def test_us_close_last_tasks(data): def test_auto_close_us_when_tasks_are_gradually_reopened(data):
assert data.user_story1.is_closed is False
data.task3.status = data.task_closed_status
data.task3.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False
data.task2.status = data.task_closed_status
data.task2.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False
data.task1.status = data.task_closed_status
data.task1.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True
def test_us_reopen_tasks(data):
data.task1.status = data.task_closed_status data.task1.status = data.task_closed_status
data.task1.save() data.task1.save()
data.task2.status = data.task_closed_status data.task2.status = data.task_closed_status
@ -167,21 +166,28 @@ def test_us_reopen_tasks(data):
data.task3.save() data.task3.save()
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
data.task3.status = data.task_open_status data.task3.status = data.task_open_status
data.task3.save() data.task3.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
data.task2.status = data.task_open_status data.task2.status = data.task_open_status
data.task2.save() data.task2.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
data.task1.status = data.task_open_status data.task1.status = data.task_open_status
data.task1.save() data.task1.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
def test_us_delete_task_then_all_closed(data): def test_auto_close_us_after_open_task_is_deleted(data):
"""
User story should be in closed state after
delete the unique open task.
"""
data.task1.status = data.task_closed_status data.task1.status = data.task_closed_status
data.task1.save() data.task1.save()
data.task2.status = data.task_closed_status data.task2.status = data.task_closed_status
@ -230,17 +236,20 @@ def test_auto_close_us_when_all_tasks_are_changed_to_close_status(data):
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
def test_us_change_task_us_then_any_open(data): def test_auto_open_us_when_add_open_task(data):
data.task1.status = data.task_closed_status data.task1.status = data.task_closed_status
data.task1.save() data.task1.save()
data.task2.status = data.task_closed_status data.task2.status = data.task_closed_status
data.task2.save() data.task2.save()
data.task3.user_story = data.user_story2 data.task3.user_story = data.user_story2
data.task3.save() data.task3.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
data.task3.user_story = data.user_story1 data.task3.user_story = data.user_story1
data.task3.save() data.task3.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False
@ -252,11 +261,14 @@ def test_task_create(data):
data.task2.save() data.task2.save()
data.task3.status = data.task_closed_status data.task3.status = data.task_closed_status
data.task3.save() data.task3.save()
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
f.TaskFactory(user_story=data.user_story1, status=data.task_closed_status) f.TaskFactory(user_story=data.user_story1, status=data.task_closed_status)
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is True assert data.user_story1.is_closed is True
f.TaskFactory(user_story=data.user_story1, status=data.task_open_status) f.TaskFactory(user_story=data.user_story1, status=data.task_open_status)
data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk)
assert data.user_story1.is_closed is False assert data.user_story1.is_closed is False

View File

@ -36,8 +36,11 @@ User Story #2
def test_update_userstories_order_in_bulk(): def test_update_userstories_order_in_bulk():
data = [{"us_id": 1, "order": 1}, {"us_id": 2, "order": 2}] data = [{"us_id": 1, "order": 1}, {"us_id": 2, "order": 2}]
project = mock.Mock()
project.pk = 1
with mock.patch("taiga.projects.userstories.services.db") as db: with mock.patch("taiga.projects.userstories.services.db") as db:
services.update_userstories_order_in_bulk(data, "backlog_order") services.update_userstories_order_in_bulk(data, "backlog_order", project)
db.update_in_bulk_with_ids.assert_called_once_with([1, 2], [{"backlog_order": 1}, {"backlog_order": 2}], db.update_in_bulk_with_ids.assert_called_once_with([1, 2], [{"backlog_order": 1}, {"backlog_order": 2}],
model=models.UserStory) model=models.UserStory)