Merge pull request #43 from taigaio/stars

Star projects
remotes/origin/enhancement/email-actions
Jesús Espino 2014-05-27 18:05:17 +02:00
commit 13ec5c3d0b
21 changed files with 707 additions and 36 deletions

View File

@ -8,3 +8,4 @@ pytest-pythonpath==0.3
coverage==3.7.1 coverage==3.7.1
coveralls==0.4.2 coveralls==0.4.2
django-testclient-extensions==0.1.1

View File

@ -186,6 +186,7 @@ INSTALLED_APPS = [
"taiga.projects.wiki", "taiga.projects.wiki",
"taiga.projects.history", "taiga.projects.history",
"taiga.projects.notifications", "taiga.projects.notifications",
"taiga.projects.stars",
"south", "south",
"reversion", "reversion",

View File

@ -40,6 +40,7 @@ from . import serializers
from . import models from . import models
from . import permissions from . import permissions
from . import services from . import services
from . import stars
class ProjectAdminViewSet(ModelCrudViewSet): class ProjectAdminViewSet(ModelCrudViewSet):
@ -73,6 +74,18 @@ class ProjectViewSet(ModelCrudViewSet):
project = self.get_object() project = self.get_object()
return Response(services.get_stats_for_project(project)) return Response(services.get_stats_for_project(project))
@detail_route(methods=['post'], permission_classes=(IsAuthenticated,))
def star(self, request, pk=None):
project = self.get_object()
stars.star(project, user=request.user)
return Response(status=status.HTTP_200_OK)
@detail_route(methods=['post'], permission_classes=(IsAuthenticated,))
def unstar(self, request, pk=None):
project = self.get_object()
stars.unstar(project, user=request.user)
return Response(status=status.HTTP_200_OK)
@detail_route(methods=['get']) @detail_route(methods=['get'])
def issues_stats(self, request, pk=None): def issues_stats(self, request, pk=None):
project = self.get_object() project = self.get_object()
@ -311,3 +324,21 @@ class ProjectTemplateViewSet(ModelCrudViewSet):
def get_queryset(self): def get_queryset(self):
return models.ProjectTemplate.objects.all() return models.ProjectTemplate.objects.all()
class FansViewSet(ModelCrudViewSet):
serializer_class = serializers.FanSerializer
list_serializer_class = serializers.FanSerializer
permission_classes = (IsAuthenticated,)
def get_queryset(self):
return stars.get_fans(self.kwargs.get("project_id"))
class StarredViewSet(ModelCrudViewSet):
serializer_class = serializers.StarredSerializer
list_serializer_class = serializers.StarredSerializer
permission_classes = (IsAuthenticated,)
def get_queryset(self):
return stars.get_starred(self.kwargs.get("user_id"))

View File

@ -19,7 +19,7 @@ from rest_framework import serializers
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from taiga.base.serializers import PickleField, JsonField from taiga.base.serializers import PickleField, JsonField
from taiga.users.models import Role from taiga.users.models import Role, User
from . import models from . import models
@ -84,6 +84,7 @@ class ProjectMembershipSerializer(serializers.ModelSerializer):
class ProjectSerializer(serializers.ModelSerializer): class ProjectSerializer(serializers.ModelSerializer):
tags = PickleField(required=False) tags = PickleField(required=False)
stars = serializers.IntegerField(source="stars.count")
class Meta: class Meta:
model = models.Project model = models.Project
@ -150,3 +151,17 @@ class ProjectTemplateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.ProjectTemplate model = models.ProjectTemplate
class FanSerializer(serializers.ModelSerializer):
full_name = serializers.CharField(source='get_full_name', required=False)
class Meta:
model = User
fields = ('id', 'username', 'first_name', 'last_name', 'full_name')
class StarredSerializer(serializers.ModelSerializer):
class Meta:
model = models.Project
fields = ['id', 'name', 'slug']

View File

@ -0,0 +1 @@
from .services import star, unstar, get_fans, get_starred

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,248 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'Fan'
db.create_table('stars_fan', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('project', self.gf('django.db.models.fields.related.ForeignKey')(related_name='fans', to=orm['projects.Project'])),
('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='fans', to=orm['users.User'])),
))
db.send_create_signal('stars', ['Fan'])
# Adding unique constraint on 'Fan', fields ['project', 'user']
db.create_unique('stars_fan', ['project_id', 'user_id'])
# Adding model 'Stars'
db.create_table('stars_stars', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('project', self.gf('django.db.models.fields.related.OneToOneField')(unique=True, to=orm['projects.Project'])),
('count', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)),
))
db.send_create_signal('stars', ['Stars'])
def backwards(self, orm):
# Removing unique constraint on 'Fan', fields ['project', 'user']
db.delete_unique('stars_fan', ['project_id', 'user_id'])
# Deleting model 'Fan'
db.delete_table('stars_fan')
# Deleting model 'Stars'
db.delete_table('stars_stars')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'to': "orm['auth.Permission']", 'symmetrical': 'False'})
},
'auth.permission': {
'Meta': {'unique_together': "(('content_type', 'codename'),)", 'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'contenttypes.contenttype': {
'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'ordering': "('name',)", 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'domains.domain': {
'Meta': {'ordering': "('domain',)", 'object_name': 'Domain'},
'alias_of': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'related_name': "'+'", 'to': "orm['domains.Domain']"}),
'default_language': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}),
'domain': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'public_register': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'scheme': ('django.db.models.fields.CharField', [], {'null': 'True', 'default': 'None', 'max_length': '60'})
},
'projects.issuestatus': {
'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'IssueStatus'},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issue_statuses'", 'to': "orm['projects.Project']"})
},
'projects.issuetype': {
'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'IssueType'},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'issue_types'", 'to': "orm['projects.Project']"})
},
'projects.membership': {
'Meta': {'unique_together': "(('user', 'project'),)", 'ordering': "['project', 'role']", 'object_name': 'Membership'},
'created_at': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'default': 'datetime.datetime.now', 'auto_now_add': 'True'}),
'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'max_length': '255'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'memberships'", 'to': "orm['projects.Project']"}),
'role': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'memberships'", 'to': "orm['users.Role']"}),
'token': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'max_length': '60'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'related_name': "'memberships'", 'to': "orm['users.User']"})
},
'projects.points': {
'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'Points'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'points'", 'to': "orm['projects.Project']"}),
'value': ('django.db.models.fields.FloatField', [], {'blank': 'True', 'null': 'True', 'default': 'None'})
},
'projects.priority': {
'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'Priority'},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'priorities'", 'to': "orm['projects.Project']"})
},
'projects.project': {
'Meta': {'ordering': "['name']", 'object_name': 'Project'},
'created_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now_add': 'True'}),
'creation_template': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'related_name': "'projects'", 'to': "orm['projects.ProjectTemplate']"}),
'default_issue_status': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.IssueStatus']"}),
'default_issue_type': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.IssueType']"}),
'default_points': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.Points']"}),
'default_priority': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.Priority']"}),
'default_severity': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.Severity']"}),
'default_task_status': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.TaskStatus']"}),
'default_us_status': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'on_delete': 'models.SET_NULL', 'unique': 'True', 'null': 'True', 'related_name': "'+'", 'to': "orm['projects.UserStoryStatus']"}),
'description': ('django.db.models.fields.TextField', [], {}),
'domain': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'related_name': "'projects'", 'to': "orm['domains.Domain']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_backlog_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_issues_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_kanban_activated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_wiki_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'members': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'projects'", 'to': "orm['users.User']", 'through': "orm['projects.Membership']"}),
'modified_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '250'}),
'owner': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'owned_projects'", 'to': "orm['users.User']"}),
'public': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'blank': 'True', 'max_length': '250'}),
'tags': ('picklefield.fields.PickledObjectField', [], {'blank': 'True'}),
'total_milestones': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True', 'default': '0'}),
'total_story_points': ('django.db.models.fields.FloatField', [], {'null': 'True', 'default': 'None'}),
'videoconferences': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'}),
'videoconferences_salt': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'})
},
'projects.projecttemplate': {
'Meta': {'unique_together': "(['slug', 'domain'],)", 'ordering': "['name']", 'object_name': 'ProjectTemplate'},
'created_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now_add': 'True'}),
'default_options': ('django_pgjson.fields.JsonField', [], {}),
'default_owner_role': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
'description': ('django.db.models.fields.TextField', [], {}),
'domain': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'related_name': "'templates'", 'to': "orm['domains.Domain']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_backlog_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_issues_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_kanban_activated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_wiki_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'issue_statuses': ('django_pgjson.fields.JsonField', [], {}),
'issue_types': ('django_pgjson.fields.JsonField', [], {}),
'modified_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '250'}),
'points': ('django_pgjson.fields.JsonField', [], {}),
'priorities': ('django_pgjson.fields.JsonField', [], {}),
'roles': ('django_pgjson.fields.JsonField', [], {}),
'severities': ('django_pgjson.fields.JsonField', [], {}),
'slug': ('django.db.models.fields.SlugField', [], {'blank': 'True', 'max_length': '250'}),
'task_statuses': ('django_pgjson.fields.JsonField', [], {}),
'us_statuses': ('django_pgjson.fields.JsonField', [], {}),
'videoconferences': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'}),
'videoconferences_salt': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'})
},
'projects.severity': {
'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'Severity'},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'severities'", 'to': "orm['projects.Project']"})
},
'projects.taskstatus': {
'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'TaskStatus'},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'task_statuses'", 'to': "orm['projects.Project']"})
},
'projects.userstorystatus': {
'Meta': {'unique_together': "(('project', 'name'),)", 'ordering': "['project', 'order', 'name']", 'object_name': 'UserStoryStatus'},
'color': ('django.db.models.fields.CharField', [], {'default': "'#999999'", 'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'us_statuses'", 'to': "orm['projects.Project']"}),
'wip_limit': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True', 'default': 'None'})
},
'stars.fan': {
'Meta': {'unique_together': "(('project', 'user'),)", 'object_name': 'Fan'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'fans'", 'to': "orm['projects.Project']"}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'fans'", 'to': "orm['users.User']"})
},
'stars.stars': {
'Meta': {'object_name': 'Stars'},
'count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'project': ('django.db.models.fields.related.OneToOneField', [], {'unique': 'True', 'to': "orm['projects.Project']"})
},
'users.role': {
'Meta': {'unique_together': "(('slug', 'project'),)", 'ordering': "['order', 'slug']", 'object_name': 'Role'},
'computable': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'roles'", 'to': "orm['auth.Permission']"}),
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'roles'", 'to': "orm['projects.Project']"}),
'slug': ('django.db.models.fields.SlugField', [], {'blank': 'True', 'max_length': '250'})
},
'users.user': {
'Meta': {'ordering': "['username']", 'object_name': 'User'},
'color': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "'#55c1f6'", 'max_length': '9'}),
'colorize_tags': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'default_language': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}),
'default_timezone': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}),
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'max_length': '75'}),
'first_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'user_set'", 'to': "orm['auth.Group']", 'symmetrical': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}),
'notify_changes_by_me': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'notify_level': ('django.db.models.fields.CharField', [], {'default': "'all_owned_projects'", 'max_length': '32'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'photo': ('django.db.models.fields.files.FileField', [], {'blank': 'True', 'null': 'True', 'max_length': '500'}),
'token': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'default': 'None', 'max_length': '200'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'blank': 'True', 'related_name': "'user_set'", 'to': "orm['auth.Permission']", 'symmetrical': 'False'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
}
}
complete_apps = ['stars']

