Task #3517 #3516: Order by fans and activity (last week/moth/year/all time)

remotes/origin/logger
Alejandro Alonso 2015-12-01 11:00:51 +01:00 committed by David Barragán Merino
parent 8aebfa4bae
commit 50e00b6d45
24 changed files with 538 additions and 308 deletions

View File

@ -33,3 +33,4 @@ django-transactional-cleanup==0.1.15
lxml==3.5.0 lxml==3.5.0
git+https://github.com/Xof/django-pglocks.git@dbb8d7375066859f897604132bd437832d2014ea git+https://github.com/Xof/django-pglocks.git@dbb8d7375066859f897604132bd437832d2014ea
pyjwkest==1.0.9 pyjwkest==1.0.9
python-dateutil==2.4.2

View File

@ -81,7 +81,7 @@ def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None)
return [field.name for field in obj._meta.fields if field.name not in include] return [field.name for field in obj._meta.fields if field.name not in include]
class CreateModelMixin(object): class CreateModelMixin:
""" """
Create a model instance. Create a model instance.
""" """
@ -107,7 +107,7 @@ class CreateModelMixin(object):
return {} return {}
class ListModelMixin(object): class ListModelMixin:
""" """
List a queryset. List a queryset.
""" """
@ -137,7 +137,7 @@ class ListModelMixin(object):
return response.Ok(serializer.data) return response.Ok(serializer.data)
class RetrieveModelMixin(object): class RetrieveModelMixin:
""" """
Retrieve a model instance. Retrieve a model instance.
""" """
@ -153,7 +153,7 @@ class RetrieveModelMixin(object):
return response.Ok(serializer.data) return response.Ok(serializer.data)
class UpdateModelMixin(object): class UpdateModelMixin:
""" """
Update a model instance. Update a model instance.
""" """
@ -220,7 +220,7 @@ class UpdateModelMixin(object):
obj.full_clean(exclude) obj.full_clean(exclude)
class DestroyModelMixin(object): class DestroyModelMixin:
""" """
Destroy a model instance. Destroy a model instance.
""" """

View File

@ -183,4 +183,5 @@ def dict_to_project(data, owner=None):
if service.get_errors(clear=False): if service.get_errors(clear=False):
raise TaigaImportError(_("error importing timelines")) raise TaigaImportError(_("error importing timelines"))
proj.refresh_totals()
return proj return proj

View File

@ -17,9 +17,13 @@
import uuid import uuid
from django.db.models import signals from django.apps import apps
from django.db.models import signals, Prefetch
from django.db.models import Value as V
from django.db.models.functions import Coalesce
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils import timezone
from taiga.base import filters from taiga.base import filters
from taiga.base import response from taiga.base import response
@ -32,12 +36,9 @@ from taiga.base.api.utils import get_object_or_404
from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.slug import slugify_uniquely
from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.history.mixins import HistoryResourceMixin
from taiga.projects.notifications.models import NotifyPolicy
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.notifications.choices import NotifyLevel from taiga.projects.notifications.choices import NotifyLevel
from taiga.projects.notifications.utils import (
attach_project_total_watchers_attrs_to_queryset,
attach_project_is_watcher_to_queryset,
attach_notify_level_to_project_queryset)
from taiga.projects.mixins.ordering import BulkUpdateOrderMixin from taiga.projects.mixins.ordering import BulkUpdateOrderMixin
from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin
@ -53,6 +54,7 @@ from . import models
from . import permissions from . import permissions
from . import services from . import services
from dateutil.relativedelta import relativedelta
###################################################### ######################################################
## Project ## Project
@ -64,6 +66,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
list_serializer_class = serializers.ProjectSerializer list_serializer_class = serializers.ProjectSerializer
permission_classes = (permissions.ProjectPermission, ) permission_classes = (permissions.ProjectPermission, )
filter_backends = (filters.CanViewProjectObjFilterBackend,) filter_backends = (filters.CanViewProjectObjFilterBackend,)
filter_fields = (('member', 'members'), filter_fields = (('member', 'members'),
'is_looking_for_people', 'is_looking_for_people',
'is_featured', 'is_featured',
@ -71,24 +74,58 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
'is_kanban_activated') 'is_kanban_activated')
order_by_fields = ("memberships__user_order", order_by_fields = ("memberships__user_order",
"total_fans") "total_fans",
"total_fans_last_week",
"total_fans_last_month",
"total_fans_last_year",
"total_activity",
"total_activity_last_week",
"total_activity_last_month",
"total_activity_last_year")
def _get_order_by_field_name(self):
order_by_query_param = filters.CanViewProjectObjFilterBackend.order_by_query_param
order_by = self.request.QUERY_PARAMS.get(order_by_query_param, None)
if order_by is not None and order_by.startswith("-"):
return order_by[1:]
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
qs = self.attach_likes_attrs_to_queryset(qs)
qs = attach_project_total_watchers_attrs_to_queryset(qs) # Prefetch doesn't work correctly if then if the field is filtered later (it generates more queries)
if self.request.user.is_authenticated(): # so we add some custom prefetching
qs = attach_project_is_watcher_to_queryset(qs, self.request.user) qs = qs.prefetch_related("members")
qs = attach_notify_level_to_project_queryset(qs, self.request.user) qs = qs.prefetch_related(Prefetch("notify_policies",
NotifyPolicy.objects.exclude(notify_level=NotifyLevel.none), to_attr="valid_notify_policies"))
Milestone = apps.get_model("milestones", "Milestone")
qs = qs.prefetch_related(Prefetch("milestones",
Milestone.objects.filter(closed=True), to_attr="closed_milestones"))
# If filtering an activity period we must exclude the activities not updated recently enough
now = timezone.now()
order_by_field_name = self._get_order_by_field_name()
if order_by_field_name == "total_fans_last_week":
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(weeks=1))
elif order_by_field_name == "total_fans_last_month":
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(months=1))
elif order_by_field_name == "total_fans_last_year":
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(years=1))
elif order_by_field_name == "total_activity_last_week":
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(weeks=1))
elif order_by_field_name == "total_activity_last_month":
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(months=1))
elif order_by_field_name == "total_activity_last_year":
qs = qs.filter(totals_updated_datetime__gte=now-relativedelta(years=1))
return qs return qs
def get_serializer_class(self): def get_serializer_class(self):
if self.action == "list": serializer_class = self.serializer_class
return self.list_serializer_class
elif self.action == "create":
return self.serializer_class
if self.action == "list":
serializer_class = self.list_serializer_class
elif self.action != "create":
if self.action == "by_slug": if self.action == "by_slug":
slug = self.request.QUERY_PARAMS.get("slug", None) slug = self.request.QUERY_PARAMS.get("slug", None)
project = get_object_or_404(models.Project, slug=slug) project = get_object_or_404(models.Project, slug=slug)
@ -96,9 +133,9 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
project = self.get_object() project = self.get_object()
if permissions_service.is_project_owner(self.request.user, project): if permissions_service.is_project_owner(self.request.user, project):
return self.admin_serializer_class serializer_class = self.admin_serializer_class
return self.serializer_class return serializer_class
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
def watch(self, request, pk=None): def watch(self, request, pk=None):

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('likes', '0001_initial'),
]
operations = [
migrations.AlterUniqueTogether(
name='likes',
unique_together=set([]),
),
migrations.RemoveField(
model_name='likes',
name='content_type',
),
migrations.DeleteModel(
name='Likes',
),
]

