diff --git a/MANIFEST.in b/MANIFEST.in index e5d4319faf6f97d547fcde6f9a4565b0119ae425..24151b168f290ebc06752be21d8d85b7a72aa5de 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,3 +5,4 @@ include tests-requirements.txt include arkindex/documents/*.xsl recursive-include arkindex/templates *.html recursive-include arkindex/templates *.json +recursive-include arkindex/templates *.txt diff --git a/arkindex/documents/api.py b/arkindex/documents/api.py index c3c14b40e808d9bcedd85c1f715187ddc9c2364c..f1ea80bd70b54381cf5a3040d9453d750a00576d 100644 --- a/arkindex/documents/api.py +++ b/arkindex/documents/api.py @@ -88,6 +88,14 @@ class CorpusList(ListAPIView): return Corpus.objects.readable(self.request.user) +class PageDetails(RetrieveAPIView): + """ + Get details for a specific page + """ + serializer_class = PageLightSerializer + queryset = Page.objects.select_related('zone__image__server') + + class SurfaceDetails(RetrieveAPIView): """ Get details for a specific surface diff --git a/arkindex/documents/models.py b/arkindex/documents/models.py index 467a165dec0ae1ab251da857c06405eb8a2015c7..93ef6128b65b9d90966a8b07aa1a6a7b39c85013 100644 --- a/arkindex/documents/models.py +++ b/arkindex/documents/models.py @@ -349,6 +349,11 @@ class Page(Element): return False def __eq__(self, other): + # Prevent AttributeError when a Page is an Element and its subclass has not been used + if not isinstance(other, Page): + if not isinstance(other, Element) and hasattr(other, 'page'): + return False + other = other.page return self.page_type == other.page_type \ and self.nb == other.nb \ and self.direction == other.direction \ diff --git a/arkindex/project/api_v1.py b/arkindex/project/api_v1.py index df0b1491a39a082828e42367c805fe7887455559..fd74750f5c00525abcebd32e3a7a2a9d9864e593 100644 --- a/arkindex/project/api_v1.py +++ b/arkindex/project/api_v1.py @@ -5,12 +5,14 @@ from arkindex.documents.api import \ VolumeManifest, ActManifest, \ PageAnnotationList, PageActAnnotationList, SurfaceAnnotationList, \ TranscriptionSearch, ActSearch, TranscriptionSearchAnnotationList, \ - ActEdit, TranscriptionCreate, TranscriptionBulk, SurfaceDetails + ActEdit, TranscriptionCreate, TranscriptionBulk, PageDetails, SurfaceDetails from arkindex.dataimport.api import \ DataImportsList, DataImportDetails, DataImportFailures, \ DataFileList, DataFileRetrieve, DataFileUpload, \ GitRepositoryImportHook, RepositoryList, AvailableRepositoriesList, RepositoryRetrieve, RepositoryStartImport -from arkindex.users.api import ProvidersList, CredentialsList, CredentialsRetrieve +from arkindex.users.api import \ + ProvidersList, CredentialsList, CredentialsRetrieve, \ + UserRetrieve, UserCreate, UserEmailLogin, UserEmailVerification api = [ @@ -22,8 +24,9 @@ api = [ url(r'elements/(?P<pk>[\w\-]+)/$', RelatedElementsList.as_view(), name='related-elements'), url(r'elements/$', ElementsList.as_view(), name='elements'), + url(r'page/(?P<pk>[\w\-]+)/?$', PageDetails.as_view(), name='page-details'), url(r'surface/(?P<pk>[\w\-]+)/?$', SurfaceDetails.as_view(), name='surface-details'), - url(r'corpus/$', CorpusList.as_view(), name='corpus'), + url(r'corpus/?$', CorpusList.as_view(), name='corpus'), # Manifests url(r'^manifest/(?P<pk>[\w\-]+)/pages/?$', @@ -78,7 +81,7 @@ api = [ url(r'^imports/repos/search/(?P<pk>[\w\-]+)/?$', AvailableRepositoriesList.as_view(), name='available-repositories'), - url(r'^imports/(?P<pk>[\w\-]+)$', DataImportDetails.as_view(), name='import-details'), + url(r'^imports/(?P<pk>[\w\-]+)/?$', DataImportDetails.as_view(), name='import-details'), url(r'^imports/(?P<pk>[\w\-]+)/failures$', DataImportFailures.as_view(), name='import-failures'), url(r'^imports/files/(?P<pk>[\w\-]+)$', DataFileList.as_view(), name='file-list'), url(r'^imports/file/(?P<pk>[\w\-]+)$', DataFileRetrieve.as_view(), name='file-retrieve'), @@ -89,4 +92,10 @@ api = [ url(r'^oauth/providers/?$', ProvidersList.as_view(), name='providers-list'), url(r'^oauth/credentials/?$', CredentialsList.as_view(), name='credentials-list'), url(r'^oauth/credentials/(?P<pk>[\w\-]+)/?$', CredentialsRetrieve.as_view(), name='credentials-retrieve'), + + # Authentication + url(r'^user/?$', UserRetrieve.as_view(), name='user-retrieve'), + url(r'^user/new/?$', UserCreate.as_view(), name='user-new'), + url(r'^user/login/?$', UserEmailLogin.as_view(), name='user-login'), + url(r'^user/token/?$', UserEmailVerification.as_view(), name='user-token'), ] diff --git a/arkindex/project/settings.py b/arkindex/project/settings.py index a6bc942a2dbb0c9fdecffa6319a52db20a702dd9..927dc10a885b46e93c95dd9513c9b947a626bb92 100644 --- a/arkindex/project/settings.py +++ b/arkindex/project/settings.py @@ -14,6 +14,15 @@ import os import logging import tempfile + +def env2list(env_name, separator=','): + ''' + Load env variable as a list + ''' + value = os.environ.get(env_name) + return value and value.split(separator) or [] + + # Admins in charge ADMINS = [ ('Bastien', 'abadie@teklia.com'), @@ -33,8 +42,7 @@ SECRET_KEY = os.environ.get('SECRET_KEY', 'jf0w^y&ml(caax8f&a1mub)(js9(l5mhbbhos ARKINDEX_ENV = os.environ.get('ARKINDEX_ENV', 'dev') DEBUG = ARKINDEX_ENV == 'dev' -hosts = os.environ.get('ALLOWED_HOSTS') -ALLOWED_HOSTS = hosts and hosts.split(',') or [] +ALLOWED_HOSTS = env2list('ALLOWED_HOSTS') # Required for django-debug-toolbar INTERNAL_IPS = ['127.0.0.1', '127.0.1.1'] @@ -53,6 +61,7 @@ INSTALLED_APPS = [ 'rest_framework', 'rest_framework.authtoken', 'webpack_loader', + 'corsheaders', # Our apps 'arkindex.images', @@ -62,6 +71,7 @@ INSTALLED_APPS = [ ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -148,11 +158,6 @@ USE_L10N = True USE_TZ = True -# Cookies -CSRF_COOKIE_NAME = 'arkindex.csrf' -SESSION_COOKIE_NAME = 'arkindex.auth' - - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.11/howto/static-files/ FRONTEND_DIR = os.environ.get( @@ -344,6 +349,18 @@ if os.environ.get('EMAIL_HOST'): else: EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +# Cookies +CSRF_COOKIE_NAME = 'arkindex.csrf' +CSRF_TRUSTED_ORIGINS = env2list('CSRF_TRUSTED_ORIGINS') +CSRF_COOKIE_DOMAIN = os.environ.get('COOKIE_DOMAIN') + +SESSION_COOKIE_NAME = 'arkindex.auth' +SESSION_COOKIE_DOMAIN = os.environ.get('COOKIE_DOMAIN') + +CORS_ORIGIN_WHITELIST = env2list('CORS_ORIGIN_WHITELIST') +CORS_ALLOW_CREDENTIALS = True +CORS_URLS_REGEX = r'^/api/.*$' + # Optional unit tests runner with code coverage try: import django_nose # noqa @@ -369,11 +386,3 @@ try: INSTALLED_APPS.append('debug_toolbar') except ImportError: pass - -try: - import corsheaders # noqa - MIDDLEWARE.insert(0, 'corsheaders.middleware.CorsMiddleware') - INSTALLED_APPS.append('corsheaders') - CORS_ORIGIN_WHITELIST = ('localhost:5000', ) -except ImportError: - pass diff --git a/arkindex/templates/registration/verification_email.html b/arkindex/templates/registration/verification_email.html new file mode 100644 index 0000000000000000000000000000000000000000..153f3c31474ba0ed00a76d61bf2509f24f616620 --- /dev/null +++ b/arkindex/templates/registration/verification_email.html @@ -0,0 +1,10 @@ +{% autoescape off %} +To verify your email address for your Arkindex account, click the link below: + +{{ url }} + +If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead. + +-- +Teklia +{% endautoescape %} diff --git a/arkindex/templates/registration/verification_email_subject.txt b/arkindex/templates/registration/verification_email_subject.txt new file mode 100644 index 0000000000000000000000000000000000000000..acfcefad2d11891cd7833990222d548eed481fc4 --- /dev/null +++ b/arkindex/templates/registration/verification_email_subject.txt @@ -0,0 +1 @@ +Arkindex email verification diff --git a/arkindex/users/admin.py b/arkindex/users/admin.py index deacd14b35625118611314a4ea70aae2183191dc..e386a8577bb4324d06142161074a631eefb7b4ea 100644 --- a/arkindex/users/admin.py +++ b/arkindex/users/admin.py @@ -67,7 +67,7 @@ class UserAdmin(BaseUserAdmin): list_display = ('email', 'is_admin') list_filter = ('is_admin',) fieldsets = ( - (None, {'fields': ('email', 'password')}), + (None, {'fields': ('email', 'verified_email', 'password')}), ('Permissions', {'fields': ('is_admin',)}), ) # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin diff --git a/arkindex/users/api.py b/arkindex/users/api.py index a25010fd133641cf50bf20fb757a00fde145bdb0..7c6a914491e6fc4957672b7b29891f6306f5a75c 100644 --- a/arkindex/users/api.py +++ b/arkindex/users/api.py @@ -1,7 +1,22 @@ -from rest_framework.generics import ListAPIView, RetrieveDestroyAPIView +from django.urls import reverse +from django.core.mail import send_mail +from django.contrib.auth import login, logout +from django.contrib.auth.tokens import default_token_generator +from django.views.generic import RedirectView +from django.template.loader import render_to_string +from django.utils.http import urlsafe_base64_encode +from rest_framework.generics import ListAPIView, RetrieveDestroyAPIView, RetrieveUpdateDestroyAPIView, CreateAPIView from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import AuthenticationFailed, ValidationError +from arkindex.documents.models import Corpus from arkindex.users.providers import oauth_providers -from arkindex.users.serializers import OAuthCredentialsSerializer, OAuthProviderClassSerializer +from arkindex.users.models import User +from arkindex.users.serializers import \ + OAuthCredentialsSerializer, OAuthProviderClassSerializer, UserSerializer, NewUserSerializer, EmailLoginSerializer +import urllib.parse +import logging + +logger = logging.getLogger(__name__) class ProvidersList(ListAPIView): @@ -31,3 +46,86 @@ class CredentialsRetrieve(RetrieveDestroyAPIView): def perform_destroy(self, instance): instance.provider_class(request=self.request, credentials=instance).disconnect() super().perform_destroy(instance) + + +class UserRetrieve(RetrieveUpdateDestroyAPIView): + permission_classes = (IsAuthenticated, ) + serializer_class = UserSerializer + + def get_object(self): + return self.request.user + + def perform_destroy(self, instance): + logout(self.request) + + +class UserCreate(CreateAPIView): + serializer_class = NewUserSerializer + + def perform_create(self, serializer): + user = serializer.save() + login(self.request, user) + + corpus = Corpus.objects.create(name=user.email) + corpus.corpus_right.create(user=user, can_write=True, can_admin=True) + + activation_url = '{}?{}'.format( + self.request.build_absolute_uri(reverse('api:user-token')), + urllib.parse.urlencode({ + 'email': user.email, + 'token': default_token_generator.make_token(user), + }), + ) + sent = send_mail( + # Subject cannot have any newlines + subject=''.join(render_to_string('registration/verification_email_subject.txt').splitlines()), + message=render_to_string( + 'registration/verification_email.html', + context={ + 'url': activation_url, + }, + request=self.request, + ), + from_email=None, + recipient_list=[user.email], + fail_silently=True, + ) + if sent == 0: + logger.error('Failed to send registration email to {}'.format(user.email)) + + +class UserEmailLogin(CreateAPIView): + serializer_class = EmailLoginSerializer + + def perform_create(self, serializer): + try: + user = User.objects.get_by_natural_key(serializer.validated_data['email']) + assert user.has_usable_password() + assert user.check_password(serializer.validated_data['password']) + except (User.DoesNotExist, AssertionError): + raise AuthenticationFailed() + login(self.request, user) + + +class UserEmailVerification(RedirectView): + + def get_redirect_url(self): + if not all(arg in self.request.GET for arg in ('email', 'token')): + raise ValidationError() + try: + user = User.objects.get_by_natural_key(self.request.GET['email']) + assert default_token_generator.check_token(user, self.request.GET['token']) + except (User.DoesNotExist, AssertionError): + raise AuthenticationFailed() + + user.verified_email = True + user.save() + + if user.has_usable_password(): + login(self.request, user) + return '/' + + return reverse('password_reset_confirm', kwargs={ + 'uidb64': urlsafe_base64_encode(str(user.id).encode()).decode(), + 'token': self.request.GET['token'], + }) diff --git a/arkindex/users/migrations/0005_user_verified_email.py b/arkindex/users/migrations/0005_user_verified_email.py new file mode 100644 index 0000000000000000000000000000000000000000..a8687a1d717a86ea250d1490b0bc344aaaf1262a --- /dev/null +++ b/arkindex/users/migrations/0005_user_verified_email.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2018-08-21 14:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_corpus_right'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='verified_email', + field=models.BooleanField(default=False), + ), + ] diff --git a/arkindex/users/models.py b/arkindex/users/models.py index 1863197b6be1c6c3907212f76900cd1a0eb8979b..1ea75d459ec756b61133063a79722facfcbdb803 100644 --- a/arkindex/users/models.py +++ b/arkindex/users/models.py @@ -13,6 +13,7 @@ class User(AbstractBaseUser): ) is_active = models.BooleanField(default=True) is_admin = models.BooleanField(default=False) + verified_email = models.BooleanField(default=False) corpus = models.ManyToManyField('documents.Corpus', through='users.CorpusRight') diff --git a/arkindex/users/serializers.py b/arkindex/users/serializers.py index a786bc63cd96fe433b4d68d26a1d6b79b5ee2ab2..1bf657496abcaa8920f8f5827f179f82e5ffb580 100644 --- a/arkindex/users/serializers.py +++ b/arkindex/users/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from arkindex.users.models import OAuthCredentials +from arkindex.users.models import OAuthCredentials, User class OAuthCredentialsSerializer(serializers.ModelSerializer): @@ -22,3 +22,44 @@ class OAuthProviderClassSerializer(serializers.Serializer): name = serializers.CharField(source='__name__') display_name = serializers.CharField() default_url = serializers.URLField(source='url') + + +class UserSerializer(serializers.ModelSerializer): + + class Meta: + model = User + fields = ( + 'id', + 'email', + 'password', + 'is_admin', + ) + extra_kwargs = { + 'id': {'read_only': True}, + 'email': {'read_only': True}, + 'password': {'write_only': True}, + 'is_admin': {'read_only': True}, + } + + def update(self, instance, validated_data): + if 'password' in validated_data: + instance.set_password(validated_data.pop('password')) + return super().update(instance, validated_data) + + +class NewUserSerializer(serializers.ModelSerializer): + + class Meta: + model = User + fields = ( + 'email', + ) + + def create(self, validated_data): + return User.objects.create_user(validated_data['email']) + + +class EmailLoginSerializer(serializers.Serializer): + + email = serializers.EmailField() + password = serializers.CharField(write_only=True) diff --git a/arkindex/users/tests/__init__.py b/arkindex/users/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/arkindex/users/tests/test_acl.py b/arkindex/users/tests/test_acl.py new file mode 100644 index 0000000000000000000000000000000000000000..950f85631c26cd1d1dbb9767f4cee27d526527b9 --- /dev/null +++ b/arkindex/users/tests/test_acl.py @@ -0,0 +1,46 @@ +from django.test import TestCase +from django.contrib.auth.models import AnonymousUser +from arkindex.documents.models import Corpus +from arkindex.users.models import User + + +class TestACL(TestCase): + ''' + Test Corpus ACL + ''' + + def setUp(self): + self.anon = AnonymousUser() + self.admin = User.objects.create_superuser('admin@address.com', 'P4$5w0Rd') + self.user = User.objects.create_user('user@address.com', 'P4$5w0Rd') + + self.corpus_public = Corpus.objects.create(name='A Public', public=True) + self.corpus_private = Corpus.objects.create(name='B Private') + self.user.corpus_right.create(corpus=self.corpus_private, user=self.user, can_write=True) + self.corpus_hidden = Corpus.objects.create(name='C Hidden') + + def test_anon(self): + # An anonymous user has only access to public + self.assertQuerysetEqual( + Corpus.objects.readable(self.anon), + [self.corpus_public.id], + transform=lambda x: x.id, + ) + + def test_user(self): + # An anonymous user has access to public & private + self.assertFalse(self.user.is_admin) + self.assertQuerysetEqual( + Corpus.objects.readable(self.user), + [self.corpus_public.id, self.corpus_private.id], + transform=lambda x: x.id, + ) + + def test_admin(self): + # An admin has access to all + self.assertTrue(self.admin.is_admin) + self.assertQuerysetEqual( + Corpus.objects.readable(self.admin), + Corpus.objects.order_by('name').values_list('id', flat=True), + transform=lambda x: x.id, + ) diff --git a/arkindex/users/tests.py b/arkindex/users/tests/test_django_views.py similarity index 51% rename from arkindex/users/tests.py rename to arkindex/users/tests/test_django_views.py index ed835110deadfa61c6ed5289abcdcd3e79a20e37..5b6176df2eefbd0e9eb98250586fe8319337ca37 100644 --- a/arkindex/users/tests.py +++ b/arkindex/users/tests/test_django_views.py @@ -3,15 +3,14 @@ from django.urls import reverse from django.core import mail from django.contrib import auth from arkindex.users.models import User -from django.contrib.auth.models import AnonymousUser -from arkindex.documents.models import Corpus from rest_framework.authtoken.models import Token class TestUsers(TestCase): - def setUp(self): - self.user = User.objects.create_user('email@address.com', 'P4$5w0Rd') + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user('email@address.com', 'P4$5w0Rd') def test_login_success(self): response = self.client.post( @@ -50,45 +49,3 @@ class TestUsers(TestCase): Check creating a user automatically creates a token """ self.assertEqual(Token.objects.filter(user=self.user).count(), 1) - - -class TestACL(TestCase): - ''' - Test Corpus ACL - ''' - - def setUp(self): - self.anon = AnonymousUser() - self.admin = User.objects.create_superuser('admin@address.com', 'P4$5w0Rd') - self.user = User.objects.create_user('user@address.com', 'P4$5w0Rd') - - self.corpus_public = Corpus.objects.create(name='A Public', public=True) - self.corpus_private = Corpus.objects.create(name='B Private') - self.user.corpus_right.create(corpus=self.corpus_private, user=self.user, can_write=True) - self.corpus_hidden = Corpus.objects.create(name='C Hidden') - - def test_anon(self): - # An anonymous user has only access to public - self.assertQuerysetEqual( - Corpus.objects.readable(self.anon), - [self.corpus_public.id], - transform=lambda x: x.id, - ) - - def test_user(self): - # An anonymous user has access to public & private - self.assertFalse(self.user.is_admin) - self.assertQuerysetEqual( - Corpus.objects.readable(self.user), - [self.corpus_public.id, self.corpus_private.id], - transform=lambda x: x.id, - ) - - def test_admin(self): - # An admin has access to all - self.assertTrue(self.admin.is_admin) - self.assertQuerysetEqual( - Corpus.objects.readable(self.admin), - Corpus.objects.order_by('name').values_list('id', flat=True), - transform=lambda x: x.id, - ) diff --git a/arkindex/users/tests/test_registration.py b/arkindex/users/tests/test_registration.py new file mode 100644 index 0000000000000000000000000000000000000000..61ac1e30b0468433df0f48e61481f678844a8350 --- /dev/null +++ b/arkindex/users/tests/test_registration.py @@ -0,0 +1,106 @@ +from rest_framework.test import APITestCase +from rest_framework import status +from django.urls import reverse +from django.core import mail +from django.contrib import auth +from django.contrib.auth.tokens import default_token_generator +from arkindex.users.models import User +import urllib.parse + + +class TestRegistration(APITestCase): + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user('email@address.com', 'P4$5w0Rd') + + def test_retrieve_requires_login(self): + response = self.client.get(reverse('api:user-retrieve')) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_requires_login(self): + response = self.client.put( + reverse('api:user-retrieve'), + data={'password': 'N€wP4$5w0Rd'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_logout_requires_login(self): + response = self.client.delete(reverse('api:user-retrieve')) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve(self): + self.client.force_login(self.user) + response = self.client.get(reverse('api:user-retrieve')) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['email'], 'email@address.com') + self.assertEqual(data['is_admin'], False) + self.assertNotIn('password', data) + + def test_update(self): + self.client.force_login(self.user) + response = self.client.put( + reverse('api:user-retrieve'), + data={'password': 'N€wP4$5w0Rd'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.user.refresh_from_db() + self.assertTrue(self.user.check_password('N€wP4$5w0Rd')) + + def test_logout(self): + self.client.force_login(self.user) + response = self.client.delete(reverse('api:user-retrieve')) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(auth.get_user(self.client).is_authenticated) + + def test_create(self): + response = self.client.post( + reverse('api:user-new'), + data={'email': 'newuser@example.com'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(auth.get_user(self.client).is_authenticated) + self.assertEqual(auth.get_user(self.client).email, 'newuser@example.com') + self.assertFalse(User.objects.get_by_natural_key('newuser@example.com').verified_email) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, ['newuser@example.com']) + + def test_login_failure(self): + response = self.client.post( + reverse('api:user-login'), + data={'email': 'email@address.com', 'password': 'wrongpassword'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(auth.get_user(self.client).is_authenticated) + + def test_login(self): + response = self.client.post( + reverse('api:user-login'), + data={'email': 'email@address.com', 'password': 'P4$5w0Rd'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(auth.get_user(self.client).is_authenticated) + self.assertEqual(auth.get_user(self.client).email, 'email@address.com') + + def test_email_verification(self): + newuser = User.objects.create_user('newuser@example.com') + self.assertFalse(newuser.verified_email) + self.assertFalse(newuser.has_usable_password()) + + response = self.client.get('{}?{}'.format( + reverse('api:user-token'), + urllib.parse.urlencode({ + 'email': 'newuser@example.com', + 'token': default_token_generator.make_token(newuser), + })), + ) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + + newuser.refresh_from_db() + self.assertTrue(newuser.verified_email) diff --git a/requirements.txt b/requirements.txt index d502a0496723dcb5d774c3fe87334f5c2ebf2732..4c81764cd7d48b9fefa5c00354c851aacb620f1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ celery==4.2.0 celery_once==2.0.0 certifi==2017.7.27.1 chardet==3.0.4 +django-cors-headers==2.4.0 django-enumfields==0.10.0 djangorestframework==3.7.1 django-webpack-loader==0.5.0