Merge remote-tracking branch 'upstream/master'

remotes/origin/enhancement/email-actions
Chris Wilson 2015-06-17 20:33:00 +01:00
commit edfb98de72
34 changed files with 6409 additions and 77 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.*.sw*
.#*
*.log
taiga/search
settings/local.py

View File

@ -1,13 +1,16 @@
# Changelog #
## 1.8.0 ??? (unreleased)
## 1.8.0 Saracenia Purpurea (unreleased)
### Features
- Improve timeline resource.
- Add sitemap of taiga-front (the web client).
- Search by reference (thanks to [@artlepool](https://github.com/artlepool))
- Add call 'by_username' to the API resource User
- i18n.
- Add deutsch (de) translation.
- Add nederlands (nl) translation.
### Misc
- Lots of small and not so small bugfixes.

View File

@ -18,3 +18,5 @@ echo "-> Load initial roles"
python manage.py loaddata initial_role --traceback
echo "-> Generate sample data"
python manage.py sample_data --traceback
echo "-> Rebuilding timeline"
python manage.py rebuild_timeline --purge

View File

@ -78,7 +78,7 @@ LANGUAGES = [
#("cs", "Čeština"), # Czech
#("cy", "Cymraeg"), # Welsh
#("da", "Dansk"), # Danish
#("de", "Deutsch"), # German
("de", "Deutsch"), # German
#("el", "Ελληνικά"), # Greek
("en", "English (US)"), # English
#("en-au", "English (Australia)"), # Australian English
@ -122,7 +122,7 @@ LANGUAGES = [
#("my", "မြန်မာ"), # Burmese
#("nb", "Norsk (bokmål)"), # Norwegian Bokmal
#("ne", "नेपाली"), # Nepali
#("nl", "Nederlands"), # Dutch
("nl", "Nederlands"), # Dutch
#("nn", "Norsk (nynorsk)"), # Norwegian Nynorsk
#("os", "Ирон æвзаг"), # Ossetic
#("pa", "ਪੰਜਾਬੀ"), # Punjabi

View File

@ -25,8 +25,8 @@ COORS_ALLOWED_HEADERS = ["content-type", "x-requested-with",
"x-session-id"]
COORS_ALLOWED_CREDENTIALS = True
COORS_EXPOSE_HEADERS = ["x-pagination-count", "x-paginated", "x-paginated-by",
"x-paginated-by", "x-pagination-current", "x-site-host",
"x-site-register"]
"x-pagination-current", "x-pagination-next", "x-pagination-prev",
"x-site-host", "x-site-register"]
class CoorsMiddleware(object):

View File

@ -379,7 +379,7 @@
}
</style>
</head>
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0">
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0" alink="#699b05" link="#699b05" bgcolor="#FFFFFF" text="#444444">
<center>
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable">
<tr>

View File

@ -340,7 +340,7 @@
}
</style>
</head>
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0">
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0" alink="#699b05" link="#699b05" bgcolor="#FFFFFF" text="#444444">
<center>
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable">
<tr>

View File

@ -379,7 +379,7 @@
}
</style>
</head>
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0">
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0" alink="#699b05" link="#699b05" bgcolor="#FFFFFF" text="#444444">
<center>
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable">
<tr>

View File

@ -241,7 +241,9 @@ class HistoryExportSerializerMixin(serializers.ModelSerializer):
history = serializers.SerializerMethodField("get_history")
def get_history(self, obj):
history_qs = history_service.get_history_queryset_by_model_instance(obj)
history_qs = history_service.get_history_queryset_by_model_instance(obj,
types=(history_models.HistoryType.change, history_models.HistoryType.create,))
return HistoryExportSerializer(history_qs, many=True).data

View File