View File

@ -20,12 +20,10 @@ from taiga.base.api import serializers
class FanResourceSerializerMixin(serializers.ModelSerializer): class FanResourceSerializerMixin(serializers.ModelSerializer):
is_fan = serializers.SerializerMethodField("get_is_fan") is_fan = serializers.SerializerMethodField("get_is_fan")
total_fans = serializers.SerializerMethodField("get_total_fans")
def get_is_fan(self, obj): def get_is_fan(self, obj):
# The "is_fan" attribute is attached in the get_queryset of the viewset. if "request" in self.context:
return getattr(obj, "is_fan", False) or False user = self.context["request"].user
return user.is_authenticated() and user.is_fan(obj)
def get_total_fans(self, obj): return False
# The "total_fans" attribute is attached in the get_queryset of the viewset.
return getattr(obj, "total_fans", 0) or 0

View File

@ -24,23 +24,9 @@ from taiga.base.decorators import detail_route
from taiga.projects.likes import serializers from taiga.projects.likes import serializers
from taiga.projects.likes import services from taiga.projects.likes import services
from taiga.projects.likes.utils import attach_total_fans_to_queryset, attach_is_fan_to_queryset
class LikedResourceMixin: class LikedResourceMixin:
# Note: Update get_queryset method:
# def get_queryset(self):
# qs = super().get_queryset()
# return self.attach_likes_attrs_to_queryset(qs)
def attach_likes_attrs_to_queryset(self, queryset):
qs = attach_total_fans_to_queryset(queryset)
if self.request.user.is_authenticated():
qs = attach_is_fan_to_queryset(self.request.user, qs)
return qs
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
def like(self, request, pk=None): def like(self, request, pk=None):
obj = self.get_object() obj = self.get_object()

View File

@ -22,27 +22,6 @@ from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
class Likes(models.Model):
content_type = models.ForeignKey("contenttypes.ContentType")
object_id = models.PositiveIntegerField()
content_object = generic.GenericForeignKey("content_type", "object_id")
count = models.PositiveIntegerField(null=False, blank=False, default=0, verbose_name=_("count"))
class Meta:
verbose_name = _("Likes")
verbose_name_plural = _("Likes")
unique_together = ("content_type", "object_id")
@property
def project(self):
if hasattr(self.content_object, 'project'):
return self.content_object.project
return None
def __str__(self):
return self.count
class Like(models.Model): class Like(models.Model):
content_type = models.ForeignKey("contenttypes.ContentType") content_type = models.ForeignKey("contenttypes.ContentType")
object_id = models.PositiveIntegerField() object_id = models.PositiveIntegerField()

View File

@ -21,7 +21,7 @@ from django.db.transaction import atomic
from django.apps import apps from django.apps import apps
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import Likes, Like from .models import Like
def add_like(obj, user): def add_like(obj, user):
@ -36,12 +36,9 @@ def add_like(obj, user):
obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj) obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj)
with atomic(): with atomic():
like, created = Like.objects.get_or_create(content_type=obj_type, object_id=obj.id, user=user) like, created = Like.objects.get_or_create(content_type=obj_type, object_id=obj.id, user=user)
if not created: if like.project is not None:
return like.project.refresh_totals()
likes, _ = Likes.objects.get_or_create(content_type=obj_type, object_id=obj.id)
likes.count = F('count') + 1
likes.save()
return like return like
@ -60,11 +57,12 @@ def remove_like(obj, user):
if not qs.exists(): if not qs.exists():
return return
like = qs.first()
project = like.project
qs.delete() qs.delete()
likes, _ = Likes.objects.get_or_create(content_type=obj_type, object_id=obj.id) if project is not None:
likes.count = F('count') - 1 project.refresh_totals()
likes.save()
def get_fans(obj): def get_fans(obj):
@ -78,21 +76,6 @@ def get_fans(obj):
return get_user_model().objects.filter(likes__content_type=obj_type, likes__object_id=obj.id) return get_user_model().objects.filter(likes__content_type=obj_type, likes__object_id=obj.id)
def get_likes(obj):
"""Get the number of likes an object has.
:param obj: Any Django model instance.
:return: Number of likes or `0` if the object has no likes at all.
"""
obj_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(obj)
try:
return Likes.objects.get(content_type=obj_type, object_id=obj.id).count
except Likes.DoesNotExist:
return 0
def get_liked(user_or_id, model): def get_liked(user_or_id, model):
"""Get the objects liked by an user. """Get the objects liked by an user.