View File

@ -0,0 +1,36 @@
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from django.db import models
class Fan(models.Model):
project = models.ForeignKey("projects.Project", null=False, blank=False, related_name="fans",
verbose_name=_("project"))
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=False, blank=False,
related_name="fans", verbose_name=_("fans"))
class Meta:
verbose_name = _("Star")
verbose_name_plural = _("Stars")
unique_together = ("project", "user")
def __unicode__(self):
return self.user
@classmethod
def create(cls, *args, **kwargs):
return cls.objects.create(*args, **kwargs)
class Stars(models.Model):
project = models.OneToOneField("projects.Project", null=False, blank=False,
related_name="stars", verbose_name=_("project"))
count = models.PositiveIntegerField(null=False, blank=False, default=0,
verbose_name=_("count"))
class Meta:
verbose_name = _("Stars")
verbose_name_plural = _("Stars")
def __unicode__(self):
return "{} stars".format(self.count)

View File

@ -0,0 +1,67 @@
from django.db.models import F
from django.db.transaction import atomic
from django.contrib.auth import get_user_model
from ..models import Project
from .models import Fan, Stars
@atomic
def star(project, user):
"""Star a project for an user.
If the user has already starred the project nothing happends so this function can be considered
idempotent.
:param project: :class:`~taiga.projects.models.Project` instance.
:param user: :class:`~taiga.users.models.User` instance.
"""
if not Fan.objects.filter(project=project, user=user).exists():
Fan.objects.create(project=project, user=user)
stars, _ = Stars.objects.get_or_create(project=project)
stars.count = F('count') + 1
stars.save()
@atomic
def unstar(project, user):
"""Unstar a project for an user.
If the user has not starred the project nothing happens so this function can be considered
idempotent.
:param project: :class:`~taiga.projects.models.Project` instance.
:param user: :class:`~taiga.users.models.User` instance.
"""
if Fan.objects.filter(project=project, user=user).exists():
Fan.objects.filter(project=project, user=user).delete()
stars, _ = Stars.objects.get_or_create(project=project)
stars.count = F('count') - 1
stars.save()
def get_stars(project):
"""Get the count of stars a project have."""
return Stars.objects.filter(project=project).count
def get_fans(project_or_id):
"""Get the fans a project have."""
qs = get_user_model().objects.get_queryset()
if isinstance(project_or_id, int):
qs = qs.filter(fans__project_id=project_or_id)
else:
qs = qs.filter(fans__project=project_or_id)
return qs
def get_starred(user_or_id):
"""Get the projects an user has starred."""
qs = Project.objects.get_queryset()
if isinstance(user_or_id, int):
qs = qs.filter(fans__user_id=user_or_id)
else:
qs = qs.filter(fans__user=user_or_id)
return qs

