diff --git a/settings/common.py b/settings/common.py index e013b3f7..fa1f9d73 100644 --- a/settings/common.py +++ b/settings/common.py @@ -524,6 +524,9 @@ FRONT_SITEMAP_CACHE_TIMEOUT = 24*60*60 # In second EXTRA_BLOCKING_CODES = [] +MAX_PRIVATE_PROJECTS_PER_USER = None # None == no limit +MAX_PUBLIC_PROJECTS_PER_USER = None # None == no limit + from .sr import * diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 2b04085e..15017817 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -51,6 +51,7 @@ from taiga.projects.tasks.models import Task from taiga.projects.issues.models import Issue from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin from taiga.permissions import service as permissions_service +from taiga.users import services as users_service from . import filters as project_filters from . import models @@ -342,9 +343,12 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, permissions_service.set_base_permissions_for_project(obj) def pre_save(self, obj): + user = self.request.user + if not users_service.has_available_slot_for_project(user, is_private=obj.is_private): + raise exc.BadRequest(_("The user can't have more projects of this type")) + if not obj.id: - obj.owner = self.request.user - # TODO REFACTOR THIS + obj.owner = user obj.template = self.request.QUERY_PARAMS.get('template', None) self._set_base_permissions(obj) diff --git a/taiga/users/admin.py b/taiga/users/admin.py index 729d64bf..d5a3a4a0 100644 --- a/taiga/users/admin.py +++ b/taiga/users/admin.py @@ -50,7 +50,7 @@ class UserAdmin(DjangoUserAdmin): (None, {'fields': ('username', 'password')}), (_('Personal info'), {'fields': ('full_name', 'email', 'bio', 'photo')}), (_('Extra info'), {'fields': ('color', 'lang', 'timezone', 'token', 'colorize_tags', 'email_token', 'new_email')}), - (_('Permissions'), {'fields': ('is_active', 'is_superuser',)}), + (_('Permissions'), {'fields': ('is_active', 'is_superuser', 'max_private_projects', 'max_public_projects')}), (_('Important dates'), {'fields': ('last_login', 'date_joined')}), ) form = UserChangeForm diff --git a/taiga/users/migrations/0015_auto_20160120_1409.py b/taiga/users/migrations/0015_auto_20160120_1409.py new file mode 100644 index 00000000..3fea9616 --- /dev/null +++ b/taiga/users/migrations/0015_auto_20160120_1409.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0014_auto_20151005_1357'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='max_private_projects', + field=models.IntegerField(null=True, verbose_name='max number of private projects owned', default=None, blank=True), + ), + migrations.AddField( + model_name='user', + name='max_public_projects', + field=models.IntegerField(null=True, verbose_name='max number of public projects owned', default=None, blank=True), + ), + ] diff --git a/taiga/users/models.py b/taiga/users/models.py index e5624fd0..9ce48998 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -25,6 +25,7 @@ import uuid from unidecode import unidecode from django.apps import apps +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db import models from django.dispatch import receiver @@ -140,6 +141,11 @@ class User(AbstractBaseUser, PermissionsMixin): new_email = models.EmailField(_('new email address'), null=True, blank=True) is_system = models.BooleanField(null=False, blank=False, default=False) + + + max_private_projects = models.IntegerField(null=True, blank=True, default=settings.MAX_PRIVATE_PROJECTS_PER_USER, verbose_name='max number of private projects owned') + max_public_projects = models.IntegerField(null=True, blank=True, default=settings.MAX_PUBLIC_PROJECTS_PER_USER, verbose_name='max number of public projects owned') + _cached_memberships = None _cached_liked_ids = None _cached_watched_ids = None diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py index 5b381252..bfceda55 100644 --- a/taiga/users/serializers.py +++ b/taiga/users/serializers.py @@ -104,14 +104,27 @@ class UserSerializer(serializers.ModelSerializer): return ContactProjectDetailSerializer(projects, many=True).data class UserAdminSerializer(UserSerializer): + total_private_projects = serializers.SerializerMethodField("get_total_private_projects") + total_public_projects = serializers.SerializerMethodField("get_total_public_projects") + class Meta: model = User # IMPORTANT: Maintain the UserSerializer Meta up to date # with this info (including here the email) fields = ("id", "username", "full_name", "full_name_display", "email", "color", "bio", "lang", "theme", "timezone", "is_active", "photo", - "big_photo") - read_only_fields = ("id", "email") + "big_photo", + "max_private_projects", "max_public_projects", + "total_private_projects", "total_public_projects") + + read_only_fields = ("id", "email", + "max_private_projects", "max_public_projects") + + def get_total_private_projects(self, user): + return user.owned_projects.filter(is_private=True).count() + + def get_total_public_projects(self, user): + return user.owned_projects.filter(is_private=False).count() class UserBasicInfoSerializer(UserSerializer): diff --git a/taiga/users/services.py b/taiga/users/services.py index 55c82ca1..45dd4be9 100644 --- a/taiga/users/services.py +++ b/taiga/users/services.py @@ -572,3 +572,16 @@ def get_voted_list(for_user, from_user, type=None, q=None): dict(zip([col[0] for col in desc], row)) for row in cursor.fetchall() ] + + +def has_available_slot_for_project(user, is_private=False): + if is_private: + if user.max_private_projects is None: + return True + + return user.owned_projects.filter(is_private=True).count() < user.max_private_projects + + if user.max_public_projects is None: + return True + + return user.owned_projects.filter(is_private=False).count() < user.max_public_projects diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 8b079fea..603b327a 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -43,6 +43,130 @@ def test_create_project(client): assert response.status_code == 201 +def test_create_private_project_without_enough_private_projects_slots(client): + user = f.create_user(max_private_projects=0) + url = reverse("projects-list") + data = { + "name": "project name", + "description": "project description", + "is_private": True + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more projects" in response.data["_error_message"] + + +def test_create_public_project_without_enough_public_projects_slots(client): + user = f.create_user(max_public_projects=0) + url = reverse("projects-list") + data = { + "name": "project name", + "description": "project description", + "is_private": False + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more projects" in response.data["_error_message"] + + +def test_change_project_from_private_to_public_without_enough_public_projects_slots(client): + project = f.create_project(is_private=True, owner__max_public_projects=0) + f.MembershipFactory(user=project.owner, project=project, is_owner=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + data = { + "is_private": False + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more projects" in response.data["_error_message"] + + +def test_change_project_from_public_to_private_without_enough_private_projects_slots(client): + project = f.create_project(is_private=False, owner__max_private_projects=0) + f.MembershipFactory(user=project.owner, project=project, is_owner=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + data = { + "is_private": True + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more projects" in response.data["_error_message"] + + +def test_create_private_project_with_enough_private_projects_slots(client): + user = f.create_user(max_private_projects=1) + url = reverse("projects-list") + data = { + "name": "project name", + "description": "project description", + "is_private": True + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + + +def test_create_public_project_with_enough_public_projects_slots(client): + user = f.create_user(max_public_projects=1) + url = reverse("projects-list") + data = { + "name": "project name", + "description": "project description", + "is_private": False + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + + +def test_change_project_from_private_to_public_with_enough_public_projects_slots(client): + project = f.create_project(is_private=True, owner__max_public_projects=1) + f.MembershipFactory(user=project.owner, project=project, is_owner=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + data = { + "is_private": False + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 200 + + +def test_change_project_from_public_to_private_with_enough_private_projects_slots(client): + project = f.create_project(is_private=False, owner__max_private_projects=1) + f.MembershipFactory(user=project.owner, project=project, is_owner=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + data = { + "is_private": True + } + + client.login(project.owner) + response = client.json.patch(url, json.dumps(data)) + + assert response.status_code == 200 + + def test_partially_update_project(client): project = f.create_project() f.MembershipFactory(user=project.owner, project=project, is_owner=True)