From bfecc26158359c851840bccd2c4f8605c1a766da Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 29 Sep 2014 09:51:17 +0200 Subject: [PATCH 01/42] Avatar refactoring --- settings/common.py | 5 ++--- taiga/urls.py | 5 ----- taiga/users/gravatar.py | 6 ++---- taiga/users/static/img/user-noimage.png | Bin 0 -> 1445 bytes 4 files changed, 4 insertions(+), 12 deletions(-) create mode 100644 taiga/users/static/img/user-noimage.png diff --git a/settings/common.py b/settings/common.py index d3ac205e..7d5f11b0 100644 --- a/settings/common.py +++ b/settings/common.py @@ -99,7 +99,7 @@ MEDIA_URL = "http://localhost:8000/media/" # Static url is not widelly used by taiga (only # if admin is activated). -STATIC_URL = "/static/" +STATIC_URL = "http://localhost:8000/static/" ADMIN_MEDIA_PREFIX = "/static/admin/" # Static configuration. @@ -309,7 +309,7 @@ SOUTH_MIGRATION_MODULES = { DEFAULT_AVATAR_SIZE = 80 # 80x80 pixels DEFAULT_BIG_AVATAR_SIZE = 300 # 300x300 pixels -DEFAULT_AVATAR_URL = '' +DEFAULT_AVATAR_URL = 'user-noimage.png' THUMBNAIL_ALIASES = { '': { @@ -345,4 +345,3 @@ 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/taiga/urls.py b/taiga/urls.py index f9ee53c4..aba8f1ba 100644 --- a/taiga/urls.py +++ b/taiga/urls.py @@ -32,11 +32,6 @@ def mediafiles_urlpatterns(): """ Method for serve media files with runserver. """ - - _media_url = settings.MEDIA_URL - if _media_url.startswith('/'): - _media_url = _media_url[1:] - from django.views.static import serve return [ url(r'^%s(?P.*)$' % 'media', serve, diff --git a/taiga/users/gravatar.py b/taiga/users/gravatar.py index ed5a8a33..fc3a8661 100644 --- a/taiga/users/gravatar.py +++ b/taiga/users/gravatar.py @@ -19,9 +19,7 @@ import hashlib 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/{}?{}" @@ -39,7 +37,7 @@ def get_gravatar_url(email: str, **options) -> str: defaults = settings.GRAVATAR_DEFAULT_OPTIONS.copy() default = defaults.get("default", None) if default: - defaults["default"] = get_absolute_url(default) + defaults["default"] = static(default) defaults.update(options) email_hash = hashlib.md5(email.lower().encode()).hexdigest() url = GRAVATAR_BASE_URL.format(email_hash, urlencode(defaults)) diff --git a/taiga/users/static/img/user-noimage.png b/taiga/users/static/img/user-noimage.png new file mode 100644 index 0000000000000000000000000000000000000000..48ae7feb8c972754ce46b78f3e8959558c634b01 GIT binary patch literal 1445 zcmZXUdpOez7{`AYExE1sgbXuvtVnYTIj+;@o=s@=&_PR1MswUIre!XTLyC1y9z-OG z9Cac!*1;~vB|79XV^i+OTqZ>>a~^+G&U60w{`3CvzR&mj{XB0v-qTrCd7m->0IIGo zm{R}%By9<-1OUKfu>cPM04U1Q)dvg)gQ?&P3;+OjM!1}f0sz(iEy+0|^Wy*j(2mBQ ziS{O6jHU!d1_Kld#q`o;QWP;LBG{B18InI|vkw3i5v~}tPh7$5F!?9n7#Lr=JTDsF zkb0hr+8mOo3zKCgEu32)&MILUT8e3)^Y?EI5057R zQXGVV(7?ix@^Xi$>Vc%hcD+MAwQPRPuj;&&(26vzbCL!g~5O9VA9z;iIK$D8Wpsg+6M!TQg9T}^z^T3ZMsE}R+W)>;&3TJ7TPNW65 z8xsYD^N4|lU?YX(6Kwt+OTB?^&M1q(dHOr2KrPdZ?TqxxaM%q+yFu!63cyPqBOd2X zRm7rAWp0Hae^y^>gloIzX7_1vPVcFps#hwqU9r=oO~3kQrptZ}N@DeOtZuXzCO6c~ ze9XxX9~8I^E|O*K*=hCP`_~9pln#7HzpnpM9NPLhPrtKMtEp2L*WEpIA*R95+1^;Y zX|UQn>}ps~m6*oflw?9|>TAv*Bjt3O_r4$!we3Xg*3!pBA4|=Dyc2 z-1-=`RR9yiHVUIthe-x|2=XCO>)~i@%}f~~F&p@E2YFqRkV5&DZA} zN5eLUmtV7sLB$p|A!PZ7fO8_zjh#RT#MLWmF~zGGU+wNSn-mLPYcy<6g^mO_Vb)z-1Zv|qs^SKB)G?QZMX=3v#Ht^arF{D7c2ue5+eG;DGu$>Y!k@FOk9$P)fA zgwtjd$sDJMsU?FnqqIW3(o!}NjVbO)Ldtu6K|ckw$*6B6S?@{B6KhU2kMDxX<`y!u z`+MI$`6znaZ^L`k=WA&bkh9-{u6xe9`g7d;3lYf)AK;DadU>~=@3y~C4YS1*G)aR9 z;+ZhaiJe-b(~aCab)7FoWch~1eg&z45D)5lUIIdok-+! zA5{_BW=WUFkY>s r{`*#9B*LQ>!o8&xHVd_*x7=3u Date: Mon, 29 Sep 2014 12:09:30 +0200 Subject: [PATCH 02/42] Minor improvements on avatar/gravatar settings. --- settings/common.py | 19 ++++--------------- taiga/urls.py | 13 ++++++++----- taiga/users/gravatar.py | 22 +++++++++++++++------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/settings/common.py b/settings/common.py index 7d5f11b0..11cda8e2 100644 --- a/settings/common.py +++ b/settings/common.py @@ -100,7 +100,7 @@ MEDIA_URL = "http://localhost:8000/media/" # Static url is not widelly used by taiga (only # if admin is activated). STATIC_URL = "http://localhost:8000/static/" -ADMIN_MEDIA_PREFIX = "/static/admin/" +ADMIN_MEDIA_PREFIX = "http://localhost:8000/static/admin/" # Static configuration. MEDIA_ROOT = os.path.join(BASE_DIR, "media") @@ -309,8 +309,6 @@ SOUTH_MIGRATION_MODULES = { DEFAULT_AVATAR_SIZE = 80 # 80x80 pixels DEFAULT_BIG_AVATAR_SIZE = 300 # 300x300 pixels -DEFAULT_AVATAR_URL = 'user-noimage.png' - THUMBNAIL_ALIASES = { '': { 'avatar': {'size': (DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), 'crop': True}, @@ -318,17 +316,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", @@ -338,7 +328,6 @@ TAGS_PREDEFINED_COLORS = ["#fce94f", "#edd400", "#c4a000", "#8ae234", "#2e3436",] # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE - TEST_RUNNER="django.test.runner.DiscoverRunner" if "test" in sys.argv: diff --git a/taiga/urls.py b/taiga/urls.py index aba8f1ba..f2de3105 100644 --- a/taiga/urls.py +++ b/taiga/urls.py @@ -27,18 +27,21 @@ urlpatterns = [ url(r'^admin/', include(admin.site.urls)), ] - -def mediafiles_urlpatterns(): +def mediafiles_urlpatterns(prefix): """ Method for serve media files with runserver. """ + 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 fc3a8661..8b19789e 100644 --- a/taiga/users/gravatar.py +++ b/taiga/users/gravatar.py @@ -16,6 +16,8 @@ # along with this program. If not, see . import hashlib +import copy + from urllib.parse import urlencode from django.conf import settings @@ -30,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"] = static(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 From 3945aa577267f0b908ba41ed6beee58a90a7df4b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 29 Sep 2014 12:15:33 +0200 Subject: [PATCH 03/42] Updating default avatar image --- taiga/users/static/img/user-noimage.png | Bin 1445 -> 1270 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/taiga/users/static/img/user-noimage.png b/taiga/users/static/img/user-noimage.png index 48ae7feb8c972754ce46b78f3e8959558c634b01..b08a904df2a076bc35508d4f03a97de9aa931cd7 100644 GIT binary patch delta 1191 zcmZ3={f%>i4c8L}2Hq!(CTqLBC%UQEvlM%}IEGZrd3)D4Lnc+`_{aOk);DvcCe4W0 z5+Klh>)Nz?UXD7Jnqgi!3%@;0;P28<)et>o-CLdV7#+Lxb%TnrE!90y z%~1cOpTmJgfvJN-LGw&CL-cLATPqB{EWhcdd~xaN&z1d&M`v^V`ehaRPUER<+t#TX z=c-w<|NY52D5=CC*ubHn)X>481k#MgII;LO=dxw>;R%0sTGcI`n2}a??B=)K<|V6_ zFE^i@TPb|-!QkV%mTiEPw`sb1M%$(D+4?7A}7_TbdQ<>UpSlJ&CanI#I zJQIlf)B8c6c|K2kZDqvPr%|WBK6@_yNxpWY*1 z`)fhj+dCIOJzsrQwRhj&f2&S^dtdG4m{9)o%k}#`tE2YjZGAWQlUmdB`L^|Wr6+$b zJsCgC?ZY$a_1TyAevf$j{?3t~p~@0fpEzYLOBOsmA3t|irqn@i^|;`jG23Q6K6G^c z9QOxjA2PfXVC-OUVGv|=VG$5?05P6ilsv5Sry|dZ`EPmT+UV7~*0Uc!dQ#b%*nV}_ z*I&Nr#eZ^RT?-zZ__)V?g34SO%ZcCXIsPAd$l$^N3O}s}(uW%x>w4$Nlx_RlzCC|y z)cQ*|SGvnfmY5x%_FMi$PV}+q(Rbh9+IlnX0Pmijvz5X_}JS)52 ztbg;)*rWeTUA$eu&X=p_eY?-MIz;QHt?d&(-I)KOS8r{(T)TxiUs*y*CbazeUBB;! z{|>J>7xUxHOS@Z)?aZJUgQi!Te;qqYlIz_HWAb;|@Mzx(kj&U+5q;4v-RTF#9cie&zEXDZMua_1BU}7;!`CY^Zo6_J;hw} z^HsOkri(d7Wc{CQxW#z#-L@{N-X`U|{fc^>bP0 Hl+XkKxz{L& delta 1367 zcmZXUeLT|%9LI+#N%E_`!kJ;}tSrqHa+`-~$U{bG-HVQrT*h(ALrlNSL*qzTcX_dh zB#~1Wsj&`wI1ka0hna24rpZDjxu%&G4b)0%X1hTDP z$pKXmm*Zptsv=zoc%QgJnK0r9-)J~bzBnTiuE{-4U05HIZWkmg&@Jqox8H4Yr0J-p zLC@T|0YiJ1U`%f#bZic=4N;T$(or#M=NaIJ;xF>$WH?^i#Bl?GVuJ7wBUBrAU^2~a-bpD1XM{r!s6YuLJV}X zN0=n`quMH|XrtY3kG`c6Se*jczb@wlmdj8mzVmyByY2B?ejR(o6tXUz_e; zCd`|acOAfH8=9A^{N()6wa@fk5ba%1Y&tsgH_e8+0_1*u<_lb(U!Dz(46%wsmz(FeE z>9IB*Xx#Idz$=f%K%smNnsDUeF~bb|&q*;CfhLpR%i%Bh%q=B24u+^ThD!5>wMep4 zBXbQqi0kR<=t>D~>gAjrXBXbPE{`jlV^Z8vFq8bdYv~V}UKwZ2bGu!H{-uRC}@M^;$!2heIXsO_!rC?WS1#Y%> cB*%4mXRZw*-ct!phd>|@7e^05wL>8F9}2^VO8@`> From 98dd64bab259c27e97f99d36d55d6d3444bc7ec8 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 29 Sep 2014 17:41:03 +0200 Subject: [PATCH 04/42] Minor fixes on sample data related to attachments model changes. --- .../management/commands/sample_data.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index b9288034..929b2a09 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -190,19 +190,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): From 43e16c2c1303d8b04ec83b1df41ff0f32655faaf Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 17 Sep 2014 10:36:40 +0200 Subject: [PATCH 05/42] Taiga-events integration (realtime taiga) --- requirements.txt | 1 + settings/common.py | 4 +- taiga/events/__init__.py | 17 +++ taiga/events/apps.py | 39 +++++++ taiga/events/backends/base.py | 2 +- taiga/events/backends/postgresql.py | 5 +- taiga/events/backends/rabbitmq.py | 65 +++++++++++ taiga/events/changes.py | 61 ----------- taiga/events/events.py | 101 ++++++++++++++++++ taiga/events/models.py | 53 --------- taiga/events/signal_handlers.py | 34 ++++++ .../management/commands/sample_data.py | 10 +- taiga/projects/userstories/api.py | 12 ++- taiga/projects/userstories/services.py | 7 +- tests/integration/test_userstories.py | 5 +- 15 files changed, 291 insertions(+), 125 deletions(-) create mode 100644 taiga/events/apps.py create mode 100644 taiga/events/backends/rabbitmq.py delete mode 100644 taiga/events/changes.py create mode 100644 taiga/events/events.py delete mode 100644 taiga/events/models.py create mode 100644 taiga/events/signal_handlers.py diff --git a/requirements.txt b/requirements.txt index f5eaa461..352c7893 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 diff --git a/settings/common.py b/settings/common.py index d3ac205e..e90ca774 100644 --- a/settings/common.py +++ b/settings/common.py @@ -87,7 +87,9 @@ DJMAIL_MAX_RETRY_NUMBER = 3 DJMAIL_TEMPLATE_EXTENSION = "jinja" # 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_STORAGE = "django.contrib.messages.storage.session.SessionStorage" 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/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index b9288034..febdb668 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 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/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/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) From 85901336e95ca810e87baf229df1b4cc85616c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Mon, 29 Sep 2014 18:33:55 +0200 Subject: [PATCH 06/42] Changed default events queue to postgresql queue --- settings/common.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/settings/common.py b/settings/common.py index 3dd8caa8..6ae12a90 100644 --- a/settings/common.py +++ b/settings/common.py @@ -87,9 +87,9 @@ DJMAIL_MAX_RETRY_NUMBER = 3 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/"} +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" From 0e535cf0f1b88c2ffa829f0f7ba4104875c1b829 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 29 Sep 2014 23:28:09 +0200 Subject: [PATCH 07/42] Fix unclear message on requirements.txt. --- requirements.txt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 352c7893..b0a568b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,9 +19,10 @@ Markdown==2.4.1 fn==0.2.13 diff-match-patch==20121119 requests==2.4.1 - -# Comment it if you are using python >= 3.4 -enum34==1.0 easy-thumbnails==2.1 celery==3.1.12 redis==2.10.3 + +# Comment it if you are using python >= 3.4 +enum34==1.0 + From 6abc792e13fa64a22ceb5a56f4aebec51c2486c9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 29 Sep 2014 23:35:32 +0200 Subject: [PATCH 08/42] Minor changes on readme. --- README.rst | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index b1f88fec..80052bbe 100644 --- a/README.rst +++ b/README.rst @@ -27,16 +27,9 @@ Just execute these commands in your virtualenv(wrapper): python manage.py sample_data -Note: taiga only runs with python 3.3+. +Ttaiga only runs with python 3.3+. -Note: Initial auth data: admin/123123 +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. +If you want a complete environment for production usage, you can try taiga bootstraping +scripts https://github.com/taigaio/taiga-scripts (warning: alpha state) From 5e7a7c5eaab3a94e9b40efada1fe2959254c2168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Tue, 30 Sep 2014 09:20:05 +0200 Subject: [PATCH 09/42] :poop: -> :toilet: --- settings/common.py | 8 +------- taiga/projects/userstories/models.py | 7 ------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/settings/common.py b/settings/common.py index 6ae12a90..72f2a0cf 100644 --- a/settings/common.py +++ b/settings/common.py @@ -252,12 +252,7 @@ LOGGING = { "handlers": ["console"], "level": "DEBUG", "propagate": False, - }, - "taiga.domains": { - "handlers": ["console"], - "level": "INFO", - "propagate": False, - }, + } } } @@ -276,7 +271,6 @@ AUTHENTICATION_BACKENDS = ( ) ANONYMOUS_USER_ID = -1 -GRAPPELLI_INDEX_DASHBOARD = "taiga.dashboard.CustomIndexDashboard" MAX_SEARCH_RESULTS = 100 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: From 02d3008180fe9ccbeb787088fc7dde0aed433908 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 1 Oct 2014 12:47:33 +0200 Subject: [PATCH 10/42] Adding extra info for stats --- taiga/projects/services/stats.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/taiga/projects/services/stats.py b/taiga/projects/services/stats.py index e06a84e4..512d5de4 100644 --- a/taiga/projects/services/stats.py +++ b/taiga/projects/services/stats.py @@ -192,16 +192,19 @@ def get_stats_for_project_issues(project): def get_stats_for_project(project): + closed_points = sum(project.closed_points.values()) + speed = closed_points / project.milestones.count() 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 From 2adc1f3fe35e935a9807a56996c427e035007f06 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 1 Oct 2014 17:06:46 +0200 Subject: [PATCH 11/42] Considering only closed sprints for calculating average speed --- taiga/projects/services/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taiga/projects/services/stats.py b/taiga/projects/services/stats.py index 512d5de4..d446be31 100644 --- a/taiga/projects/services/stats.py +++ b/taiga/projects/services/stats.py @@ -193,7 +193,7 @@ def get_stats_for_project_issues(project): def get_stats_for_project(project): closed_points = sum(project.closed_points.values()) - speed = closed_points / project.milestones.count() + speed = closed_points / project.milestones.filter(closed=True).count() project_stats = { 'name': project.name, 'total_milestones': project.total_milestones, From 83308c351b10db1aa01a4943f547b56e16e087ac Mon Sep 17 00:00:00 2001 From: Ward Vandewege Date: Wed, 1 Oct 2014 13:25:10 -0400 Subject: [PATCH 12/42] Fix typo in README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 80052bbe..c8ba55b8 100644 --- a/README.rst +++ b/README.rst @@ -31,5 +31,5 @@ Ttaiga only runs with python 3.3+. Initial auth data: admin/123123 -If you want a complete environment for production usage, you can try taiga bootstraping +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) From 9de7bac1b6db762ea2e26d583e31d9dc1b0576b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Wed, 1 Oct 2014 20:22:51 +0200 Subject: [PATCH 13/42] Generate migration for my last change in userstories models --- .../migrations/0004_auto_20141001_1817.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 taiga/projects/userstories/migrations/0004_auto_20141001_1817.py 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']}, + ), + ] From 7213006402d03044ee35e8f8eec5780ab0ff1208 Mon Sep 17 00:00:00 2001 From: Andrew McNett Date: Wed, 1 Oct 2014 12:03:35 -0700 Subject: [PATCH 14/42] Typo in README.rst Ttaiga -> Taiga --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c8ba55b8..cbbfa45c 100644 --- a/README.rst +++ b/README.rst @@ -27,7 +27,7 @@ Just execute these commands in your virtualenv(wrapper): python manage.py sample_data -Ttaiga only runs with python 3.3+. +Taiga only runs with python 3.3+. Initial auth data: admin/123123 From ef82bc62cfcff40e7b70aebd11e96490485a9cc1 Mon Sep 17 00:00:00 2001 From: Damien Date: Wed, 1 Oct 2014 23:31:42 +0200 Subject: [PATCH 15/42] Typo in local_settings: Githup -> Github Kebap ou Kebab ? --- settings/local.py.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 27f12f7be90f6c797991570cf76d75f29dbc0d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 1 Oct 2014 23:49:23 +0200 Subject: [PATCH 16/42] Fixed security bug on users info --- taiga/users/api.py | 3 +-- taiga/users/permissions.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/taiga/users/api.py b/taiga/users/api.py index e342a34c..89fd9429 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -54,8 +54,7 @@ class MembersFilterBackend(BaseFilterBackend): return queryset.filter(Q(memberships__project=project) | Q(id=project.owner.id)).distinct() else: raise exc.PermissionDenied(_("You don't have permisions to see this project users.")) - else: - return queryset + return [] class UsersViewSet(ModelCrudViewSet): diff --git a/taiga/users/permissions.py b/taiga/users/permissions.py index 2c3c8c9c..c067fa19 100644 --- a/taiga/users/permissions.py +++ b/taiga/users/permissions.py @@ -27,7 +27,7 @@ class IsTheSameUser(PermissionComponent): class UserPermission(TaigaResourcePermission): enought_perms = IsSuperUser() global_perms = None - retrieve_perms = AllowAny() + retrieve_perms = IsTheSameUser() update_perms = IsTheSameUser() destroy_perms = IsTheSameUser() list_perms = AllowAny() From db812101b0c63438bdb525d27046639a62b04d3c Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 2 Oct 2014 00:11:41 +0200 Subject: [PATCH 17/42] Fixing speed calculation when there are no sprints --- taiga/projects/services/stats.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/taiga/projects/services/stats.py b/taiga/projects/services/stats.py index d446be31..9782879f 100644 --- a/taiga/projects/services/stats.py +++ b/taiga/projects/services/stats.py @@ -193,7 +193,11 @@ def get_stats_for_project_issues(project): def get_stats_for_project(project): closed_points = sum(project.closed_points.values()) - speed = closed_points / project.milestones.filter(closed=True).count() + 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, From 7c5b85ea5cf87d6b7a20ab48d972a2b443c8cb73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Thu, 2 Oct 2014 01:44:48 +0200 Subject: [PATCH 18/42] Fix small test fails introduced in the commit 27f12f7 --- taiga/users/api.py | 16 +++++++++++++++- .../test_users_resources.py | 8 ++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/taiga/users/api.py b/taiga/users/api.py index 89fd9429..cc8edf86 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -54,6 +54,10 @@ class MembersFilterBackend(BaseFilterBackend): return queryset.filter(Q(memberships__project=project) | Q(id=project.owner.id)).distinct() else: raise exc.PermissionDenied(_("You don't have permisions to see this project users.")) + + if request.user.is_superuser: + return queryset + return [] @@ -61,11 +65,21 @@ class UsersViewSet(ModelCrudViewSet): permission_classes = (permissions.UserPermission,) serializer_class = serializers.UserSerializer queryset = models.User.objects.all() - filter_backends = (MembersFilterBackend,) def create(self, *args, **kwargs): raise exc.NotSupported() + def list(self, request, *args, **kwargs): + self.object_list = MembersFilterBackend().filter_queryset(request, self.get_queryset(), self) + + page = self.paginate_queryset(self.object_list) + if page is not None: + serializer = self.get_pagination_serializer(page) + else: + serializer = self.get_serializer(self.object_list, many=True) + + return Response(serializer.data) + @list_route(methods=["POST"]) def password_recovery(self, request, pk=None): username_or_email = request.DATA.get('username', None) diff --git a/tests/integration/resources_permissions/test_users_resources.py b/tests/integration/resources_permissions/test_users_resources.py index 63492a6a..90bf1f98 100644 --- a/tests/integration/resources_permissions/test_users_resources.py +++ b/tests/integration/resources_permissions/test_users_resources.py @@ -44,7 +44,7 @@ def test_user_retrieve(client, data): ] results = helper_test_http_method(client, 'get', url, None, users) - assert results == [200, 200, 200, 200] + assert results == [401, 200, 403, 200] def test_user_update(client, data): @@ -82,21 +82,21 @@ def test_user_list(client, data): response = client.get(url) users_data = json.loads(response.content.decode('utf-8')) - assert len(users_data) == 3 + assert len(users_data) == 0 assert response.status_code == 200 client.login(data.registered_user) response = client.get(url) users_data = json.loads(response.content.decode('utf-8')) - assert len(users_data) == 3 + assert len(users_data) == 0 assert response.status_code == 200 client.login(data.other_user) response = client.get(url) users_data = json.loads(response.content.decode('utf-8')) - assert len(users_data) == 3 + assert len(users_data) == 0 assert response.status_code == 200 client.login(data.superuser) From 50fbd00b3e8fd822f1797bcffab806c7ddf41998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Thu, 2 Oct 2014 02:36:15 +0200 Subject: [PATCH 19/42] Fix problem on user deletion --- taiga/users/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taiga/users/api.py b/taiga/users/api.py index cc8edf86..8c602b89 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -262,7 +262,7 @@ class UsersViewSet(ModelCrudViewSet): user = self.get_object() self.check_permissions(request, "destroy", user) user.username = slugify_uniquely("deleted-user", models.User, slugfield="username") - user.email = "deleted-user@taiga.io" + user.email = "{}@taiga.io".format(user.username) user.is_active = False user.full_name = "Deleted user" user.color = "" From 56e98bba11561d84069003079d0c14ee235e8b61 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Oct 2014 02:18:18 +0200 Subject: [PATCH 20/42] Fix wrong handing integrity error on register or accept invitation. --- taiga/auth/services.py | 14 +++++++++---- tests/integration/test_auth_api.py | 32 +++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/taiga/auth/services.py b/taiga/auth/services.py index 72a40af2..725373b5 100644 --- a/taiga/auth/services.py +++ b/taiga/auth/services.py @@ -72,7 +72,7 @@ def is_user_already_registred(*, username:str, email:str, github_id:int=None) -> or_expr = Q(username=username) | Q(email=email) if github_id: - or_expr = or_expr | Q(email=email) + or_expr = or_expr | Q(github_id=github_id) qs = user_model.objects.filter(or_expr) return qs.exists() @@ -113,7 +113,10 @@ def public_register(username:str, password:str, email:str, full_name:str): email=email, full_name=full_name) user.set_password(password) - user.save() + try: + user.save() + except IntegrityError: + raise exc.IntegrityError("User is already register.") # send_public_register_email(user) return user @@ -132,8 +135,11 @@ def private_register_for_existing_user(token:str, username:str, password:str): user = get_and_validate_user(username=username, password=password) membership = get_membership_by_token(token) - membership.user = user - membership.save(update_fields=["user"]) + try: + membership.user = user + membership.save(update_fields=["user"]) + except IntegrityError: + raise exc.IntegrityError("Membership with user is already exists.") # send_private_register_email(user) return user diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py index 1cb81a9b..56a8140f 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -37,18 +37,20 @@ def register_form(): "type": "public"} -def test_respond_201_if_domain_allows_public_registration(client, settings, register_form): +def test_respond_201_when_public_registration_is_enabled(client, settings, register_form): settings.PUBLIC_REGISTER_ENABLED = True response = client.post(reverse("auth-register"), register_form) assert response.status_code == 201 -def test_respond_400_if_domain_does_not_allow_public_registration(client, register_form): +def test_respond_400_when_public_registration_is_disabled(client, register_form, settings): + settings.PUBLIC_REGISTER_ENABLED = False response = client.post(reverse("auth-register"), register_form) assert response.status_code == 400 -def test_respond_201_with_invitation_if_domain_does_not_allows_public_registration(client, register_form): +def test_respond_201_with_invitation_without_public_registration(client, register_form, settings): + settings.PUBLIC_REGISTER_ENABLED = False user = factories.UserFactory() membership = factories.MembershipFactory(user=user) @@ -66,7 +68,8 @@ def test_respond_201_with_invitation_if_domain_does_not_allows_public_registrati assert response.status_code == 201, response.data -def test_response_200_in_registration_with_github_account(client): +def test_response_200_in_registration_with_github_account(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False form = {"type": "github", "code": "xxxxxx"} @@ -87,7 +90,8 @@ def test_response_200_in_registration_with_github_account(client): assert response.data["github_id"] == 1955 -def test_response_200_in_registration_with_github_account_in_a_project(client): +def test_response_200_in_registration_with_github_account_in_a_project(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False membership_model = apps.get_model("projects", "Membership") membership = factories.MembershipFactory(user=None) form = {"type": "github", @@ -106,7 +110,8 @@ def test_response_200_in_registration_with_github_account_in_a_project(client): assert membership_model.objects.get(token=form["token"]).user.username == "mmcfly" -def test_response_404_in_registration_with_github_account_in_a_project_with_invalid_token(client): +def test_response_404_in_registration_with_github_in_a_project_with_invalid_token(client, settings): + settings.PUBLIC_REGISTER_ENABLED = False form = {"type": "github", "code": "xxxxxx", "token": "123456"} @@ -122,7 +127,7 @@ def test_response_404_in_registration_with_github_account_in_a_project_with_inva assert response.status_code == 404 -def test_respond_400_If_username_is_invalid(client, settings, register_form): +def test_respond_400_if_username_is_invalid(client, settings, register_form): settings.PUBLIC_REGISTER_ENABLED = True register_form.update({"username": "User Examp:/e"}) @@ -132,3 +137,16 @@ def test_respond_400_If_username_is_invalid(client, settings, register_form): register_form.update({"username": 300*"a"}) response = client.post(reverse("auth-register"), register_form) assert response.status_code == 400 + + +def test_respond_400_if_username_or_email_is_duplicate(client, settings, register_form): + settings.PUBLIC_REGISTER_ENABLED = True + + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 201 + + + register_form["username"] = "username" + register_form["email"] = "ff@dd.com" + response = client.post(reverse("auth-register"), register_form) + assert response.status_code == 400 From b1cb7fdf665d0225fe14337424c16a446b3f6e18 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Oct 2014 03:43:11 +0200 Subject: [PATCH 21/42] Fix wrong handling patch request with user_stories list on milestone resource. (fix #1163) --- taiga/projects/milestones/serializers.py | 4 +- tests/factories.py | 9 ++++ tests/integration/test_milestones.py | 54 ++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_milestones.py diff --git a/taiga/projects/milestones/serializers.py b/taiga/projects/milestones/serializers.py index 009a8b3b..e333c71f 100644 --- a/taiga/projects/milestones/serializers.py +++ b/taiga/projects/milestones/serializers.py @@ -24,7 +24,7 @@ from . import models class MilestoneSerializer(serializers.ModelSerializer): - user_stories = UserStorySerializer(many=True, required=False) + user_stories = UserStorySerializer(many=True, required=False, read_only=True) total_points = serializers.SerializerMethodField("get_total_points") closed_points = serializers.SerializerMethodField("get_closed_points") client_increment_points = serializers.SerializerMethodField("get_client_increment_points") @@ -32,7 +32,7 @@ class MilestoneSerializer(serializers.ModelSerializer): class Meta: model = models.Milestone - read_only_fields = ('id', 'created_date', 'modified_date') + read_only_fields = ("id", "created_date", "modified_date") def get_total_points(self, obj): return sum(obj.total_points.values()) diff --git a/tests/factories.py b/tests/factories.py index ffd368f7..a833b996 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -93,6 +93,15 @@ class PointsFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") +class RolePointsFactory(Factory): + class Meta: + model = "userstories.RolePoints" + strategy = factory.CREATE_STRATEGY + + user_story = factory.SubFactory("tests.factories.UserStoryFactory") + role = factory.SubFactory("tests.factories.RoleFactory") + points = factory.SubFactory("tests.factories.PointsFactory") + class UserStoryAttachmentFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") diff --git a/tests/integration/test_milestones.py b/tests/integration/test_milestones.py new file mode 100644 index 00000000..7af07411 --- /dev/null +++ b/tests/integration/test_milestones.py @@ -0,0 +1,54 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# Copyright (C) 2014 Anler Hernández +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +from unittest.mock import patch, Mock + +from django.apps import apps +from django.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects.userstories.serializers import UserStorySerializer + +from .. import factories as f + + +pytestmark = pytest.mark.django_db + + +def test_api_update_milestone(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + role = f.RoleFactory.create(project=project) + member = f.MembershipFactory.create(project=project, user=user, role=role) + sprint = f.MilestoneFactory.create(project=project, owner=user) + + points = f.PointsFactory.create(project=project, value=None) + us = f.UserStoryFactory.create(project=project, owner=user) + # role_points = f.RolePointsFactory.create(points=points, user_story=us, role=role) + + url = reverse("milestones-detail", args=[sprint.pk]) + + form_data = { + "name": "test", + "user_stories": [UserStorySerializer(us).data] + } + + client.login(user) + response = client.json.patch(url, json.dumps(form_data)) + assert response.status_code == 200 + From 295ed2b6e7c7faf7c218b645aa862ef5e34204b2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Oct 2014 04:24:26 +0200 Subject: [PATCH 22/42] Fix possible race condition on register using select for update. --- taiga/auth/services.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/taiga/auth/services.py b/taiga/auth/services.py index 725373b5..aeb4debe 100644 --- a/taiga/auth/services.py +++ b/taiga/auth/services.py @@ -69,12 +69,13 @@ def is_user_already_registred(*, username:str, email:str, github_id:int=None) -> """ user_model = apps.get_model("users", "User") + qs = user_model.objects.select_for_update() or_expr = Q(username=username) | Q(email=email) if github_id: or_expr = or_expr | Q(github_id=github_id) - qs = user_model.objects.filter(or_expr) + qs = qs.filter(or_expr) return qs.exists() From 1039d432ad1d3683dae5c66875ec22208184f0bb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 2 Oct 2014 12:37:13 +0200 Subject: [PATCH 23/42] Remove useless six usage that causes some import errors. --- taiga/base/tags.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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): From 45c339485c305a665f5220a5c177e6c4183ff419 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 2 Oct 2014 13:01:08 +0200 Subject: [PATCH 24/42] Improving feedback when registering users --- taiga/auth/services.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/taiga/auth/services.py b/taiga/auth/services.py index aeb4debe..fe07b1bf 100644 --- a/taiga/auth/services.py +++ b/taiga/auth/services.py @@ -63,20 +63,25 @@ def send_private_register_email(user, **kwargs) -> bool: return bool(email.send()) -def is_user_already_registred(*, username:str, email:str, github_id:int=None) -> bool: +def is_user_already_registered(*, username:str, email:str, github_id:int=None) -> (bool, str): """ Checks if a specified user is already registred. + + Returns a tuple containing a boolean value that indicates if the user exists + and in case he does whats the duplicated attribute """ user_model = apps.get_model("users", "User") - qs = user_model.objects.select_for_update() + if user_model.objects.filter(username=username): + return (True, _("Username is already in use.")) - or_expr = Q(username=username) | Q(email=email) - if github_id: - or_expr = or_expr | Q(github_id=github_id) + if user_model.objects.filter(email=email): + return (True, _("Email is already in use.")) - qs = qs.filter(or_expr) - return qs.exists() + if github_id and user_model.objects.filter(github_id=github_id): + return (True, _("Github id is already in use")) + + return (False, None) def get_membership_by_token(token:str): @@ -106,8 +111,9 @@ def public_register(username:str, password:str, email:str, full_name:str): :returns: User """ - if is_user_already_registred(username=username, email=email): - raise exc.IntegrityError("User is already registred.") + is_registered, reason = is_user_already_registered(username=username, email=email) + if is_registered: + raise exc.WrongArguments(reason) user_model = apps.get_model("users", "User") user = user_model(username=username, @@ -117,7 +123,7 @@ def public_register(username:str, password:str, email:str, full_name:str): try: user.save() except IntegrityError: - raise exc.IntegrityError("User is already register.") + raise exc.WrongArguments("User is already register.") # send_public_register_email(user) return user @@ -153,8 +159,9 @@ def private_register_for_new_user(token:str, username:str, email:str, Given a inviation token, try register new user matching the invitation token. """ - if is_user_already_registred(username=username, email=email): - raise exc.WrongArguments(_("Username or Email is already in use.")) + is_registered, reason = is_user_already_registered(username=username, email=email) + if is_registered: + raise exc.WrongArguments(reason) user_model = apps.get_model("users", "User") user = user_model(username=username, @@ -165,7 +172,7 @@ def private_register_for_new_user(token:str, username:str, email:str, try: user.save() except IntegrityError: - raise exc.IntegrityError(_("Error on creating new user.")) + raise exc.WrongArguments(_("Error on creating new user.")) membership = get_membership_by_token(token) membership.user = user From a4b1be474f8d51cab41d998ef2d0a3e14fe0faee Mon Sep 17 00:00:00 2001 From: riot Date: Thu, 2 Oct 2014 18:04:00 +0200 Subject: [PATCH 25/42] Mention 3.3 eariler, helps avoid traffic --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index cbbfa45c..6de73769 100644 --- a/README.rst +++ b/README.rst @@ -15,6 +15,8 @@ Taiga Backend Setup development environment ----------------------------- +*Taiga only runs with Python 3.3+.* + Just execute these commands in your virtualenv(wrapper): .. code-block:: console @@ -27,8 +29,6 @@ Just execute these commands in your virtualenv(wrapper): python manage.py sample_data -Taiga only runs with python 3.3+. - Initial auth data: admin/123123 If you want a complete environment for production usage, you can try the taiga bootstrapping From 02fe357fe2933c6397c98ba4ced3309c38fef73e Mon Sep 17 00:00:00 2001 From: Julien Date: Thu, 2 Oct 2014 19:13:10 +0200 Subject: [PATCH 26/42] What I missed here, I put here. --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index 6de73769..5ab29983 100644 --- a/README.rst +++ b/README.rst @@ -33,3 +33,10 @@ 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) + +Configuration +------------- + +Configuration is on the `taiga-back/settings/local.py` file. + +After modifing the configuration, a `circusctl restart taiga` should be ran. From 693f37e43c46bf02c2d2ab16ab293e4145fccec5 Mon Sep 17 00:00:00 2001 From: Julien Date: Fri, 3 Oct 2014 09:55:50 +0200 Subject: [PATCH 27/42] Update README.rst FIX: Be less precise as there is many ways to restart taiga-back. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 5ab29983..6c8a9e20 100644 --- a/README.rst +++ b/README.rst @@ -39,4 +39,4 @@ Configuration Configuration is on the `taiga-back/settings/local.py` file. -After modifing the configuration, a `circusctl restart taiga` should be ran. +After modifing the configuration, you have to restart taiga-back. From 1992c55aca6ff17fc23fae2160db9a97b5739f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Fri, 3 Oct 2014 10:41:15 +0200 Subject: [PATCH 28/42] Fix bug #60 on github: Emojis urls problem --- taiga/mdrender/extensions/emojify.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/taiga/mdrender/extensions/emojify.py b/taiga/mdrender/extensions/emojify.py index 502b0495..cdf986ef 100644 --- a/taiga/mdrender/extensions/emojify.py +++ b/taiga/mdrender/extensions/emojify.py @@ -27,13 +27,15 @@ import re +from django.conf import settings + from markdown.extensions import Extension from markdown.preprocessors import Preprocessor # Grab the emojis (+800) here: https://github.com/arvida/emoji-cheat-sheet.com # This **crazy long** list was generated by walking through the emojis.png -emojis_path = "http://localhost:8000/static/img/emojis/" +emojis_path = "{}://{}/static/img/emojis/".format(settings.SITES["api"]["scheme"], settings.SITES["api"]["domain"]) emojis_set = { "+1", "-1", "100", "1234", "8ball", "a", "ab", "abc", "abcd", "accept", "aerial_tramway", "airplane", "alarm_clock", "alien", "ambulance", "anchor", "angel", "anger", "angry", "anguished", "ant", "apple", From 3cb223d002d660d4c067c210a6e07715cd6515c1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 3 Oct 2014 15:16:44 +0200 Subject: [PATCH 29/42] Change readme format from rst to markdown. Conflicts: README.rst --- README.md | 33 +++++++++++++++++++++++++++++++++ README.rst | 42 ------------------------------------------ 2 files changed, 33 insertions(+), 42 deletions(-) create mode 100644 README.md delete mode 100644 README.rst diff --git a/README.md b/README.md new file mode 100644 index 00000000..cc802e21 --- /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.3+. + +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-script (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 6c8a9e20..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 ------------------------------ - -*Taiga only runs with Python 3.3+.* - -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 - - -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) - -Configuration -------------- - -Configuration is on the `taiga-back/settings/local.py` file. - -After modifing the configuration, you have to restart taiga-back. From 8405f29c5f0e728d134e0d18859da22c02a8022f Mon Sep 17 00:00:00 2001 From: Alexander Shorin Date: Fri, 3 Oct 2014 21:05:47 +0400 Subject: [PATCH 30/42] Fix url for taiga-scripts --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cc802e21..f3c752c2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Taiga only runs with python 3.3+. 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-script (warning: alpha state) +scripts https://github.com/taigaio/taiga-scripts (warning: alpha state) ## Community ## From 101cad3358852d57fc1924ad1bcdc843cd1a0f56 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 3 Oct 2014 20:10:39 +0200 Subject: [PATCH 31/42] Fix unexpected exception when delete task with deleted milestone. --- README.md | 2 +- taiga/projects/tasks/signals.py | 9 +++++--- tests/integration/test_close_uss.py | 35 +++++++++++++++++++++++++---- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f3c752c2..f99c51b7 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ python manage.py loaddata initial_role python manage.py sample_data ``` -Taiga only runs with python 3.3+. +Taiga only runs with python 3.4+ Initial auth data: admin/123123 diff --git a/taiga/projects/tasks/signals.py b/taiga/projects/tasks/signals.py index cbc633d1..03e65ddd 100644 --- a/taiga/projects/tasks/signals.py +++ b/taiga/projects/tasks/signals.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from contextlib import suppress +from django.core.exceptions import ObjectDoesNotExist #################################### # Signals for cached prev task @@ -84,7 +86,8 @@ def _try_to_close_or_open_milestone_when_create_or_edit_task(instance): def _try_to_close_milestone_when_delete_task(instance): - from taiga.projects.milestones import services as milestone_service + from taiga.projects.milestones import services - if instance.milestone_id and milestone_service.calculate_milestone_is_closed(instance.milestone): - milestone_service.close_milestone(instance.milestone) + with suppress(ObjectDoesNotExist): + if instance.milestone_id and services.calculate_milestone_is_closed(instance.milestone): + services.close_milestone(instance.milestone) diff --git a/tests/integration/test_close_uss.py b/tests/integration/test_close_uss.py index 64f515d8..992f30de 100644 --- a/tests/integration/test_close_uss.py +++ b/tests/integration/test_close_uss.py @@ -15,12 +15,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from tests import factories as f - -from taiga.projects.userstories.models import UserStory import pytest +from taiga.projects.userstories.models import UserStory +from taiga.projects.tasks.models import Task + +from tests import factories as f pytestmark = pytest.mark.django_db @@ -191,7 +192,33 @@ def test_us_delete_task_then_all_closed(data): assert data.user_story1.is_closed is True -def test_us_change_task_us_then_all_closed(data): +def test_auto_close_userstory_with_milestone_when_task_and_milestone_are_removed(data): + milestone = f.MilestoneFactory.create() + + data.task1.status = data.task_closed_status + data.task1.milestone = milestone + data.task1.save() + data.task2.status = data.task_closed_status + data.task2.milestone = milestone + data.task2.save() + data.task3.status = data.task_open_status + data.task3.milestone = milestone + data.task3.save() + data.user_story1.milestone = milestone + 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 = Task.objects.get(pk=data.task3.pk) + milestone.delete() + data.task3.delete() + + data.user_story1 = UserStory.objects.get(pk=data.user_story1.pk) + assert data.user_story1.is_closed is False + + +def test_auto_close_us_when_all_tasks_are_changed_to_close_status(data): data.task1.status = data.task_closed_status data.task1.save() data.task2.status = data.task_closed_status From ad169764341bf3aa2a390151455e80e714bb9aef Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 3 Oct 2014 20:11:44 +0200 Subject: [PATCH 32/42] Rename and improve test names on us autoclossing tests module. --- ...st_close_uss.py => test_us_autoclosing.py} | 62 +++++++++++-------- 1 file changed, 37 insertions(+), 25 deletions(-) rename tests/integration/{test_close_uss.py => test_us_autoclosing.py} (89%) 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 From 14af7eb99a0964c1f1c522dba235c53a70f4df29 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 3 Oct 2014 20:18:34 +0200 Subject: [PATCH 33/42] Increment python version to 3.4 on .travis.yml --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From b82f6a4743126e269d422b386d1891b2b0bcc5cc Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 3 Oct 2014 01:41:37 +0200 Subject: [PATCH 34/42] Making the project name not mandatory and using the owner username for the slug generation --- requirements.txt | 2 +- taiga/base/utils/slug.py | 6 ++- .../migrations/0004_auto_20141002_2337.py | 19 ++++++++++ taiga/projects/models.py | 5 ++- tests/unit/test_slug.py | 37 +++++++++++++++++++ 5 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 taiga/projects/migrations/0004_auto_20141002_2337.py create mode 100644 tests/unit/test_slug.py diff --git a/requirements.txt b/requirements.txt index b0a568b7..882403d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ requests==2.4.1 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/taiga/base/utils/slug.py b/taiga/base/utils/slug.py index 14e17abf..c4a389e7 100644 --- a/taiga/base/utils/slug.py +++ b/taiga/base/utils/slug.py @@ -19,6 +19,8 @@ from django.template.defaultfilters import slugify import time +from unidecode import unidecode + def slugify_uniquely(value, model, slugfield="slug"): """ @@ -26,13 +28,13 @@ def slugify_uniquely(value, model, slugfield="slug"): """ suffix = 0 - potential = base = slugify(value) + potential = base = slugify(unidecode(value)) if len(potential) == 0: potential = 'null' while True: if suffix: potential = "-".join([base, str(suffix)]) - if not model.objects.filter(**{slugfield: potential}).count(): + if not model.objects.filter(**{slugfield: potential}).exists(): return potential suffix += 1 diff --git a/taiga/projects/migrations/0004_auto_20141002_2337.py b/taiga/projects/migrations/0004_auto_20141002_2337.py new file mode 100644 index 00000000..14876435 --- /dev/null +++ b/taiga/projects/migrations/0004_auto_20141002_2337.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0003_auto_20140913_1710'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='name', + field=models.CharField(max_length=250, verbose_name='name'), + ), + ] diff --git a/taiga/projects/models.py b/taiga/projects/models.py index ed2adf4a..c8306c46 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -118,7 +118,7 @@ class ProjectDefaults(models.Model): class Project(ProjectDefaults, TaggedMixin, models.Model): - name = models.CharField(max_length=250, unique=True, null=False, blank=False, + name = models.CharField(max_length=250, null=False, blank=False, verbose_name=_("name")) slug = models.SlugField(max_length=250, unique=True, null=False, blank=True, verbose_name=_("slug")) @@ -189,7 +189,8 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): self.modified_date = timezone.now() if not self.slug: - base_slug = slugify_uniquely(self.name, self.__class__) + base_name = "{}-{}".format(self.owner.username, self.name) + base_slug = slugify_uniquely(base_name, self.__class__) slug = base_slug for i in arithmetic_progression(): if not type(self).objects.filter(slug=slug).exists() or i > 100: diff --git a/tests/unit/test_slug.py b/tests/unit/test_slug.py new file mode 100644 index 00000000..ea7dd17d --- /dev/null +++ b/tests/unit/test_slug.py @@ -0,0 +1,37 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# Copyright (C) 2014 Anler Hernández +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.projects.models import Project +from taiga.users.models import User + +import pytest +pytestmark = pytest.mark.django_db + + +def test_project_slug_with_special_chars(): + user = User.objects.create(username="test") + project = Project.objects.create(name="漢字", description="漢字", owner=user) + project.save() + + assert project.slug == "test-han-zi" + +def test_project_with_existing_name_slug_with_special_chars(): + user = User.objects.create(username="test") + Project.objects.create(name="漢字", description="漢字", owner=user) + project = Project.objects.create(name="漢字", description="漢字", owner=user) + + assert project.slug == "test-han-zi-1" From bcb2948417b336d8f788d0a09f762b564070baab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 29 Sep 2014 16:44:14 +0200 Subject: [PATCH 35/42] US #954: Task #1115: Create feedback endpoint --- settings/common.py | 6 +++ taiga/feedback/__init__.py | 17 +++++++ taiga/feedback/api.py | 51 +++++++++++++++++++ taiga/feedback/apps.py | 32 ++++++++++++ taiga/feedback/migrations/0001_initial.py | 29 +++++++++++ taiga/feedback/migrations/__init__.py | 0 taiga/feedback/models.py | 34 +++++++++++++ taiga/feedback/permissions.py | 23 +++++++++ taiga/feedback/routers.py | 22 ++++++++ taiga/feedback/serializers.py | 24 +++++++++ taiga/feedback/services.py | 29 +++++++++++ .../feedback_notification-body-html.jinja | 37 ++++++++++++++ .../feedback_notification-body-text.jinja | 11 ++++ .../feedback_notification-subject.jinja | 1 + taiga/routers.py | 7 +++ .../resources_permissions/test_feedback.py | 27 ++++++++++ tests/integration/test_feedback.py | 47 +++++++++++++++++ 17 files changed, 397 insertions(+) create mode 100644 taiga/feedback/__init__.py create mode 100644 taiga/feedback/api.py create mode 100644 taiga/feedback/apps.py create mode 100644 taiga/feedback/migrations/0001_initial.py create mode 100644 taiga/feedback/migrations/__init__.py create mode 100644 taiga/feedback/models.py create mode 100644 taiga/feedback/permissions.py create mode 100644 taiga/feedback/routers.py create mode 100644 taiga/feedback/serializers.py create mode 100644 taiga/feedback/services.py create mode 100644 taiga/feedback/templates/emails/feedback_notification-body-html.jinja create mode 100644 taiga/feedback/templates/emails/feedback_notification-body-text.jinja create mode 100644 taiga/feedback/templates/emails/feedback_notification-subject.jinja create mode 100644 tests/integration/resources_permissions/test_feedback.py create mode 100644 tests/integration/test_feedback.py diff --git a/settings/common.py b/settings/common.py index 72f2a0cf..d726741f 100644 --- a/settings/common.py +++ b/settings/common.py @@ -193,6 +193,7 @@ INSTALLED_APPS = [ "taiga.timeline", "taiga.mdrender", "taiga.export_import", + "taiga.feedback", "rest_framework", "djmail", @@ -323,6 +324,11 @@ TAGS_PREDEFINED_COLORS = ["#fce94f", "#edd400", "#c4a000", "#8ae234", "#5c3566", "#ef2929", "#cc0000", "#a40000", "#2e3436",] +# 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" 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/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/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/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() From 32c5a681c7dd498204d38d5d1152aa7f67e09069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 2 Oct 2014 21:25:49 +0200 Subject: [PATCH 36/42] Add feedback entries to the Admin panel --- taiga/feedback/admin.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 taiga/feedback/admin.py 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) From 49db57b7e4466f2ca1d7d4bd4cdb5527c1a220dd Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 3 Oct 2014 23:16:58 +0200 Subject: [PATCH 37/42] Normalize final attachment filename. --- taiga/projects/attachments/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/taiga/projects/attachments/models.py b/taiga/projects/attachments/models.py index 1f84b7c4..eb444a7d 100644 --- a/taiga/projects/attachments/models.py +++ b/taiga/projects/attachments/models.py @@ -18,6 +18,8 @@ import hashlib import os import os.path as path +from unidecode import unidecode + from django.db import models from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -25,12 +27,16 @@ from django.contrib.contenttypes import generic from django.utils import timezone from django.utils.encoding import force_bytes from django.utils.translation import ugettext_lazy as _ +from django.template.defaultfilters import slugify from taiga.base.utils.iterators import split_by_n def get_attachment_file_path(instance, filename): basename = path.basename(filename).lower() + base, ext = path.splitext(basename) + base = slugify(unidecode(base)) + basename = "".join([base, ext]) hs = hashlib.sha256() hs.update(force_bytes(timezone.now().isoformat())) From 9da22fc91d6628e6828afcd016f701d0e940e852 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Tue, 7 Oct 2014 13:02:30 +0200 Subject: [PATCH 38/42] Create CHANGELOG.md --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..9bbc3632 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ + +# 1.0.0 taiga-front (2014-10-07) + +### Misc +- Lots of small and not so small bugfixes + +### Features +- Redesign for taskboard and backlog summaries +- Allow feedback for users from the platform +- Real time changes for backlog, taskboard, kanban and issues From a1eec49d89ed24f403da8db4c4764a35474d0877 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Tue, 7 Oct 2014 13:03:37 +0200 Subject: [PATCH 39/42] Fixing typos in CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bbc3632..275e949b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ -# 1.0.0 taiga-front (2014-10-07) +# 1.0.0 taiga-back (2014-10-07) ### Misc - Lots of small and not so small bugfixes ### Features -- Redesign for taskboard and backlog summaries +- 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 From 55c3e0c7cf8e28dbb6f3d1c34d6f3d835107e70a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Juli=C3=A1n?= Date: Tue, 7 Oct 2014 15:10:19 +0200 Subject: [PATCH 40/42] Updated user-noimage with a non-gender related one --- taiga/users/static/img/user-noimage.png | Bin 1270 -> 6240 bytes taiga/users/static/img/user-noimage_OLD.png | Bin 0 -> 1270 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 taiga/users/static/img/user-noimage_OLD.png diff --git a/taiga/users/static/img/user-noimage.png b/taiga/users/static/img/user-noimage.png index b08a904df2a076bc35508d4f03a97de9aa931cd7..d779bbd4d88f785d5792eb98a16b83746425dfe1 100644 GIT binary patch delta 6214 zcmV-M7`f;63E(i07Ycv~1^@s6Ec^=fks%uj*8l(w*8xH(n|P5%Cw~{+Nkl`##&p9&HZn7!r&|4&m_!zuEP=#~=JAMk9y%(4)(dNRo zC&B5-XK{G^QGdtx9UgxarzfBFphuez+nNMp(W7|mfp7GD|6>n)17p#n6nnHev297< z<3o7*p%=h1e9vvL43DQDdI3H@M6qX^58IXmpFZ_{M8$E}?L@_KeEQV)DfDRbVq22n z?13-f;Mh58?HwFDhqDL1K&`#ahHXcJ-9r!J(PLky-hb|+$G(o;Ll07KcQasHk-*P~ z@$}gjz!3V2qYUBj^w}5S=fl+79yGQM2?z*2bNYFN{kuGSM%cd#pE>^U`~VDLUA5x`3}NxqnIGV17ycNMoORU} z$NCTrjBglh<9)z4)OLL`Kke#yrhTB^m*A0Rgq^L`eI#CRQ zR4t!JK=9<5=MnOc(EKxOA><#ylV_gCFE0HVbXX^B3!G>SusGJ7(T!J8h&p<1-sCrS z3#uf@OU^pjN|5o`18v_+a(12cxTj*0kQE6ZI`k#%8+n*k_Z?&3$iw*1p)cXm^gqJK z1%DB)&*G>!j!1KMkHIfQs6BJ@lYnvuMIi=Jh+#Z-tnE97u-Fqn0Zmt+YBgwD4XS3m z*L96*KN=u}0n6|$|2RB(=Ff3z@^er%#gk`lL|$5<-X>jj;7}6u3YOt95kCp8!@7W` zt57$Pqo!7%*&HZ_VZpQFy1`}`9!v*kYJVbr3XAEhP_)c@0s_{~3@60Z0u;;e7>yo=UBWrR)**oh9UThY2a$L0Yugd? zEa6-apU6ir6l&k#;uc#*9nkJ*K!1oK7NBEr*^wl)M0qSQfq)RB(pCdrsaonJXt;^A z5b(#T3D$m+fWYWGO>7x;0MK<4(}h{o)C!fh?AQdodIlR&Q!7XpW~sCV0F_$76Za7C zhy*mB?nA*W>5nAPpc#GERo87b;FT%>JR$+@4$DdzYlXY6+kdfT7*Zg? zTH!ABS?iT506ZfB{SFtTBnp+3t2PKo7b3WG4*{T1NueMmUA19`tw#cnI-DxZKvvxu zomMlQW!$-kvMM1}m~qiUXLz|d=Z#I^QHOP1L(-*vU`>MFRe=VhZ(c29Z#kQQwwu@r zS&^`ozti*mRvv;I60GI#cz^ac16xoX0QTHOA0I+2IN>-yuuxe=p|aXjzpOljUfsi9 z`ol6jhQs@81(dvW4*?HL*yYLz42LH>d`l1!M-lW712Bq6WJ`;V{?Jt64t#tN0_&`p z1Z{O7t7W7L&Loqc4XnZyt z-iJ`jXWgLd8dkDXj(u^f+3u7Cnyz9cJ7wDrARvf{;}{C<1!<9fq7cPM_y9Q8XL_HP z_5k213EDgE=ffC@8~|^cc|;Hv#}E(QZ(BXoRLSuy?Cd5wcMnsAJE+<8Rt%y~BG@66 z%&`JS!Us&#%(t#2Sbx`Xf`~YVq0rv01MK5N*cCZwyJk?al5#v#%x1h(_b@N5plCC0 z;pfBH)sg)7KnOuJup6=91PHk|G+yU!dPagq$IZll*G@59T|L;_Pq0>;Mb&{lVNL@3 zs-UWru~zK9EU>U*I41X{OL0BBEs|RZ_-HadD zv=7YOM7t!=bk$ZopegvQK##NE z46-U=@9=5Fg7@Ll)K8HqEl_K_0jiQQL60<{o3 z4yPx72j`A_6#{!t1bPuh_3=SV^`Pr|*R^~Cf|1B1L?H@Mh+;TwO{_*vgRYxzED?o> zVf%P+ib}0zBC&+S*~#Cka3Dfi*%&U?pY89*b z+a2%cxqkrsEs3k55Vf>9R`a(_cSsX7T)O+#xpXvPwrYA`{3Jek^1B$SOI){b)$@t? z34HS8cbnpWBit_gT7w`g?lRPaiBVUorje7D+ddb#KnL%DGbKV!T1KgwZu^{TivRZD z4o!5LNR7#NsE2B;8_^R97{cMqRbDoPRQ!d%;Rq7XGApM5w)%SxrPgu|K1XAL#%!V_^!f_n`7n62x* zl?DbH>S1HiDjhnKxhbf+(v<+MZ3$ces;(fJn_5?ZqEkfAE7+Yx6Y&#x;>2@^hk8>F zj(`3VLl^yNNAYilnulxb8xazBVR(ajP_-HeVH_a>a-<$ew-ZuB{(ljC-+$-_ zp=nCjO)#dCpb`9`StNS>QyB=XA3PU;z=;sJ0C-M>z>0>&O?uDyB_Ig;hpAicHMN3L zWvy$aGAlF&T2>^~lrm&hLQRn%E9I`0+T36{`yO7O{-=(rxs3#F#lLGb2_iz^M1v@- zK@^VlgIEy6D*`lNhX}f^Laq<2F@L}kWYu`@nM2T5b-uF|&=qR$Uh7^b#60T%Y?DZY}-qIwEs` zvG#p>@>v`hePmGmvj<&At^R|zf*~x9PJE&--yhx*ymGkAyb?mtY=^WmKDIU0yxHJ z`0k9*d~L+>Go=O7!?0;0Q(6Ea40aDa2wm5pXfhPFdhfOVelr?~om|B>7t!+=K_?JW zAK1n~HwM`-#O#)?3;KsWBSNOMV7n@+4jqarL(%HQXnEgnf^_l<8h>aD-D$dlD|h}4 z$^0}PKJZ1l>Y+K%8)h>r)XyE~Sd-Er@R&l$oBrx9J#hh#an`*HQOQL3(CF?|h+Dx<2D@ZjE0J1&Q-Lx0DO`TxdZ`WgsfFdjSR zxzV{1#>8=kQZQ$Ka&_S;_0TTEX=wzSyXQ(*f6eRMf>=t$*nuiJ#On7#%-AI8D) zbFeRoblgq8j>Ysf{!=L8?oRX&kvIf=KwS2I{IMRXJ^tEeg^g#06( zCY?%hu7BgPI*aLRc;)7gQM4Jm=!Bk002oCgW>?<;#|oyUX#`&Ec~@>XC~6h+Yi}V} zZr@t~7-?;0&#u;q5EgeeC6r#50IdXQfMqyvi~vc_nY2|zURpuGH{_Y^Q&oy>8(>vk z!8^0R#5=S9LFE80S0n(8A8;ji6PeNi#y6-3KYt(bL}V5#Da@v>K<=>T(9ehKrRL4W zezjJ>-0BtVjvKj;Z%qHQk^0u*dY%)ZYBkfUBn|bzGQ20Jp48L|sx7gCVs#C#y!T@) zW$2yGN-f!MIdcR5bL}tj$l)(zIJD1boU(gp>?4@Qn#JM`%kYSY?uV5ppy>+ktX_hu z8-HSUE_DS*#vilXN*)VN7}-K5+tPJiLn8Yg%GEUX#E(<;9$F3S`Sd$@R?kkR1CI4U)fHU1`|r4T>wh0SNl~M2(dfE{tF!-wSKj|SYFU!{`pN}*%WxBy^*4s@f5BOqTOYIrz;e9Nuy|d1}bbpFm zIf2>LH*6Qk>J^@mz%N9gYBkIzFZYyyQBx({OI5usd6UxlVCo^O5gj06FnvGuF5DReG%#aJe_0Ym&>v~?^!saG6vve(co zs^(<793jNm^Jv2WtSM4mJ=j}>l7E#l?j$d*D@)^M^N$^PNP_h~da;r+^m;YZ@t%ge zX-E7w5DDxy-Ii_zvjMLul4WFWBRJM)d#Tz`4>vKt_O_*8pDQn8X63cEuyjD!sw6Pk z1lox2o1F<@i2v7Z?VW2t6e9I7uzhH{f|}NDVQbKAS0}yq{HX0(i+~YjoqtKTs|Q`z zu#~xh#q>4PH1;j54$M2=@bza?Z#LCKK}y!ee|uD}6F5e|Q0RWg{c5;}rr-6j_!155 zv0Z%?XsCxv%^ZcQQo>B~BC-}$$!H&~w-KK04DA*!E>+erv+^23{$XTG?crK05D>(} z_H^<_Sm}PlO|)Nm6)^0BczxjRJL+8V~aQfCwiKf;Ry-+Ld3Jg3#WQ`Bdpay?*%nP zA?oRhsh4(vCnWHy!@bV9R#>Y8cU4skPo*~r)eT;H2wR2(hHpOUT7SY=4YX4?aU%$+ z$1FUMf{nO~7x0Dz9$2$>qjXm2%iH_;Ok&}6LIN(Kuz3w75@@=LYfo?ACjR?5ir5DK@0Eq|j9y!2l`m9|uCnN*U#928rxV zjK_`v#25s*R)*>#y?Y~6U4bO$A)$SCvxIAsf@L@?Wo|&=LYbBIg2e_I=SvFqnMhHvQH14im z#{RLvl;mB?Tw6x~08lj<1u2Pwlx(}6W%wo`?h$1OqAvzNAGSphF9JqXNziJ&>d=i- zL#p>?aeuk_K5ILrycw-6S`FcR5Mr9HbSlvgmHdfU-Gs#ZgxvWh}wwe35GaBY&K z@!l^)ngsE}O{7YKy;{lqkpjb(n zZaHHJ2T_P1=!>r_xcaoPNReRqK-w8EE!qoBwY}GRktg!7!py>*3Ex$zfjAmWO)ba(>QZ?HF+l~Z) zQGfPrX5}Ka_U6~#MqWx#Yj3k*Tao|(QiT~LbGKZ#lPfK`DipaDux&{I0C$s@E$bn4 zgIp_PD&f9T`ew(rCV{4_xV7{Wv|gf+004JaFWWlAO0JbKz2aTXz|DvqLjqmb zaBKM`%ah0&*X#s4pcOlY1b|VM{IzdEv;-h#f@&07w_-v7EVK^7-7rRzU}i z9Y+Gdu>2e61OrCt``d}%4zNdq#f~I_u4|ZDHevv}uHx46%hWc#Zbf*Ufw6f}RU=Bc kop==j7qor!?RLfg0jlx1a;D+emH+?%07*qoM6N<$f-y$23;+NC delta 1205 zcmaE0@QrhVI9DSF8v_Hw^945pCMrsEJz-$reZpw6w%dE6n|eJRyl-rMGe>IDjEF4(0^PT+O}ppisAH)a=9RPX+tUR8E)7);(X%Hfi}PFl=<0aj zc%|9B)hUnBu}fb!C@Fh(SBO{AB(({7W=m(^ocVD<($So6nRDB3rhoby!!`HhnfXs= zpLu6soqX*0KHt_--4oRe^-uaa99R^XIye+G&r~x+-S)^U1lD!UrEb{%vYquKT{;@Yb<~&F-du9%;|aIX(NZqdicbMe#j)mK$}_x=61>h!nw)n1MX9emPf9Q zUaf0A`|+bEm92^GS9g8=<(ppoCpXr$;K7NHd)z0e%$2d6_`ROv|DlHrE)1aX(|RC% zxUsRWcaBWiw!iJ$^S4H=zjSk@yS!wH+3{(=P_X;*$(3j|KFxVfLZwMQZ2O2{gGi<{q)u4X(kueV!z z<3Ry~5(CK1^+>Lt@>%k*O7EKMy^}>vlXe@IItpp}zS&-D{pg5>=UYEccC{&2{%yZ` zQnyK^x1{Xcze}$-C-b!zrd0p$s5*7~liXreK6V8r0YqqkgGF+lVNBn;jG~XRtZGvN zR~#rgF@uklE7j-6_5HH-|Mu5ANe2J<_h`#Hi@k|k*9CeuzAUl1_{ykTA#{=3rYTpS zaXtL>sQTLLZ&S;&vg^(IH}8x+`oGl0+Xd`=xq9BW`+TcIv~JqkKJn9y`5$`q)|Sh) zTbT2eC8T6R%dg+{`)>H}@QQOWKhC_gyT#bf42m&mdbRo2v7;op-mNeuf0qr9_N@TP zj9oUK$ycYPU%%sSoAo#1p3(=iCVzj~=;EK#&Q_QH*vLOyFMdtj)#LGeskYOmTgWtU zI6xvkRl+gf-%i|9%r!q>b$e~Pm}5lN|JjCHj3+-nJ^f~7)4ES?Z|{Db^YxeKtQTL- z&rjJ}{fB?1YXIl(t*`43ZuNg~XO-Z35xXYwZ?y~GnVyTfr`45IJN@$GqKae=NGu^m z5jyiEkKta2GG6k3KO^+U V>U_&zE(Qh$22WQ%mvv4FO#t^rEZG16 diff --git a/taiga/users/static/img/user-noimage_OLD.png b/taiga/users/static/img/user-noimage_OLD.png new file mode 100644 index 0000000000000000000000000000000000000000..b08a904df2a076bc35508d4f03a97de9aa931cd7 GIT binary patch literal 1270 zcmeAS@N?(olHy`uVBq!ia0y~yU}ywk4mJh`hUW`z1~4!%uoOFahH!9jaMW<5bTBY5 za29w(7BevLJ^^7yleOL63=9mCC9V-A!TD(=<%vb94CUqJdYO6I#mR{Use1WE>9gP2 zNP!IUba4!+nDh3oZ-z{&%<+%+jjeCyNKKj%u_ZvD`_{E-_q-f+EH%Ttau$Ajn!w+s zp{gNz_T*%7e#;+S9SFWk1WzX&k@k*McHX+Y!>Fk>`KQ2f*n)5Al zZu`yjPk&>$=AJw=|LN>A?+mPyj~(CV+ghr7qMG4JKZgU00#gTvg65fOhUnXJw^kT@ zS$@+^`Qp;kpDX(lkIv@!^~);soyJq!wyje&&Q-Hy|NE15P*RCOuz^ECsiA{G38Wc~ zabodn&SlH$!xR4Ow5nS=F(a+)*v)Ud%}Z7EURA!QGPT#RvOgf=p38xFCJ^_h_k%w3e4hB)%80E`qfUQ)_FVkS zId=KAyWY%9ZohgpbmOi2;mePil)v3Sy+^+G*MhRQcP@T-T$BNA1nq`flzgwWjCuZSzV`{#<%8ewN#ZXVUAlFYo;x@%H_lBR@lxC8|Dg z%378zczQm5?yO9ygWl?K!8>EN&3b(3==?eE56(VhcqhQv!QjFm$mqf%Am{*MJh>=& zSm#ego)h!m^2oK(t97ksKYsM2vNf^&>aMT9eAA2nqw53BU9x!yZj)HG?gajBz_rth2WwbqZ0Xn4N$ z<78Kxa^>Iln2R2x8o?%Si zx{RWav8-xS0#_U;IWdEel`GZf$MyZP_5b!eNe2J<_h`#Hi@k|k*9CeuzAUl1_{ykT zA#{=3rYTpSaXtL>sQTLLZ&S;&vg^(IH}8x+`oGl0+Xd`+xq9BW`+TcIv~JqkKJn9y z`5$`q)|Sh)TbT2eC8T6R%dg+{`)>H}@QQOWKhC_gyT#bf42mvjQnmTlv7;o}tuQ8k zmkp2htpLf4T{fP{SEr?4zvFJ3^*7?4(g(99e}CEN;-AybR+s+R$Uj>zeofrfH9uc2z3*VWZi@K-Pl~g Date: Tue, 7 Oct 2014 15:06:20 +0200 Subject: [PATCH 41/42] Fix bug #1186: Fixed emails sent when a wiki page is updated --- taiga/projects/history/models.py | 9 +++++++++ .../templates/emails/includes/fields_diff-html.jinja | 11 +++++++++-- .../templates/emails/includes/fields_diff-text.jinja | 4 +++- taiga/projects/history/templatetags/functions.py | 1 + 4 files changed, 22 insertions(+), 3 deletions(-) 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") } From 3618d6eca6d1fc0d20613c25da317691b5d56dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 7 Oct 2014 15:26:28 +0200 Subject: [PATCH 42/42] Remove unnecesary file --- taiga/users/static/img/user-noimage_OLD.png | Bin 1270 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 taiga/users/static/img/user-noimage_OLD.png diff --git a/taiga/users/static/img/user-noimage_OLD.png b/taiga/users/static/img/user-noimage_OLD.png deleted file mode 100644 index b08a904df2a076bc35508d4f03a97de9aa931cd7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1270 zcmeAS@N?(olHy`uVBq!ia0y~yU}ywk4mJh`hUW`z1~4!%uoOFahH!9jaMW<5bTBY5 za29w(7BevLJ^^7yleOL63=9mCC9V-A!TD(=<%vb94CUqJdYO6I#mR{Use1WE>9gP2 zNP!IUba4!+nDh3oZ-z{&%<+%+jjeCyNKKj%u_ZvD`_{E-_q-f+EH%Ttau$Ajn!w+s zp{gNz_T*%7e#;+S9SFWk1WzX&k@k*McHX+Y!>Fk>`KQ2f*n)5Al zZu`yjPk&>$=AJw=|LN>A?+mPyj~(CV+ghr7qMG4JKZgU00#gTvg65fOhUnXJw^kT@ zS$@+^`Qp;kpDX(lkIv@!^~);soyJq!wyje&&Q-Hy|NE15P*RCOuz^ECsiA{G38Wc~ zabodn&SlH$!xR4Ow5nS=F(a+)*v)Ud%}Z7EURA!QGPT#RvOgf=p38xFCJ^_h_k%w3e4hB)%80E`qfUQ)_FVkS zId=KAyWY%9ZohgpbmOi2;mePil)v3Sy+^+G*MhRQcP@T-T$BNA1nq`flzgwWjCuZSzV`{#<%8ewN#ZXVUAlFYo;x@%H_lBR@lxC8|Dg z%378zczQm5?yO9ygWl?K!8>EN&3b(3==?eE56(VhcqhQv!QjFm$mqf%Am{*MJh>=& zSm#ego)h!m^2oK(t97ksKYsM2vNf^&>aMT9eAA2nqw53BU9x!yZj)HG?gajBz_rth2WwbqZ0Xn4N$ z<78Kxa^>Iln2R2x8o?%Si zx{RWav8-xS0#_U;IWdEel`GZf$MyZP_5b!eNe2J<_h`#Hi@k|k*9CeuzAUl1_{ykT zA#{=3rYTpSaXtL>sQTLLZ&S;&vg^(IH}8x+`oGl0+Xd`+xq9BW`+TcIv~JqkKJn9y z`5$`q)|Sh)TbT2eC8T6R%dg+{`)>H}@QQOWKhC_gyT#bf42mvjQnmTlv7;o}tuQ8k zmkp2htpLf4T{fP{SEr?4zvFJ3^*7?4(g(99e}CEN;-AybR+s+R$Uj>zeofrfH9uc2z3*VWZi@K-Pl~g