View File

@ -59,9 +59,13 @@ from taiga.projects.api import IssueTypeViewSet
from taiga.projects.api import PriorityViewSet from taiga.projects.api import PriorityViewSet
from taiga.projects.api import SeverityViewSet from taiga.projects.api import SeverityViewSet
from taiga.projects.api import ProjectTemplateViewSet from taiga.projects.api import ProjectTemplateViewSet
from taiga.projects.api import FansViewSet
from taiga.projects.api import StarredViewSet
router.register(r"roles", RolesViewSet, base_name="roles") router.register(r"roles", RolesViewSet, base_name="roles")
router.register(r"projects", ProjectViewSet, base_name="projects") router.register(r"projects", ProjectViewSet, base_name="projects")
router.register(r"projects/(?P<project_id>\d+)/fans", FansViewSet, base_name="project-fans")
router.register(r"users/(?P<user_id>\d+)/starred", StarredViewSet, base_name="user-starred")
router.register(r"project-templates", ProjectTemplateViewSet, base_name="project-templates") router.register(r"project-templates", ProjectTemplateViewSet, base_name="project-templates")
router.register(r"memberships", MembershipViewSet, base_name="memberships") router.register(r"memberships", MembershipViewSet, base_name="memberships")
router.register(r"invitations", InvitationViewSet, base_name="invitations") router.register(r"invitations", InvitationViewSet, base_name="invitations")