View File

@ -1,77 +0,0 @@
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# Copyright (C) 2014-2016 Anler Hernández <hello@anler.me>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.apps import apps
def attach_total_fans_to_queryset(queryset, as_field="total_fans"):
"""Attach likes count to each object of the queryset.
Because of laziness of like objects creation, this makes much simpler and more efficient to
access to liked-object number of likes.
(The other way was to do it in the serializer with some try/except blocks and additional
queries)
:param queryset: A Django queryset object.
:param as_field: Attach the likes-count as an attribute with this name.
:return: Queryset object with the additional `as_field` field.
"""
model = queryset.model
type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model)
sql = """SELECT coalesce(SUM(total_fans), 0) FROM (
SELECT coalesce(likes_likes.count, 0) total_fans
FROM likes_likes
WHERE likes_likes.content_type_id = {type_id}
AND likes_likes.object_id = {tbl}.id
) as e"""
sql = sql.format(type_id=type.id, tbl=model._meta.db_table)
qs = queryset.extra(select={as_field: sql})
return qs
def attach_is_fan_to_queryset(user, queryset, as_field="is_fan"):
"""Attach is_like boolean to each object of the queryset.
Because of laziness of like objects creation, this makes much simpler and more efficient to
access to likes-object and check if the curren user like it.
(The other way was to do it in the serializer with some try/except blocks and additional
queries)
:param user: A users.User object model
:param queryset: A Django queryset object.
:param as_field: Attach the boolean as an attribute with this name.
:return: Queryset object with the additional `as_field` field.
"""
model = queryset.model
type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model)
sql = ("""SELECT CASE WHEN (SELECT count(*)
FROM likes_like
WHERE likes_like.content_type_id = {type_id}
AND likes_like.object_id = {tbl}.id
AND likes_like.user_id = {user_id}) > 0
THEN TRUE
ELSE FALSE
END""")
sql = sql.format(type_id=type.id, tbl=model._meta.db_table, user_id=user.id)
qs = queryset.extra(select={as_field: sql})
return qs

View File