@ -22,7 +22,7 @@ from django.template.defaultfilters import slugify
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from taiga.projects.history.services import make_key_from_model_object
from taiga.projects.history.services import make_key_from_model_object, take_snapshot
from taiga.timeline.service import build_project_namespace
from taiga.projects.references import sequences as seq
from taiga.projects.references import models as refs
@ -229,9 +229,13 @@ def store_task(project, data):
for task_attachment in data.get("attachments", []):
store_attachment(project, serialized.object, task_attachment)
for history in data.get("history", []):
history_entries = data.get("history", [])
for history in history_entries:
store_history(project, serialized.object, history)
if not history_entries:
take_snapshot(serialized.object, user=serialized.object.owner)
custom_attributes_values = data.get("custom_attributes_values", None)
if custom_attributes_values:
custom_attributes = serialized.object.project.taskcustomattributes.all().values('id', 'name')
@ -319,9 +323,13 @@ def store_wiki_page(project, wiki_page):
for attachment in wiki_page.get("attachments", []):
store_attachment(project, serialized.object, attachment)
for history in wiki_page.get("history", []):
history_entries = wiki_page.get("history", [])
for history in history_entries:
store_history(project, serialized.object, history)
if not history_entries:
take_snapshot(serialized.object, user=serialized.object.owner)
return serialized
add_errors("wiki_pages", serialized.errors)
@ -381,9 +389,13 @@ def store_user_story(project, data):
for role_point in data.get("role_points", []):
store_role_point(project, serialized.object, role_point)
for history in data.get("history", []):
history_entries = data.get("history", [])
for history in history_entries:
store_history(project, serialized.object, history)
if not history_entries:
take_snapshot(serialized.object, user=serialized.object.owner)
custom_attributes_values = data.get("custom_attributes_values", None)
if custom_attributes_values:
custom_attributes = serialized.object.project.userstorycustomattributes.all().values('id', 'name')
@ -434,9 +446,13 @@ def store_issue(project, data):
for attachment in data.get("attachments", []):
store_attachment(project, serialized.object, attachment)
for history in data.get("history", []):
history_entries = data.get("history", [])
for history in history_entries:
store_history(project, serialized.object, history)
if not history_entries:
take_snapshot(serialized.object, user=serialized.object.owner)
custom_attributes_values = data.get("custom_attributes_values", None)
if custom_attributes_values:
custom_attributes = serialized.object.project.issuecustomattributes.all().values('id', 'name')

View File

@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-09 09:47+0200\n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
"PO-Revision-Date: 2015-06-09 07:47+0000\n"
"Last-Translator: Taiga Dev Team <support@taiga.io>\n"
"Language-Team: Catalan (http://www.transifex.com/projects/p/taiga-back/"
@ -2554,7 +2554,7 @@ msgstr ""
msgid "Stakeholder"
msgstr ""
#: taiga/projects/userstories/api.py:173
#: taiga/projects/userstories/api.py:174
#, python-brace-format
msgid ""
"Generating the user story [US #{ref} - {subject}](:us:{ref} \"US #{ref} - "

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-09 09:47+0200\n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
"PO-Revision-Date: 2015-03-25 20:09+0100\n"
"Last-Translator: Taiga Dev Team <support@taiga.io>\n"
"Language-Team: Taiga Dev Team <support@taiga.io>\n"
@ -2503,7 +2503,7 @@ msgstr ""
msgid "Stakeholder"
msgstr ""
#: taiga/projects/userstories/api.py:173
#: taiga/projects/userstories/api.py:174
#, python-brace-format
msgid ""
"Generating the user story [US #{ref} - {subject}](:us:{ref} \"US #{ref} - "

View File

@ -12,9 +12,9 @@ msgid ""
msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-09 09:47+0200\n"
"PO-Revision-Date: 2015-06-09 07:47+0000\n"
"Last-Translator: Taiga Dev Team <support@taiga.io>\n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
"PO-Revision-Date: 2015-06-17 07:24+0000\n"
"Last-Translator: David Barragán <bameda@gmail.com>\n"
"Language-Team: Spanish (http://www.transifex.com/projects/p/taiga-back/"
"language/es/)\n"
"MIME-Version: 1.0\n"
@ -477,6 +477,9 @@ msgid ""
"%(comment)s</p>\n"
" "
msgstr ""
"\n"
"<h3>comentario:</h3>\n"
"<p>%(comment)s</p>"
#: taiga/base/templates/emails/updates-body-text.jinja:6
#, python-format
@ -851,7 +854,7 @@ msgid ""
msgstr ""
"\n"
"<h1>Feedback</h1>\n"
"<p>Taiga ha recivido feedback de %(full_name)s <%(email)s></p>"
"<p>Taiga ha recibido feedback de %(full_name)s <%(email)s></p>"
#: taiga/feedback/templates/emails/feedback_notification-body-html.jinja:9
#, python-format
@ -2477,7 +2480,7 @@ msgstr "La versión debe ser un número entero"
#: taiga/projects/occ/mixins.py:58
msgid "The version parameter is not valid"
msgstr ""
msgstr "La versión no es válida"
#: taiga/projects/occ/mixins.py:74
msgid "The version doesn't match with the current one"
@ -2961,7 +2964,7 @@ msgstr "Product Owner"
msgid "Stakeholder"
msgstr "Stakeholder"
#: taiga/projects/userstories/api.py:173
#: taiga/projects/userstories/api.py:174
#, python-brace-format
msgid ""
"Generating the user story [US #{ref} - {subject}](:us:{ref} \"US #{ref} - "
@ -3165,7 +3168,7 @@ msgstr "idioma por defecto"
#: taiga/users/models.py:122
msgid "default theme"
msgstr ""
msgstr "tema por defecto"
#: taiga/users/models.py:124
msgid "default timezone"

