diff --git a/bothub/api/v2/internal/__init__.py b/bothub/api/v2/internal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bothub/api/v2/internal/organization/__init__.py b/bothub/api/v2/internal/organization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bothub/api/v2/internal/organization/permissions.py b/bothub/api/v2/internal/organization/permissions.py new file mode 100644 index 000000000..6edbf607c --- /dev/null +++ b/bothub/api/v2/internal/organization/permissions.py @@ -0,0 +1,11 @@ +from rest_framework import permissions + +from bothub.utils import get_user + + +class InternalOrganizationAdminHasPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + authorization = obj.organization.get_organization_authorization( + get_user(request.query_params.get("user_email", None)) + ) + return authorization.is_admin diff --git a/bothub/api/v2/internal/organization/serializers.py b/bothub/api/v2/internal/organization/serializers.py new file mode 100644 index 000000000..26456b3ae --- /dev/null +++ b/bothub/api/v2/internal/organization/serializers.py @@ -0,0 +1,83 @@ +from django.db.models import F +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from bothub.authentication.models import User +from bothub.common.models import Organization, OrganizationAuthorization + + +class OrganizationSerializer(serializers.ModelSerializer): + + users = serializers.SerializerMethodField() + + def get_users(self, org: Organization): + return list( + org.organization_authorizations.exclude( + role=OrganizationAuthorization.LEVEL_NOTHING + ) + .annotate( + org_user_id=F("user__user_owner__pk"), + org_user_email=F("user__user_owner__email"), + org_user_nickname=F("user__user_owner__nickname"), + org_user_name=F("user__user_owner__name"), + ) + .values( + "org_user_id", "org_user_email", "org_user_nickname", "org_user_name" + ) + ) + + class Meta: + model = Organization + fields = ["id", "name", "users"] + + +class OrgCreateSerializer(serializers.ModelSerializer): + + organization_name = serializers.CharField() + user_email = serializers.CharField() + + def validate_user_email(self, value: str) -> str: + try: + User.objects.get(email=value) + except User.DoesNotExist: + raise ValidationError(_("{} not found!").format(value)) + + return value + + class Meta: + model = Organization + fields = ["organization_name", "user_email"] + + +class OrgUpdateSerializer(serializers.ModelSerializer): + + id = serializers.IntegerField() + name = serializers.CharField(required=False) + + def validate_id(self, value): + try: + Organization.objects.get(pk=value) + except Organization.DoesNotExist: + raise ValidationError(f"{value} not found!") + + return value + + def save(self): + data = dict(self.validated_data) + + org = Organization.objects.get(pk=data.get("id")) + + updated_fields = self.get_updated_fields(data) + + if updated_fields: + org.__dict__.update(**updated_fields) + org.save() + + def get_updated_fields(self, data): + return {key: value for key, value in data.items() if key not in ["id"]} + + class Meta: + model = Organization + fields = ["id", "name"] diff --git a/bothub/api/v2/internal/organization/tests.py b/bothub/api/v2/internal/organization/tests.py new file mode 100644 index 000000000..c4a988c41 --- /dev/null +++ b/bothub/api/v2/internal/organization/tests.py @@ -0,0 +1,237 @@ +import json + +from django.test import tag +from django.test import RequestFactory +from django.test import TestCase +from rest_framework import status + +from bothub.api.v2.internal.organization.views import InternalOrganizationViewSet +from bothub.api.v2.tests.utils import create_user_and_token +from bothub.common.models import ( + Organization, + OrganizationAuthorization, + RepositoryCategory, +) +from bothub.api.v2.tests.utils import get_valid_mockups, create_repository_from_mockup + + +class InternalOrganizationTestCase(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.moduser, self.moduser_token = create_user_and_token("module", module=True) + self.owner, self.owner_token = create_user_and_token("owner") + self.category = RepositoryCategory.objects.create(name="Category 1") + self.org = Organization.objects.create( + name="Organization 1", nickname="organization1" + ) + self.org_2 = Organization.objects.create( + name="Organization 2", nickname="organization2" + ) + OrganizationAuthorization.objects.create( + user=self.owner, + organization=self.org, + role=OrganizationAuthorization.ROLE_ADMIN, + ) + OrganizationAuthorization.objects.create( + user=self.owner, + organization=self.org_2, + role=OrganizationAuthorization.ROLE_ADMIN, + ) + self.repositories = [ + create_repository_from_mockup(self.org, **mockup) + for mockup in get_valid_mockups([self.category]) + ] + for rep in self.repositories: + rep.get_user_authorization(self.org) + + self.repository_auth = self.repositories[0].get_user_authorization(self.owner) + + +@tag("internal") +class InternalOrganizationListTestCase(InternalOrganizationTestCase): + def request(self, params, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + + request = self.factory.get( + "/v2/internal/organization/", params, **authorization_header + ) + response = InternalOrganizationViewSet.as_view({"get": "list"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email}, + self.moduser_token, + ) + self.assertEqual(len(content_data), 2) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_not_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email}, + self.owner_token, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalOrganizationCreateTestCase(InternalOrganizationTestCase): + def request(self, data, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + authorization_header["content_type"] = "application/json" + + request = self.factory.post( + "/v2/internal/organization/", data, **authorization_header + ) + + response = InternalOrganizationViewSet.as_view({"post": "create"})(request) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email, "organization_name": "org3"}, + self.moduser_token, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + organization = Organization.objects.get(pk=content_data.get("id")) + self.assertEqual(organization.name, "org3") + organization_authorization = OrganizationAuthorization.objects.filter( + organization=organization, user=self.owner + ) + self.assertEqual(organization_authorization.count(), 1) + self.assertEqual( + organization_authorization.first().role, + OrganizationAuthorization.ROLE_ADMIN, + ) + + def test_not_ok(self): + response, content_data = self.request( + {"user_email": self.owner.email, "organization_name": "org3"}, + self.owner_token, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalOrganizationDestroyTestCase(InternalOrganizationTestCase): + def request(self, email, id, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + authorization_header["content_type"] = "application/json" + + request = self.factory.delete( + f"/v2/internal/organization/?user_email={email}", **authorization_header + ) + + response = InternalOrganizationViewSet.as_view({"delete": "destroy"})( + request, pk=id + ) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + self.owner.email, + self.org_2.id, + self.moduser_token, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + organization = Organization.objects.filter(pk=self.org_2.pk) + self.assertEqual(organization.count(), 0) + + def test_not_ok(self): + response, content_data = self.request( + self.owner.email, + self.org_2.id, + self.owner_token, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalOrganizationUpdateTestCase(InternalOrganizationTestCase): + def request(self, data, email, id, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + authorization_header["content_type"] = "application/json" + + request = self.factory.put( + f"/v2/internal/organization/?user_email={email}", + data, + **authorization_header, + ) + response = InternalOrganizationViewSet.as_view({"put": "update"})( + request, pk=id + ) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + {"name": "org222"}, + self.owner.email, + self.org_2.id, + self.moduser_token, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + organization = Organization.objects.get(pk=self.org_2.pk) + + self.assertEqual(organization.name, "org222") + + def test_not_ok(self): + response, content_data = self.request( + {"name": "org222"}, + self.owner.email, + self.org_2.id, + self.owner_token, + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@tag("internal") +class InternalOrganizationRetrieveTestCase(InternalOrganizationTestCase): + def request(self, email, id, token=None): + authorization_header = ( + {"HTTP_AUTHORIZATION": "Token {}".format(token.key)} if token else {} + ) + authorization_header["content_type"] = "application/json" + + request = self.factory.get( + f"/v2/internal/organization/?user_email={email}", **authorization_header + ) + response = InternalOrganizationViewSet.as_view({"get": "retrieve"})( + request, pk=id + ) + response.render() + content_data = json.loads(response.content) + return (response, content_data) + + def test_ok(self): + response, content_data = self.request( + self.owner.email, + self.org.id, + self.moduser_token, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(content_data["repositories_count"], 2) + + def test_not_ok(self): + response, content_data = self.request( + self.owner.email, + self.org.id, + self.owner_token, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/bothub/api/v2/internal/organization/views.py b/bothub/api/v2/internal/organization/views.py new file mode 100644 index 000000000..3fe839f18 --- /dev/null +++ b/bothub/api/v2/internal/organization/views.py @@ -0,0 +1,120 @@ +from django.utils.translation import ugettext_lazy as _ +from django.db import models +from rest_framework import mixins +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from bothub.api.v2.metadata import Metadata +from bothub.authentication.models import User +from bothub.common.models import ( + Organization, + OrganizationAuthorization, + Repository, + RepositoryAuthorization, +) +from bothub.api.v2.internal.organization.serializers import ( + OrganizationSerializer, + OrgCreateSerializer, + OrgUpdateSerializer, +) +from bothub import utils +from bothub.api.v2.internal.permissions import ModuleHasPermission +from bothub.api.v2.internal.organization.permissions import ( + InternalOrganizationAdminHasPermission, +) + + +class InternalOrganizationViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + GenericViewSet, +): + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer + lookup_field = "pk" + permission_classes = [ModuleHasPermission] + metadata_class = Metadata + + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + """ + + permission_classes = self.permission_classes + if self.action in ["destroy", "update"]: + permission_classes.append(InternalOrganizationAdminHasPermission) + return [permission() for permission in self.permission_classes] + + def list(self, request, *args, **kwargs): + user = utils.get_user(request.query_params.get("user_email")) + serializer = OrganizationSerializer(user.get_user_organizations, many=True) + + return Response(serializer.data) + + def create(self, request, *args, **kwargs): + user, created = User.objects.get_or_create( + email=request.data.get("user_email", None), + defaults={"nickname": request.data.get("user_email", None)}, + ) + + serializer = OrgCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + validated_data = { + "name": serializer.validated_data.get("organization_name"), + "nickname": utils.organization_unique_slug_generator( + serializer.validated_data.get("organization_name") + ), + "description": "", + "locale": "", + } + + org = Organization.objects.create(**validated_data) + + org.organization_authorizations.create( + user=user, role=OrganizationAuthorization.ROLE_ADMIN + ) + org_serializer = OrganizationSerializer(org) + + return Response(org_serializer.data) + + def destroy(self, request, *args, **kwargs): + org = self.get_object() + user, created = User.objects.get_or_create( + email=request.query_params.get("user_email"), + defaults={"nickname": request.query_params.get("user_email")}, + ) + + org.delete() + return Response({_("Organization deleted with success")}) + + def update(self, request, *args, **kwargs): + org = self.get_object() + data = {"id": org.pk, **request.data} + serializer = OrgUpdateSerializer(data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + + def retrieve(self, request, *args, **kwargs): + org = self.get_object() + + auths = ( + RepositoryAuthorization.objects.exclude(repository__owner=org) + .exclude(role=RepositoryAuthorization.ROLE_NOT_SETTED) + .filter(user=org) + ) + + response = { + "repositories_count": int( + Repository.objects.filter( + models.Q(uuid__in=auths) | models.Q(owner=org) + ).count() + ) + } + + return Response(response) diff --git a/bothub/api/v2/internal/permissions.py b/bothub/api/v2/internal/permissions.py new file mode 100644 index 000000000..4974aa763 --- /dev/null +++ b/bothub/api/v2/internal/permissions.py @@ -0,0 +1,9 @@ +from rest_framework import permissions + + +class ModuleHasPermission(permissions.BasePermission): + def has_permission(self, request, view): # pragma: no cover + return request.user.has_perm("authentication.can_communicate_internally") + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) diff --git a/bothub/api/v2/routers.py b/bothub/api/v2/routers.py index 55e8b818d..fde5909bf 100644 --- a/bothub/api/v2/routers.py +++ b/bothub/api/v2/routers.py @@ -66,6 +66,8 @@ RepositoryTranslatorViewSet, ) +from bothub.api.v2.internal.organization.views import InternalOrganizationViewSet + class Router(routers.SimpleRouter): routes = [ @@ -233,3 +235,4 @@ def get_lookup_regex(self, viewset, lookup_prefix=""): router.register("org/organization", OrganizationViewSet) router.register("org/profile", OrganizationProfileViewSet) router.register("org/authorizations", OrganizationAuthorizationViewSet) +router.register("internal/organization", InternalOrganizationViewSet) diff --git a/bothub/api/v2/tests/test_repository.py b/bothub/api/v2/tests/test_repository.py index 9aa601e63..025753f9b 100644 --- a/bothub/api/v2/tests/test_repository.py +++ b/bothub/api/v2/tests/test_repository.py @@ -42,48 +42,11 @@ from bothub.common.models import RepositoryVote from bothub.common.models import RequestRepositoryAuthorization - -def get_valid_mockups(categories): - return [ - { - "name": "Repository 1", - "slug": "repository-1", - "description": "", - "language": languages.LANGUAGE_EN, - "categories": [category.pk for category in categories], - }, - { - "name": "Repository 2", - "description": "", - "language": languages.LANGUAGE_PT, - "categories": [category.pk for category in categories], - }, - ] - - -def get_invalid_mockups(categories): - return [ - { - "name": "", - "slug": "repository-1", - "language": languages.LANGUAGE_EN, - "categories": [category.pk for category in categories], - }, - { - "name": "Repository 3", - "language": "out", - "categories": [category.pk for category in categories], - "is_private": False, - }, - ] - - -def create_repository_from_mockup(owner, categories, **mockup): - r = Repository.objects.create(owner_id=owner.id, **mockup) - r.current_version() - for category in categories: - r.categories.add(category) - return r +from bothub.api.v2.tests.utils import ( + get_valid_mockups, + get_invalid_mockups, + create_repository_from_mockup, +) class CreateRepositoryAPITestCase(TestCase): diff --git a/bothub/api/v2/tests/utils.py b/bothub/api/v2/tests/utils.py index 7ede7eb6f..5d1c69015 100644 --- a/bothub/api/v2/tests/utils.py +++ b/bothub/api/v2/tests/utils.py @@ -1,9 +1,58 @@ +from bothub.utils import check_module_permission from rest_framework.authtoken.models import Token from bothub.authentication.models import User +from bothub.common import languages +from bothub.common.models import Repository -def create_user_and_token(nickname="fake"): +def create_user_and_token(nickname="fake", module=False): user = User.objects.create_user("{}@user.com".format(nickname), nickname) + if module is True: + check_module_permission({"can_communicate_internally": module}, user) + user = User.objects.get(email=user.email) token, create = Token.objects.get_or_create(user=user) return (user, token) + + +def get_valid_mockups(categories): + return [ + { + "name": "Repository 1", + "slug": "repository-1", + "description": "", + "language": languages.LANGUAGE_EN, + "categories": [category.pk for category in categories], + }, + { + "name": "Repository 2", + "description": "", + "language": languages.LANGUAGE_PT, + "categories": [category.pk for category in categories], + }, + ] + + +def get_invalid_mockups(categories): + return [ + { + "name": "", + "slug": "repository-1", + "language": languages.LANGUAGE_EN, + "categories": [category.pk for category in categories], + }, + { + "name": "Repository 3", + "language": "out", + "categories": [category.pk for category in categories], + "is_private": False, + }, + ] + + +def create_repository_from_mockup(owner, categories, **mockup): + r = Repository.objects.create(owner_id=owner.id, **mockup) + r.current_version() + for category in categories: + r.categories.add(category) + return r diff --git a/bothub/authentication/authorization.py b/bothub/authentication/authorization.py index ccc89e809..b796a1566 100644 --- a/bothub/authentication/authorization.py +++ b/bothub/authentication/authorization.py @@ -1,6 +1,9 @@ import logging from django.utils.translation import ugettext_lazy as _ +from bothub.utils import check_module_permission + + from mozilla_django_oidc.auth import OIDCAuthenticationBackend from rest_framework import exceptions from rest_framework.authentication import TokenAuthentication, get_authorization_header @@ -97,11 +100,14 @@ def create_user(self, claims): user.name = claims.get("name", "") user.save() + check_module_permission(claims, user) + return user def update_user(self, user, claims): user.name = claims.get("name", "") user.email = claims.get("email", "") user.save() + check_module_permission(claims, user) return user diff --git a/bothub/utils.py b/bothub/utils.py index 38949d12a..3e9dedd46 100644 --- a/bothub/utils.py +++ b/bothub/utils.py @@ -20,6 +20,7 @@ from rest_framework import status from rest_framework.exceptions import APIException, ValidationError + entity_regex = re.compile( r"\[(?P[^\]]+)" r"\]\((?P[^:)]*?)" r"(?:\:(?P[^)]+))?\)" ) @@ -375,6 +376,17 @@ def get_organization(request, organization_id: int): ) +def get_user_and_organization(user_email: str, organization_id: int): + from bothub.authentication.models import User + from bothub.common.models import Organization + + org = Organization.objects.get(pk=organization_id) + user, created = User.objects.get_or_create( + email=user_email, defaults={"nickname": user_email} + ) + return user, org + + class TimeBasedDocument(Document): def save(self, action="create", **kwargs): return super().save(action=action, **kwargs) @@ -462,3 +474,21 @@ def filter_has_invalid_entities(self, queryset, name, value): return filter_validate_entities(queryset, value).exclude( original_entities_count=F("entities_count") ) + + +def check_module_permission(claims, user): + from django.contrib.auth.models import Permission + from django.contrib.contenttypes.models import ContentType + from bothub.common.models import User + + if claims.get("can_communicate_internally", False): + content_type = ContentType.objects.get_for_model(User) + permission, created = Permission.objects.get_or_create( + codename="can_communicate_internally", + name="can communicate internally", + content_type=content_type, + ) + if not user.has_perm("authentication.can_communicate_internally"): + user.user_permissions.add(permission) + return True + return False