View File

@ -18,6 +18,8 @@ from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from rest_framework import serializers from rest_framework import serializers
from taiga.projects.models import Project
from .models import User, Role from .models import User, Role

View File

@ -1,13 +1,6 @@
import pytest import pytest
from .fixtures import *
class Object:
pass
@pytest.fixture
def object():
return Object()
def pytest_addoption(parser): def pytest_addoption(parser):

View File

@ -1,4 +1,5 @@
import uuid import uuid
import threading
import factory import factory
from django.conf import settings from django.conf import settings
@ -7,11 +8,25 @@ import taiga.projects.models
import taiga.projects.userstories.models import taiga.projects.userstories.models
import taiga.projects.issues.models import taiga.projects.issues.models
import taiga.projects.milestones.models import taiga.projects.milestones.models
import taiga.projects.stars.models
import taiga.users.models import taiga.users.models
import taiga.userstorage.models import taiga.userstorage.models
class ProjectTemplateFactory(factory.DjangoModelFactory): class Factory(factory.DjangoModelFactory):
FACTORY_STRATEGY = factory.CREATE_STRATEGY
_SEQUENCE = 1
_SEQUENCE_LOCK = threading.Lock()
@classmethod
def _setup_next_sequence(cls):
with cls._SEQUENCE_LOCK:
cls._SEQUENCE += 1
return cls._SEQUENCE
class ProjectTemplateFactory(Factory):
FACTORY_FOR = taiga.projects.models.ProjectTemplate FACTORY_FOR = taiga.projects.models.ProjectTemplate
FACTORY_DJANGO_GET_OR_CREATE = ("slug", ) FACTORY_DJANGO_GET_OR_CREATE = ("slug", )
@ -28,7 +43,7 @@ class ProjectTemplateFactory(factory.DjangoModelFactory):
roles = [] roles = []
class ProjectFactory(factory.DjangoModelFactory): class ProjectFactory(Factory):
FACTORY_FOR = taiga.projects.models.Project FACTORY_FOR = taiga.projects.models.Project
name = factory.Sequence(lambda n: "Project {}".format(n)) name = factory.Sequence(lambda n: "Project {}".format(n))
@ -38,14 +53,30 @@ class ProjectFactory(factory.DjangoModelFactory):
creation_template = factory.SubFactory("tests.factories.ProjectTemplateFactory") creation_template = factory.SubFactory("tests.factories.ProjectTemplateFactory")
class RoleFactory(factory.DjangoModelFactory): class RoleFactory(Factory):
FACTORY_FOR = taiga.users.models.Role FACTORY_FOR = taiga.users.models.Role
name = "Tester" name = "Tester"
project = factory.SubFactory("tests.factories.ProjectFactory") project = factory.SubFactory("tests.factories.ProjectFactory")
class UserFactory(factory.DjangoModelFactory): class PointsFactory(Factory):
FACTORY_FOR = taiga.projects.models.Points
name = factory.Sequence(lambda n: "Points {}".format(n))
value = 2
project = factory.SubFactory("tests.factories.ProjectFactory")
class RolePointsFactory(Factory):
FACTORY_FOR = taiga.projects.userstories.models.RolePoints
user_story = factory.SubFactory("tests.factories.UserStoryFactory")
role = factory.SubFactory("tests.factories.RoleFactory")
points = factory.SubFactory("tests.factories.PointsFactory")
class UserFactory(Factory):
FACTORY_FOR = taiga.users.models.User FACTORY_FOR = taiga.users.models.User
username = factory.Sequence(lambda n: "user{}".format(n)) username = factory.Sequence(lambda n: "user{}".format(n))
@ -53,7 +84,7 @@ class UserFactory(factory.DjangoModelFactory):
password = factory.PostGeneration(lambda obj, *args, **kwargs: obj.set_password(obj.username)) password = factory.PostGeneration(lambda obj, *args, **kwargs: obj.set_password(obj.username))
class MembershipFactory(factory.DjangoModelFactory): class MembershipFactory(Factory):
FACTORY_FOR = taiga.projects.models.Membership FACTORY_FOR = taiga.projects.models.Membership
token = factory.LazyAttribute(lambda obj: str(uuid.uuid1())) token = factory.LazyAttribute(lambda obj: str(uuid.uuid1()))
@ -62,7 +93,7 @@ class MembershipFactory(factory.DjangoModelFactory):
user = factory.SubFactory("tests.factories.UserFactory") user = factory.SubFactory("tests.factories.UserFactory")
class StorageEntryFactory(factory.DjangoModelFactory): class StorageEntryFactory(Factory):
FACTORY_FOR = taiga.userstorage.models.StorageEntry FACTORY_FOR = taiga.userstorage.models.StorageEntry
owner = factory.SubFactory("tests.factories.UserFactory") owner = factory.SubFactory("tests.factories.UserFactory")
@ -70,7 +101,7 @@ class StorageEntryFactory(factory.DjangoModelFactory):
value = factory.Sequence(lambda n: "value {}".format(n)) value = factory.Sequence(lambda n: "value {}".format(n))
class UserStoryFactory(factory.DjangoModelFactory): class UserStoryFactory(Factory):
FACTORY_FOR = taiga.projects.userstories.models.UserStory FACTORY_FOR = taiga.projects.userstories.models.UserStory
ref = factory.Sequence(lambda n: n) ref = factory.Sequence(lambda n: n)
@ -79,7 +110,7 @@ class UserStoryFactory(factory.DjangoModelFactory):
subject = factory.Sequence(lambda n: "User Story {}".format(n)) subject = factory.Sequence(lambda n: "User Story {}".format(n))
class MilestoneFactory(factory.DjangoModelFactory): class MilestoneFactory(Factory):
FACTORY_FOR = taiga.projects.milestones.models.Milestone FACTORY_FOR = taiga.projects.milestones.models.Milestone
name = factory.Sequence(lambda n: "Milestone {}".format(n)) name = factory.Sequence(lambda n: "Milestone {}".format(n))
@ -87,7 +118,7 @@ class MilestoneFactory(factory.DjangoModelFactory):
project = factory.SubFactory("tests.factories.ProjectFactory") project = factory.SubFactory("tests.factories.ProjectFactory")
class IssueFactory(factory.DjangoModelFactory): class IssueFactory(Factory):
FACTORY_FOR = taiga.projects.issues.models.Issue FACTORY_FOR = taiga.projects.issues.models.Issue
subject = factory.Sequence(lambda n: "Issue {}".format(n)) subject = factory.Sequence(lambda n: "Issue {}".format(n))
@ -100,29 +131,43 @@ class IssueFactory(factory.DjangoModelFactory):
milestone = factory.SubFactory("tests.factories.MilestoneFactory") milestone = factory.SubFactory("tests.factories.MilestoneFactory")
class IssueStatusFactory(factory.DjangoModelFactory): class IssueStatusFactory(Factory):
FACTORY_FOR = taiga.projects.models.IssueStatus FACTORY_FOR = taiga.projects.models.IssueStatus
name = factory.Sequence(lambda n: "Issue Status {}".format(n)) name = factory.Sequence(lambda n: "Issue Status {}".format(n))
project = factory.SubFactory("tests.factories.ProjectFactory") project = factory.SubFactory("tests.factories.ProjectFactory")
class SeverityFactory(factory.DjangoModelFactory): class SeverityFactory(Factory):
FACTORY_FOR = taiga.projects.models.Severity FACTORY_FOR = taiga.projects.models.Severity
name = factory.Sequence(lambda n: "Severity {}".format(n)) name = factory.Sequence(lambda n: "Severity {}".format(n))
project = factory.SubFactory("tests.factories.ProjectFactory") project = factory.SubFactory("tests.factories.ProjectFactory")
class PriorityFactory(factory.DjangoModelFactory): class PriorityFactory(Factory):
FACTORY_FOR = taiga.projects.models.Priority FACTORY_FOR = taiga.projects.models.Priority
name = factory.Sequence(lambda n: "Priority {}".format(n)) name = factory.Sequence(lambda n: "Priority {}".format(n))
project = factory.SubFactory("tests.factories.ProjectFactory") project = factory.SubFactory("tests.factories.ProjectFactory")
class IssueTypeFactory(factory.DjangoModelFactory): class IssueTypeFactory(Factory):
FACTORY_FOR = taiga.projects.models.IssueType FACTORY_FOR = taiga.projects.models.IssueType
name = factory.Sequence(lambda n: "Issue Type {}".format(n)) name = factory.Sequence(lambda n: "Issue Type {}".format(n))
project = factory.SubFactory("tests.factories.ProjectFactory") project = factory.SubFactory("tests.factories.ProjectFactory")
class FanFactory(Factory):
FACTORY_FOR = taiga.projects.stars.models.Fan
project = factory.SubFactory("tests.factories.ProjectFactory")
user = factory.SubFactory("tests.factories.UserFactory")
class StarsFactory(Factory):
FACTORY_FOR = taiga.projects.stars.models.Stars
project = factory.SubFactory("tests.factories.ProjectFactory")
count = 0