View File

@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-09 09:47+0200\n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
"PO-Revision-Date: 2015-06-09 07:47+0000\n"
"Last-Translator: Taiga Dev Team <support@taiga.io>\n"
"Language-Team: Finnish (http://www.transifex.com/projects/p/taiga-back/"
@ -2954,7 +2954,7 @@ msgstr "Tuoteomistaja"
msgid "Stakeholder"
msgstr "Sidosryhmä"
#: taiga/projects/userstories/api.py:173
#: taiga/projects/userstories/api.py:174
#, python-brace-format
msgid ""
"Generating the user story [US #{ref} - {subject}](:us:{ref} \"US #{ref} - "

View File

@ -8,15 +8,16 @@
# Florent B. <me@kxrz.me>, 2015
# Louis-Michel Couture <louim_1@hotmail.com>, 2015
# Matthieu Durocher <matthieu@technocyclope.com>, 2015
# Nlko <nospam1@thomasson.fr>, 2015
# Stéphane Mor <stephanemor@gmail.com>, 2015
# William Godin <williamgodin@gmail.com>, 2015
msgid ""
msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-09 09:47+0200\n"
"PO-Revision-Date: 2015-06-09 07:47+0000\n"
"Last-Translator: Taiga Dev Team <support@taiga.io>\n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
"PO-Revision-Date: 2015-06-12 21:30+0000\n"
"Last-Translator: Nlko <nospam1@thomasson.fr>\n"
"Language-Team: French (http://www.transifex.com/projects/p/taiga-back/"
"language/fr/)\n"
"MIME-Version: 1.0\n"
@ -469,6 +470,9 @@ msgid ""
"%(comment)s</p>\n"
" "
msgstr ""
"\n"
"<h3>commentaire:</h3>\n"
"<p>%(comment)s</p>"
#: taiga/base/templates/emails/updates-body-text.jinja:6
#, python-format
@ -894,6 +898,9 @@ msgid ""
"\n"
"{message}"
msgstr ""
"Commentaire provenant de GitHub:\n"
"\n"
"{message}"
#: taiga/hooks/gitlab/event_hooks.py:87
msgid "Status changed from GitLab commit"
@ -1800,6 +1807,8 @@ msgid ""
"\n"
"[%(project)s] Created the sprint \"%(milestone)s\"\n"
msgstr ""
"\n"
"[%(project)s] Sprint \"%(milestone)s\" créé\n"
#: taiga/projects/notifications/templates/emails/milestones/milestone-delete-body-html.jinja:4
#, python-format
@ -1831,6 +1840,8 @@ msgid ""
"\n"
"[%(project)s] Deleted the Sprint \"%(milestone)s\"\n"
msgstr ""
"\n"
"[%(project)s] Sprint \"%(milestone)s\" Éffacé\n"
#: taiga/projects/notifications/templates/emails/tasks/task-change-body-html.jinja:4
#, python-format
@ -1860,6 +1871,8 @@ msgid ""
"\n"
"[%(project)s] Updated the task #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
"[%(project)s] Tâche #%(ref)s \"%(subject)s\" mise à jour\n"
#: taiga/projects/notifications/templates/emails/tasks/task-create-body-html.jinja:4
#, python-format
@ -1893,6 +1906,8 @@ msgid ""
"\n"
"[%(project)s] Created the task #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
"[%(project)s] Tâche #%(ref)s \"%(subject)s\" créée\n"
#: taiga/projects/notifications/templates/emails/tasks/task-delete-body-html.jinja:4
#, python-format
@ -1924,6 +1939,8 @@ msgid ""
"\n"
"[%(project)s] Deleted the task #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
"[%(project)s] Tâche #%(ref)s \"%(subject)s\" supprimée\n"
#: taiga/projects/notifications/templates/emails/userstories/userstory-change-body-html.jinja:4
#, python-format
@ -1953,6 +1970,8 @@ msgid ""
"\n"
"[%(project)s] Updated the US #%(ref)s \"%(subject)s\"\n"
msgstr ""
"\n"
"[%(project)s] US #%(ref)s \"%(subject)s\" mise à jour\n"
#: taiga/projects/notifications/templates/emails/userstories/userstory-create-body-html.jinja:4
#, python-format
@ -2594,7 +2613,7 @@ msgstr "Product Owner"
msgid "Stakeholder"
msgstr "Participant"
#: taiga/projects/userstories/api.py:173
#: taiga/projects/userstories/api.py:174
#, python-brace-format
msgid ""
"Generating the user story [US #{ref} - {subject}](:us:{ref} \"US #{ref} - "

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ msgid ""
msgstr ""
"Project-Id-Version: taiga-back\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-06-09 09:47+0200\n"
"POT-Creation-Date: 2015-06-15 12:34+0200\n"
"PO-Revision-Date: 2015-06-09 07:47+0000\n"
"Last-Translator: Taiga Dev Team <support@taiga.io>\n"
"Language-Team: Chinese Traditional (http://www.transifex.com/projects/p/"
@ -2946,7 +2946,7 @@ msgstr "產品所有人"
msgid "Stakeholder"
msgstr "利害關係人"
#: taiga/projects/userstories/api.py:173
#: taiga/projects/userstories/api.py:174
#, python-brace-format
msgid ""
"Generating the user story [US #{ref} - {subject}](:us:{ref} \"US #{ref} - "