@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import connection, migrations, models
from django.utils.timezone import utc
import datetime
def update_totals(apps, schema_editor):
model = apps.get_model("projects", "Project")
type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model)
sql="""
UPDATE projects_project
SET
totals_updated_datetime = totals.totals_updated_datetime,
total_fans = totals.total_fans,
total_fans_last_week = totals.total_fans_last_week,
total_fans_last_month = totals.total_fans_last_month,
total_fans_last_year = totals.total_fans_last_year,
total_activity = totals.total_activity,
total_activity_last_week = totals.total_activity_last_week,
total_activity_last_month = totals.total_activity_last_month,
total_activity_last_year = totals.total_activity_last_year
FROM (
WITH
totals_activity AS (SELECT
split_part(timeline_timeline.namespace, ':', 2)::integer as project_id,
count(timeline_timeline.namespace) total_activity,
MAX (created) updated_datetime
FROM timeline_timeline
WHERE namespace LIKE 'project:%'
GROUP BY namespace),
totals_activity_week AS (SELECT
split_part(timeline_timeline.namespace, ':', 2)::integer as project_id,
count(timeline_timeline.namespace) total_activity_last_week
FROM timeline_timeline
WHERE namespace LIKE 'project:%'
AND timeline_timeline.created > current_date - interval '7' day
GROUP BY namespace),
totals_activity_month AS (SELECT
split_part(timeline_timeline.namespace, ':', 2)::integer as project_id,
count(timeline_timeline.namespace) total_activity_last_month
FROM timeline_timeline
WHERE namespace LIKE 'project:%'
AND timeline_timeline.created > current_date - interval '30' day
GROUP BY namespace),
totals_activity_year AS (SELECT
split_part(timeline_timeline.namespace, ':', 2)::integer as project_id,
count(timeline_timeline.namespace) total_activity_last_year
FROM timeline_timeline
WHERE namespace LIKE 'project:%'
AND timeline_timeline.created > current_date - interval '365' day
GROUP BY namespace),
totals_fans AS (SELECT
object_id as project_id,
COUNT(likes_like.object_id) total_fans,
MAX (created_date) updated_datetime
FROM likes_like
WHERE content_type_id = {type_id}
GROUP BY object_id),
totals_fans_week AS (SELECT
object_id as project_id,
COUNT(likes_like.object_id) total_fans_last_week
FROM likes_like
WHERE content_type_id = {type_id}
AND likes_like.created_date > current_date - interval '7' day
GROUP BY object_id),
totals_fans_month AS (SELECT
object_id as project_id,
COUNT(likes_like.object_id) total_fans_last_month
FROM likes_like
WHERE content_type_id = {type_id}
AND likes_like.created_date > current_date - interval '30' day
GROUP BY object_id),
totals_fans_year AS (SELECT
object_id as project_id,
COUNT(likes_like.object_id) total_fans_last_year
FROM likes_like
WHERE content_type_id = {type_id}
AND likes_like.created_date > current_date - interval '365' day
GROUP BY object_id)
SELECT
totals_activity.project_id,
COALESCE(total_activity, 0) total_activity,
COALESCE(total_activity_last_week, 0) total_activity_last_week,
COALESCE(total_activity_last_month, 0) total_activity_last_month,
COALESCE(total_activity_last_year, 0) total_activity_last_year,
COALESCE(total_fans, 0) total_fans,
COALESCE(total_fans_last_week, 0) total_fans_last_week,
COALESCE(total_fans_last_month, 0) total_fans_last_month,
COALESCE(total_fans_last_year, 0) total_fans_last_year,
totals_activity.updated_datetime totals_updated_datetime
FROM totals_activity
LEFT JOIN totals_fans ON totals_activity.project_id = totals_fans.project_id
LEFT JOIN totals_fans_week ON totals_activity.project_id = totals_fans_week.project_id
LEFT JOIN totals_fans_month ON totals_activity.project_id = totals_fans_month.project_id
LEFT JOIN totals_fans_year ON totals_activity.project_id = totals_fans_year.project_id
LEFT JOIN totals_activity_week ON totals_activity.project_id = totals_activity_week.project_id
LEFT JOIN totals_activity_month ON totals_activity.project_id = totals_activity_month.project_id
LEFT JOIN totals_activity_year ON totals_activity.project_id = totals_activity_year.project_id
) totals
WHERE projects_project.id = totals.project_id
""".format(type_id=type.id)
cursor = connection.cursor()
cursor.execute(sql)
class Migration(migrations.Migration):
dependencies = [
('projects', '0029_project_is_looking_for_people'),
('timeline', '0004_auto_20150603_1312'),
]
operations = [
migrations.AddField(
model_name='project',
name='total_activity',
field=models.PositiveIntegerField(default=0, verbose_name='count', db_index=True),
),
migrations.AddField(
model_name='project',
name='total_activity_last_month',
field=models.PositiveIntegerField(default=0, verbose_name='activity last month', db_index=True),
),
migrations.AddField(
model_name='project',
name='total_activity_last_week',
field=models.PositiveIntegerField(default=0, verbose_name='activity last week', db_index=True),
),
migrations.AddField(
model_name='project',
name='total_activity_last_year',
field=models.PositiveIntegerField(default=0, verbose_name='activity last year', db_index=True),
),
migrations.AddField(
model_name='project',
name='total_fans',
field=models.PositiveIntegerField(default=0, verbose_name='count', db_index=True),
),
migrations.AddField(
model_name='project',
name='total_fans_last_month',
field=models.PositiveIntegerField(default=0, verbose_name='fans last month', db_index=True),
),
migrations.AddField(
model_name='project',
name='total_fans_last_week',
field=models.PositiveIntegerField(default=0, verbose_name='fans last week', db_index=True),
),
migrations.AddField(
model_name='project',
name='total_fans_last_year',
field=models.PositiveIntegerField(default=0, verbose_name='fans last year', db_index=True),
),
migrations.AddField(
model_name='project',
name='totals_updated_datetime',
field=models.DateTimeField(default=datetime.datetime(2015, 11, 28, 7, 57, 11, 743976, tzinfo=utc), auto_now_add=True, verbose_name='updated date time', db_index=True),
preserve_default=False,
),
migrations.RunPython(update_totals),
]

View File

@ -84,7 +84,7 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView
if self.request.user.is_authenticated(): if self.request.user.is_authenticated():
us_qs = attach_is_voter_to_queryset(self.request.user, us_qs) us_qs = attach_is_voter_to_queryset(self.request.user, us_qs)
us_qs = attach_is_watcher_to_queryset(self.request.user, us_qs) us_qs = attach_is_watcher_to_queryset(us_qs, self.request.user)
qs = qs.prefetch_related(Prefetch("user_stories", queryset=us_qs)) qs = qs.prefetch_related(Prefetch("user_stories", queryset=us_qs))

View File

