diff --git a/requirements-devel.txt b/requirements-devel.txt index 0a4dda9d..04aadb04 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -8,3 +8,4 @@ pytest-pythonpath==0.3 coverage==3.7.1 coveralls==0.4.2 +django-testclient-extensions==0.1.1 diff --git a/settings/common.py b/settings/common.py index b5016666..a1f5254e 100644 --- a/settings/common.py +++ b/settings/common.py @@ -186,6 +186,7 @@ INSTALLED_APPS = [ "taiga.projects.wiki", "taiga.projects.history", "taiga.projects.notifications", + "taiga.projects.stars", "south", "reversion", diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 2a607c61..e18d3807 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -40,6 +40,7 @@ from . import serializers from . import models from . import permissions from . import services +from . import stars class ProjectAdminViewSet(ModelCrudViewSet): @@ -73,6 +74,18 @@ class ProjectViewSet(ModelCrudViewSet): project = self.get_object() 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']) def issues_stats(self, request, pk=None): project = self.get_object() diff --git a/taiga/projects/stars/__init__.py b/taiga/projects/stars/__init__.py new file mode 100644 index 00000000..87225831 --- /dev/null +++ b/taiga/projects/stars/__init__.py @@ -0,0 +1 @@ +from .services import star, unstar diff --git a/taiga/projects/stars/admin.py b/taiga/projects/stars/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/taiga/projects/stars/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/taiga/projects/stars/migrations/0001_initial.py b/taiga/projects/stars/migrations/0001_initial.py new file mode 100644 index 00000000..101886b9 --- /dev/null +++ b/taiga/projects/stars/migrations/0001_initial.py @@ -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'] \ No newline at end of file diff --git a/taiga/projects/stars/migrations/__init__.py b/taiga/projects/stars/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/stars/models.py b/taiga/projects/stars/models.py new file mode 100644 index 00000000..0abe06b4 --- /dev/null +++ b/taiga/projects/stars/models.py @@ -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, + 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) diff --git a/taiga/projects/stars/services.py b/taiga/projects/stars/services.py new file mode 100644 index 00000000..8f6e48b5 --- /dev/null +++ b/taiga/projects/stars/services.py @@ -0,0 +1,55 @@ +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): + """Get the fans a project have.""" + return get_user_model().objects.filter(fans__project=project) + + +def get_starred_projects(user): + """Get the projects an user has starred.""" + return Project.objects.filter(fans__user=user) diff --git a/tests/factories.py b/tests/factories.py index 29472888..fdf83324 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,4 +1,5 @@ import uuid +import threading import factory from django.conf import settings @@ -7,11 +8,25 @@ import taiga.projects.models import taiga.projects.userstories.models import taiga.projects.issues.models import taiga.projects.milestones.models +import taiga.projects.stars.models import taiga.users.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_DJANGO_GET_OR_CREATE = ("slug", ) @@ -28,7 +43,7 @@ class ProjectTemplateFactory(factory.DjangoModelFactory): roles = [] -class ProjectFactory(factory.DjangoModelFactory): +class ProjectFactory(Factory): FACTORY_FOR = taiga.projects.models.Project name = factory.Sequence(lambda n: "Project {}".format(n)) @@ -38,14 +53,30 @@ class ProjectFactory(factory.DjangoModelFactory): creation_template = factory.SubFactory("tests.factories.ProjectTemplateFactory") -class RoleFactory(factory.DjangoModelFactory): +class RoleFactory(Factory): FACTORY_FOR = taiga.users.models.Role name = "Tester" 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 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)) -class MembershipFactory(factory.DjangoModelFactory): +class MembershipFactory(Factory): FACTORY_FOR = taiga.projects.models.Membership token = factory.LazyAttribute(lambda obj: str(uuid.uuid1())) @@ -62,7 +93,7 @@ class MembershipFactory(factory.DjangoModelFactory): user = factory.SubFactory("tests.factories.UserFactory") -class StorageEntryFactory(factory.DjangoModelFactory): +class StorageEntryFactory(Factory): FACTORY_FOR = taiga.userstorage.models.StorageEntry owner = factory.SubFactory("tests.factories.UserFactory") @@ -70,7 +101,7 @@ class StorageEntryFactory(factory.DjangoModelFactory): value = factory.Sequence(lambda n: "value {}".format(n)) -class UserStoryFactory(factory.DjangoModelFactory): +class UserStoryFactory(Factory): FACTORY_FOR = taiga.projects.userstories.models.UserStory ref = factory.Sequence(lambda n: n) @@ -79,7 +110,7 @@ class UserStoryFactory(factory.DjangoModelFactory): subject = factory.Sequence(lambda n: "User Story {}".format(n)) -class MilestoneFactory(factory.DjangoModelFactory): +class MilestoneFactory(Factory): FACTORY_FOR = taiga.projects.milestones.models.Milestone name = factory.Sequence(lambda n: "Milestone {}".format(n)) @@ -87,7 +118,7 @@ class MilestoneFactory(factory.DjangoModelFactory): project = factory.SubFactory("tests.factories.ProjectFactory") -class IssueFactory(factory.DjangoModelFactory): +class IssueFactory(Factory): FACTORY_FOR = taiga.projects.issues.models.Issue subject = factory.Sequence(lambda n: "Issue {}".format(n)) @@ -100,29 +131,36 @@ class IssueFactory(factory.DjangoModelFactory): milestone = factory.SubFactory("tests.factories.MilestoneFactory") -class IssueStatusFactory(factory.DjangoModelFactory): +class IssueStatusFactory(Factory): FACTORY_FOR = taiga.projects.models.IssueStatus name = factory.Sequence(lambda n: "Issue Status {}".format(n)) project = factory.SubFactory("tests.factories.ProjectFactory") -class SeverityFactory(factory.DjangoModelFactory): +class SeverityFactory(Factory): FACTORY_FOR = taiga.projects.models.Severity name = factory.Sequence(lambda n: "Severity {}".format(n)) project = factory.SubFactory("tests.factories.ProjectFactory") -class PriorityFactory(factory.DjangoModelFactory): +class PriorityFactory(Factory): FACTORY_FOR = taiga.projects.models.Priority name = factory.Sequence(lambda n: "Priority {}".format(n)) project = factory.SubFactory("tests.factories.ProjectFactory") -class IssueTypeFactory(factory.DjangoModelFactory): +class IssueTypeFactory(Factory): FACTORY_FOR = taiga.projects.models.IssueType name = factory.Sequence(lambda n: "Issue Type {}".format(n)) 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") diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py index 7b993651..fe1054c9 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -5,14 +5,15 @@ from .. import factories pytestmark = pytest.mark.django_db + @pytest.fixture def register_form(): - return {"username": "username", - "password": "password", - "first_name": "fname", - "last_name": "lname", - "email": "user@email.com", - "type": "public"} + return {"username": "username", + "password": "password", + "first_name": "fname", + "last_name": "lname", + "email": "user@email.com", + "type": "public"} def test_respond_201_if_domain_allows_public_registration(client, register_form): diff --git a/tests/integration/test_stars.py b/tests/integration/test_stars.py new file mode 100644 index 00000000..754115a0 --- /dev/null +++ b/tests/integration/test_stars.py @@ -0,0 +1,54 @@ +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 diff --git a/tests/unit/test_stars.py b/tests/unit/test_stars.py new file mode 100644 index 00000000..5a0cc1a1 --- /dev/null +++ b/tests/unit/test_stars.py @@ -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