View File

@ -98,6 +98,7 @@ class BasicUserStoryStatusSerializer(serializers.ModelSerializer):
class Meta:
model = models.UserStoryStatus
i18n_fields = ("name",)
fields = ("name", "color")
@ -128,6 +129,7 @@ class BasicTaskStatusSerializerSerializer(serializers.ModelSerializer):
class Meta:
model = models.TaskStatus
i18n_fields = ("name",)
fields = ("name", "color")
@ -170,6 +172,7 @@ class BasicIssueStatusSerializer(serializers.ModelSerializer):
class Meta:
model = models.IssueStatus
i18n_fields = ("name",)
fields = ("name", "color")
@ -273,6 +276,7 @@ class ProjectMembershipSerializer(serializers.ModelSerializer):
full_name = serializers.CharField(source='user.get_full_name', required=False)
username = serializers.CharField(source='user.username', required=False)
color = serializers.CharField(source='user.color', required=False)
is_active = serializers.BooleanField(source='user.is_active', required=False)
photo = serializers.SerializerMethodField("get_photo")
class Meta:

View File

@ -26,7 +26,7 @@ from taiga.projects.validators import ProjectExistsValidator
from taiga.projects.validators import UserStoryStatusExistsValidator
from taiga.projects.userstories.validators import UserStoryExistsValidator
from taiga.projects.notifications.validators import WatchersValidator
from taiga.projects.serializers import UserStoryStatusSerializer
from taiga.projects.serializers import BasicUserStoryStatusSerializer
from taiga.users.serializers import BasicInfoSerializer as UserBasicInfoSerializer
from . import models
@ -53,7 +53,7 @@ class UserStorySerializer(WatchersValidator, serializers.ModelSerializer):
origin_issue = serializers.SerializerMethodField("get_origin_issue")
blocked_note_html = serializers.SerializerMethodField("get_blocked_note_html")
description_html = serializers.SerializerMethodField("get_description_html")
status_extra_info = UserStoryStatusSerializer(source="status", required=False, read_only=True)
status_extra_info = BasicUserStoryStatusSerializer(source="status", required=False, read_only=True)
assigned_to_extra_info = UserBasicInfoSerializer(source="assigned_to", required=False, read_only=True)
class Meta:

View File

@ -32,3 +32,5 @@ class TimelineAppConfig(AppConfig):
sender=apps.get_model("projects", "Membership"))
signals.post_delete.connect(handlers.delete_membership_push_to_timeline,
sender=apps.get_model("projects", "Membership"))
signals.post_save.connect(handlers.create_user_push_to_timeline,
sender=apps.get_model("users", "User"))

View File

@ -0,0 +1,36 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf import settings
from django.core.management.base import BaseCommand
from taiga.timeline.models import Timeline
from taiga.projects.models import Project
class Command(BaseCommand):
help = 'Regenerate unnecessary new memberships entry lines'
def handle(self, *args, **options):
debug_enabled = settings.DEBUG
if debug_enabled:
print("Please, execute this script only with DEBUG mode disabled (DEBUG=False)")
return
removing_timeline_ids = []
for t in Timeline.objects.filter(event_type="projects.membership.create").order_by("created"):
print(t.created)
if t.project.owner.id == t.data["user"].get("id", None):
removing_timeline_ids.append(t.id)
Timeline.objects.filter(id__in=removing_timeline_ids).delete()

View File

@ -46,13 +46,16 @@ class BulkCreator(object):
self.timeline_objects = []
self.created = None
def createElement(self, element):
def create_element(self, element):
self.timeline_objects.append(element)
if len(self.timeline_objects) > 1000:
Timeline.objects.bulk_create(self.timeline_objects, batch_size=1000)
del self.timeline_objects
self.timeline_objects = []
gc.collect()
self.flush()
def flush(self):
Timeline.objects.bulk_create(self.timeline_objects, batch_size=1000)
del self.timeline_objects
self.timeline_objects = []
gc.collect()
bulk_creator = BulkCreator()
@ -63,7 +66,7 @@ def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, n
event_type_key = _get_impl_key_from_model(instance.__class__, event_type)
impl = _timeline_impl_map.get(event_type_key, None)
bulk_creator.createElement(Timeline(
bulk_creator.create_element(Timeline(
content_object=obj,
namespace=namespace,
event_type=event_type_key,
@ -74,13 +77,15 @@ def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, n
))
def generate_timeline(initial_date, final_date):
if initial_date or final_date:
def generate_timeline(initial_date, final_date, project_id):
if initial_date or final_date or project_id:
timelines = Timeline.objects.all()
if initial_date:
timelines = timelines.filter(created__gte=initial_date)
if final_date:
timelines = timelines.filter(created__lt=final_date)
if project_id:
timelines = timelines.filter(project__id=project_id)
timelines.delete()
@ -97,6 +102,22 @@ def generate_timeline(initial_date, final_date):
projects = projects.filter(created_date__lt=final_date)
history_entries = history_entries.filter(created_at__lt=final_date)
if project_id:
project = Project.objects.get(id=project_id)
us_keys = ['userstories.userstory:%s'%(id) for id in project.user_stories.values_list("id", flat=True)]
tasks_keys = ['tasks.task:%s'%(id) for id in project.tasks.values_list("id", flat=True)]
issue_keys = ['issues.issue:%s'%(id) for id in project.issues.values_list("id", flat=True)]
wiki_keys = ['wiki.wikipage:%s'%(id) for id in project.wiki_pages.values_list("id", flat=True)]
keys = us_keys + tasks_keys + issue_keys + wiki_keys
projects = projects.filter(id=project_id)
history_entries = history_entries.filter(key__in=keys)
#Memberships
for membership in project.memberships.exclude(user=None).exclude(user=project.owner):
bulk_creator.created = membership.created_at
_push_to_timelines(project, membership.user, membership, "create")
for project in projects.iterator():
bulk_creator.created = project.created_date
print("Project:", bulk_creator.created)
@ -115,6 +136,8 @@ def generate_timeline(initial_date, final_date):
except ObjectDoesNotExist as e:
print("Ignoring")
bulk_creator.flush()
class Command(BaseCommand):
help = 'Regenerate project timeline'
@ -136,6 +159,12 @@ class Command(BaseCommand):
dest='final_date',
default=None,
help='Final date for timeline generation'),
) + (
make_option('--project',
action='store',
dest='project',
default=None,
help='Selected project id for timeline generation'),
)
@ -148,4 +177,4 @@ class Command(BaseCommand):
if options["purge"] == True:
Timeline.objects.all().delete()
generate_timeline(options["initial_date"], options["final_date"])
generate_timeline(options["initial_date"], options["final_date"], options["project"])

View File

@ -0,0 +1,98 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Examples:
# python manage.py rebuild_timeline_for_user_creation --settings=settings.local_timeline
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.management.base import BaseCommand
from django.db.models import Model
from django.db import reset_queries
from taiga.timeline.service import (_get_impl_key_from_model,
_timeline_impl_map, extract_user_info)
from taiga.timeline.models import Timeline
from taiga.timeline.signals import _push_to_timelines
from taiga.users.models import User
from unittest.mock import patch
import gc
class BulkCreator(object):
def __init__(self):
self.timeline_objects = []
self.created = None
def create_element(self, element):
self.timeline_objects.append(element)
if len(self.timeline_objects) > 1000:
self.flush()
def flush(self):
Timeline.objects.bulk_create(self.timeline_objects, batch_size=1000)
del self.timeline_objects
self.timeline_objects = []
gc.collect()
bulk_creator = BulkCreator()
def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, namespace:str="default", extra_data:dict={}):
assert isinstance(obj, Model), "obj must be a instance of Model"
assert isinstance(instance, Model), "instance must be a instance of Model"
event_type_key = _get_impl_key_from_model(instance.__class__, event_type)
impl = _timeline_impl_map.get(event_type_key, None)
bulk_creator.create_element(Timeline(
content_object=obj,
namespace=namespace,
event_type=event_type_key,
project=None,
data=impl(instance, extra_data=extra_data),
data_content_type = ContentType.objects.get_for_model(instance.__class__),
created = bulk_creator.created,
))
def generate_timeline():
with patch('taiga.timeline.service._add_to_object_timeline', new=custom_add_to_object_timeline):
# Users api wasn't a HistoryResourceMixin so we can't interate on the HistoryEntries in this case
users = User.objects.order_by("date_joined")
for user in users.iterator():
bulk_creator.created = user.date_joined
print("User:", user.date_joined)
extra_data = {
"values_diff": {},
"user": extract_user_info(user),
}
_push_to_timelines(None, user, user, "create", extra_data=extra_data)
del extra_data
bulk_creator.flush()
class Command(BaseCommand):
help = 'Regenerate project timeline'
def handle(self, *args, **options):
debug_enabled = settings.DEBUG
if debug_enabled:
print("Please, execute this script only with DEBUG mode disabled (DEBUG=False)")
return
generate_timeline()

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('timeline', '0003_auto_20150410_0829'),
]
operations = [
migrations.AlterField(
model_name='timeline',
name='project',
field=models.ForeignKey(null=True, to='projects.Project'),
preserve_default=True,
),
]

View File

@ -31,7 +31,7 @@ class Timeline(models.Model):
content_object = GenericForeignKey('content_type', 'object_id')
namespace = models.CharField(max_length=250, default="default", db_index=True)
event_type = models.CharField(max_length=250, db_index=True)
project = models.ForeignKey(Project)
project = models.ForeignKey(Project, null=True)
data = JsonField()
data_content_type = models.ForeignKey(ContentType, related_name="data_timelines")
created = models.DateTimeField(default=timezone.now)

View File

@ -43,6 +43,7 @@ class TimelineDataJsonField(serializers.WritableField):
"photo": get_photo_or_gravatar_url(user),
"big_photo": get_big_photo_or_gravatar_url(user),
"username": user.username,
"date_joined": user.date_joined,
}
except User.DoesNotExist:
pass

View File