@ -46,8 +46,12 @@ from taiga.projects.notifications.services import (
set_notify_policy_level_to_ignore, set_notify_policy_level_to_ignore,
create_notify_policy_if_not_exists) create_notify_policy_if_not_exists)
from taiga.timeline.service import build_project_namespace
from . import choices from . import choices
from dateutil.relativedelta import relativedelta
class Membership(models.Model): class Membership(models.Model):
# This model stores all project memberships. Also # This model stores all project memberships. Also
@ -198,6 +202,36 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
tags_colors = TextArrayField(dimension=2, default=[], null=False, blank=True, tags_colors = TextArrayField(dimension=2, default=[], null=False, blank=True,
verbose_name=_("tags colors")) verbose_name=_("tags colors"))
#Totals:
totals_updated_datetime = models.DateTimeField(null=False, blank=False, auto_now_add=True,
verbose_name=_("updated date time"), db_index=True)
total_fans = models.PositiveIntegerField(null=False, blank=False, default=0,
verbose_name=_("count"), db_index=True)
total_fans_last_week = models.PositiveIntegerField(null=False, blank=False, default=0,
verbose_name=_("fans last week"), db_index=True)
total_fans_last_month = models.PositiveIntegerField(null=False, blank=False, default=0,
verbose_name=_("fans last month"), db_index=True)
total_fans_last_year = models.PositiveIntegerField(null=False, blank=False, default=0,
verbose_name=_("fans last year"), db_index=True)
total_activity = models.PositiveIntegerField(null=False, blank=False, default=0,
verbose_name=_("count"), db_index=True)
total_activity_last_week = models.PositiveIntegerField(null=False, blank=False, default=0,
verbose_name=_("activity last week"), db_index=True)
total_activity_last_month = models.PositiveIntegerField(null=False, blank=False, default=0,
verbose_name=_("activity last month"), db_index=True)
total_activity_last_year = models.PositiveIntegerField(null=False, blank=False, default=0,
verbose_name=_("activity last year"), db_index=True)
_cached_user_stories = None
_importing = None _importing = None
class Meta: class Meta:
@ -233,6 +267,51 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def refresh_totals(self, save=True):
now = timezone.now()
self.totals_updated_datetime = now
Like = apps.get_model("likes", "Like")
content_type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(Project)
qs = Like.objects.filter(content_type=content_type, object_id=self.id)
self.total_fans = qs.count()
qs_week = qs.filter(created_date__gte=now-relativedelta(weeks=1))
self.total_fans_last_week = qs_week.count()
qs_month = qs.filter(created_date__gte=now-relativedelta(months=1))
self.total_fans_last_month = qs_month.count()
qs_year = qs.filter(created_date__gte=now-relativedelta(years=1))
self.total_fans_last_year = qs_year.count()
tl_model = apps.get_model("timeline", "Timeline")
namespace = build_project_namespace(self)
qs = tl_model.objects.filter(namespace=namespace)
self.total_activity = qs.count()
qs_week = qs.filter(created__gte=now-relativedelta(weeks=1))
self.total_activity_last_week = qs_week.count()
qs_month = qs.filter(created__gte=now-relativedelta(months=1))
self.total_activity_last_month = qs_month.count()
qs_year = qs.filter(created__gte=now-relativedelta(years=1))
self.total_activity_last_year = qs_year.count()
if save:
self.save()
@property
def cached_user_stories(self):
print(1111111, self._cached_user_stories)
if self._cached_user_stories is None:
self._cached_user_stories = list(self.user_stories.all())
return self._cached_user_stories
def get_roles(self): def get_roles(self):
return self.roles.all() return self.roles.all()

View File

@ -53,12 +53,12 @@ class WatchedResourceMixin:
_not_notify = False _not_notify = False
def attach_watchers_attrs_to_queryset(self, queryset): def attach_watchers_attrs_to_queryset(self, queryset):
qs = attach_watchers_to_queryset(queryset) queryset = attach_watchers_to_queryset(queryset)
qs = attach_total_watchers_to_queryset(qs) queryset = attach_total_watchers_to_queryset(queryset)
if self.request.user.is_authenticated(): if self.request.user.is_authenticated():
qs = attach_is_watcher_to_queryset(self.request.user, qs) queryset = attach_is_watcher_to_queryset(queryset, self.request.user)
return qs return queryset
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
def watch(self, request, pk=None): def watch(self, request, pk=None):
@ -187,8 +187,11 @@ class WatchedResourceModelSerializer(serializers.ModelSerializer):
total_watchers = serializers.SerializerMethodField("get_total_watchers") total_watchers = serializers.SerializerMethodField("get_total_watchers")
def get_is_watcher(self, obj): def get_is_watcher(self, obj):
# The "is_watcher" attribute is attached in the get_queryset of the viewset. if "request" in self.context:
return getattr(obj, "is_watcher", False) or False user = self.context["request"].user
return user.is_authenticated() and user.is_watcher(obj)
return False
def get_total_watchers(self, obj): def get_total_watchers(self, obj):
# The "total_watchers" attribute is attached in the get_queryset of the viewset. # The "total_watchers" attribute is attached in the get_queryset of the viewset.

View File

@ -41,11 +41,11 @@ def attach_watchers_to_queryset(queryset, as_field="watchers"):
return qs return qs
def attach_is_watcher_to_queryset(user, queryset, as_field="is_watcher"): def attach_is_watcher_to_queryset(queryset, user, as_field="is_watcher"):
"""Attach is_watcher boolean to each object of the queryset. """Attach is_watcher boolean to each object of the queryset.
:param user: A users.User object model
:param queryset: A Django queryset object. :param queryset: A Django queryset object.
:param user: A users.User object model
:param as_field: Attach the boolean as an attribute with this name. :param as_field: Attach the boolean as an attribute with this name.
:return: Queryset object with the additional `as_field` field. :return: Queryset object with the additional `as_field` field.
@ -83,74 +83,3 @@ def attach_total_watchers_to_queryset(queryset, as_field="total_watchers"):
sql = sql.format(type_id=type.id, tbl=model._meta.db_table) sql = sql.format(type_id=type.id, tbl=model._meta.db_table)
qs = queryset.extra(select={as_field: sql}) qs = queryset.extra(select={as_field: sql})
return qs return qs
def attach_project_is_watcher_to_queryset(queryset, user, as_field="is_watcher"):
"""Attach is_watcher boolean to each object of the projects queryset.
:param user: A users.User object model
:param queryset: A Django projects queryset object.
:param as_field: Attach the boolean as an attribute with this name.
:return: Queryset object with the additional `as_field` field.
"""
model = queryset.model
type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model)
sql = ("""SELECT CASE WHEN (SELECT count(*)
FROM notifications_notifypolicy
WHERE notifications_notifypolicy.project_id = {tbl}.id
AND notifications_notifypolicy.user_id = {user_id}
AND notifications_notifypolicy.notify_level != {ignore_notify_level}) > 0
THEN TRUE
ELSE FALSE
END""")
sql = sql.format(tbl=model._meta.db_table, user_id=user.id, ignore_notify_level=NotifyLevel.none)
qs = queryset.extra(select={as_field: sql})
return qs
def attach_project_total_watchers_attrs_to_queryset(queryset, as_field="total_watchers"):
"""Attach watching user ids to each project of the queryset.
:param queryset: A Django projects queryset object.
:param as_field: Attach the watchers as an attribute with this name.
:return: Queryset object with the additional `as_field` field.
"""
model = queryset.model
type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model)
sql = ("""SELECT count(user_id)
FROM notifications_notifypolicy
WHERE notifications_notifypolicy.project_id = {tbl}.id
AND notifications_notifypolicy.notify_level != {ignore_notify_level}""")
sql = sql.format(tbl=model._meta.db_table, ignore_notify_level=NotifyLevel.none)
qs = queryset.extra(select={as_field: sql})
return qs
def attach_notify_level_to_project_queryset(queryset, user):
"""
Function that attach "notify_level" attribute on each queryset
result for query notification level of current user for each
project in the most efficient way.
:param queryset: A Django queryset object.
:param user: A User model object.
:return: Queryset object with the additional `as_field` field.
"""
user_id = getattr(user, "id", None) or "NULL"
default_level = NotifyLevel.involved
sql = strip_lines("""
COALESCE((SELECT notifications_notifypolicy.notify_level
FROM notifications_notifypolicy
WHERE notifications_notifypolicy.project_id = projects_project.id
AND notifications_notifypolicy.user_id = {user_id}),
{default_level})
""")
sql = sql.format(user_id=user_id, default_level=default_level)
return queryset.extra(select={"notify_level": sql})