17
tests/fixtures.py Normal file
View File

@ -0,0 +1,17 @@
import pytest
class Object:
pass
@pytest.fixture
def object():
return Object()
@pytest.fixture
def client():
from testclient_extensions import Client
return Client()

View File

@ -5,14 +5,15 @@ from .. import factories
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@pytest.fixture @pytest.fixture
def register_form(): def register_form():
return {"username": "username", return {"username": "username",
"password": "password", "password": "password",
"first_name": "fname", "first_name": "fname",
"last_name": "lname", "last_name": "lname",
"email": "user@email.com", "email": "user@email.com",
"type": "public"} "type": "public"}
def test_respond_201_if_domain_allows_public_registration(client, register_form): def test_respond_201_if_domain_allows_public_registration(client, register_form):

View File

@ -7,7 +7,13 @@ from taiga.projects.userstories.models import UserStory
from taiga.projects.issues.models import Issue from taiga.projects.issues.models import Issue
from taiga.base.utils.db import filter_by_tags from taiga.base.utils.db import filter_by_tags
from taiga.base import neighbors as n from taiga.base import neighbors as n
from .. import factories as f from .. import factories as f
from ..utils import disconnect_signals
def setup_module():
disconnect_signals()
class TestGetAttribute: class TestGetAttribute:
@ -53,7 +59,6 @@ def test_disjunction_filters():
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.slow
class TestUserStories: class TestUserStories:
def test_no_filters(self): def test_no_filters(self):
project = f.ProjectFactory.create() project = f.ProjectFactory.create()
@ -99,7 +104,6 @@ class TestUserStories:
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.slow
class TestIssues: class TestIssues:
def test_no_filters(self): def test_no_filters(self):
project = f.ProjectFactory.create() project = f.ProjectFactory.create()

