diff --git a/.travis.yml b/.travis.yml index 9befd7f5..e9f47601 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: python python: - - "3.3" + - "3.4" services: - rabbitmq # will start rabbitmq-server addons: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..275e949b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ + +# 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 diff --git a/README.md b/README.md new file mode 100644 index 00000000..f99c51b7 --- /dev/null +++ b/README.md @@ -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. diff --git a/README.rst b/README.rst deleted file mode 100644 index b1f88fec..00000000 --- a/README.rst +++ /dev/null @@ -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. diff --git a/requirements.txt b/requirements.txt index 52ae0365..41e6ffa4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ psycopg2==2.5.4 pillow==2.5.3 pytz==2014.4 six==1.8.0 +amqp==1.4.6 djmail==0.9 django-pgjson==0.2.0 djorm-pgarray==1.0.4 @@ -25,3 +26,7 @@ enum34==1.0 easy-thumbnails==2.1 celery==3.1.12 redis==2.10.3 +Unidecode==0.04.16 + +# Comment it if you are using python >= 3.4 +enum34==1.0 diff --git a/settings/common.py b/settings/common.py index d3ac205e..d726741f 100644 --- a/settings/common.py +++ b/settings/common.py @@ -88,6 +88,8 @@ DJMAIL_TEMPLATE_EXTENSION = "jinja" # Events backend 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_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 # if admin is activated). -STATIC_URL = "/static/" -ADMIN_MEDIA_PREFIX = "/static/admin/" +STATIC_URL = "http://localhost:8000/static/" +ADMIN_MEDIA_PREFIX = "http://localhost:8000/static/admin/" # Static configuration. MEDIA_ROOT = os.path.join(BASE_DIR, "media") @@ -191,6 +193,7 @@ INSTALLED_APPS = [ "taiga.timeline", "taiga.mdrender", "taiga.export_import", + "taiga.feedback", "rest_framework", "djmail", @@ -250,12 +253,7 @@ LOGGING = { "handlers": ["console"], "level": "DEBUG", "propagate": False, - }, - "taiga.domains": { - "handlers": ["console"], - "level": "INFO", - "propagate": False, - }, + } } } @@ -274,7 +272,6 @@ AUTHENTICATION_BACKENDS = ( ) ANONYMOUS_USER_ID = -1 -GRAPPELLI_INDEX_DASHBOARD = "taiga.dashboard.CustomIndexDashboard" MAX_SEARCH_RESULTS = 100 @@ -309,8 +306,6 @@ SOUTH_MIGRATION_MODULES = { DEFAULT_AVATAR_SIZE = 80 # 80x80 pixels DEFAULT_BIG_AVATAR_SIZE = 300 # 300x300 pixels -DEFAULT_AVATAR_URL = '' - THUMBNAIL_ALIASES = { '': { 'avatar': {'size': (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), 'crop': True}, @@ -318,17 +313,9 @@ THUMBNAIL_ALIASES = { }, } -GRAVATAR_DEFAULT_OPTIONS = { - 'default': DEFAULT_AVATAR_URL, # default avatar to show if there's no gravatar image - 'size': DEFAULT_AVATAR_SIZE -} - -try: - IN_DEVELOPMENT_SERVER = sys.argv[1] == 'runserver' -except IndexError: - IN_DEVELOPMENT_SERVER = False - -ATTACHMENTS_TOKEN_SALT = "ATTACHMENTS_TOKEN_SALT" +# GRAVATAR_DEFAULT_AVATAR = "img/user-noimage.png" +GRAVATAR_DEFAULT_AVATAR = "" +GRAVATAR_AVATAR_SIZE = DEFAULT_AVATAR_SIZE TAGS_PREDEFINED_COLORS = ["#fce94f", "#edd400", "#c4a000", "#8ae234", "#73d216", "#4e9a06", "#d3d7cf", "#fcaf3e", @@ -337,12 +324,15 @@ TAGS_PREDEFINED_COLORS = ["#fce94f", "#edd400", "#c4a000", "#8ae234", "#5c3566", "#ef2929", "#cc0000", "#a40000", "#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" if "test" in sys.argv: print ("\033[1;91mNo django tests.\033[0m") print ("Try: \033[1;33mpy.test\033[0m") sys.exit(0) - diff --git a/settings/local.py.example b/settings/local.py.example index 9ba66b76..2588f9fd 100644 --- a/settings/local.py.example +++ b/settings/local.py.example @@ -47,7 +47,7 @@ from .development import * #EMAIL_HOST_PASSWORD = 'yourpassword' #EMAIL_PORT = 587 -# GITHUP SETTINGS +# GITHUB SETTINGS #GITHUB_URL = "https://github.com/" #GITHUB_API_URL = "https://api.github.com/" #GITHUB_API_CLIENT_ID = "yourgithubclientid" diff --git a/taiga/base/tags.py b/taiga/base/tags.py index b2dcc15f..8a02ab9e 100644 --- a/taiga/base/tags.py +++ b/taiga/base/tags.py @@ -18,8 +18,6 @@ import re from functools import partial -import six - from django.db import models from django.utils.translation import ugettext_lazy as _ @@ -106,7 +104,7 @@ def _tags_filter(**filters_map): else: qs = model_or_qs - for filter_name, filter_value in six.iteritems(filters): + for filter_name, filter_value in filters.items(): try: filter = get_filter(filter_name) or get_filter_matching(filter_name) except (LookupError, AttributeError): diff --git a/taiga/events/__init__.py b/taiga/events/__init__.py index e69de29b..bc6d8fa2 100644 --- a/taiga/events/__init__.py +++ b/taiga/events/__init__.py @@ -0,0 +1,17 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +default_app_config = "taiga.events.apps.EventsAppConfig" diff --git a/taiga/events/apps.py b/taiga/events/apps.py new file mode 100644 index 00000000..40b51834 --- /dev/null +++ b/taiga/events/apps.py @@ -0,0 +1,39 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import 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() diff --git a/taiga/events/backends/base.py b/taiga/events/backends/base.py index 58f7a1a7..4eefcb55 100644 --- a/taiga/events/backends/base.py +++ b/taiga/events/backends/base.py @@ -21,7 +21,7 @@ from django.conf import settings class BaseEventsPushBackend(object, metaclass=abc.ABCMeta): @abc.abstractmethod - def emit_event(self, message:str, *, channel:str="events"): + def emit_event(self, message:str, *, routing_key:str, channel:str="events"): pass diff --git a/taiga/events/backends/postgresql.py b/taiga/events/backends/postgresql.py index 90750465..696a0813 100644 --- a/taiga/events/backends/postgresql.py +++ b/taiga/events/backends/postgresql.py @@ -20,7 +20,10 @@ from . import base class EventsPushBackend(base.BaseEventsPushBackend): @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) cursor = connection.cursor() cursor.execute(sql, [message]) diff --git a/taiga/events/backends/rabbitmq.py b/taiga/events/backends/rabbitmq.py new file mode 100644 index 00000000..a745a196 --- /dev/null +++ b/taiga/events/backends/rabbitmq.py @@ -0,0 +1,65 @@ +# Copyright (C) 2014 Andrey Antukh +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +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() diff --git a/taiga/events/changes.py b/taiga/events/changes.py deleted file mode 100644 index fc15c8d3..00000000 --- a/taiga/events/changes.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (C) 2014 Andrey Antukh -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import json - -from django.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") - diff --git a/taiga/events/events.py b/taiga/events/events.py new file mode 100644 index 00000000..f1d053af --- /dev/null +++ b/taiga/events/events.py @@ -0,0 +1,101 @@ +# Copyright (C) 2014 Andrey Antukh +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +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) diff --git a/taiga/events/models.py b/taiga/events/models.py deleted file mode 100644 index 6958276f..00000000 --- a/taiga/events/models.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (C) 2014 Andrey Antukh -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from django.db.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") diff --git a/taiga/events/signal_handlers.py b/taiga/events/signal_handlers.py new file mode 100644 index 00000000..c2841a76 --- /dev/null +++ b/taiga/events/signal_handlers.py @@ -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") diff --git a/taiga/feedback/__init__.py b/taiga/feedback/__init__.py new file mode 100644 index 00000000..17e45261 --- /dev/null +++ b/taiga/feedback/__init__.py @@ -0,0 +1,17 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +default_app_config = "taiga.feedback.apps.FeedbackAppConfig" diff --git a/taiga/feedback/admin.py b/taiga/feedback/admin.py new file mode 100644 index 00000000..512abb16 --- /dev/null +++ b/taiga/feedback/admin.py @@ -0,0 +1,31 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.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) diff --git a/taiga/feedback/api.py b/taiga/feedback/api.py new file mode 100644 index 00000000..8476c365 --- /dev/null +++ b/taiga/feedback/api.py @@ -0,0 +1,51 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base import 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) diff --git a/taiga/feedback/apps.py b/taiga/feedback/apps.py new file mode 100644 index 00000000..7ae2c1af --- /dev/null +++ b/taiga/feedback/apps.py @@ -0,0 +1,32 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.apps import AppConfig +from django.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))) diff --git a/taiga/feedback/migrations/0001_initial.py b/taiga/feedback/migrations/0001_initial.py new file mode 100644 index 00000000..118638c5 --- /dev/null +++ b/taiga/feedback/migrations/0001_initial.py @@ -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,), + ), + ] diff --git a/taiga/feedback/migrations/__init__.py b/taiga/feedback/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/feedback/models.py b/taiga/feedback/models.py new file mode 100644 index 00000000..a56de2b9 --- /dev/null +++ b/taiga/feedback/models.py @@ -0,0 +1,34 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +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"] diff --git a/taiga/feedback/permissions.py b/taiga/feedback/permissions.py new file mode 100644 index 00000000..6b755975 --- /dev/null +++ b/taiga/feedback/permissions.py @@ -0,0 +1,23 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from taiga.base.api.permissions import TaigaResourcePermission +from taiga.base.api.permissions import IsAuthenticated + + +class FeedbackPermission(TaigaResourcePermission): + create_perms = IsAuthenticated() diff --git a/taiga/feedback/routers.py b/taiga/feedback/routers.py new file mode 100644 index 00000000..a3486b52 --- /dev/null +++ b/taiga/feedback/routers.py @@ -0,0 +1,22 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base import routers +from . import api + + +router = routers.DefaultRouter(trailing_slash=False) +router.register(r"feedback", api.FeedbackViewSet, base_name="feedback") diff --git a/taiga/feedback/serializers.py b/taiga/feedback/serializers.py new file mode 100644 index 00000000..f04d5b3e --- /dev/null +++ b/taiga/feedback/serializers.py @@ -0,0 +1,24 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from rest_framework import serializers + +from . import models + + +class FeedbackEntrySerializer(serializers.ModelSerializer): + class Meta: + model = models.FeedbackEntry diff --git a/taiga/feedback/services.py b/taiga/feedback/services.py new file mode 100644 index 00000000..10362208 --- /dev/null +++ b/taiga/feedback/services.py @@ -0,0 +1,29 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.conf import settings + +from 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() diff --git a/taiga/feedback/templates/emails/feedback_notification-body-html.jinja b/taiga/feedback/templates/emails/feedback_notification-body-html.jinja new file mode 100644 index 00000000..2888f56f --- /dev/null +++ b/taiga/feedback/templates/emails/feedback_notification-body-html.jinja @@ -0,0 +1,37 @@ +{% extends "emails/base.jinja" %} + +{% block body %} + + + + + + + + + + {% if extra %} + + + + +
+ From: + + {{ feedback_entry.full_name }} [{{ feedback_entry.email }}] +
+ Comment: + + {{ feedback_entry.comment|linebreaks }} +
+ Extra: + +
+ {% for k, v in extra.items() %} +
{{ k }}
+
{{ v }}
+ {% endfor %} +
+
+{% endblock %} diff --git a/taiga/feedback/templates/emails/feedback_notification-body-text.jinja b/taiga/feedback/templates/emails/feedback_notification-body-text.jinja new file mode 100644 index 00000000..fd23785b --- /dev/null +++ b/taiga/feedback/templates/emails/feedback_notification-body-text.jinja @@ -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 %}---------- diff --git a/taiga/feedback/templates/emails/feedback_notification-subject.jinja b/taiga/feedback/templates/emails/feedback_notification-subject.jinja new file mode 100644 index 00000000..8f0f4b9c --- /dev/null +++ b/taiga/feedback/templates/emails/feedback_notification-subject.jinja @@ -0,0 +1 @@ +[Taiga] Feedback from {{ feedback_entry.full_name }} <{{ feedback_entry.email }}> diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py index 505a6730..9fa3deb5 100644 --- a/taiga/projects/history/models.py +++ b/taiga/projects/history/models.py @@ -111,6 +111,15 @@ class HistoryEntry(models.Model): if description_diff: key = "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: value = [resolve_value("users", x) for x in self.diff[key]] elif key == "watchers": diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja index be864da3..ebd2cce4 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja @@ -1,6 +1,8 @@ {% set excluded_fields = [ "description", - "description_html" + "description_html", + "content", + "content_html" ] %}
@@ -94,7 +96,12 @@ {# DESCRIPTIONS #} {% elif field_name in ["description_diff"] %}
- to: {{ mdrender(object.project, values.1) }} + diff: {{ mdrender(object.project, values.1) }} +
+ {# CONTENT #} + {% elif field_name in ["content_diff"] %} +
+ diff: {{ mdrender(object.project, values.1) }}
{# ASSIGNED TO #} {% elif field_name == "assigned_to" %} diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja index c87e318a..71e6dbbe 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja @@ -1,6 +1,8 @@ {% set excluded_fields = [ "description_diff", - "description_html" + "description_html", + "content_diff", + "content_html" ] %} {% for field_name, values in changed_fields.items() %} {% if field_name not in excluded_fields %} diff --git a/taiga/projects/history/templatetags/functions.py b/taiga/projects/history/templatetags/functions.py index 07e7dcd3..c66ef67f 100644 --- a/taiga/projects/history/templatetags/functions.py +++ b/taiga/projects/history/templatetags/functions.py @@ -23,6 +23,7 @@ register = library.Library() EXTRA_FIELD_VERBOSE_NAMES = { "description_diff": _("description"), + "content_diff": _("content") } diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index b9288034..bc798a9b 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -14,11 +14,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import random +import datetime + from django.core.management.base import BaseCommand from django.db import transaction from django.utils.timezone import now from django.conf import settings - from django.contrib.webdesign import lorem_ipsum 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.history.services import take_snapshot +from taiga.events.apps import disconnect_events_signals -import random -import datetime ATTACHMENT_SAMPLE_DATA = [ "taiga/projects/management/commands/sample_data", @@ -102,6 +103,9 @@ class Command(BaseCommand): @transaction.atomic def handle(self, *args, **options): + # Prevent events emission when sample data is running + disconnect_events_signals() + self.users = [User.objects.get(is_superuser=True)] # create users @@ -190,19 +194,19 @@ class Command(BaseCommand): project.save() - def create_attachment(self, object, order): - attachment = Attachment.objects.create(project=object.project, - content_type=ContentType.objects.get_for_model(object.__class__), - content_object=object, + def create_attachment(self, obj, order): + attached_file = self.sd.file_from_directory(*ATTACHMENT_SAMPLE_DATA) + membership = self.sd.db_object_from_queryset(obj.project.memberships + .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, + owner=membership.user, is_deprecated=self.sd.boolean(), description=self.sd.words(3, 12), - object_id=object.id, - 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)) - + attached_file=attached_file) return attachment def create_wiki(self, project, slug): diff --git a/taiga/projects/services/stats.py b/taiga/projects/services/stats.py index e06a84e4..9782879f 100644 --- a/taiga/projects/services/stats.py +++ b/taiga/projects/services/stats.py @@ -192,16 +192,23 @@ def get_stats_for_project_issues(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 = { 'name': project.name, 'total_milestones': project.total_milestones, 'total_points': project.total_story_points, - 'closed_points': sum(project.closed_points.values()), + 'closed_points': closed_points, 'closed_points_per_role': project.closed_points, 'defined_points': sum(project.defined_points.values()), 'defined_points_per_role': project.defined_points, 'assigned_points': sum(project.assigned_points.values()), 'assigned_points_per_role': project.assigned_points, - 'milestones': _get_milestones_stats_for_backlog(project) + 'milestones': _get_milestones_stats_for_backlog(project), + 'speed': speed, } return project_stats diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 9999fa52..f4b604ed 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -87,7 +87,9 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi project = get_object_or_404(Project, pk=data["project_id"]) 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) return response.NoContent() @@ -102,7 +104,9 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi project = get_object_or_404(Project, pk=data["project_id"]) 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) return response.NoContent() @@ -116,7 +120,9 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi project = get_object_or_404(Project, pk=data["project_id"]) 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) return response.NoContent() diff --git a/taiga/projects/userstories/migrations/0004_auto_20141001_1817.py b/taiga/projects/userstories/migrations/0004_auto_20141001_1817.py new file mode 100644 index 00000000..8c9c9299 --- /dev/null +++ b/taiga/projects/userstories/migrations/0004_auto_20141001_1817.py @@ -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']}, + ), + ] diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index 377aef52..a520b3df 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -42,9 +42,6 @@ class RolePoints(models.Model): verbose_name_plural = "role points" unique_together = ("user_story", "role") ordering = ["user_story", "role"] - permissions = ( - ("view_rolepoints", "Can view role points"), - ) def __str__(self): 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_plural = "user stories" ordering = ["project", "backlog_order", "ref"] - #unique_together = ("ref", "project") - permissions = ( - ("view_userstory", "Can view user story"), - ) def save(self, *args, **kwargs): if not self._importing or not self.modified_date: diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index 54fb3810..0d70cb1e 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -18,6 +18,7 @@ from django.utils import timezone from taiga.base.utils import db, text from taiga.projects.history.services import take_snapshot +from taiga.events import events from . import models @@ -48,7 +49,7 @@ def create_userstories_in_bulk(bulk_data, callback=None, precall=None, **additio 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. `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"]) 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) diff --git a/taiga/routers.py b/taiga/routers.py index 7807aa73..32bb9001 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -128,4 +128,11 @@ router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links") # Notify policies from taiga.projects.notifications.api import NotifyPolicyViewSet + router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications") + + +# feedback +# - see taiga.feedback.routers and taiga.feedback.apps + + diff --git a/taiga/urls.py b/taiga/urls.py index f9ee53c4..f2de3105 100644 --- a/taiga/urls.py +++ b/taiga/urls.py @@ -27,23 +27,21 @@ urlpatterns = [ url(r'^admin/', include(admin.site.urls)), ] - -def mediafiles_urlpatterns(): +def mediafiles_urlpatterns(prefix): """ Method for serve media files with runserver. """ - - _media_url = settings.MEDIA_URL - if _media_url.startswith('/'): - _media_url = _media_url[1:] - + import re from django.views.static import serve + return [ - url(r'^%s(?P.*)$' % 'media', serve, + url(r'^%s(?P.*)$' % re.escape(prefix.lstrip('/')), serve, {'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" diff --git a/taiga/users/gravatar.py b/taiga/users/gravatar.py index ed5a8a33..8b19789e 100644 --- a/taiga/users/gravatar.py +++ b/taiga/users/gravatar.py @@ -16,12 +16,12 @@ # along with this program. If not, see . import hashlib +import copy + from urllib.parse import urlencode from django.conf import settings - -from taiga.base.utils.urls import get_absolute_url - +from django.templatetags.static import static 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. - `default` defines what image url to show if no gravatar exists - `size` defines the size of the avatar. - By default the `settings.GRAVATAR_DEFAULT_OPTIONS` are used. :return: Gravatar url. """ - defaults = settings.GRAVATAR_DEFAULT_OPTIONS.copy() - default = defaults.get("default", None) - if default: - defaults["default"] = get_absolute_url(default) - defaults.update(options) + + params = copy.copy(options) + + default_avatar = getattr(settings, "GRAVATAR_DEFAULT_AVATAR", None) + 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() - url = GRAVATAR_BASE_URL.format(email_hash, urlencode(defaults)) + url = GRAVATAR_BASE_URL.format(email_hash, urlencode(params)) return url diff --git a/taiga/users/static/img/user-noimage.png b/taiga/users/static/img/user-noimage.png new file mode 100644 index 00000000..d779bbd4 Binary files /dev/null and b/taiga/users/static/img/user-noimage.png differ diff --git a/tests/integration/resources_permissions/test_feedback.py b/tests/integration/resources_permissions/test_feedback.py new file mode 100644 index 00000000..c3a8d51e --- /dev/null +++ b/tests/integration/resources_permissions/test_feedback.py @@ -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] diff --git a/tests/integration/test_feedback.py b/tests/integration/test_feedback.py new file mode 100644 index 00000000..478afc8f --- /dev/null +++ b/tests/integration/test_feedback.py @@ -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() diff --git a/tests/integration/test_close_uss.py b/tests/integration/test_us_autoclosing.py similarity index 89% rename from tests/integration/test_close_uss.py rename to tests/integration/test_us_autoclosing.py index 992f30de..35620026 100644 --- a/tests/integration/test_close_uss.py +++ b/tests/integration/test_us_autoclosing.py @@ -40,7 +40,7 @@ def data(): 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 data.user_story2.status = data.us_closed_status 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 -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 data.user_story1.status = data.us_closed_status 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 -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.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is False + data.task3.delete() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is False + data.task2.delete() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is False + data.task1.delete() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) 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.save() data.task2.status = data.task_closed_status data.task2.save() 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 True + data.task3.delete() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is True + data.task2.delete() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is True + data.task1.delete() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) 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.save() data.task2.status = data.task_closed_status data.task2.save() 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 True + data.task3.user_story = data.user_story2 data.task3.save() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is True + data.task2.user_story = data.user_story2 data.task2.save() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is True + data.task1.user_story = data.user_story2 data.task1.save() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) 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.save() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is False + data.task3.user_story = data.user_story2 data.task3.save() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is False + data.task2.user_story = data.user_story2 data.task2.save() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is False + data.task1.user_story = data.user_story2 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_close_last_tasks(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): +def test_auto_close_us_when_tasks_are_gradually_reopened(data): data.task1.status = data.task_closed_status data.task1.save() data.task2.status = data.task_closed_status @@ -167,21 +166,28 @@ def test_us_reopen_tasks(data): data.task3.save() assert data.user_story1.is_closed is True + data.task3.status = data.task_open_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_open_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_open_status data.task1.save() data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) 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.save() 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 -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.save() data.task2.status = data.task_closed_status data.task2.save() data.task3.user_story = data.user_story2 data.task3.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is True + data.task3.user_story = data.user_story1 data.task3.save() + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is False @@ -252,11 +261,14 @@ def test_task_create(data): data.task2.save() 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 True + f.TaskFactory(user_story=data.user_story1, status=data.task_closed_status) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is True + f.TaskFactory(user_story=data.user_story1, status=data.task_open_status) data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) assert data.user_story1.is_closed is False diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index c58e7890..63721eca 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -36,8 +36,11 @@ User Story #2 def test_update_userstories_order_in_bulk(): 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: - 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}], model=models.UserStory)