View File

@ -26,6 +26,7 @@ from taiga.base.fields import PgArrayField
from taiga.base.fields import TagsField from taiga.base.fields import TagsField
from taiga.base.fields import TagsColorsField from taiga.base.fields import TagsColorsField
from taiga.projects.notifications.choices import NotifyLevel
from taiga.users.services import get_photo_or_gravatar_url from taiga.users.services import get_photo_or_gravatar_url
from taiga.users.serializers import UserSerializer from taiga.users.serializers import UserSerializer
from taiga.users.serializers import UserBasicInfoSerializer from taiga.users.serializers import UserBasicInfoSerializer
@ -318,6 +319,7 @@ class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializ
tags_colors = TagsColorsField(required=False) tags_colors = TagsColorsField(required=False)
total_closed_milestones = serializers.SerializerMethodField("get_total_closed_milestones") total_closed_milestones = serializers.SerializerMethodField("get_total_closed_milestones")
notify_level = serializers.SerializerMethodField("get_notify_level") notify_level = serializers.SerializerMethodField("get_notify_level")
total_watchers = serializers.SerializerMethodField("get_total_watchers")
class Meta: class Meta:
model = models.Project model = models.Project
@ -336,10 +338,27 @@ class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializ
return False return False
def get_total_closed_milestones(self, obj): def get_total_closed_milestones(self, obj):
# The "closed_milestone" attribute can be attached in the get_queryset method of the viewset.
qs_closed_milestones = getattr(obj, "closed_milestones", None)
if qs_closed_milestones is not None:
return qs_closed_milestones
return obj.milestones.filter(closed=True).count() return obj.milestones.filter(closed=True).count()
def get_notify_level(self, obj): def get_notify_level(self, obj):
return getattr(obj, "notify_level", None) if "request" in self.context:
user = self.context["request"].user
return user.is_authenticated() and user.get_notify_level(obj)
return None
def get_total_watchers(self, obj):
# The "valid_notify_policies" attribute can be attached in the get_queryset method of the viewset.
qs_valid_notify_policies = getattr(obj, "valid_notify_policies", None)
if qs_valid_notify_policies is not None:
return len(qs_valid_notify_policies)
return obj.notify_policies.exclude(notify_level=NotifyLevel.none).count()
class ProjectDetailSerializer(ProjectSerializer): class ProjectDetailSerializer(ProjectSerializer):

View File

@ -46,6 +46,8 @@ def _push_to_timelines(project, user, obj, event_type, created_datetime, extra_d
namespace=build_project_namespace(project), namespace=build_project_namespace(project),
extra_data=extra_data) extra_data=extra_data)
project.refresh_totals()
if hasattr(obj, "get_related_people"): if hasattr(obj, "get_related_people"):
related_people = obj.get_related_people() related_people = obj.get_related_people()

View File

