Merge branch 'master' into stable
Conflicts: requirements.txtremotes/origin/enhancement/email-actions 1.0.0
commit
d38a7a9e93
|
@ -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:
|
||||||
|
|
|
@ -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
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Taiga Backend #
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[](https://travis-ci.org/taigaio/taiga-back "Travis Badge")
|
||||||
|
|
||||||
|
[](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.
|
42
README.rst
42
README.rst
|
@ -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.
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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"
|
|
@ -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()
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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()
|
|
@ -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")
|
|
||||||
|
|
|
@ -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)
|
|
@ -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")
|
|
|
@ -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")
|
|
@ -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"
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)))
|
|
@ -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,),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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"]
|
|
@ -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()
|
|
@ -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")
|
|
@ -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
|
|
@ -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()
|
|
@ -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 %}
|
|
@ -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 %}----------
|
|
@ -0,0 +1 @@
|
||||||
|
[Taiga] Feedback from {{ feedback_entry.full_name }} <{{ feedback_entry.email }}>
|
|
@ -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":
|
||||||
|
|
|
@ -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" %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -23,6 +23,7 @@ register = library.Library()
|
||||||
|
|
||||||
EXTRA_FIELD_VERBOSE_NAMES = {
|
EXTRA_FIELD_VERBOSE_NAMES = {
|
||||||
"description_diff": _("description"),
|
"description_diff": _("description"),
|
||||||
|
"content_diff": _("content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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']},
|
||||||
|
),
|
||||||
|
]
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 |
|
@ -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]
|
|
@ -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()
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue