diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py
index 54c9eefb..1e1038cb 100644
--- a/taiga/projects/history/freeze_impl.py
+++ b/taiga/projects/history/freeze_impl.py
@@ -96,10 +96,13 @@ def _common_users_values(diff):
return values
+def project_values(diff):
+ values = _common_users_values(diff)
+ return values
+
def milestone_values(diff):
values = _common_users_values(diff)
-
return values
diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py
index 3be5e8da..593eebad 100644
--- a/taiga/projects/history/services.py
+++ b/taiga/projects/history/services.py
@@ -367,12 +367,14 @@ register_freeze_implementation("issues.issue", issue_freezer)
register_freeze_implementation("tasks.task", task_freezer)
register_freeze_implementation("wiki.wikipage", wikipage_freezer)
+from .freeze_impl import project_values
from .freeze_impl import milestone_values
from .freeze_impl import userstory_values
from .freeze_impl import issue_values
from .freeze_impl import task_values
from .freeze_impl import wikipage_values
+register_values_implementation("projects.project", project_values)
register_values_implementation("milestones.milestone", milestone_values)
register_values_implementation("userstories.userstory", userstory_values)
register_values_implementation("issues.issue", issue_values)
diff --git a/taiga/projects/models.py b/taiga/projects/models.py
index 39d294db..1dc89966 100644
--- a/taiga/projects/models.py
+++ b/taiga/projects/models.py
@@ -281,6 +281,11 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
user_stories = user_stories.prefetch_related('role_points', 'role_points__points')
return self._get_user_stories_points(user_stories)
+
+ @property
+ def project(self):
+ return self
+
@property
def future_team_increment(self):
team_increment = self._get_points_increment(False, True)
diff --git a/taiga/timeline/apps.py b/taiga/timeline/apps.py
index c5d8e4a2..f7790905 100644
--- a/taiga/timeline/apps.py
+++ b/taiga/timeline/apps.py
@@ -19,6 +19,7 @@ from django.apps import apps
from django.db.models import signals
from . import signals as handlers
+from taiga.projects.history.models import HistoryEntry
class TimelineAppConfig(AppConfig):
@@ -26,13 +27,8 @@ class TimelineAppConfig(AppConfig):
verbose_name = "Timeline"
def ready(self):
- signals.post_save.connect(handlers.create_project_push_to_timeline,
- sender=apps.get_model("projects", "Project"))
- signals.post_save.connect(handlers.create_user_story_push_to_timeline,
- sender=apps.get_model("userstories", "UserStory"))
- signals.post_save.connect(handlers.create_issue_push_to_timeline,
- sender=apps.get_model("issues", "Issue"))
+ signals.post_save.connect(handlers.on_new_history_entry, sender=HistoryEntry, dispatch_uid="timeline")
signals.pre_save.connect(handlers.create_membership_push_to_timeline,
- sender=apps.get_model("projects", "Membership"))
+ sender=apps.get_model("projects", "Membership"))
signals.post_delete.connect(handlers.delete_membership_push_to_timeline,
- sender=apps.get_model("projects", "Membership"))
+ sender=apps.get_model("projects", "Membership"))
diff --git a/taiga/timeline/migrations/0002_auto_20150327_1056.py b/taiga/timeline/migrations/0002_auto_20150327_1056.py
new file mode 100644
index 00000000..e5290c62
--- /dev/null
+++ b/taiga/timeline/migrations/0002_auto_20150327_1056.py
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import Model
+
+from taiga.projects.models import Project
+from taiga.projects.history import services as history_services
+from taiga.projects.history.choices import HistoryType
+from taiga.projects.history.models import HistoryEntry
+from taiga.timeline.models import Timeline
+from taiga.timeline.service import (_add_to_object_timeline, _get_impl_key_from_model,
+ _timeline_impl_map)
+from taiga.timeline.signals import on_new_history_entry, _push_to_timelines
+from taiga.users.models import User
+
+from unittest.mock import patch
+from django.contrib.contenttypes.models import ContentType
+
+import django_pgjson.fields
+import django.utils.timezone
+
+
+timelime_objects = []
+created = None
+
+def custom_add_to_object_timeline(obj:object, instance:object, event_type:str, namespace:str="default", extra_data:dict={}):
+ global created
+ global timelime_objects
+ 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)
+
+ timelime_objects.append(Timeline(
+ content_object=obj,
+ namespace=namespace,
+ event_type=event_type_key,
+ project=instance.project,
+ data=impl(instance, extra_data=extra_data),
+ data_content_type = ContentType.objects.get_for_model(instance.__class__),
+ created = created,
+ ))
+
+
+def generate_timeline(apps, schema_editor):
+ global created
+ global timelime_objects
+ with patch('taiga.timeline.service._add_to_object_timeline', new=custom_add_to_object_timeline):
+ # Projects api wasn't a HistoryResourceMixin so we can't interate on the HistoryEntries in this case
+ for project in Project.objects.order_by("created_date").iterator():
+ created = project.created_date
+ print("Project:", created)
+ extra_data = {
+ "values_diff": {},
+ "user": {
+ "pk": project.owner.id,
+ "user_name": project.owner.get_full_name(),
+ },
+ }
+ _push_to_timelines(project, project.owner, project, "create", extra_data=extra_data)
+
+ Timeline.objects.bulk_create(timelime_objects, batch_size=10000)
+ timelime_objects = []
+
+ for historyEntry in HistoryEntry.objects.order_by("created_at").iterator():
+ print("History entry:", historyEntry.created_at)
+ try:
+ created = historyEntry.created_at
+ on_new_history_entry(None, historyEntry, None)
+ except ObjectDoesNotExist as e:
+ print("Ignoring")
+
+ Timeline.objects.bulk_create(timelime_objects, batch_size=10000)
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0019_auto_20150311_0821'),
+ ('contenttypes', '0001_initial'),
+ ('timeline', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='Timeline',
+ ),
+ migrations.CreateModel(
+ name='Timeline',
+ fields=[
+ ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)),
+ ('object_id', models.PositiveIntegerField()),
+ ('namespace', models.SlugField(default='default')),
+ ('event_type', models.SlugField()),
+ ('project', models.ForeignKey(to='projects.Project')),
+ ('data', django_pgjson.fields.JsonField()),
+ ('data_content_type', models.ForeignKey(to='contenttypes.ContentType', related_name='data_timelines')),
+ ('created', models.DateTimeField(default=django.utils.timezone.now)),
+ ('content_type', models.ForeignKey(to='contenttypes.ContentType', related_name='content_type_timelines')),
+ ],
+ options={
+ },
+ bases=(models.Model,),
+ ),
+ migrations.AlterIndexTogether(
+ name='timeline',
+ index_together=set([('content_type', 'object_id', 'namespace')]),
+ ),
+ migrations.RunPython(generate_timeline),
+ ]
diff --git a/taiga/timeline/models.py b/taiga/timeline/models.py
index 2b0982fa..40f46899 100644
--- a/taiga/timeline/models.py
+++ b/taiga/timeline/models.py
@@ -23,14 +23,17 @@ from django.core.exceptions import ValidationError
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.generic import GenericForeignKey
+from taiga.projects.models import Project
class Timeline(models.Model):
- content_type = models.ForeignKey(ContentType)
+ content_type = models.ForeignKey(ContentType, related_name="content_type_timelines")
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
namespace = models.SlugField(default="default")
event_type = models.SlugField()
+ project = models.ForeignKey(Project)
data = JsonField()
+ data_content_type = models.ForeignKey(ContentType, related_name="data_timelines")
created = models.DateTimeField(default=timezone.now)
def save(self, *args, **kwargs):
diff --git a/taiga/timeline/service.py b/taiga/timeline/service.py
index 4eb9a339..da039a57 100644
--- a/taiga/timeline/service.py
+++ b/taiga/timeline/service.py
@@ -14,12 +14,16 @@
# 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 Model
-from django.db.models.query import QuerySet
-from functools import partial, wraps
+from django.apps import apps
from django.contrib.contenttypes.models import ContentType
+from django.db.models import Model
+from django.db.models import Q
+from django.db.models.query import QuerySet
+
+from functools import partial, wraps
from taiga.base.utils.db import get_typename_for_model_class
+from taiga.celery import app
_timeline_impl_map = {}
@@ -37,22 +41,28 @@ def _get_impl_key_from_typename(typename:str, event_type:str):
raise Exception("Not valid typename parameter")
-def _get_class_implementation(model:Model, event_type:str):
- key = _get_impl_key_from_model(model, event_type)
- return _timeline_impl_map.get(key, None)
+def build_user_namespace(user:object):
+ return "{0}:{1}".format("user", user.id)
+
+
+def build_project_namespace(project:object):
+ return "{0}:{1}".format("project", project.id)
def _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"
from .models import Timeline
+ event_type_key = _get_impl_key_from_model(instance.__class__, event_type)
+ impl = _timeline_impl_map.get(event_type_key, None)
- impl = _get_class_implementation(instance.__class__, event_type)
Timeline.objects.create(
content_object=obj,
namespace=namespace,
- event_type=event_type,
- data=impl(instance, extra_data=extra_data)
+ event_type=event_type_key,
+ project=instance.project,
+ data=impl(instance, extra_data=extra_data),
+ data_content_type = ContentType.objects.get_for_model(instance.__class__),
)
@@ -61,6 +71,7 @@ def _add_to_objects_timeline(objects, instance:object, event_type:str, namespace
_add_to_object_timeline(obj, instance, event_type, namespace, extra_data)
+@app.task
def push_to_timeline(objects, instance:object, event_type:str, namespace:str="default", extra_data:dict={}):
if isinstance(objects, Model):
_add_to_object_timeline(objects, instance, event_type, namespace, extra_data)
@@ -70,12 +81,73 @@ def push_to_timeline(objects, instance:object, event_type:str, namespace:str="de
raise Exception("Invalid objects parameter")
-def get_timeline(obj, namespace="default"):
+def get_timeline(obj, namespace=None):
assert isinstance(obj, Model), "obj must be a instance of Model"
from .models import Timeline
ct = ContentType.objects.get_for_model(obj.__class__)
- return Timeline.objects.filter(content_type=ct, object_id=obj.pk, namespace=namespace)
+ timeline = Timeline.objects.filter(content_type=ct, object_id=obj.pk)
+ if namespace is not None:
+ timeline = timeline.filter(namespace=namespace)
+
+ timeline = timeline.order_by("-created")
+ return timeline
+
+
+def filter_timeline_for_user(timeline, user):
+ # Filtering public projects
+ tl_filter = Q(project__is_private=False)
+
+ # Filtering private project with some public parts
+ content_types = {
+ "view_project": ContentType.objects.get(app_label="projects", model="project"),
+ "view_milestones": ContentType.objects.get(app_label="milestones", model="milestone"),
+ "view_us": ContentType.objects.get(app_label="userstories", model="userstory"),
+ "view_tasks": ContentType.objects.get(app_label="tasks", model="task"),
+ "view_issues": ContentType.objects.get(app_label="issues", model="issue"),
+ "view_wiki_pages": ContentType.objects.get(app_label="wiki", model="wikipage"),
+ "view_wiki_links": ContentType.objects.get(app_label="wiki", model="wikilink"),
+ }
+
+ for content_type_key, content_type in content_types.items():
+ tl_filter |= Q(project__is_private=True,
+ project__anon_permissions__contains=[content_type_key],
+ data_content_type=content_type)
+
+ # Filtering private projects where user is member
+ if not user.is_anonymous():
+ membership_model = apps.get_model('projects', 'Membership')
+ memberships_qs = membership_model.objects.filter(user=user)
+ for membership in memberships_qs:
+ for content_type_key, content_type in content_types.items():
+ if content_type_key in membership.role.permissions or membership.is_owner:
+ tl_filter |= Q(project=membership.project, data_content_type=content_type)
+
+ timeline = timeline.filter(tl_filter)
+ return timeline
+
+
+def get_profile_timeline(user, accessing_user=None):
+ timeline = get_timeline(user)
+ if accessing_user is not None:
+ timeline = filter_timeline_for_user(timeline, accessing_user)
+ return timeline
+
+
+def get_user_timeline(user, accessing_user=None):
+ namespace = build_user_namespace(user)
+ timeline = get_timeline(user, namespace)
+ if accessing_user is not None:
+ timeline = filter_timeline_for_user(timeline, accessing_user)
+ return timeline
+
+
+def get_project_timeline(project, accessing_user=None):
+ namespace = build_project_namespace(project)
+ timeline = get_timeline(project, namespace)
+ if accessing_user is not None:
+ timeline = filter_timeline_for_user(timeline, accessing_user)
+ return timeline
def register_timeline_implementation(typename:str, event_type:str, fn=None):
diff --git a/taiga/timeline/signals.py b/taiga/timeline/signals.py
index d3427a29..8361048a 100644
--- a/taiga/timeline/signals.py
+++ b/taiga/timeline/signals.py
@@ -14,43 +14,90 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from taiga.timeline.service import push_to_timeline
+from django.conf import settings
+
+from taiga.projects.history import services as history_services
+from taiga.projects.models import Project
+from taiga.users.models import User
+from taiga.projects.history.choices import HistoryType
+from taiga.timeline.service import push_to_timeline, build_user_namespace, build_project_namespace
# TODO: Add events to followers timeline when followers are implemented.
# TODO: Add events to project watchers timeline when project watchers are implemented.
-
-def create_project_push_to_timeline(sender, instance, created, **kwargs):
- if created:
- push_to_timeline(instance, instance, "create")
+def _push_to_timeline(*args, **kwargs):
+ if settings.CELERY_ENABLED:
+ push_to_timeline.delay(*args, **kwargs)
+ else:
+ push_to_timeline(*args, **kwargs)
-def create_user_story_push_to_timeline(sender, instance, created, **kwargs):
- if created:
- push_to_timeline(instance.project, instance, "create")
+def _push_to_timelines(project, user, obj, event_type, extra_data={}):
+ # Project timeline
+ _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,
+ namespace=build_user_namespace(user),
+ extra_data=extra_data)
+
+ # Related people: watchers and assigned to
+ if hasattr(obj, "assigned_to") and obj.assigned_to and user != obj.assigned_to:
+ _push_to_timeline(obj.assigned_to, obj, event_type,
+ namespace=build_user_namespace(user),
+ extra_data=extra_data)
+
+ watchers = hasattr(obj, "watchers") and obj.watchers.exclude(id=user.id) or []
+ if watchers:
+ _push_to_timeline(watchers, obj, event_type,
+ namespace=build_user_namespace(user),
+ extra_data=extra_data)
-def create_issue_push_to_timeline(sender, instance, created, **kwargs):
- if created:
- push_to_timeline(instance.project, instance, "create")
+def on_new_history_entry(sender, instance, created, **kwargs):
+ if instance.is_hidden:
+ return None
+
+ model = history_services.get_model_from_key(instance.key)
+ pk = history_services.get_pk_from_key(instance.key)
+ obj = model.objects.get(pk=pk)
+ project = obj.project
+
+ if instance.type == HistoryType.create:
+ event_type = "create"
+ elif instance.type == HistoryType.change:
+ event_type = "change"
+ elif instance.type == HistoryType.delete:
+ event_type = "delete"
+
+ extra_data = {
+ "values_diff": instance.values_diff,
+ "user": instance.user,
+ "comment": instance.comment,
+ }
+
+ user = User.objects.get(id=instance.user["pk"])
+ _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:
- push_to_timeline(instance.project, instance, "create")
+ _push_to_timelines(instance.project, instance.user, instance, "create")
+
+ #Updating existing membership
elif instance.pk:
prev_instance = sender.objects.get(pk=instance.pk)
- if prev_instance.user != prev_instance.user:
- push_to_timeline(instance.project, instance, "create")
- elif prev_instance.role != prev_instance.role:
- extra_data = {
- "prev_role": {
- "id": prev_instance.role.pk,
- "name": prev_instance.role.name,
- }
- }
- push_to_timeline(instance.project, instance, "role-changed", extra_data=extra_data)
+ if instance.user != prev_instance.user:
+ # The new member
+ _push_to_timelines(instance.project, instance.user, instance, "create")
+ # If we are updating the old user is removed from project
+ if prev_instance.user:
+ _push_to_timelines(instance.project, prev_instance.user, prev_instance, "delete")
def delete_membership_push_to_timeline(sender, instance, **kwargs):
- push_to_timeline(instance.project, instance, "delete")
+ if instance.user:
+ _push_to_timelines(instance.project, instance.user, instance, "delete")
diff --git a/taiga/timeline/timeline_implementations.py b/taiga/timeline/timeline_implementations.py
index bf726f03..364027a2 100644
--- a/taiga/timeline/timeline_implementations.py
+++ b/taiga/timeline/timeline_implementations.py
@@ -18,23 +18,45 @@ from taiga.timeline.service import register_timeline_implementation
@register_timeline_implementation("projects.project", "create")
-def project_create_timeline(instance, extra_data={}):
- return {
+@register_timeline_implementation("projects.project", "change")
+@register_timeline_implementation("projects.project", "delete")
+def project_timeline(instance, extra_data={}):
+ result ={
"project": {
"id": instance.pk,
"slug": instance.slug,
"name": instance.name,
},
- "creator": {
- "id": instance.owner.pk,
- "name": instance.owner.get_full_name(),
+ }
+ result.update(extra_data)
+ return result
+
+
+@register_timeline_implementation("milestones.milestone", "create")
+@register_timeline_implementation("milestones.milestone", "change")
+@register_timeline_implementation("milestones.milestone", "delete")
+def project_timeline(instance, extra_data={}):
+ result ={
+ "milestone": {
+ "id": instance.pk,
+ "slug": instance.slug,
+ "name": instance.name,
+ },
+ "project": {
+ "id": instance.project.pk,
+ "slug": instance.project.slug,
+ "name": instance.project.name,
}
}
+ result.update(extra_data)
+ return result
@register_timeline_implementation("userstories.userstory", "create")
-def userstory_create_timeline(instance, extra_data={}):
- return {
+@register_timeline_implementation("userstories.userstory", "change")
+@register_timeline_implementation("userstories.userstory", "delete")
+def userstory_timeline(instance, extra_data={}):
+ result ={
"userstory": {
"id": instance.pk,
"subject": instance.subject,
@@ -43,17 +65,17 @@ def userstory_create_timeline(instance, extra_data={}):
"id": instance.project.pk,
"slug": instance.project.slug,
"name": instance.project.name,
- },
- "creator": {
- "id": instance.owner.pk,
- "name": instance.owner.get_full_name(),
}
}
+ result.update(extra_data)
+ return result
@register_timeline_implementation("issues.issue", "create")
-def issue_create_timeline(instance, extra_data={}):
- return {
+@register_timeline_implementation("issues.issue", "change")
+@register_timeline_implementation("issues.issue", "delete")
+def issue_timeline(instance, extra_data={}):
+ result ={
"issue": {
"id": instance.pk,
"subject": instance.subject,
@@ -62,63 +84,54 @@ def issue_create_timeline(instance, extra_data={}):
"id": instance.project.pk,
"slug": instance.project.slug,
"name": instance.project.name,
- },
- "creator": {
- "id": instance.owner.pk,
- "name": instance.owner.get_full_name(),
}
}
+ result.update(extra_data)
+ return result
+
+
+@register_timeline_implementation("tasks.task", "create")
+@register_timeline_implementation("tasks.task", "change")
+@register_timeline_implementation("tasks.task", "delete")
+def task_timeline(instance, extra_data={}):
+ result ={
+ "task": {
+ "id": instance.pk,
+ "subject": instance.subject,
+ },
+ "project": {
+ "id": instance.project.pk,
+ "slug": instance.project.slug,
+ "name": instance.project.name,
+ }
+ }
+ result.update(extra_data)
+ return result
+
+
+@register_timeline_implementation("wiki.wikipage", "create")
+@register_timeline_implementation("wiki.wikipage", "change")
+@register_timeline_implementation("wiki.wikipage", "delete")
+def wiki_page_timeline(instance, extra_data={}):
+ result ={
+ "wiki_page": {
+ "id": instance.pk,
+ "slug": instance.slug,
+ },
+ "project": {
+ "id": instance.project.pk,
+ "slug": instance.project.slug,
+ "name": instance.project.name,
+ }
+ }
+ result.update(extra_data)
+ return result
@register_timeline_implementation("projects.membership", "create")
-def membership_create_timeline(instance, extra_data={}):
- return {
- "user": {
- "id": instance.user.pk,
- "name": instance.user.get_full_name(),
- },
- "project": {
- "id": instance.project.pk,
- "slug": instance.project.slug,
- "name": instance.project.name,
- },
- "role": {
- "id": instance.role.pk,
- "name": instance.role.name,
- }
- }
-
-
@register_timeline_implementation("projects.membership", "delete")
-def membership_delete_timeline(instance, extra_data={}):
- if instance.user:
- return {
- "user": {
- "id": instance.user.pk,
- "name": instance.user.get_full_name(),
- },
- "project": {
- "id": instance.project.pk,
- "slug": instance.project.slug,
- "name": instance.project.name,
- },
- }
- return {
- "invitation": {
- "id": instance.pk,
- "email": instance.email,
- },
- "project": {
- "id": instance.project.pk,
- "slug": instance.project.slug,
- "name": instance.project.name,
- },
- }
-
-
-@register_timeline_implementation("projects.membership", "role-changed")
-def membership_role_changed_timeline(instance, extra_data={}):
- result = {
+def membership_create_timeline(instance, extra_data={}):
+ result = {
"user": {
"id": instance.user.pk,
"name": instance.user.get_full_name(),
@@ -128,9 +141,6 @@ def membership_role_changed_timeline(instance, extra_data={}):
"slug": instance.project.slug,
"name": instance.project.name,
},
- "role": {
- "id": instance.role.pk,
- "name": instance.role.name,
- }
}
- return dict(result.items() + extra_data.items())
+ result.update(extra_data)
+ return result