@ -224,7 +224,7 @@ def _build_watched_sql_for_projects(for_user):
tags, notifications_notifypolicy.project_id AS object_id, projects_project.id AS project, tags, notifications_notifypolicy.project_id AS object_id, projects_project.id AS project,
slug, projects_project.name, null::text AS subject, slug, projects_project.name, null::text AS subject,
notifications_notifypolicy.created_at as created_date, notifications_notifypolicy.created_at as created_date,
coalesce(watchers, 0) AS total_watchers, coalesce(likes_likes.count, 0) AS total_fans, null::integer AS total_voters, coalesce(watchers, 0) AS total_watchers, projects_project.total_fans AS total_fans, null::integer AS total_voters,
null::integer AS assigned_to, null::text as status, null::text as status_color null::integer AS assigned_to, null::text as status, null::text as status_color
FROM notifications_notifypolicy FROM notifications_notifypolicy
INNER JOIN projects_project INNER JOIN projects_project
@ -235,8 +235,6 @@ def _build_watched_sql_for_projects(for_user):
GROUP BY project_id GROUP BY project_id
) type_watchers ) type_watchers
ON projects_project.id = type_watchers.project_id ON projects_project.id = type_watchers.project_id
LEFT JOIN likes_likes
ON (projects_project.id = likes_likes.object_id AND {project_content_type_id} = likes_likes.content_type_id)
WHERE WHERE
notifications_notifypolicy.user_id = {for_user_id} notifications_notifypolicy.user_id = {for_user_id}
AND notifications_notifypolicy.notify_level != {none_notify_level} AND notifications_notifypolicy.notify_level != {none_notify_level}
@ -254,7 +252,7 @@ def _build_liked_sql_for_projects(for_user):
tags, likes_like.object_id AS object_id, projects_project.id AS project, tags, likes_like.object_id AS object_id, projects_project.id AS project,
slug, projects_project.name, null::text AS subject, slug, projects_project.name, null::text AS subject,
likes_like.created_date, likes_like.created_date,
coalesce(watchers, 0) AS total_watchers, coalesce(likes_likes.count, 0) AS total_fans, coalesce(watchers, 0) AS total_watchers, projects_project.total_fans AS total_fans,
null::integer AS assigned_to, null::text as status, null::text as status_color null::integer AS assigned_to, null::text as status, null::text as status_color
FROM likes_like FROM likes_like
INNER JOIN projects_project INNER JOIN projects_project
@ -265,8 +263,6 @@ def _build_liked_sql_for_projects(for_user):
GROUP BY project_id GROUP BY project_id
) type_watchers ) type_watchers
ON projects_project.id = type_watchers.project_id ON projects_project.id = type_watchers.project_id
LEFT JOIN likes_likes
ON (projects_project.id = likes_likes.object_id AND {project_content_type_id} = likes_likes.content_type_id)
WHERE likes_like.user_id = {for_user_id} AND {project_content_type_id} = likes_like.content_type_id WHERE likes_like.user_id = {for_user_id} AND {project_content_type_id} = likes_like.content_type_id
""" """
sql = sql.format( sql = sql.format(

View File

@ -423,15 +423,6 @@ class LikeFactory(Factory):
user = factory.SubFactory("tests.factories.UserFactory") user = factory.SubFactory("tests.factories.UserFactory")
class LikesFactory(Factory):
class Meta:
model = "likes.Likes"
strategy = factory.CREATE_STRATEGY
content_type = factory.SubFactory("tests.factories.ContentTypeFactory")
object_id = factory.Sequence(lambda n: n)
class VoteFactory(Factory): class VoteFactory(Factory):
class Meta: class Meta:
model = "votes.Vote" model = "votes.Vote"

View File

@ -77,10 +77,6 @@ def data():
f.LikeFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_member_with_perms) f.LikeFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_member_with_perms)
f.LikeFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_owner) f.LikeFactory(content_type=project_ct, object_id=m.private_project2.pk, user=m.project_owner)
f.LikesFactory(content_type=project_ct, object_id=m.public_project.pk, count=2)
f.LikesFactory(content_type=project_ct, object_id=m.private_project1.pk, count=2)
f.LikesFactory(content_type=project_ct, object_id=m.private_project2.pk, count=2)
return m return m

View File