@ -57,11 +57,15 @@ def _add_to_object_timeline(obj:object, instance:object, event_type:str, namespa
event_type_key = _get_impl_key_from_model(instance.__class__, event_type)
impl = _timeline_impl_map.get(event_type_key, None)
project = None
if hasattr(instance, "project"):
project = instance.project
Timeline.objects.create(
content_object=obj,
namespace=namespace,
event_type=event_type_key,
project=instance.project,
project=project,
data=impl(instance, extra_data=extra_data),
data_content_type = ContentType.objects.get_for_model(instance.__class__),
)
@ -96,8 +100,8 @@ def get_timeline(obj, namespace=None):
def filter_timeline_for_user(timeline, user):
# Filtering public projects
tl_filter = Q(project__is_private=False)
# Filtering entities from public projects or entities without project
tl_filter = Q(project__is_private=False) | Q(project=None)
# Filtering private project with some public parts
content_types = {
@ -115,6 +119,10 @@ def filter_timeline_for_user(timeline, user):
project__anon_permissions__contains=[content_type_key],
data_content_type=content_type)
# There is no specific permission for seeing new memberships
membership_content_type = ContentType.objects.get(app_label="projects", model="membership")
tl_filter |= Q(project__is_private=True, data_content_type=membership_content_type)
# Filtering private projects where user is member
if not user.is_anonymous():
membership_model = apps.get_model('projects', 'Membership')

View File

@ -34,10 +34,11 @@ def _push_to_timeline(*args, **kwargs):
def _push_to_timelines(project, user, obj, event_type, extra_data={}):
if project is not None:
# Project timeline
_push_to_timeline(project, obj, event_type,
namespace=build_project_namespace(project),
extra_data=extra_data)
_push_to_timeline(project, obj, event_type,
namespace=build_project_namespace(project),
extra_data=extra_data)
# User timeline
_push_to_timeline(user, obj, event_type,
@ -56,18 +57,18 @@ def _push_to_timelines(project, user, obj, event_type, extra_data={}):
if watchers:
related_people |= watchers
# Team
team_members_ids = project.memberships.filter(user__isnull=False).values_list("id", flat=True)
team = User.objects.filter(id__in=team_members_ids)
related_people |= team
if project is not None:
# Team
team_members_ids = project.memberships.filter(user__isnull=False).values_list("id", flat=True)
team = User.objects.filter(id__in=team_members_ids)
related_people |= team
related_people = related_people.distinct()
related_people = related_people.distinct()
_push_to_timeline(related_people, obj, event_type,
namespace=build_user_namespace(user),
extra_data=extra_data)
_push_to_timeline(related_people, obj, event_type,
namespace=build_user_namespace(user),
extra_data=extra_data)
#Related people: team members
#Related people: team members
def on_new_history_entry(sender, instance, created, **kwargs):
@ -98,12 +99,18 @@ def on_new_history_entry(sender, instance, created, **kwargs):
"comment_html": instance.comment_html,
}
# Detect deleted comment
if instance.delete_comment_date:
extra_data["comment_deleted"] = True
_push_to_timelines(project, user, obj, event_type, extra_data=extra_data)
def create_membership_push_to_timeline(sender, instance, **kwargs):
# Creating new membership with associated user
if not instance.pk and instance.user:
# If the user is the project owner we don't do anything because that info will
# be shown in created project timeline entry
if not instance.pk and instance.user and instance.user != instance.project.owner:
_push_to_timelines(instance.project, instance.user, instance, "create")
#Updating existing membership
@ -120,3 +127,10 @@ def create_membership_push_to_timeline(sender, instance, **kwargs):
def delete_membership_push_to_timeline(sender, instance, **kwargs):
if instance.user:
_push_to_timelines(instance.project, instance.user, instance, "delete")
def create_user_push_to_timeline(sender, instance, created, **kwargs):
if created:
project = None
user = instance
_push_to_timelines(project, user, user, "create")

View File

@ -105,3 +105,11 @@ def membership_timeline(instance, extra_data={}):
}
result.update(extra_data)
return result
@register_timeline_implementation("users.user", "create")
def user_timeline(instance, extra_data={}):
result = {
"user": service.extract_user_info(instance),
}
result.update(extra_data)
return result

View File

@ -44,7 +44,7 @@ class UserPermission(TaigaResourcePermission):
me_perms = IsAuthenticated()
remove_avatar_perms = IsAuthenticated()
starred_perms = AllowAny()
change_email_perms = IsTheSameUser()
change_email_perms = AllowAny()
contacts_perms = AllowAny()

