diff --git a/cadasta/accounts/migrations/0003_auto_20160204_1838.py b/cadasta/accounts/migrations/0003_auto_20160204_1838.py new file mode 100644 index 000000000..385da1992 --- /dev/null +++ b/cadasta/accounts/migrations/0003_auto_20160204_1838.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-02-04 18:38 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_user_verify_email_by'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='username', + field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=30, unique=True, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.')], verbose_name='username'), + ), + ] diff --git a/cadasta/accounts/tests/test_urls.py b/cadasta/accounts/tests/test_urls.py index 94c3698e8..e77dac3bc 100644 --- a/cadasta/accounts/tests/test_urls.py +++ b/cadasta/accounts/tests/test_urls.py @@ -1,35 +1,52 @@ from django.test import TestCase from django.core.urlresolvers import reverse, resolve + +from core.tests.url_utils import version_ns, version_url from .. import views class UserUrlsTest(TestCase): def test_account_user(self): - self.assertEqual(reverse('accounts:user'), '/account/') + self.assertEqual( + reverse(version_ns('accounts:user')), + version_url('/account/') + ) - resolved = resolve('/account/') + resolved = resolve(version_url('/account/')) self.assertEqual(resolved.func.__name__, views.AccountUser.__name__) def test_account_register(self): - self.assertEqual(reverse('accounts:register'), '/account/register/') + self.assertEqual( + reverse(version_ns('accounts:register')), + version_url('/account/register/') + ) - resolved = resolve('/account/register/') + resolved = resolve(version_url('/account/register/')) self.assertEqual(resolved.func.__name__, views.AccountRegister.__name__) def test_account_login(self): - self.assertEqual(reverse('accounts:login'), '/account/login/') + self.assertEqual( + reverse(version_ns('accounts:login')), + version_url('/account/login/') + ) - resolved = resolve('/account/login/') + resolved = resolve(version_url('/account/login/')) self.assertEqual(resolved.func.__name__, views.AccountLogin.__name__) def test_account_activate(self): - self.assertEqual(reverse('accounts:activate'), '/account/activate/') + self.assertEqual( + reverse(version_ns('accounts:activate')), + version_url('/account/activate/') + ) - resolved = resolve('/account/activate/') + resolved = resolve(version_url('/account/activate/')) self.assertEqual(resolved.func.__name__, views.AccountVerify.__name__) def test_password_reset(self): - self.assertEqual(reverse('accounts:password_reset'), '/account/password/reset/') + self.assertEqual( + reverse(version_ns('accounts:password_reset')), + version_url('/account/password/reset/') + ) - resolved = resolve('/account/password/reset/') + resolved = resolve(version_url('/account/password/reset/')) self.assertEqual(resolved.func.__name__, views.PasswordReset.__name__) diff --git a/cadasta/accounts/tests/test_views.py b/cadasta/accounts/tests/test_views.py index 04701f208..b98dd7d6e 100644 --- a/cadasta/accounts/tests/test_views.py +++ b/cadasta/accounts/tests/test_views.py @@ -46,7 +46,7 @@ def test_keep_email_address(self): 'username': 'imagine71' } - request = APIRequestFactory().put('/account/', data) + request = APIRequestFactory().put('/v1/account/', data) force_authenticate(request, user=user) response = AccountUser.as_view()(request).render() self.assertEqual(response.status_code, 200) @@ -66,7 +66,7 @@ def test_update_with_existing_email(self): 'username': user.username } - request = APIRequestFactory().put('/account/', data) + request = APIRequestFactory().put('/v1/account/', data) force_authenticate(request, user=user) response = AccountUser.as_view()(request).render() self.assertEqual(response.status_code, 400) @@ -85,7 +85,7 @@ def test_update_username(self): 'username': 'john' } - request = APIRequestFactory().put('/account/', data) + request = APIRequestFactory().put('/v1/account/', data) force_authenticate(request, user=user) response = AccountUser.as_view()(request).render() self.assertEqual(response.status_code, 200) @@ -107,7 +107,7 @@ def test_update_with_existing_username(self): 'username': 'boss' } - request = APIRequestFactory().put('/account/', data) + request = APIRequestFactory().put('/v1/account/', data) force_authenticate(request, user=user) response = AccountUser.as_view()(request).render() self.assertEqual(response.status_code, 400) @@ -126,14 +126,10 @@ def test_user_signs_up(self): 'last_name': 'Lennon', } - request = APIRequestFactory().post('/account/register/', data) + request = APIRequestFactory().post('/v1/account/register/', data) response = AccountRegister.as_view()(request).render() self.assertEqual(response.status_code, 201) - # self.assertIn('auth_token', response.content.decode("utf-8")) - self.assertEqual(User.objects.count(), 1) - # user = User.objects.first() - # self.assertTrue(user.is_authenticated()) def test_user_signs_up_with_invalid(self): """The server should respond with an 404 error code when a user tries @@ -145,7 +141,7 @@ def test_user_signs_up_with_invalid(self): 'last_name': 'Lennon', } - request = APIRequestFactory().post('/account/register/', data) + request = APIRequestFactory().post('/v1/account/register/', data) response = AccountRegister.as_view()(request).render() self.assertEqual(response.status_code, 400) self.assertEqual(User.objects.count(), 0) @@ -161,7 +157,7 @@ def setUp(self): def test_successful_login(self): """The view should return a token to authenticate API calls""" - request = APIRequestFactory().post('/account/login/', { + request = APIRequestFactory().post('/v1/account/login/', { 'username': 'imagine71', 'password': 'iloveyoko79' }) @@ -171,7 +167,7 @@ def test_successful_login(self): def test_unsuccessful_login(self): """The view should return a token to authenticate API calls""" - request = APIRequestFactory().post('/account/login/', { + request = APIRequestFactory().post('/v1/account/login/', { 'username': 'imagine71', 'password': 'iloveyoko78' }) @@ -185,7 +181,7 @@ def test_login_with_unverified_email(self): self.user.verify_email_by = datetime.now() self.user.save() - request = APIRequestFactory().post('/account/login/', { + request = APIRequestFactory().post('/v1/account/login/', { 'username': 'imagine71', 'password': 'iloveyoko79' }) @@ -204,7 +200,7 @@ def test_activate_account(self): user.last_login = datetime.now() user.save() - request = APIRequestFactory().post('/account/activate/', { + request = APIRequestFactory().post('/v1/account/activate/', { 'uid': encode_uid(user.pk), 'token': token }) diff --git a/cadasta/accounts/urls.py b/cadasta/accounts/urls.py index 86e88b634..e6fee79a0 100644 --- a/cadasta/accounts/urls.py +++ b/cadasta/accounts/urls.py @@ -1,12 +1,11 @@ -from django.conf.urls import patterns, url +from django.conf.urls import url from . import views -urlpatterns = patterns( - '', +urlpatterns = [ url(r'^$', views.AccountUser.as_view(), name='user'), url(r'^register/$', views.AccountRegister.as_view(), name='register'), url(r'^login/$', views.AccountLogin.as_view(), name='login'), url(r'^activate/$', views.AccountVerify.as_view(), name='activate'), url(r'^password/reset/$', views.PasswordReset.as_view(), name='password_reset'), -) +] diff --git a/cadasta/accounts/views.py b/cadasta/accounts/views.py index 168975998..50e269832 100644 --- a/cadasta/accounts/views.py +++ b/cadasta/accounts/views.py @@ -1,4 +1,3 @@ -from django.contrib.auth import user_logged_in from django.utils.translation import ugettext as _ from rest_framework.serializers import ValidationError @@ -6,7 +5,6 @@ from rest_framework.generics import GenericAPIView from rest_framework.permissions import AllowAny from rest_framework import status -from rest_framework.authtoken.models import Token from djoser import views as djoser_views from djoser import utils as djoser_utils @@ -24,7 +22,6 @@ class AccountUser(djoser_utils.SendEmailViewMixin, djoser_views.UserView): plain_body_template_name = 'change_email.txt' serializer_class = serializers.UserSerializer - def get_email_context(self, user): context = super(AccountUser, self).get_email_context(user) context['url'] = settings.get('ACTIVATION_URL').format(**context) diff --git a/cadasta/config/permissions/default.json b/cadasta/config/permissions/default.json new file mode 100644 index 000000000..21eb01b80 --- /dev/null +++ b/cadasta/config/permissions/default.json @@ -0,0 +1,13 @@ +{ + "clause": [ + { + "effect": "allow", + "object": ["*"], + "action": ["org.list"] + }, { + "effect": "allow", + "object": ["organization/*"], + "action": ["org.view"] + } + ] +} diff --git a/cadasta/config/permissions/org-admin.json b/cadasta/config/permissions/org-admin.json new file mode 100644 index 000000000..3fd019a7c --- /dev/null +++ b/cadasta/config/permissions/org-admin.json @@ -0,0 +1,13 @@ +{ + "clause": [ + { + "effect": "allow", + "object": ["*"], + "action": ["org.*"] + }, { + "effect": "allow", + "object": ["organization/*"], + "action": ["org.*"] + } + ] +} diff --git a/cadasta/config/permissions/superuser.json b/cadasta/config/permissions/superuser.json new file mode 100644 index 000000000..610cf2ea3 --- /dev/null +++ b/cadasta/config/permissions/superuser.json @@ -0,0 +1,9 @@ +{ + "clause": [ + { + "effect": "allow", + "object": ["organization/*"], + "action": ["org.*"] + } + ] +} diff --git a/cadasta/config/settings/default.py b/cadasta/config/settings/default.py index 127e6437b..35e15b87d 100644 --- a/cadasta/config/settings/default.py +++ b/cadasta/config/settings/default.py @@ -36,11 +36,15 @@ 'django.contrib.staticfiles', 'corsheaders', + 'crispy_forms', 'rest_framework', 'rest_framework.authtoken', 'djoser', + 'tutelary', + 'core', 'accounts', + 'organization' ) MIDDLEWARE_CLASSES = ( @@ -53,12 +57,16 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', + 'audit_log.middleware.UserLoggingMiddleware', ) REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.TokenAuthentication', ), + 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning', + 'DEFAULT_VERSION': 'v1', + 'EXCEPTION_HANDLER': 'core.views.exception_handler' } ROOT_URLCONF = 'config.urls' @@ -79,6 +87,8 @@ }, ] +AUTHENTICATION_BACKENDS = ['core.backends.Auth'] + DJOSER = { 'SITE_NAME': 'Cadasta', 'SET_PASSWORD_RETYPE': True, diff --git a/cadasta/config/urls.py b/cadasta/config/urls.py index 0c86b8719..e3ebd5fc9 100644 --- a/cadasta/config/urls.py +++ b/cadasta/config/urls.py @@ -15,7 +15,13 @@ """ from django.conf.urls import include, url -urlpatterns = [ +urls = [ url(r'^account/', include('accounts.urls', namespace='accounts')), url(r'^account/', include('djoser.urls.authtoken')), + + url(r'^organizations/', include('organization.urls', namespace='organization')), +] + +urlpatterns = [ + url(r'^v1/', include(urls, namespace='v1')) ] diff --git a/cadasta/core/backends.py b/cadasta/core/backends.py new file mode 100644 index 000000000..4ed57da1f --- /dev/null +++ b/cadasta/core/backends.py @@ -0,0 +1,6 @@ +from tutelary.backends import Backend as TutelaryBackend +from django.contrib.auth.backends import ModelBackend + + +class Auth(TutelaryBackend, ModelBackend): + pass diff --git a/cadasta/core/exceptions.py b/cadasta/core/exceptions.py new file mode 100644 index 000000000..28d126f36 --- /dev/null +++ b/cadasta/core/exceptions.py @@ -0,0 +1,9 @@ +import json + + +class JsonValidationError(BaseException): + def __init__(self, errors): + self.errors = errors + + def __str__(self): + return json.dumps(self.errors) diff --git a/cadasta/core/serializers.py b/cadasta/core/serializers.py new file mode 100644 index 000000000..f1fdbe1be --- /dev/null +++ b/cadasta/core/serializers.py @@ -0,0 +1,12 @@ +from django.db.models.query import QuerySet + + +class DetailSerializer: + def __init__(self, *args, **kwargs): + detail = kwargs.pop('detail', False) + super(DetailSerializer, self).__init__(*args, **kwargs) + + is_list = type(self.instance) in [list, QuerySet] + if is_list and not detail: + for field_name in self.Meta.detail_only_fields: + self.fields.pop(field_name) diff --git a/cadasta/core/tests/test_exceptions.py b/cadasta/core/tests/test_exceptions.py new file mode 100644 index 000000000..86eeb637b --- /dev/null +++ b/cadasta/core/tests/test_exceptions.py @@ -0,0 +1,12 @@ +from django.test import TestCase + +from ..exceptions import JsonValidationError + + +class JsonValidationErrorTest(TestCase): + def test_to_string(self): + exc = JsonValidationError({ + 'field': "Some error" + }) + + assert str(exc) == '{"field": "Some error"}' diff --git a/cadasta/core/tests/test_models.py b/cadasta/core/tests/test_models.py index 3a527ed4d..fd34ae168 100644 --- a/cadasta/core/tests/test_models.py +++ b/cadasta/core/tests/test_models.py @@ -1,11 +1,16 @@ -from .testcases import AbstractModelTestCase +from django.test import TestCase from ..models import RandomIDModel -class RandomIDModelTest(AbstractModelTestCase): +class MyTestModel(RandomIDModel): + class Meta: + app_label = 'core' + + +class RandomIDModelTest(TestCase): abstract_model = RandomIDModel def test_save(self): - instance = self.model() + instance = MyTestModel() instance.save() self.assertIsNotNone(instance.id) diff --git a/cadasta/core/tests/test_serializer.py b/cadasta/core/tests/test_serializer.py new file mode 100644 index 000000000..fe18ae2ed --- /dev/null +++ b/cadasta/core/tests/test_serializer.py @@ -0,0 +1,56 @@ +from django.test import TestCase +from django.db import models +from rest_framework.serializers import ModelSerializer + +from ..serializers import DetailSerializer + + +class SerializerModel(models.Model): + name = models.CharField(max_length=200) + description = models.CharField(max_length=200) + + class Meta: + app_label = 'core' + + +class MyTestSerializer(DetailSerializer, ModelSerializer): + class Meta: + model = SerializerModel + fields = ('name', 'description',) + detail_only_fields = ('description',) + + +class DetailSerializerTest(TestCase): + def test_detail_fields_are_included_with_single_instance(self): + model = SerializerModel( + name='Blah', + description='Blah' + ) + serializer = MyTestSerializer(model) + assert 'description' in serializer.data + + def test_detail_fields_are_not_included_when_instance_list(self): + model = SerializerModel( + name='Blah', + description='Blah' + ) + serializer = MyTestSerializer([model], many=True) + assert 'description' not in serializer.data[0] + + def test_detail_fields_are_included_when_instance_list_detail_true(self): + model = SerializerModel( + name='Blah', + description='Blah' + ) + serializer = MyTestSerializer([model], detail=True, many=True) + assert 'description' in serializer.data[0] + + def test_detail_fields_are_included_instance_is_created(self): + data = { + 'name': 'Blah', + 'description': 'Blah' + } + serializer = MyTestSerializer(data=data) + assert serializer.is_valid() + serializer.save() + assert 'description' in serializer.data diff --git a/cadasta/core/tests/test_validators.py b/cadasta/core/tests/test_validators.py new file mode 100644 index 000000000..da0dad2b7 --- /dev/null +++ b/cadasta/core/tests/test_validators.py @@ -0,0 +1,68 @@ +import pytest +from django.test import TestCase +from ..validators import validate_json, JsonValidationError + + +class ValidationTest(TestCase): + def test_validate_valid(self): + schema = { + "$schema": "http://json-schema.org/schema#", + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + "required": ["name"] + } + + assert validate_json({'name': 'val'}, schema) is None + + def test_validate_anyof(self): + schema = { + "$schema": "http://json-schema.org/schema#", + "type": "object", + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + }, + "anyOf": [ + {"required": ["name"]}, + {"required": ["description"]}, + ] + } + + with pytest.raises(JsonValidationError) as exc: + assert validate_json({}, schema) is None + + assert exc.value.errors['name'] == ( + "Please provide either name or description") + assert exc.value.errors['description'] == ( + "Please provide either name or description") + + def test_validate_invalid_required(self): + schema = { + "$schema": "http://json-schema.org/schema#", + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + "required": ["name"] + } + + with pytest.raises(JsonValidationError) as exc: + validate_json({'some': 'val'}, schema) + + assert exc.value.errors['name'] == "This field is required." + + def test_validate_invalid_format(self): + schema = { + "$schema": "http://json-schema.org/schema#", + "type": "object", + "properties": { + "email": {"type": "string", "format": "email"}, + }, + } + + with pytest.raises(JsonValidationError) as exc: + validate_json({'email': 'blah'}, schema) + + assert exc.value.errors['email'] == "'blah' is not a 'email'" diff --git a/cadasta/core/tests/test_views.py b/cadasta/core/tests/test_views.py new file mode 100644 index 000000000..9f4638441 --- /dev/null +++ b/cadasta/core/tests/test_views.py @@ -0,0 +1,50 @@ +from django.test import TestCase +from django.http import Http404 +from rest_framework.exceptions import NotFound +from organization.validators import JsonValidationError + +from ..views import set_exception, eval_json + + +class ExceptionHandleTest(TestCase): + def test_set_exception_with_404(self): + exception = Http404("No Organization matches the given query.") + + e = set_exception(exception) + assert type(e) == NotFound + assert str(e) == "Organization not found." + + def test_set_exception_with_404_and_different_error(self): + exception = Http404("Error Message") + + e = set_exception(exception) + assert type(e) == Http404 + assert str(e) == "Error Message" + + def test_set_exception_with_NotFound(self): + exception = NotFound("Error Message") + + e = set_exception(exception) + assert type(e) == NotFound + assert str(e) == "Error Message" + + def test_evaluate_json(self): + response_data = { + 'contacts': [ + '{"name": "This field is required.", "url": "\'blah\' is not a \'uri\'"}', + '{"name": "This field is required."}', + ], + 'field': "Something went wrong" + } + + actual = eval_json(response_data) + + expected = { + 'contacts': [ + {'name': "This field is required.", 'url': "\'blah\' is not a \'uri\'"}, + {'name': "This field is required."}, + ], + 'field': "Something went wrong" + } + + assert actual == expected diff --git a/cadasta/core/tests/testcases.py b/cadasta/core/tests/testcases.py deleted file mode 100644 index ef16eec23..000000000 --- a/cadasta/core/tests/testcases.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.test import TestCase -from django.db import connection -from django.core.management.color import no_style -from django.db.models.base import ModelBase - - -class AbstractModelTestCase(TestCase): - def setUp(self): - # Create a dummy model which extends the abstract model - self.model = ModelBase( - '__TestModel__' + self.abstract_model.__name__, - (self.abstract_model,), - {'__module__': self.abstract_model.__module__} - ) - - # Create the schema for our test model - self._style = no_style() - sql, _ = connection.creation.sql_create_model(self.model, self._style) - - self._cursor = connection.cursor() - for statement in sql: - self._cursor.execute(statement) - - def tearDown(self): - # Delete the schema for the test model - sql = connection.creation.sql_destroy_model( - self.model, (), self._style - ) - for statement in sql: - self._cursor.execute(statement) diff --git a/cadasta/core/tests/url_utils.py b/cadasta/core/tests/url_utils.py new file mode 100644 index 000000000..f4aea2b30 --- /dev/null +++ b/cadasta/core/tests/url_utils.py @@ -0,0 +1,17 @@ +from django.conf import settings + +VERSION = settings.REST_FRAMEWORK.get('DEFAULT_VERSION') + + +def version_ns(ns): + return'{v}:{ns}'.format( + v=VERSION, + ns=ns + ) + + +def version_url(url): + return'/{v}{url}'.format( + v=VERSION, + url=url + ) diff --git a/cadasta/core/validators.py b/cadasta/core/validators.py new file mode 100644 index 000000000..3c9d9ac83 --- /dev/null +++ b/cadasta/core/validators.py @@ -0,0 +1,29 @@ +from django.utils.translation import ugettext as _ + +from jsonschema import Draft4Validator, FormatChecker +from .exceptions import JsonValidationError + + +def validate_json(value, schema): + v = Draft4Validator(schema, format_checker=FormatChecker()) + errors = sorted(v.iter_errors(value), key=lambda e: e.path) + + message_dict = {} + for e in errors: + if e.validator == 'anyOf': + fields = [ + f.message.split(' ')[0].replace('\'', '') for f in e.context + ] + + for f in fields: + message_dict[f] = _("Please provide either %s" % " or ".join(fields)) + + elif e.validator == 'required': + field = e.message.split(' ')[0].replace('\'', '') + message_dict[field] = _("This field is required.") + else: + field = '.'.join([str(el) for el in e.path]) + message_dict[field] = e.message + + if message_dict: + raise JsonValidationError(message_dict) diff --git a/cadasta/core/views.py b/cadasta/core/views.py new file mode 100644 index 000000000..8e7c23ec3 --- /dev/null +++ b/cadasta/core/views.py @@ -0,0 +1,102 @@ +import json +import re +from django.http import Http404 + +from django.core.exceptions import ImproperlyConfigured +from django.utils import six +from django.utils.translation import ugettext_lazy as _ + +from rest_framework.exceptions import NotFound +from rest_framework.views import exception_handler as drf_exception_handler + +from tutelary.engine import Object + + +def set_exception(exception): + if (type(exception) is Http404): + exception_msg = str(exception) + try: + model = re.search( + 'No (.+?) matches the given query.', exception_msg).group(1) + exception = NotFound(_("{name} not found.").format(name=model)) + except AttributeError: + pass + return exception + + +def eval_json(response_data): + """ + Evaluate stringified JSON objects, so we can return structure + error responses + """ + for key in response_data: + errors = [] + if not isinstance(response_data[key], six.string_types): + for e in response_data[key]: + try: + errors.append(json.loads(e)) + except ValueError: + errors.append(e) + response_data[key] = errors + + return response_data + + +def exception_handler(exception, context): + """ + Overwriting Django Rest Frameworks exception handler to provide more + meaniful exception messages for 404 errors. + """ + exception = set_exception(exception) + + response = drf_exception_handler(exception, context) + response.data = eval_json(response.data) + + return response + + +class PermissionRequiredMixin: + def get_permission_required(self): + """ + Override this method to override the permission_required attribute. + Must return an iterable. + """ + if self.permission_required is None: + raise ImproperlyConfigured( + '{0} is missing the permission_required attribute. Define ' + '{0}.permission_required, or override ' + '{0}.get_permission_required().'.format( + self.__class__.__name__) + ) + if isinstance(self.permission_required, dict): + perms = self.permission_required[self.request.method] + else: + perms = self.permission_required + + if isinstance(perms, six.string_types): + perms = (perms, ) + + if hasattr(self, 'add_permission_required'): + perms = perms + self.add_permission_required + + return perms + + def check_permissions(self, request): + obj = None + allowed = {} + if hasattr(self, 'model') and hasattr(self.model, 'TutelaryMeta'): + obj = Object(self.model.TutelaryMeta.perm_type) + allowed = self.model.TutelaryMeta.allowed_methods + try: + if hasattr(self, 'get_object') and self.get_object() is not None: + obj = self.get_object().get_permissions_object() + except: + pass + perms = self.get_permission_required() + + has_perm = all(self.request.user.has_perm(p, obj) for p in perms + if not (p in allowed and + self.request.method in allowed[p])) + + if not has_perm: + self.permission_denied(request, message="Permission denied.") diff --git a/cadasta/organization/__init__.py b/cadasta/organization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cadasta/organization/migrations/0001_initial.py b/cadasta/organization/migrations/0001_initial.py new file mode 100644 index 000000000..effff1734 --- /dev/null +++ b/cadasta/organization/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-02-05 11:23 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import organization.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Organization', + fields=[ + ('id', models.CharField(max_length=24, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('slug', models.SlugField(unique=True)), + ('description', models.TextField(blank=True, null=True)), + ('archived', models.BooleanField(default=False)), + ('urls', django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), default=[], size=None)), + ('contacts', django.contrib.postgres.fields.jsonb.JSONField(default={}, validators=[organization.validators.validate_contact])), + ('users', models.ManyToManyField(to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/cadasta/organization/migrations/__init__.py b/cadasta/organization/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cadasta/organization/mixins.py b/cadasta/organization/mixins.py new file mode 100644 index 000000000..9cc94e451 --- /dev/null +++ b/cadasta/organization/mixins.py @@ -0,0 +1,16 @@ +from django.shortcuts import get_object_or_404 + +from .models import Organization + + +class OrganizationMixin: + def get_organization(self, slug): + return get_object_or_404(Organization, slug=slug) + + +class OrganizationUsersQuerySet(OrganizationMixin): + lookup_field = 'username' + + def get_queryset(self): + self.org = self.get_organization(self.kwargs['slug']) + return self.org.users.all() diff --git a/cadasta/organization/models.py b/cadasta/organization/models.py new file mode 100644 index 000000000..119755871 --- /dev/null +++ b/cadasta/organization/models.py @@ -0,0 +1,35 @@ +from django.db import models +from django.contrib.postgres.fields import JSONField, ArrayField + +from tutelary.decorators import permissioned_model + +from core.models import RandomIDModel +from .validators import validate_contact + + +@permissioned_model +class Organization(RandomIDModel): + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=50, unique=True) + description = models.TextField(null=True, blank=True) + archived = models.BooleanField(default=False) + urls = ArrayField(models.URLField(), default=[]) + contacts = JSONField(validators=[validate_contact], default={}) + users = models.ManyToManyField('accounts.User') + # logo = TemporalForeignKey('Resource') + + class TutelaryMeta: + perm_type = 'organization' + path_fields = ('slug',) + actions = (('org.list', "List existing organisations"), + ('org.view', "View existing organisations"), + ('org.create', "Create organisations"), + ('org.update', "Update an existing organization"), + ('org.archive', "Archive an existing organization"), + ('org.unarchive', "Unarchive an existing organization"), + ('org.users.list', "List members of an organization"), + ('org.users.add', "Add a member to an organization"), + ('org.users.remove', "Remove a member from an organization")) + + def __str__(self): + return "".format(name=self.name) diff --git a/cadasta/organization/serializers.py b/cadasta/organization/serializers.py new file mode 100644 index 000000000..3ae683d85 --- /dev/null +++ b/cadasta/organization/serializers.py @@ -0,0 +1,23 @@ +from django.utils.text import slugify +from rest_framework.serializers import ModelSerializer + +from core.serializers import DetailSerializer +from accounts.serializers import UserSerializer +from .models import Organization + + +class OrganizationSerializer(DetailSerializer, ModelSerializer): + users = UserSerializer(many=True, read_only=True) + + class Meta: + model = Organization + fields = ('id', 'slug', 'name', 'description', 'archived', 'urls', + 'contacts', 'users') + read_only_fields = ('id',) + detail_only_fields = ('users',) + + def to_internal_value(self, data): + if not data.get('slug'): + data['slug'] = slugify(data.get('name')) + + return super(OrganizationSerializer, self).to_internal_value(data) diff --git a/cadasta/organization/tests/__init__.py b/cadasta/organization/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cadasta/organization/tests/factories.py b/cadasta/organization/tests/factories.py new file mode 100644 index 000000000..036ad2acd --- /dev/null +++ b/cadasta/organization/tests/factories.py @@ -0,0 +1,24 @@ +import factory + +from ..models import Organization + + +class OrganizationFactory(factory.django.DjangoModelFactory): + class Meta: + model = Organization + + name = factory.Sequence(lambda n: "Organization #%s" % n) + slug = factory.Sequence(lambda n: "organization-%s" % n) + description = factory.Sequence( + lambda n: "Organization #%s description" % n) + urls = ['http://example.com'] + contacts = [] + + @factory.post_generation + def add_users(self, create, users, **kwargs): + if not create: + return + + if users: + for u in users: + self.users.add(u) diff --git a/cadasta/organization/tests/test_models.py b/cadasta/organization/tests/test_models.py new file mode 100644 index 000000000..48bb2a227 --- /dev/null +++ b/cadasta/organization/tests/test_models.py @@ -0,0 +1,13 @@ +from django.test import TestCase + +from .factories import OrganizationFactory + + +class OrganizationTest(TestCase): + def test_str(self): + org = OrganizationFactory.create(**{'name': 'Org'}) + assert str(org) == '' + + def test_has_random_id(self): + org = OrganizationFactory.create() + assert type(org.id) is not int diff --git a/cadasta/organization/tests/test_serializers.py b/cadasta/organization/tests/test_serializers.py new file mode 100644 index 000000000..a066d9b4c --- /dev/null +++ b/cadasta/organization/tests/test_serializers.py @@ -0,0 +1,46 @@ +from django.utils.text import slugify +from django.test import TestCase + +from ..serializers import OrganizationSerializer + +from accounts.tests.factories import UserFactory +from .factories import OrganizationFactory + + +class OrganizationSerializerTest(TestCase): + def test_slug_field_is_set(self): + org_data = { + 'name': 'Test Organization', + } + serializer = OrganizationSerializer(data=org_data) + serializer.is_valid(raise_exception=True) + serializer.save() + + org_instance = serializer.instance + assert org_instance.slug == slugify(org_data['name']) + + def test_slug_field_is_unique(self): + OrganizationFactory.create(**{ + 'slug': 'org-slug' + }) + + org_data = { + 'name': 'Org Slug', + 'slug': 'org-slug' + } + serializer = OrganizationSerializer(data=org_data) + assert not serializer.is_valid() + + def test_users_are_not_serialized(self): + users = UserFactory.create_batch(2) + org = OrganizationFactory.create(add_users=users) + + serializer = OrganizationSerializer([org], many=True) + assert 'users' not in serializer.data[0] + + def test_users_are_serialized_detail_view(self): + users = UserFactory.create_batch(2) + org = OrganizationFactory.create(add_users=users) + + serializer = OrganizationSerializer(org, detail=True) + assert 'users' in serializer.data diff --git a/cadasta/organization/tests/test_urls.py b/cadasta/organization/tests/test_urls.py new file mode 100644 index 000000000..5a73fefdf --- /dev/null +++ b/cadasta/organization/tests/test_urls.py @@ -0,0 +1,65 @@ +from django.test import TestCase +from django.core.urlresolvers import reverse, resolve +from core.tests.url_utils import version_ns, version_url + +from .. import views + + +class OrganizationUrlTest(TestCase): + def test_organization_list(self): + self.assertEqual( + reverse(version_ns('organization:list')), + version_url('/organizations/') + ) + + resolved = resolve(version_url('/organizations/')) + self.assertEqual( + resolved.func.__name__, + views.OrganizationList.__name__) + + def test_organization_detail(self): + self.assertEqual( + reverse( + version_ns('organization:detail'), + kwargs={'slug': 'org-slug'}), + version_url('/organizations/org-slug/') + ) + + resolved = resolve(version_url('/organizations/org-slug/')) + self.assertEqual( + resolved.func.__name__, + views.OrganizationDetail.__name__) + self.assertEqual(resolved.kwargs['slug'], 'org-slug') + + def test_organization_users(self): + self.assertEqual( + reverse( + version_ns('organization:users'), + kwargs={'slug': 'org-slug'}), + version_url('/organizations/org-slug/users/') + ) + + resolved = resolve(version_url('/organizations/org-slug/users/')) + self.assertEqual( + resolved.func.__name__, + views.OrganizationUsers.__name__) + self.assertEqual(resolved.kwargs['slug'], 'org-slug') + + def test_organization_users_detail(self): + self.assertEqual( + reverse(version_ns('organization:users_detail'), + kwargs={ + 'slug': 'org-slug', + 'username': 'n_smith' + }), + version_url('/organizations/org-slug/users/n_smith/') + ) + + resolved = resolve( + version_url('/organizations/org-slug/users/n_smith/')) + + self.assertEqual( + resolved.func.__name__, + views.OrganizationUsersDetail.__name__) + self.assertEqual(resolved.kwargs['slug'], 'org-slug') + self.assertEqual(resolved.kwargs['username'], 'n_smith') diff --git a/cadasta/organization/tests/test_validators.py b/cadasta/organization/tests/test_validators.py new file mode 100644 index 000000000..404aad1a3 --- /dev/null +++ b/cadasta/organization/tests/test_validators.py @@ -0,0 +1,62 @@ +import json +import pytest +from django.test import TestCase +from django.core.exceptions import ValidationError + +from ..validators import validate_contact + + +class ValidateContactTest(TestCase): + def test_valid_contact(self): + value = { + 'name': 'Nicole Smith', + 'email': 'n.smith@example.com' + } + + assert validate_contact(value) is None + + def test_validate_single_error(self): + value = { + 'email': 'n.smith@example.com' + } + + with pytest.raises(ValidationError) as exc: + validate_contact(value) + + assert len(exc.value.error_list) == 1 + assert exc.value.error_list[0].messages[0] == ( + '{"name": "This field is required."}') + + def test_validate_multiple_errors(self): + value = { + 'email': "noemail" + } + + with pytest.raises(ValidationError) as exc: + validate_contact(value) + + assert len(exc.value.error_list) == 1 + + actual = json.loads(exc.value.error_list[0].messages[0]) + expected = json.loads( + '{"name": "This field is required.", ' + '"email": "\'noemail\' is not a \'email\'"}' + ) + assert actual == expected + + def test_validate_multiple_contacts(self): + value = [{ + 'email': 'n.smith@exampl.com' + }, { + 'name': "Nicole Smith", + 'email': "noemail" + }] + + with pytest.raises(ValidationError) as exc: + validate_contact(value) + + assert len(exc.value.error_list) == 2 + assert exc.value.error_list[0].messages[0] == ( + '{"name": "This field is required."}') + assert exc.value.error_list[1].messages[0] == ( + '{"email": "\'noemail\' is not a \'email\'"}') diff --git a/cadasta/organization/tests/test_views.py b/cadasta/organization/tests/test_views.py new file mode 100644 index 000000000..cf2e58a8d --- /dev/null +++ b/cadasta/organization/tests/test_views.py @@ -0,0 +1,706 @@ +import json + +from django.test import TestCase +from django.http import QueryDict +from django.contrib.auth.models import AnonymousUser +from rest_framework.test import APIRequestFactory, force_authenticate +from tutelary.models import Policy, assign_user_policies + +from accounts.tests.factories import UserFactory +from .factories import OrganizationFactory +from .. import views +from ..models import Organization + + +class OrganizationListAPITest(TestCase): + def setUp(self): + clause = { + 'clause': [ + { + 'effect': 'allow', + 'object': ['*'], + 'action': ['org.list'] + }, { + 'effect': 'allow', + 'object': ['organization/*'], + 'action': ['org.view'] + } + ] + } + + policy = Policy.objects.create( + name='default', + body=json.dumps(clause)) + self.user = UserFactory.create() + assign_user_policies(self.user, policy) + + def test_full_list(self): + """ + It should return all organizations. + """ + OrganizationFactory.create_batch(2) + request = APIRequestFactory().get('/v1/organizations/') + force_authenticate(request, user=self.user) + + response = views.OrganizationList.as_view()(request).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 200 + assert len(content) == 2 + assert 'users' not in content[0] + + # def test_list_only_one_organization_is_authorized(self): + # """ + # It should return all organizations. + # """ + # OrganizationFactory.create() + # OrganizationFactory.create(**{'slug': 'unauthorized'}) + + # clause = { + # 'clause': [{ + # "effect": "allow", + # "object": ["*"], + # "action": ["org.list"] + # }, { + # "effect": "allow", + # "object": ["organization/*"], + # "action": ["org.view"] + # }, { + # 'effect': 'deny', + # 'object': ['organization/unauthorized'], + # 'action': ['org.view'] + # }] + # } + + # policy = Policy.objects.create( + # name='deny', + # body=json.dumps(clause)) + # assign_user_policies(self.user, policy) + + # request = APIRequestFactory().get('/v1/organizations/') + # force_authenticate(request, user=self.user) + + # response = views.OrganizationList.as_view()(request).render() + # content = json.loads(response.content.decode('utf-8')) + + # assert response.status_code == 200 + # assert len(content) == 1 + # assert content[0]['slug'] != 'unauthorized' + + def test_full_list_with_unautorized_user(self): + """ + It should 403 Permission denied. + """ + OrganizationFactory.create_batch(2) + request = APIRequestFactory().get('/v1/organizations/') + force_authenticate(request, user=AnonymousUser()) + + response = views.OrganizationList.as_view()(request).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 403 + assert content['detail'] == "Permission denied." + + def test_filter_active(self): + """ + It should return only one active organization. + """ + OrganizationFactory.create(**{'archived': True}) + OrganizationFactory.create(**{'archived': False}) + + request = APIRequestFactory().get('/v1/organizations/?archived=True') + setattr(request, 'GET', QueryDict('archived=True')) + force_authenticate(request, user=self.user) + + response = views.OrganizationList.as_view()(request).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 200 + assert len(content) == 1 + + def test_search_filter(self): + """ + It should return only two matching organizations. + """ + OrganizationFactory.create(**{'name': 'A Match'}) + OrganizationFactory.create(**{'description': 'something that matches'}) + OrganizationFactory.create(**{'name': 'Excluded'}) + + request = APIRequestFactory().get('/v1/organizations/?search=match') + setattr(request, 'GET', QueryDict('search=match')) + force_authenticate(request, user=self.user) + + response = views.OrganizationList.as_view()(request).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 200 + assert len(content) == 2 + + for org in content: + assert org['name'] != 'Excluded' + + def test_ordering(self): + OrganizationFactory.create(**{'name': 'A'}) + OrganizationFactory.create(**{'name': 'C'}) + OrganizationFactory.create(**{'name': 'B'}) + + request = APIRequestFactory().get('/v1/organizations/?ordering=name') + setattr(request, 'GET', QueryDict('ordering=name')) + force_authenticate(request, user=self.user) + + response = views.OrganizationList.as_view()(request).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 200 + assert len(content) == 3 + + prev_name = '' + for org in content: + if prev_name: + assert org['name'] > prev_name + + prev_name = org['name'] + + def test_reverse_ordering(self): + OrganizationFactory.create(**{'name': 'A'}) + OrganizationFactory.create(**{'name': 'C'}) + OrganizationFactory.create(**{'name': 'B'}) + + request = APIRequestFactory().get('/v1/organizations/?ordering=-name') + setattr(request, 'GET', QueryDict('ordering=-name')) + force_authenticate(request, user=self.user) + + response = views.OrganizationList.as_view()(request).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 200 + assert len(content) == 3 + + prev_name = '' + for org in content: + if prev_name: + assert org['name'] < prev_name + + prev_name = org['name'] + + +class OrganizationCreateAPITest(TestCase): + def setUp(self): + clause = { + "clause": [ + { + "effect": "allow", + "object": ["*"], + "action": ["org.*"] + }, { + "effect": "allow", + "object": ["organization/*"], + "action": ["org.*"] + } + ] + } + + policy = Policy.objects.create( + name='default', + body=json.dumps(clause)) + + self.user = UserFactory.create() + assign_user_policies(self.user, policy) + + def test_create_valid_organization(self): + data = { + 'name': 'Org Name', + 'description': 'Org description' + } + request = APIRequestFactory().post('/v1/organizations/', data) + force_authenticate(request, user=self.user) + + response = views.OrganizationList.as_view()(request).render() + + assert response.status_code == 201 + assert Organization.objects.count() == 1 + + def test_create_invalid_organization(self): + data = { + 'description': 'Org description' + } + request = APIRequestFactory().post('/v1/organizations/', data) + force_authenticate(request, user=self.user) + + response = views.OrganizationList.as_view()(request).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 400 + assert content['name'][0] == 'This field is required.' + assert Organization.objects.count() == 0 + + def test_create_organization_with_unauthorized_user(self): + clause = { + 'clause': [ + { + 'effect': 'allow', + 'object': ['*'], + 'action': ['org.list'] + }, { + 'effect': 'allow', + 'object': ['organization/*'], + 'action': ['org.view'] + } + ] + } + + policy = Policy.objects.create( + name='default', + body=json.dumps(clause)) + unauthorized_user = UserFactory.create() + assign_user_policies(unauthorized_user, policy) + + data = { + 'description': 'Org description' + } + request = APIRequestFactory().post('/v1/organizations/', data) + force_authenticate(request, user=unauthorized_user) + + response = views.OrganizationList.as_view()(request).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 403 + assert content['detail'] == 'Permission denied.' + assert Organization.objects.count() == 0 + + +class OrganizationDetailTest(TestCase): + def setUp(self): + self.view = views.OrganizationDetail.as_view() + + clause = { + "clause": [ + { + "effect": "allow", + "object": ["*"], + "action": ["org.*"] + }, { + "effect": "allow", + "object": ["organization/*"], + "action": ["org.*"] + } + ] + } + + policy = Policy.objects.create( + name='default', + body=json.dumps(clause)) + + self.user = UserFactory.create() + assign_user_policies(self.user, policy) + + def test_get_organization(self): + org = OrganizationFactory.create(**{'slug': 'org'}) + request = APIRequestFactory().get( + '/v1/organizations/{slug}/'.format(slug=org.slug), + ) + force_authenticate(request, user=self.user) + response = self.view(request, slug=org.slug).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 200 + assert content['id'] == org.id + assert 'users' in content + + def test_get_organization_with_unauthorized_user(self): + org = OrganizationFactory.create(**{'slug': 'org'}) + request = APIRequestFactory().get( + '/v1/organizations/{slug}/'.format(slug=org.slug), + ) + force_authenticate(request, user=AnonymousUser()) + response = self.view(request, slug=org.slug).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 403 + assert content['detail'] == "Permission denied." + + def test_get_organization_that_does_not_exist(self): + request = APIRequestFactory().get('/v1/organizations/some-org/') + force_authenticate(request, user=self.user) + + response = self.view(request, slug='some-org').render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 404 + assert content['detail'] == "Organization not found." + + def test_valid_update(self): + org = OrganizationFactory.create(**{'slug': 'org'}) + + data = {'name': 'Org Name'} + request = APIRequestFactory().patch( + '/v1/organizations/{slug}/'.format(slug=org.slug), + data + ) + force_authenticate(request, user=self.user) + + response = self.view(request, slug=org.slug).render() + org.refresh_from_db() + + assert response.status_code == 200 + assert org.name == data.get('name') + + def test_update_with_unauthorized_user(self): + org = OrganizationFactory.create(**{'name': 'Org name', 'slug': 'org'}) + + data = {'name': 'Org Name'} + request = APIRequestFactory().patch( + '/v1/organizations/{slug}/'.format(slug=org.slug), + data + ) + force_authenticate(request, user=AnonymousUser()) + + response = self.view(request, slug=org.slug).render() + org.refresh_from_db() + + assert response.status_code == 403 + assert org.name == 'Org name' + + def test_invalid_update(self): + org = OrganizationFactory.create(**{'name': 'Org name', 'slug': 'org'}) + + data = {'name': ''} + request = APIRequestFactory().patch( + '/v1/organizations/{slug}/'.format(slug=org.slug), + data + ) + force_authenticate(request, user=self.user) + + response = self.view(request, slug=org.slug).render() + content = json.loads(response.content.decode('utf-8')) + org.refresh_from_db() + + assert response.status_code == 400 + assert org.name == 'Org name' + assert content['name'][0] == 'This field may not be blank.' + + def test_archive(self): + org = OrganizationFactory.create(**{'name': 'Org name', 'slug': 'org'}) + + data = {'archived': True} + request = APIRequestFactory().patch( + '/v1/organizations/{slug}/'.format(slug=org.slug), + data + ) + force_authenticate(request, user=self.user) + + response = self.view(request, slug=org.slug).render() + org.refresh_from_db() + + assert response.status_code == 200 + assert org.archived + + def test_archive_with_unauthorized_user(self): + org = OrganizationFactory.create(**{'slug': 'org'}) + + clause = { + "clause": [ + { + "effect": "allow", + "object": ["organization/*"], + "action": ["org.update"] + }, { + "effect": "deny", + "object": ["organization/*"], + "action": ["org.archive"] + } + ] + } + + policy = Policy.objects.create( + name='default', + body=json.dumps(clause)) + assign_user_policies(self.user, policy) + + data = {'archived': True} + request = APIRequestFactory().patch( + '/v1/organizations/{slug}/'.format(slug=org.slug), + data + ) + force_authenticate(request, user=self.user) + + response = self.view(request, slug=org.slug).render() + org.refresh_from_db() + + assert response.status_code == 403 + assert not org.archived + + def test_unarchive(self): + org = OrganizationFactory.create(**{'slug': 'org', 'archived': True}) + + data = {'archived': False} + request = APIRequestFactory().patch( + '/v1/organizations/{slug}/'.format(slug=org.slug), + data + ) + force_authenticate(request, user=self.user) + + response = self.view(request, slug=org.slug).render() + org.refresh_from_db() + + assert response.status_code == 200 + assert not org.archived + + def test_unarchive_unauthorized_user(self): + org = OrganizationFactory.create(**{'slug': 'org', 'archived': True}) + + clause = { + "clause": [ + { + "effect": "allow", + "object": ["organization/*"], + "action": ["org.update"] + }, { + "effect": "deny", + "object": ["organization/*"], + "action": ["org.unarchive"] + } + ] + } + + policy = Policy.objects.create( + name='default', + body=json.dumps(clause)) + assign_user_policies(self.user, policy) + + data = {'archived': False} + request = APIRequestFactory().patch( + '/v1/organizations/{slug}/'.format(slug=org.slug), + data + ) + force_authenticate(request, user=self.user) + + response = self.view(request, slug=org.slug).render() + org.refresh_from_db() + + assert response.status_code == 403 + assert org.archived + + +class OrganizationUsersTest(TestCase): + def setUp(self): + self.view = views.OrganizationUsers.as_view() + + clause = { + "clause": [ + { + "effect": "allow", + "object": ["*"], + "action": ["org.*"] + }, { + "effect": "allow", + "object": ["organization/*"], + "action": ["org.*"] + } + ] + } + + policy = Policy.objects.create( + name='default', + body=json.dumps(clause)) + + self.user = UserFactory.create() + assign_user_policies(self.user, policy) + + def test_get_users(self): + org_users = UserFactory.create_batch(2) + other_user = UserFactory.create() + + org = OrganizationFactory.create(add_users=org_users) + request = APIRequestFactory().get( + '/v1/organizations/{slug}/users/'.format(slug=org.slug) + ) + force_authenticate(request, user=self.user) + response = self.view(request, slug=org.slug).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 200 + assert len(content) == 2 + assert other_user.username not in [u['username'] for u in content] + + def test_get_users_with_unauthorized_user(self): + org = OrganizationFactory.create() + request = APIRequestFactory().get( + '/v1/organizations/{slug}/users/'.format(slug=org.slug) + ) + force_authenticate(request, user=AnonymousUser()) + response = self.view(request, slug=org.slug).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 403 + assert content['detail'] == 'Permission denied.' + + def test_add_user(self): + org_users = UserFactory.create_batch(2) + new_user = UserFactory.create() + data = {'username': new_user.username} + + org = OrganizationFactory.create(add_users=org_users) + request = APIRequestFactory().post( + '/v1/organizations/{slug}/users/'.format(slug=org.slug), + data + ) + force_authenticate(request, user=self.user) + response = self.view(request, slug=org.slug).render() + + assert response.status_code == 201 + assert org.users.count() == 3 + + def test_add_user_with_unauthorized_user(self): + org_users = UserFactory.create_batch(2) + new_user = UserFactory.create() + data = {'username': new_user.username} + + org = OrganizationFactory.create(add_users=org_users) + request = APIRequestFactory().post( + '/v1/organizations/{slug}/users/'.format(slug=org.slug), + data + ) + force_authenticate(request, user=AnonymousUser()) + response = self.view(request, slug=org.slug).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 403 + assert content['detail'] == 'Permission denied.' + assert org.users.count() == 2 + + def test_add_user_that_does_not_exist(self): + org_users = UserFactory.create_batch(2) + data = {'username': 'some_username'} + + org = OrganizationFactory.create(add_users=org_users) + request = APIRequestFactory().post( + '/v1/organizations/{slug}/users/'.format(slug=org.slug), + data + ) + force_authenticate(request, user=self.user) + response = self.view(request, slug=org.slug).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 400 + assert org.users.count() == 2 + assert content['detail'] == 'User with given username does not exist.' + + def test_add_user_to_organization_that_does_not_exist(self): + new_user = UserFactory.create() + data = {'username': new_user.username} + + request = APIRequestFactory().post( + '/v1/organizations/some-org/users/', + data + ) + force_authenticate(request, user=self.user) + response = self.view(request, slug='some-org').render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 404 + assert content['detail'] == "Organization not found." + + +class OrganizationUsersDetailTest(TestCase): + def setUp(self): + self.view = views.OrganizationUsersDetail.as_view() + + clause = { + "clause": [ + { + "effect": "allow", + "object": ["*"], + "action": ["org.*"] + }, { + "effect": "allow", + "object": ["organization/*"], + "action": ["org.*"] + } + ] + } + + policy = Policy.objects.create( + name='default', + body=json.dumps(clause)) + + self.user = UserFactory.create() + assign_user_policies(self.user, policy) + + def test_remove_user(self): + user = UserFactory.create() + user_to_remove = UserFactory.create() + org = OrganizationFactory.create(add_users=[user, user_to_remove]) + + request = APIRequestFactory().delete( + '/v1/organizations/{org}/users/{username}'.format( + org=org.slug, + username=user_to_remove.username) + ) + force_authenticate(request, user=self.user) + response = self.view( + request, + slug=org.slug, + username=user_to_remove.username).render() + assert response.status_code == 204 + assert org.users.count() == 1 + assert user_to_remove not in org.users.all() + + def test_remove_with_unauthorized_user(self): + user = UserFactory.create() + user_to_remove = UserFactory.create() + org = OrganizationFactory.create(add_users=[user, user_to_remove]) + + request = APIRequestFactory().delete( + '/v1/organizations/{org}/users/{username}'.format( + org=org.slug, + username=user_to_remove.username) + ) + force_authenticate(request, user=AnonymousUser()) + response = self.view( + request, + slug=org.slug, + username=user_to_remove.username).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 403 + assert org.users.count() == 2 + assert content['detail'] == 'Permission denied.' + + def test_remove_user_that_does_not_exist(self): + user = UserFactory.create() + org = OrganizationFactory.create(add_users=[user]) + + request = APIRequestFactory().delete( + '/v1/organizations/{org}/users/{username}'.format( + org=org.slug, + username='some_username') + ) + force_authenticate(request, user=self.user) + response = self.view( + request, + slug=org.slug, + username='some_username').render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 404 + assert org.users.count() == 1 + assert content['detail'] == "User not found." + + def test_remove_user_from_organization_that_does_not_exist(self): + user = UserFactory.create() + + request = APIRequestFactory().delete( + '/v1/organizations/{org}/users/{username}'.format( + org='some-org', + username=user.username) + ) + force_authenticate(request, user=self.user) + response = self.view( + request, + slug='some-org', + username=user.username).render() + content = json.loads(response.content.decode('utf-8')) + + assert response.status_code == 404 + assert content['detail'] == "Organization not found." diff --git a/cadasta/organization/urls.py b/cadasta/organization/urls.py new file mode 100644 index 000000000..d8aac33ad --- /dev/null +++ b/cadasta/organization/urls.py @@ -0,0 +1,22 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url( + r'^$', + views.OrganizationList.as_view(), + name='list'), + url( + r'^(?P[-\w]+)/$', + views.OrganizationDetail.as_view(), + name='detail'), + url( + r'^(?P[-\w]+)/users/$', + views.OrganizationUsers.as_view(), + name='users'), + url( + r'^(?P[-\w]+)/users/(?P[-\w]+)/$', + views.OrganizationUsersDetail.as_view(), + name='users_detail'), +] diff --git a/cadasta/organization/validators.py b/cadasta/organization/validators.py new file mode 100644 index 000000000..6e31f7fbd --- /dev/null +++ b/cadasta/organization/validators.py @@ -0,0 +1,44 @@ +from django.core.exceptions import ValidationError + +from jsonschema import Draft4Validator + +from core.exceptions import JsonValidationError +from core.validators import validate_json + + +SCHEMA_CONTACT = { + "$schema": "http://json-schema.org/schema#", + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + "url": {"type": "string", "format": "uri"}, + "street-address": {"type": "string"}, + "locality": {"type": "string"}, + "postal-code": {"type": "string"}, + "country-name": {"type": "string"}, + "tel": {"type": "string"}, + "job-title": {"type": "string"} + }, + "anyOf": [ + {"required": ["email"]}, + {"required": ["tel"]} + ], + "required": ["name"] +} +Draft4Validator.check_schema(SCHEMA_CONTACT) + + +def validate_contact(value): + errors = [] + if not isinstance(value, list): + value = (value, ) + + for item in value: + try: + validate_json(item, SCHEMA_CONTACT) + except JsonValidationError as e: + errors.append(e) + + if errors: + raise ValidationError(errors) diff --git a/cadasta/organization/views.py b/cadasta/organization/views.py new file mode 100644 index 000000000..9d82e7c9c --- /dev/null +++ b/cadasta/organization/views.py @@ -0,0 +1,90 @@ +from rest_framework.response import Response +from rest_framework import generics +from rest_framework import filters, status + +from core.views import PermissionRequiredMixin +from accounts.serializers import UserSerializer +from accounts.models import User +from .models import Organization +from .serializers import OrganizationSerializer +from .mixins import OrganizationUsersQuerySet + + +class OrganizationList(PermissionRequiredMixin, generics.ListCreateAPIView): + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer + filter_backends = (filters.DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter,) + filter_fields = ('archived',) + search_fields = ('name', 'description',) + ordering_fields = ('name', 'description',) + permission_required = { + 'GET': 'org.list', + 'POST': 'org.create', + } + + +class OrganizationDetail(PermissionRequiredMixin, + generics.RetrieveUpdateAPIView): + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer + lookup_field = 'slug' + permission_required = { + 'GET': 'org.view', + 'PATCH': 'org.update', + } + + def initial(self, request, *args, **kwargs): + if hasattr(request, 'data'): + is_archived = self.get_object().archived + new_archived = request.data.get('archived', is_archived) + + if not is_archived and (is_archived != new_archived): + # Add required permission when archiving + self.add_permission_required = ('org.archive', ) + elif is_archived and (is_archived != new_archived): + # Add required permission when unarchiving + self.add_permission_required = ('org.unarchive', ) + + return super(OrganizationDetail, self).initial( + request, *args, **kwargs) + + +class OrganizationUsers(PermissionRequiredMixin, + OrganizationUsersQuerySet, + generics.ListCreateAPIView): + serializer_class = UserSerializer + permission_required = { + 'GET': 'org.users.list', + 'POST': 'org.users.add', + } + + def create(self, request, *args, **kwargs): + try: + new_user = User.objects.get(username=request.POST['username']) + + org = self.get_organization(self.kwargs['slug']) + org.users.add(new_user) + + return Response( + self.serializer_class(new_user).data, + status=status.HTTP_201_CREATED + ) + except User.DoesNotExist: + return Response( + {'detail': "User with given username does not exist."}, + status=status.HTTP_400_BAD_REQUEST + ) + + +class OrganizationUsersDetail(PermissionRequiredMixin, + OrganizationUsersQuerySet, + generics.DestroyAPIView): + permission_required = 'org.users.remove' + + def destroy(self, request, *args, **kwargs): + user = self.get_object() + self.org.users.remove(user) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/requirements/common.txt b/requirements/common.txt index d0692f58f..628e65033 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -1,4 +1,11 @@ -Django==1.8.6 +Django==1.9.2 psycopg2==2.6.1 djoser==0.4.0 django-cors-headers==1.1.0 +django-filter==0.12.0 +django-crispy-forms==1.6.0 +jsonschema==2.5.1 +rfc3987==1.3.5 +-e git+https://github.com/Cadasta/django-tutelary.git#egg=tutelary +django-audit-log==0.7.0 +simplejson==3.8.1