@ -76,26 +76,10 @@ def test_get_project_fan(client):
assert response.data['id'] == like.user.id assert response.data['id'] == like.user.id
def test_get_project_total_fans(client):
user = f.UserFactory.create()
project = f.create_project(owner=user)
f.MembershipFactory.create(project=project, user=user, is_owner=True)
url = reverse("projects-detail", args=(project.id,))
f.LikesFactory.create(content_object=project, count=5)
client.login(user)
response = client.get(url)
assert response.status_code == 200
assert response.data['total_fans'] == 5
def test_get_project_is_fan(client): def test_get_project_is_fan(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.create_project(owner=user) project = f.create_project(owner=user)
f.MembershipFactory.create(project=project, user=user, is_owner=True) f.MembershipFactory.create(project=project, user=user, is_owner=True)
f.LikesFactory.create(content_object=project)
url_detail = reverse("projects-detail", args=(project.id,)) url_detail = reverse("projects-detail", args=(project.id,))
url_like = reverse("projects-like", args=(project.id,)) url_like = reverse("projects-like", args=(project.id,))
url_unlike = reverse("projects-unlike", args=(project.id,)) url_unlike = reverse("projects-unlike", args=(project.id,))

View File

@ -53,24 +53,6 @@ def mail():
return mail return mail
def test_attach_notify_level_to_project_queryset():
project1 = f.ProjectFactory.create()
f.ProjectFactory.create()
qs = project1.__class__.objects.order_by("id")
qs = utils.attach_notify_level_to_project_queryset(qs, project1.owner)
assert len(qs) == 2
assert qs[0].notify_level == NotifyLevel.involved
assert qs[1].notify_level == NotifyLevel.involved
services.create_notify_policy(project1, project1.owner, NotifyLevel.all)
qs = project1.__class__.objects.order_by("id")
qs = utils.attach_notify_level_to_project_queryset(qs, project1.owner)
assert qs[0].notify_level == NotifyLevel.all
assert qs[1].notify_level == NotifyLevel.involved
def test_create_retrieve_notify_policy(): def test_create_retrieve_notify_policy():
project = f.ProjectFactory.create() project = f.ProjectFactory.create()

View File

@ -0,0 +1,154 @@
# Copyright (C) 2014-2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014-2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2015 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2015 Anler Hernández <hello@anler.me>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
import datetime
from .. import factories as f
from taiga.projects.history.choices import HistoryType
from taiga.projects.models import Project
from django.core.urlresolvers import reverse
pytestmark = pytest.mark.django_db
def test_project_totals_updated_on_activity(client):
project = f.create_project()
totals_updated_datetime = project.totals_updated_datetime
now = datetime.datetime.now()
assert project.total_activity == 0
totals_updated_datetime = project.totals_updated_datetime
us = f.UserStoryFactory.create(project=project, owner=project.owner)
f.HistoryEntryFactory.create(
user={"pk": project.owner.id},
comment="",
type=HistoryType.change,
key="userstories.userstory:{}".format(us.id),
is_hidden=False,
diff=[],
created_at=now - datetime.timedelta(days=3)
)
project = Project.objects.get(id=project.id)
assert project.total_activity == 1
assert project.total_activity_last_week == 1
assert project.total_activity_last_month == 1
assert project.total_activity_last_year == 1
assert project.totals_updated_datetime > totals_updated_datetime
totals_updated_datetime = project.totals_updated_datetime
f.HistoryEntryFactory.create(
user={"pk": project.owner.id},
comment="",
type=HistoryType.change,
key="userstories.userstory:{}".format(us.id),
is_hidden=False,
diff=[],
created_at=now - datetime.timedelta(days=13)
)
project = Project.objects.get(id=project.id)
assert project.total_activity == 2
assert project.total_activity_last_week == 1
assert project.total_activity_last_month == 2
assert project.total_activity_last_year == 2
assert project.totals_updated_datetime > totals_updated_datetime
totals_updated_datetime = project.totals_updated_datetime
f.HistoryEntryFactory.create(
user={"pk": project.owner.id},
comment="",
type=HistoryType.change,
key="userstories.userstory:{}".format(us.id),
is_hidden=False,
diff=[],
created_at=now - datetime.timedelta(days=33)
)
project = Project.objects.get(id=project.id)
assert project.total_activity == 3
assert project.total_activity_last_week == 1
assert project.total_activity_last_month == 2
assert project.total_activity_last_year == 3
assert project.totals_updated_datetime > totals_updated_datetime
totals_updated_datetime = project.totals_updated_datetime
f.HistoryEntryFactory.create(
user={"pk": project.owner.id},
comment="",
type=HistoryType.change,
key="userstories.userstory:{}".format(us.id),
is_hidden=False,
diff=[],
created_at=now - datetime.timedelta(days=380)
)
project = Project.objects.get(id=project.id)
assert project.total_activity == 4
assert project.total_activity_last_week == 1
assert project.total_activity_last_month == 2
assert project.total_activity_last_year == 3
assert project.totals_updated_datetime > totals_updated_datetime
def test_project_totals_updated_on_like(client):
project = f.create_project()
f.MembershipFactory.create(project=project, user=project.owner, is_owner=True)
totals_updated_datetime = project.totals_updated_datetime
now = datetime.datetime.now()
assert project.total_activity == 0
now = datetime.datetime.now()
totals_updated_datetime = project.totals_updated_datetime
us = f.UserStoryFactory.create(project=project, owner=project.owner)
l = f.LikeFactory.create(content_object=project)
l.created_date=now-datetime.timedelta(days=13)
l.save()
l = f.LikeFactory.create(content_object=project)
l.created_date=now-datetime.timedelta(days=33)
l.save()
l = f.LikeFactory.create(content_object=project)
l.created_date=now-datetime.timedelta(days=633)
l.save()
project.refresh_totals()
project = Project.objects.get(id=project.id)
assert project.total_fans == 3
assert project.total_fans_last_week == 0
assert project.total_fans_last_month == 1
assert project.total_fans_last_year == 2
assert project.totals_updated_datetime > totals_updated_datetime
client.login(project.owner)
url_like = reverse("projects-like", args=(project.id,))
response = client.post(url_like)
print(response.data)
project = Project.objects.get(id=project.id)
assert project.total_fans == 4
assert project.total_fans_last_week == 1
assert project.total_fans_last_month == 2
assert project.total_fans_last_year == 3
assert project.totals_updated_datetime > totals_updated_datetime

View File

@ -388,7 +388,6 @@ def test_get_liked_list():
membership = f.MembershipFactory(project=project, role=role, user=fan_user) membership = f.MembershipFactory(project=project, role=role, user=fan_user)
content_type = ContentType.objects.get_for_model(project) content_type = ContentType.objects.get_for_model(project)
f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user)
f.LikesFactory(content_type=content_type, object_id=project.id, count=1)
assert len(get_liked_list(fan_user, viewer_user)) == 1 assert len(get_liked_list(fan_user, viewer_user)) == 1
assert len(get_liked_list(fan_user, viewer_user, type="project")) == 1 assert len(get_liked_list(fan_user, viewer_user, type="project")) == 1
@ -495,7 +494,7 @@ def test_get_liked_list_valid_info():
project = f.ProjectFactory(is_private=False, name="Testing project", tags=['test', 'tag']) project = f.ProjectFactory(is_private=False, name="Testing project", tags=['test', 'tag'])
content_type = ContentType.objects.get_for_model(project) content_type = ContentType.objects.get_for_model(project)
like = f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) like = f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user)
f.LikesFactory(content_type=content_type, object_id=project.id, count=1) project.refresh_totals()
raw_project_like_info = get_liked_list(fan_user, viewer_user)[0] raw_project_like_info = get_liked_list(fan_user, viewer_user)[0]
project_like_info = LikedObjectSerializer(raw_project_like_info).data project_like_info = LikedObjectSerializer(raw_project_like_info).data
@ -762,7 +761,6 @@ def test_get_liked_list_permissions():
membership = f.MembershipFactory(project=project, role=role, user=viewer_priviliged_user) membership = f.MembershipFactory(project=project, role=role, user=viewer_priviliged_user)
content_type = ContentType.objects.get_for_model(project) content_type = ContentType.objects.get_for_model(project)
f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user) f.LikeFactory(content_type=content_type, object_id=project.id, user=fan_user)
f.LikesFactory(content_type=content_type, object_id=project.id, count=1)
#If the project is private a viewer user without any permission shouldn' see #If the project is private a viewer user without any permission shouldn' see
# any vote # any vote