View File

@ -272,9 +272,10 @@ def test_user_action_password_recovery(client, data):
def test_user_action_change_email(client, data):
url = reverse('users-change-email')
data.registered_user.email_token = "test-token"
data.registered_user.new_email = "new@email.com"
data.registered_user.save()
def after_each_request():
data.registered_user.email_token = "test-token"
data.registered_user.new_email = "new@email.com"
data.registered_user.save()
users = [
None,
@ -283,5 +284,6 @@ def test_user_action_change_email(client, data):
]
patch_data = json.dumps({"email_token": "test-token"})
results = helper_test_http_method(client, 'post', url, patch_data, users)
assert results == [401, 204, 400]
after_each_request()
results = helper_test_http_method(client, 'post', url, patch_data, users, after_each_request=after_each_request)
assert results == [204, 204, 204]

View File

@ -36,7 +36,7 @@ def test_add_to_object_timeline():
service._add_to_object_timeline(user1, task, "test")
assert Timeline.objects.filter(object_id=user1.id).count() == 1
assert Timeline.objects.filter(object_id=user1.id).count() == 2
assert Timeline.objects.order_by("-id")[0].data == id(task)
@ -59,9 +59,9 @@ def test_get_timeline():
service._add_to_object_timeline(user1, task4, "test")
service._add_to_object_timeline(user2, task1, "test")
assert Timeline.objects.filter(object_id=user1.id).count() == 4
assert Timeline.objects.filter(object_id=user2.id).count() == 1
assert Timeline.objects.filter(object_id=user3.id).count() == 0
assert Timeline.objects.filter(object_id=user1.id).count() == 5
assert Timeline.objects.filter(object_id=user2.id).count() == 2
assert Timeline.objects.filter(object_id=user3.id).count() == 1
def test_filter_timeline_no_privileges():
@ -72,7 +72,7 @@ def test_filter_timeline_no_privileges():
service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: str(id(x)))
service._add_to_object_timeline(user1, task1, "test")
timeline = Timeline.objects.all()
timeline = Timeline.objects.exclude(event_type="users.user.create")
timeline = service.filter_timeline_for_user(timeline, user2)
assert timeline.count() == 0
@ -88,7 +88,7 @@ def test_filter_timeline_public_project():
service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: str(id(x)))
service._add_to_object_timeline(user1, task1, "test")
service._add_to_object_timeline(user1, task2, "test")
timeline = Timeline.objects.all()
timeline = Timeline.objects.exclude(event_type="users.user.create")
timeline = service.filter_timeline_for_user(timeline, user2)
assert timeline.count() == 1
@ -104,7 +104,7 @@ def test_filter_timeline_private_project_anon_permissions():
service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: str(id(x)))
service._add_to_object_timeline(user1, task1, "test")
service._add_to_object_timeline(user1, task2, "test")
timeline = Timeline.objects.all()
timeline = Timeline.objects.exclude(event_type="users.user.create")
timeline = service.filter_timeline_for_user(timeline, user2)
assert timeline.count() == 1
@ -123,9 +123,9 @@ def test_filter_timeline_private_project_member_permissions():
service.register_timeline_implementation("tasks.task", "test", lambda x, extra_data=None: str(id(x)))
service._add_to_object_timeline(user1, task1, "test")
service._add_to_object_timeline(user1, task2, "test")
timeline = Timeline.objects.all()
timeline = Timeline.objects.exclude(event_type="users.user.create")
timeline = service.filter_timeline_for_user(timeline, user2)
assert timeline.count() == 1
assert timeline.count() == 3
def test_create_project_timeline():

View File

@ -93,6 +93,18 @@ def test_validate_requested_email_change(client):
assert user.new_email is None
assert user.email == "new@email.com"
def test_validate_requested_email_change_for_anonymous_user(client):
user = f.UserFactory.create(email_token="change_email_token", new_email="new@email.com")
url = reverse('users-change-email')
data = {"email_token": "change_email_token"}
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 204
user = models.User.objects.get(pk=user.id)
assert user.email_token is None
assert user.new_email is None
assert user.email == "new@email.com"
def test_validate_requested_email_change_without_token(client):
user = f.UserFactory.create(email_token="change_email_token", new_email="new@email.com")