From 982d79d8aa66ebe151af3d82722a02f81f8530ac Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 6 Apr 2015 12:34:41 +0200 Subject: [PATCH] Refactoring timeline --- taiga/projects/history/freeze_impl.py | 5 +- taiga/projects/history/services.py | 2 + taiga/projects/models.py | 5 + taiga/timeline/apps.py | 12 +- .../migrations/0002_auto_20150327_1056.py | 112 +++++++++++++ taiga/timeline/models.py | 5 +- taiga/timeline/service.py | 94 +++++++++-- taiga/timeline/signals.py | 93 ++++++++--- taiga/timeline/timeline_implementations.py | 148 ++++++++++-------- 9 files changed, 363 insertions(+), 113 deletions(-) create mode 100644 taiga/timeline/migrations/0002_auto_20150327_1056.py 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