diff --git a/settings/common.py b/settings/common.py index e013b3f7..7096f6e6 100644 --- a/settings/common.py +++ b/settings/common.py @@ -524,6 +524,11 @@ 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 +MAX_MEMBERS_PRIVATE_PROJECTS = None # None == no limit +MAX_MEMBERS_PUBLIC_PROJECTS = None # None == no limit + from .sr import * diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index cdfc36f0..af472e3d 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -36,6 +36,7 @@ from taiga.projects.models import Project, Membership from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task from taiga.projects.serializers import ProjectSerializer +from taiga.users import services as users_service from . import mixins from . import serializers @@ -90,6 +91,14 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi data = request.DATA.copy() data['owner'] = data.get('owner', request.user.email) + is_private = data.get('is_private', False) + (enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project( + self.request.user, + project=Project(is_private=is_private, id=None) + ) + if not enough_slots: + raise exc.BadRequest(not_enough_slots_error) + # Create Project project_serialized = service.store_project(data) @@ -106,6 +115,14 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi # Create memberships if "memberships" in data: + members = len(data['memberships']) + (enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project( + self.request.user, + project=Project(is_private=is_private, id=None), + members=max(members, 1) + ) + if not enough_slots: + raise exc.BadRequest(not_enough_slots_error) service.store_memberships(project_serialized.object, data) try: @@ -202,17 +219,35 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi try: dump = json.load(reader(dump)) + is_private = dump["is_private"] except Exception: raise exc.WrongArguments(_("Invalid dump format")) + user = request.user + (enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project( + user, + project=Project(is_private=is_private, id=None) + ) + if not enough_slots: + raise exc.BadRequest(not_enough_slots_error) + if Project.objects.filter(slug=dump['slug']).exists(): del dump['slug'] + members = len(dump.get("memberships", [])) + (enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project( + user, + project=Project(is_private=is_private, id=None), + members=max(members, 1) + ) + if not enough_slots: + raise exc.BadRequest(not_enough_slots_error) + if settings.CELERY_ENABLED: - task = tasks.load_project_dump.delay(request.user, dump) + task = tasks.load_project_dump.delay(user, dump) return response.Accepted({"import_id": task.id}) - project = dump_service.dict_to_project(dump, request.user.email) + project = dump_service.dict_to_project(dump, request.user) response_data = ProjectSerializer(project).data return response.Created(response_data) diff --git a/taiga/export_import/dump_service.py b/taiga/export_import/dump_service.py index 8029fa0f..adbaa6fe 100644 --- a/taiga/export_import/dump_service.py +++ b/taiga/export_import/dump_service.py @@ -17,7 +17,8 @@ from django.utils.translation import ugettext as _ -from taiga.projects.models import Membership +from taiga.projects.models import Membership, Project +from taiga.users import services as users_service from . import serializers from . import service @@ -89,7 +90,15 @@ def store_tags_colors(project, data): def dict_to_project(data, owner=None): if owner: - data["owner"] = owner + data["owner"] = owner.email + members = len(data.get("memberships", [])) + (enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project( + owner, + project=Project(is_private=data["is_private"], id=None), + members=members + ) + if not enough_slots: + raise TaigaImportError(not_enough_slots_error) project_serialized = service.store_project(data) diff --git a/taiga/export_import/management/commands/load_dump.py b/taiga/export_import/management/commands/load_dump.py index 1b44adbf..367a2401 100644 --- a/taiga/export_import/management/commands/load_dump.py +++ b/taiga/export_import/management/commands/load_dump.py @@ -25,6 +25,7 @@ from taiga.projects.models import Project from taiga.export_import.renderers import ExportRenderer from taiga.export_import.dump_service import dict_to_project, TaigaImportError from taiga.export_import.service import get_errors +from taiga.users.models import User class Command(BaseCommand): @@ -58,7 +59,9 @@ class Command(BaseCommand): except Project.DoesNotExist: pass signals.post_delete.receivers = receivers_back - dict_to_project(data, args[1]) + + user = User.objects.get(email=args[1]) + dict_to_project(data, user) except TaigaImportError as e: print("ERROR:", end=" ") print(e.message) diff --git a/taiga/export_import/tasks.py b/taiga/export_import/tasks.py index c6389b8b..8044f35c 100644 --- a/taiga/export_import/tasks.py +++ b/taiga/export_import/tasks.py @@ -79,7 +79,7 @@ def delete_project_dump(project_id, project_slug, task_id): @app.task def load_project_dump(user, dump): try: - project = dict_to_project(dump, user.email) + project = dict_to_project(dump, user) except Exception: ctx = { "user": user, diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 2b04085e..a5e2838c 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,13 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, permissions_service.set_base_permissions_for_project(obj) def pre_save(self, obj): + user = self.request.user + (enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project(user, project=obj) + if not enough_slots: + raise exc.BadRequest(not_enough_slots_error) + 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) @@ -550,6 +555,15 @@ class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet): # TODO: this should be moved to main exception handler instead # of handling explicit exception catchin here. + if "bulk_memberships" in data and isinstance(data["bulk_memberships"], list): + (enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project( + request.user, + project=project, + members=len(data["bulk_memberships"]) + ) + if not enough_slots: + raise exc.BadRequest(not_enough_slots_error) + try: members = services.create_members_in_bulk(data["bulk_memberships"], project=project, @@ -577,6 +591,15 @@ class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet): raise exc.BadRequest(_("The project must have an owner and at least one of the users must be an active admin")) def pre_save(self, obj): + if not obj.id: + (enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project( + self.request.user, + project=obj.project, + members=1 + ) + if not enough_slots: + raise exc.BadRequest(not_enough_slots_error) + if not obj.token: obj.token = str(uuid.uuid1()) diff --git a/taiga/users/admin.py b/taiga/users/admin.py index 729d64bf..76bc701d 100644 --- a/taiga/users/admin.py +++ b/taiga/users/admin.py @@ -30,14 +30,14 @@ admin.site.unregister(Group) class RoleAdmin(admin.ModelAdmin): list_display = ["name"] - filter_horizontal = ('permissions',) + filter_horizontal = ("permissions",) def formfield_for_manytomany(self, db_field, request=None, **kwargs): - if db_field.name == 'permissions': - qs = kwargs.get('queryset', db_field.rel.to.objects) + if db_field.name == "permissions": + qs = kwargs.get("queryset", db_field.rel.to.objects) # Avoid a major performance hit resolving permission names which # triggers a content_type load: - kwargs['queryset'] = qs.select_related('content_type') + kwargs["queryset"] = qs.select_related("content_type") return super().formfield_for_manytomany( db_field, request=request, **kwargs) @@ -47,18 +47,21 @@ class RoleAdmin(admin.ModelAdmin): class UserAdmin(DjangoUserAdmin): fieldsets = ( - (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',)}), - (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + (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")}), + (_("Restrictions"), {"fields": (("max_private_projects", "max_members_private_projects"), + ("max_public_projects", "max_members_public_projects"))}), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), ) form = UserChangeForm add_form = UserCreationForm - list_display = ('username', 'email', 'full_name') - list_filter = ('is_superuser', 'is_active') - search_fields = ('username', 'full_name', 'email') - ordering = ('username',) + list_display = ("username", "email", "full_name") + list_filter = ("is_superuser", "is_active") + search_fields = ("username", "full_name", "email") + ordering = ("username",) filter_horizontal = () class RoleInline(admin.TabularInline): 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..2b62b760 --- /dev/null +++ b/taiga/users/migrations/0015_auto_20160120_1409.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +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=settings.MAX_PRIVATE_PROJECTS_PER_USER, 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=settings.MAX_PUBLIC_PROJECTS_PER_USER, blank=True), + ), + ] diff --git a/taiga/users/migrations/0016_auto_20160204_1050.py b/taiga/users/migrations/0016_auto_20160204_1050.py new file mode 100644 index 00000000..d148f56a --- /dev/null +++ b/taiga/users/migrations/0016_auto_20160204_1050.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0015_auto_20160120_1409'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='max_members_private_projects', + field=models.IntegerField(default=settings.MAX_MEMBERS_PRIVATE_PROJECTS, blank=True, verbose_name='max number of memberships for each owned private project', null=True), + ), + migrations.AddField( + model_name='user', + name='max_members_public_projects', + field=models.IntegerField(default=settings.MAX_MEMBERS_PUBLIC_PROJECTS, blank=True, verbose_name='max number of memberships for each owned public project', null=True), + ), + ] diff --git a/taiga/users/models.py b/taiga/users/models.py index e5624fd0..e55bf72c 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 @@ -71,11 +72,11 @@ def get_user_file_path(instance, filename): class PermissionsMixin(models.Model): """ A mixin class that adds the fields and methods necessary to support - Django's Permission model using the ModelBackend. + Django"s Permission model using the ModelBackend. """ - is_superuser = models.BooleanField(_('superuser status'), default=False, - help_text=_('Designates that this user has all permissions without ' - 'explicitly assigning them.')) + is_superuser = models.BooleanField(_("superuser status"), default=False, + help_text=_("Designates that this user has all permissions without " + "explicitly assigning them.")) class Meta: abstract = True @@ -104,25 +105,25 @@ class PermissionsMixin(models.Model): class User(AbstractBaseUser, PermissionsMixin): - username = models.CharField(_('username'), max_length=255, unique=True, - help_text=_('Required. 30 characters or fewer. Letters, numbers and ' - '/./-/_ characters'), + username = models.CharField(_("username"), max_length=255, unique=True, + help_text=_("Required. 30 characters or fewer. Letters, numbers and " + "/./-/_ characters"), validators=[ - validators.RegexValidator(re.compile('^[\w.-]+$'), _('Enter a valid username.'), 'invalid') + validators.RegexValidator(re.compile("^[\w.-]+$"), _("Enter a valid username."), "invalid") ]) - email = models.EmailField(_('email address'), max_length=255, blank=True, unique=True) - is_active = models.BooleanField(_('active'), default=True, - help_text=_('Designates whether this user should be treated as ' - 'active. Unselect this instead of deleting accounts.')) + email = models.EmailField(_("email address"), max_length=255, blank=True, unique=True) + is_active = models.BooleanField(_("active"), default=True, + help_text=_("Designates whether this user should be treated as " + "active. Unselect this instead of deleting accounts.")) - full_name = models.CharField(_('full name'), max_length=256, blank=True) + full_name = models.CharField(_("full name"), max_length=256, blank=True) color = models.CharField(max_length=9, null=False, blank=True, default=generate_random_hex_color, verbose_name=_("color")) bio = models.TextField(null=False, blank=True, default="", verbose_name=_("biography")) photo = models.FileField(upload_to=get_user_file_path, max_length=500, null=True, blank=True, verbose_name=_("photo")) - date_joined = models.DateTimeField(_('date joined'), default=timezone.now) + date_joined = models.DateTimeField(_("date joined"), default=timezone.now) lang = models.CharField(max_length=20, null=True, blank=True, default="", verbose_name=_("default language")) theme = models.CharField(max_length=100, null=True, blank=True, default="", @@ -137,16 +138,33 @@ class User(AbstractBaseUser, PermissionsMixin): email_token = models.CharField(max_length=200, null=True, blank=True, default=None, verbose_name=_("email token")) - new_email = models.EmailField(_('new email address'), null=True, blank=True) + 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")) + max_members_private_projects = models.IntegerField(null=True, blank=True, + default=settings.MAX_MEMBERS_PRIVATE_PROJECTS, + verbose_name=_("max number of memberships for " + "each owned private project")) + max_members_public_projects = models.IntegerField(null=True, blank=True, + default=settings.MAX_MEMBERS_PUBLIC_PROJECTS, + verbose_name=_("max number of memberships for " + "each owned public project")) + _cached_memberships = None _cached_liked_ids = None _cached_watched_ids = None _cached_notify_levels = None - USERNAME_FIELD = 'username' - REQUIRED_FIELDS = ['email'] + USERNAME_FIELD = "username" + REQUIRED_FIELDS = ["email"] objects = UserManager() @@ -154,9 +172,6 @@ class User(AbstractBaseUser, PermissionsMixin): verbose_name = "user" verbose_name_plural = "users" ordering = ["username"] - permissions = ( - ("view_user", "Can view user"), - ) def __str__(self): return self.get_full_name() @@ -279,16 +294,13 @@ class Role(models.Model): verbose_name_plural = "roles" ordering = ["order", "slug"] unique_together = (("slug", "project"),) - permissions = ( - ("view_role", "Can view role"), - ) def __str__(self): return self.name class AuthData(models.Model): - user = models.ForeignKey('users.User', related_name="auth_data") + user = models.ForeignKey("users.User", related_name="auth_data") key = models.SlugField(max_length=50) value = models.CharField(max_length=300) extra = JsonField() diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py index 5b381252..204852ad 100644 --- a/taiga/users/serializers.py +++ b/taiga/users/serializers.py @@ -104,14 +104,30 @@ 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", + "max_members_private_projects", "max_members_public_projects", + "total_private_projects", "total_public_projects") + + read_only_fields = ("id", "email", + "max_private_projects", "max_public_projects", + "max_members_private_projects", + "max_members_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..54ce79d7 100644 --- a/taiga/users/services.py +++ b/taiga/users/services.py @@ -572,3 +572,43 @@ 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, project, members=1): + (enough, error) = _has_available_slot_for_project_type(user, project) + if not enough: + return (enough, error) + return _has_available_slot_for_project_members(user, project, members) + + +def _has_available_slot_for_project_type(user, project): + if project.is_private: + if user.max_private_projects is None: + return (True, None) + elif user.owned_projects.filter(is_private=True).exclude(id=project.id).count() < user.max_private_projects: + return (True, None) + return (False, _("You can't have more private projects")) + else: + if user.max_public_projects is None: + return (True, None) + elif user.owned_projects.filter(is_private=False).exclude(id=project.id).count() < user.max_public_projects: + return (True, None) + return (False, _("You can't have more public projects")) + + + +def _has_available_slot_for_project_members(user, project, members): + current_memberships = project.memberships.count() + + if project.is_private: + if user.max_members_private_projects is None: + return (True, None) + elif current_memberships + members <= user.max_members_private_projects: + return (True, None) + return (False, _("You have reached the limit of memberships for private projects")) + else: + if user.max_members_public_projects is None: + return (True, None) + elif current_memberships + members <= user.max_members_public_projects: + return (True, None) + return (False, _("You have reached the limit of memberships for public projects")) diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 360550fb..52784d47 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -23,6 +23,7 @@ from django.core.urlresolvers import reverse from django.core.files.base import ContentFile from taiga.base.utils import json +from taiga.export_import.dump_service import dict_to_project, TaigaImportError from taiga.projects.models import Project, Membership from taiga.projects.issues.models import Issue from taiga.projects.userstories.models import UserStory @@ -72,6 +73,85 @@ def test_valid_project_import_without_extra_data(client): assert response_data["watchers"] == [user.email, user_watching.email] +def test_valid_project_without_enough_public_projects_slots(client): + user = f.UserFactory.create(max_public_projects=0) + + url = reverse("importer-list") + data = { + "slug": "public-project-without-slots", + "name": "Imported project", + "description": "Imported project", + "roles": [{"name": "Role"}], + "is_private": False + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more public projects" in response.data["_error_message"] + assert Project.objects.filter(slug="public-project-without-slots").count() == 0 + + +def test_valid_project_without_enough_private_projects_slots(client): + user = f.UserFactory.create(max_private_projects=0) + + url = reverse("importer-list") + data = { + "slug": "private-project-without-slots", + "name": "Imported project", + "description": "Imported project", + "roles": [{"name": "Role"}], + "is_private": True + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "can't have more private projects" in response.data["_error_message"] + assert Project.objects.filter(slug="private-project-without-slots").count() == 0 + + +def test_valid_project_with_enough_public_projects_slots(client): + user = f.UserFactory.create(max_public_projects=1) + + url = reverse("importer-list") + data = { + "slug": "public-project-with-slots", + "name": "Imported project", + "description": "Imported project", + "roles": [{"name": "Role"}], + "is_private": False + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + print(response.content) + assert response.status_code == 201 + assert Project.objects.filter(slug="public-project-with-slots").count() == 1 + + +def test_valid_project_with_enough_private_projects_slots(client): + user = f.UserFactory.create(max_private_projects=1) + + url = reverse("importer-list") + data = { + "slug": "private-project-with-slots", + "name": "Imported project", + "description": "Imported project", + "roles": [{"name": "Role"}], + "is_private": True + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + assert Project.objects.filter(slug="private-project-with-slots").count() == 1 + + def test_valid_project_import_with_not_existing_memberships(client): user = f.UserFactory.create() client.login(user) @@ -930,6 +1010,92 @@ def test_milestone_import_duplicated_milestone(client): assert response_data["milestones"][0]["name"][0] == "Name duplicated for the project" +def test_dict_to_project_with_no_projects_slots_available(client): + user = f.UserFactory.create(max_private_projects=0) + + data = { + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True + } + + with pytest.raises(TaigaImportError) as excinfo: + project = dict_to_project(data, owner=user) + + assert "can't have more private projects" in str(excinfo.value) + + +def test_dict_to_project_with_no_members_private_project_slots_available(client): + user = f.UserFactory.create(max_members_private_projects=2) + + data = { + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True, + "roles": [{"name": "Role"}], + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + } + ] + } + + with pytest.raises(TaigaImportError) as excinfo: + project = dict_to_project(data, owner=user) + + assert "reached the limit of memberships for private" in str(excinfo.value) + + +def test_dict_to_project_with_no_members_public_project_slots_available(client): + user = f.UserFactory.create(max_members_public_projects=2) + + data = { + "slug": "valid-project", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False, + "roles": [{"name": "Role"}], + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + } + ] + } + + with pytest.raises(TaigaImportError) as excinfo: + project = dict_to_project(data, owner=user) + + assert "reached the limit of memberships for public" in str(excinfo.value) + + def test_invalid_dump_import(client): user = f.UserFactory.create() client.login(user) @@ -957,6 +1123,7 @@ def test_valid_dump_import_with_logo(client, settings): "slug": "valid-project", "name": "Valid project", "description": "Valid project desc", + "is_private": False, "logo": { "name": "logo.bmp", "data": base64.b64encode(DUMMY_BMP_DATA).decode("utf-8") @@ -986,7 +1153,8 @@ def test_valid_dump_import_with_celery_disabled(client, settings): data = ContentFile(bytes(json.dumps({ "slug": "valid-project", "name": "Valid project", - "description": "Valid project desc" + "description": "Valid project desc", + "is_private": True }), "utf-8")) data.name = "test" @@ -1008,7 +1176,8 @@ def test_valid_dump_import_with_celery_enabled(client, settings): data = ContentFile(bytes(json.dumps({ "slug": "valid-project", "name": "Valid project", - "description": "Valid project desc" + "description": "Valid project desc", + "is_private": True }), "utf-8")) data.name = "test" @@ -1028,7 +1197,8 @@ def test_dump_import_duplicated_project(client): data = ContentFile(bytes(json.dumps({ "slug": project.slug, "name": "Test import", - "description": "Valid project desc" + "description": "Valid project desc", + "is_private": True }), "utf-8")) data.name = "test" @@ -1051,7 +1221,8 @@ def test_dump_import_throttling(client, settings): data = ContentFile(bytes(json.dumps({ "slug": project.slug, "name": "Test import", - "description": "Valid project desc" + "description": "Valid project desc", + "is_private": True }), "utf-8")) data.name = "test" @@ -1059,3 +1230,249 @@ def test_dump_import_throttling(client, settings): assert response.status_code == 201 response = client.post(url, {'dump': data}) assert response.status_code == 429 + + +def test_valid_dump_import_without_enough_public_projects_slots(client): + user = f.UserFactory.create(max_public_projects=0) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "public-project-without-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert "can't have more public projects" in response.data["_error_message"] + assert Project.objects.filter(slug="public-project-without-slots").count() == 0 + + +def test_valid_dump_import_without_enough_private_projects_slots(client): + user = f.UserFactory.create(max_private_projects=0) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "private-project-without-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert "can't have more private projects" in response.data["_error_message"] + assert Project.objects.filter(slug="private-project-without-slots").count() == 0 + + +def test_valid_dump_import_without_enough_membership_private_project_slots_one_project(client): + user = f.UserFactory.create(max_members_private_projects=5) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "project-without-memberships-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True, + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + { + "email": "test6@test.com", + "role": "Role", + }, + ], + "roles": [{"name": "Role"}] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert "reached the limit of memberships for private" in response.data["_error_message"] + assert Project.objects.filter(slug="project-without-memberships-slots").count() == 0 + + +def test_valid_dump_import_without_enough_membership_public_project_slots_one_project(client): + user = f.UserFactory.create(max_members_public_projects=5) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "project-without-memberships-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False, + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + { + "email": "test6@test.com", + "role": "Role", + }, + ], + "roles": [{"name": "Role"}] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 400 + assert "reached the limit of memberships for public" in response.data["_error_message"] + assert Project.objects.filter(slug="project-without-memberships-slots").count() == 0 + + +def test_valid_dump_import_with_enough_membership_private_project_slots_multiple_projects(client, settings): + settings.CELERY_ENABLED = False + + user = f.UserFactory.create(max_members_private_projects=10) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "project-without-memberships-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": True, + "roles": [{"name": "Role"}], + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + { + "email": "test6@test.com", + "role": "Role", + } + ] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + response_data = response.data + assert "id" in response_data + assert response_data["name"] == "Valid project" + + +def test_valid_dump_import_with_enough_membership_public_project_slots_multiple_projects(client, settings): + settings.CELERY_ENABLED = False + + user = f.UserFactory.create(max_members_public_projects=10) + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + f.MembershipFactory.create(project=project) + client.login(user) + + url = reverse("importer-load-dump") + + data = ContentFile(bytes(json.dumps({ + "slug": "project-without-memberships-slots", + "name": "Valid project", + "description": "Valid project desc", + "is_private": False, + "roles": [{"name": "Role"}], + "memberships": [ + { + "email": "test1@test.com", + "role": "Role", + }, + { + "email": "test2@test.com", + "role": "Role", + }, + { + "email": "test3@test.com", + "role": "Role", + }, + { + "email": "test4@test.com", + "role": "Role", + }, + { + "email": "test5@test.com", + "role": "Role", + }, + { + "email": "test6@test.com", + "role": "Role", + } + ] + }), "utf-8")) + data.name = "test" + + response = client.post(url, {'dump': data}) + assert response.status_code == 201 + response_data = response.data + assert "id" in response_data + assert response_data["name"] == "Valid project" diff --git a/tests/integration/test_memberships.py b/tests/integration/test_memberships.py index 75d7f29a..ec4e3124 100644 --- a/tests/integration/test_memberships.py +++ b/tests/integration/test_memberships.py @@ -53,6 +53,112 @@ def test_api_create_bulk_members(client): assert response.data[1]["email"] == joseph.email +def test_api_create_bulk_members_without_enough_memberships_private_project_slots_one_project(client): + user = f.UserFactory.create(max_members_private_projects=3) + project = f.ProjectFactory(owner=user, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_owner=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "email": "test1@test.com"}, + {"role_id": role.pk, "email": "test2@test.com"}, + {"role_id": role.pk, "email": "test3@test.com"}, + {"role_id": role.pk, "email": "test4@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached the limit of memberships for private" in response.data["_error_message"] + + +def test_api_create_bulk_members_with_enough_memberships_private_project_slots_multiple_projects(client): + user = f.UserFactory.create(max_members_private_projects=6) + project = f.ProjectFactory(owner=user, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_owner=True) + + other_project = f.ProjectFactory(owner=user) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "email": "test1@test.com"}, + {"role_id": role.pk, "email": "test2@test.com"}, + {"role_id": role.pk, "email": "test3@test.com"}, + {"role_id": role.pk, "email": "test4@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + + +def test_api_create_bulk_members_without_enough_memberships_public_project_slots_one_project(client): + user = f.UserFactory.create(max_members_public_projects=3) + project = f.ProjectFactory(owner=user, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_owner=True) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "email": "test1@test.com"}, + {"role_id": role.pk, "email": "test2@test.com"}, + {"role_id": role.pk, "email": "test3@test.com"}, + {"role_id": role.pk, "email": "test4@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached the limit of memberships for public" in response.data["_error_message"] + + +def test_api_create_bulk_members_with_enough_memberships_public_project_slots_multiple_projects(client): + user = f.UserFactory.create(max_members_public_projects=6) + project = f.ProjectFactory(owner=user, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_owner=True) + + other_project = f.ProjectFactory(owner=user) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + + url = reverse("memberships-bulk-create") + + data = { + "project_id": project.id, + "bulk_memberships": [ + {"role_id": role.pk, "email": "test1@test.com"}, + {"role_id": role.pk, "email": "test2@test.com"}, + {"role_id": role.pk, "email": "test3@test.com"}, + {"role_id": role.pk, "email": "test4@test.com"}, + ] + } + client.login(user) + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + + def test_api_create_bulk_members_with_extra_text(client, outbox): project = f.ProjectFactory() tester = f.RoleFactory(project=project, name="Tester") @@ -162,6 +268,76 @@ def test_api_create_membership(client): assert response.data["user_email"] == user.email +def test_api_create_membership_without_enough_memberships_private_project_slots_one_projects(client): + user = f.UserFactory.create(max_members_private_projects=1) + project = f.ProjectFactory(owner=user, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_owner=True) + + client.login(user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "email": "test@test.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached the limit of memberships for private" in response.data["_error_message"] + + +def test_api_create_membership_with_enough_memberships_private_project_slots_multiple_projects(client): + user = f.UserFactory.create(max_members_private_projects=5) + project = f.ProjectFactory(owner=user, is_private=True) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_owner=True) + + other_project = f.ProjectFactory(owner=user) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + + client.login(user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "email": "test@test.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + + +def test_api_create_membership_without_enough_memberships_public_project_slots_one_projects(client): + user = f.UserFactory.create(max_members_public_projects=1) + project = f.ProjectFactory(owner=user, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_owner=True) + + client.login(user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "email": "test@test.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "reached the limit of memberships for public" in response.data["_error_message"] + + +def test_api_create_membership_with_enough_memberships_public_project_slots_multiple_projects(client): + user = f.UserFactory.create(max_members_public_projects=5) + project = f.ProjectFactory(owner=user, is_private=False) + role = f.RoleFactory(project=project, name="Test") + f.MembershipFactory(project=project, user=user, is_owner=True) + + other_project = f.ProjectFactory(owner=user) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + f.MembershipFactory.create(project=other_project) + + client.login(user) + url = reverse("memberships-list") + data = {"role": role.pk, "project": project.pk, "email": "test@test.com"} + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 201 + + def test_api_edit_membership(client): membership = f.MembershipFactory(is_owner=True) client.login(membership.user) diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 8b079fea..1627d537 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -43,6 +43,145 @@ 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 private 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 public 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 public 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 private 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_change_project_other_data_with_enough_private_projects_slots(client): + project = f.create_project(is_private=True, owner__max_private_projects=1) + f.MembershipFactory(user=project.owner, project=project, is_owner=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + data = { + "name": "test-project-change" + } + + 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)