View File

@ -0,0 +1,117 @@
import pytest
from django.core.urlresolvers import reverse
from .. import factories as f
pytestmark = pytest.mark.django_db
def test_project_owner_star_project(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
url = reverse("projects-star", args=(project.id,))
client.login(user)
response = client.post(url)
assert response.status_code == 200
def test_project_owner_unstar_project(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
url = reverse("projects-unstar", args=(project.id,))
client.login(user)
response = client.post(url)
assert response.status_code == 200
def test_project_member_star_project(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create()
role = f.RoleFactory.create(project=project)
f.MembershipFactory.create(project=project, user=user, role=role)
url = reverse("projects-star", args=(project.id,))
client.login(user)
response = client.post(url)
assert response.status_code == 200
def test_project_member_unstar_project(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create()
role = f.RoleFactory.create(project=project)
f.MembershipFactory.create(project=project, user=user, role=role)
url = reverse("projects-unstar", args=(project.id,))
client.login(user)
response = client.post(url)
assert response.status_code == 200
def test_list_project_fans(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
fan = f.FanFactory.create(project=project)
url = reverse("project-fans-list", args=(project.id,))
client.login(user)
response = client.get(url)
assert response.status_code == 200
assert response.data[0]['id'] == fan.user.id
def test_get_project_fan(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
fan = f.FanFactory.create(project=project)
url = reverse("project-fans-detail", args=(project.id, fan.user.id))
client.login(user)
response = client.get(url)
assert response.status_code == 200
assert response.data['id'] == fan.user.id
def test_list_user_starred_projects(client):
user = f.UserFactory.create()
fan = f.FanFactory.create(user=user)
url = reverse("user-starred-list", args=(user.id,))
client.login(user)
response = client.get(url)
assert response.status_code == 200
assert response.data[0]['id'] == fan.project.id
def test_get_user_starred_project(client):
user = f.UserFactory.create()
fan = f.FanFactory.create(user=user)
url = reverse("user-starred-detail", args=(user.id, fan.project.id))
client.login(user)
response = client.get(url)
assert response.status_code == 200
assert response.data['id'] == fan.project.id
def test_get_project_stars(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
f.StarsFactory.create(project=project, count=5)
url = reverse("projects-detail", args=(project.id,))
client.login(user)
response = client.get(url)
assert response.status_code == 200
assert response.data['stars'] == 5

View File

@ -1,9 +1,4 @@
from django.db.models import signals from ..utils import disconnect_signals
def disconnect_signals():
signals.pre_save.receivers = []
signals.post_save.receivers = []
def pytest_runtest_setup(item): def pytest_runtest_setup(item):

84
tests/unit/test_stars.py Normal file
View File

@ -0,0 +1,84 @@
from unittest import mock
from taiga.projects.stars import services as stars
from .. import factories as f
def test_user_star_project():
"An user can star a project"
user = f.UserFactory.build()
project = f.ProjectFactory.build()
with mock.patch("taiga.projects.stars.services.Fan") as Fan:
with mock.patch("taiga.projects.stars.services.Stars") as Stars:
stars_instance = mock.Mock()
stars_instance.save = mock.Mock()
Fan.objects.filter(project=project, user=user).exists = mock.Mock(return_value=False)
Stars.objects.get_or_create = mock.MagicMock(return_value=(stars_instance, True))
stars.star(project, user=user)
assert Fan.objects.create.called
assert stars_instance.count.connector == '+'
assert stars_instance.count.children[1] == 1
assert stars_instance.save.called
def test_idempotence_user_star_project():
"An user can star a project many times but only one star is counted"
user = f.UserFactory.build()
project = f.ProjectFactory.build()
with mock.patch("taiga.projects.stars.services.Fan") as Fan:
with mock.patch("taiga.projects.stars.services.Stars") as Stars:
stars_instance = mock.Mock()
stars_instance.save = mock.Mock()
Fan.objects.filter(project=project, user=user).exists = mock.Mock(return_value=True)
Stars.objects.get_or_create = mock.MagicMock(return_value=(stars_instance, True))
stars.star(project, user=user)
assert not Fan.objects.create.called
assert not stars_instance.save.called
def test_user_unstar_project():
"An user can unstar a project"
fan = f.FanFactory.build()
with mock.patch("taiga.projects.stars.services.Fan") as Fan:
with mock.patch("taiga.projects.stars.services.Stars") as Stars:
delete_mock = mock.Mock()
stars_instance = mock.Mock()
stars_instance.save = mock.Mock()
Fan.objects.filter(project=fan.project, user=fan.user).exists = mock.Mock(
return_value=True)
Fan.objects.filter(project=fan.project, user=fan.user).delete = delete_mock
Stars.objects.get_or_create = mock.MagicMock(return_value=(stars_instance, True))
stars.unstar(fan.project, user=fan.user)
assert delete_mock.called
assert stars_instance.count.connector == '-'
assert stars_instance.count.children[1] == 1
assert stars_instance.save.called
def test_idempotence_user_unstar_project():
"An user can unstar a project many times but only one star is discounted"
fan = f.FanFactory.build()
with mock.patch("taiga.projects.stars.services.Fan") as Fan:
with mock.patch("taiga.projects.stars.services.Stars") as Stars:
delete_mock = mock.Mock()
stars_instance = mock.Mock()
stars_instance.save = mock.Mock()
Fan.objects.filter(project=fan.project, user=fan.user).exists = mock.Mock(
return_value=False)
Fan.objects.filter(project=fan.project, user=fan.user).delete = delete_mock
Stars.objects.get_or_create = mock.MagicMock(return_value=(stars_instance, True))
stars.unstar(fan.project, user=fan.user)
assert not delete_mock.called
assert not stars_instance.save.called

6
tests/utils.py Normal file
View File

@ -0,0 +1,6 @@
from django.db.models import signals
def disconnect_signals():
signals.pre_save.receivers = []
signals.post_save.receivers = []