From 0aee9b643157572a1699786a9baa01a9b76a661e Mon Sep 17 00:00:00 2001 From: bee_keeper Date: Tue, 1 Dec 2015 16:48:46 +0000 Subject: [PATCH] Removed the dependancy on django-allauth, this package is now a generic invite app --- .travis.yml | 41 ++++-- ChangeLog | 11 +- README.md | 58 ++++++--- invitations/adapters.py | 118 +++++++++++++++++ invitations/app_settings.py | 6 + invitations/forms.py | 5 +- invitations/models.py | 35 ++--- invitations/tests/__init__.py | 0 invitations/tests/allauth/__init__.py | 0 invitations/tests/allauth/test_allauth.py | 84 ++++++++++++ invitations/tests/basic/__init__.py | 0 invitations/{ => tests/basic}/tests.py | 152 +++++++++++++--------- invitations/utils.py | 13 ++ invitations/views.py | 19 ++- requirements.txt | 1 - setup.py | 9 +- test_allauth_settings.py | 22 ++++ test_settings.py | 15 +-- test_urls.py | 9 +- tox.ini | 13 +- 20 files changed, 473 insertions(+), 138 deletions(-) create mode 100644 invitations/adapters.py create mode 100644 invitations/tests/__init__.py create mode 100644 invitations/tests/allauth/__init__.py create mode 100644 invitations/tests/allauth/test_allauth.py create mode 100644 invitations/tests/basic/__init__.py rename invitations/{ => tests/basic}/tests.py (78%) create mode 100644 invitations/utils.py create mode 100644 test_allauth_settings.py diff --git a/.travis.yml b/.travis.yml index f954523..b95c9b5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,24 +2,39 @@ language: python env: matrix: - - TOX_ENV=py27-django16 - - TOX_ENV=py27-django17 - - TOX_ENV=py27-django18 - - TOX_ENV=py27-django19 - - TOX_ENV=py33-django16 - - TOX_ENV=py33-django17 - - TOX_ENV=py33-django18 - - TOX_ENV=py34-django16 - - TOX_ENV=py34-django17 - - TOX_ENV=py34-django18 - - TOX_ENV=py34-django19 + - TOX_ENV=py27-django16-backendBasic + - TOX_ENV=py27-django17-backendBasic + - TOX_ENV=py27-django18-backendBasic + - TOX_ENV=py27-django19-backendBasic + - TOX_ENV=py33-django16-backendBasic + - TOX_ENV=py33-django17-backendBasic + - TOX_ENV=py33-django18-backendBasic + - TOX_ENV=py34-django16-backendBasic + - TOX_ENV=py34-django17-backendBasic + - TOX_ENV=py34-django18-backendBasic + - TOX_ENV=py34-django19-backendBasic + - TOX_ENV=py27-django16-backendAllauth + - TOX_ENV=py27-django17-backendAllauth + - TOX_ENV=py27-django18-backendAllauth + - TOX_ENV=py27-django19-backendAllauth + - TOX_ENV=py33-django16-backendAllauth + - TOX_ENV=py33-django17-backendAllauth + - TOX_ENV=py33-django18-backendAllauth + - TOX_ENV=py34-django16-backendAllauth + - TOX_ENV=py34-django17-backendAllauth + - TOX_ENV=py34-django18-backendAllauth + - TOX_ENV=py34-django19-backendAllauth - TOX_ENV=flake8 matrix: include: - python: "3.5" - env: TOX_ENV=py35-django18 + env: TOX_ENV=py35-django18-backendBasic - python: "3.5" - env: TOX_ENV=py35-django19 + env: TOX_ENV=py35-django18-backendAllauth + - python: "3.5" + env: TOX_ENV=py35-django19-backendBasic + - python: "3.5" + env: TOX_ENV=py35-django19-backendAllauth install: - pip install tox diff --git a/ChangeLog b/ChangeLog index ec28db7..b8825fe 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,14 +1,21 @@ Changelog ========= -%%version%% (unreleased) +1.5 (2015-12-07) ------------------------ +- Removed the dependancy on django-allauth, this package is now a + generic invite app. [bee_keeper] + +1.4 (2015-11-27) +---------------- + +- 1.4. [bee_keeper] + - Merge pull request #23 from bee-keeper/devel. [bee-keeper] Coverage and exposing inviter in admin -- Coverage and exposing inviter in admin. [bee_keeper] 1.3 (2015-11-26) ---------------- diff --git a/README.md b/README.md index 17e372d..585dd32 100644 --- a/README.md +++ b/README.md @@ -2,34 +2,64 @@ [![Build Status](https://travis-ci.org/bee-keeper/django-invitations.svg?branch=master)](https://travis-ci.org/bee-keeper/django-invitations) -[![Coverage Status](https://coveralls.io/repos/bee-keeper/django-invitations/badge.svg?branch=devel&service=github)](https://coveralls.io/github/bee-keeper/django-invitations?branch=devel) +[![Coverage Status](https://coveralls.io/repos/bee-keeper/django-invitations/badge.svg?branch=master&service=github)](https://coveralls.io/github/bee-keeper/django-invitations?branch=master) ###About -A Django invite app for the excellent [django-allauth](https://github.com/pennersr/django-allauth). All emails and messages are fully customisable. +Generic invitations solution with adaptable backend. All emails and messages are fully customisable. -Invitation flow: +Originally written as an invitations solution for the excellent [django-allauth](https://github.com/pennersr/django-allauth), this app has been refactored to remove the allauth dependency whilst retaining 100% backwards compatibility. -* Priviledged user invites prospective user by email (either via Django admin or via dedicated form post) +Generic Invitation flow: + +* Priviledged user invites prospective user by email (via either Django admin, form post, JSON post or programmatically) * User receives invitation email with confirmation link +* User clicks link and is redirected to a preconfigured url (default is accounts/signup) + + +Allauth Invitation flow: + +* As above but.. * User clicks link, their email is confirmed and they are redirected to signup -* Confirmed email is prefilled, and upon signing up with their password they are logged into the site +* The signup URL has the email prefilled and upon signing up the user is logged into the site -###Installation +###Generic Installation ``` pip install django-invitations -# Add to settings.py (after all-auth), INSTALLED_APPS +# Add to settings.py, INSTALLED_APPS 'invitations', -# Add to settings.py, django-allauth setting -ACCOUNT_ADAPTER = 'invitations.models.InvitationsAdapter' - # Append to urls.py url(r'^invitations/', include('invitations.urls', namespace='invitations')), ``` +###Allauth Integration + +As above but note that invitations must come after allauth in the INSTALLED_APPS + +``` +# Add to settings.py +ACCOUNT_ADAPTER = 'invitations.models.InvitationsAdapter' +``` + +###Sending Invites + +``` +# inviter argument is optional +invite = Invitation.create('email@example.com', inviter=request.user) +invite.send_invitation(request) +``` + +To send invites via django admin, just add an invite and save. + + +###Bulk Invites + +Bulk invites are supported via JSON. Post a list of comma separated emails to the dedicated URL and Invitations will return a data object containing a list of valid and invalid invitations. + + ###Testing `python manage.py test` or `tox` @@ -52,6 +82,9 @@ Expose a URL for authenticated posting of invitees URL name of your signup URL. +**INVITATIONS_ADAPTER** (default='invitations.adapters.BaseInvitationsAdapter') + +Used for custom integrations. ACCOUNT_ADAPTER overrides this setting ###Signals @@ -65,8 +98,3 @@ The following signals are emitted: Expired and accepted invites can be cleared as so: `python manage.py clear_expired_invitations` - - -###Roadmap - -* Refactor to make an generic invitations app with pluggable backends diff --git a/invitations/adapters.py b/invitations/adapters.py new file mode 100644 index 0000000..ce45776 --- /dev/null +++ b/invitations/adapters.py @@ -0,0 +1,118 @@ +from django.template.loader import render_to_string +from django.contrib import messages +from django.conf import settings +from django.template import TemplateDoesNotExist +from django.core.mail import EmailMultiAlternatives, EmailMessage +from django.contrib.sites.models import Site + +try: + from django.utils.encoding import force_text +except ImportError: + from django.utils.encoding import force_unicode as force_text + +from .app_settings import app_settings +from .utils import import_attribute + + +# Code credits here to django-allauth +class BaseInvitationsAdapter(object): + + def stash_verified_email(self, request, email): + request.session['account_verified_email'] = email + + def unstash_verified_email(self, request): + ret = request.session.get('account_verified_email') + request.session['account_verified_email'] = None + return ret + + def format_email_subject(self, subject): + site = Site.objects.get_current() + prefix = "[{name}] ".format(name=site.name) + return prefix + force_text(subject) + + def render_mail(self, template_prefix, email, context): + """ + Renders an e-mail to `email`. `template_prefix` identifies the + e-mail that is to be sent, e.g. "account/email/email_confirmation" + """ + subject = render_to_string('{0}_subject.txt'.format(template_prefix), + context) + # remove superfluous line breaks + subject = " ".join(subject.splitlines()).strip() + subject = self.format_email_subject(subject) + + bodies = {} + for ext in ['html', 'txt']: + try: + template_name = '{0}_message.{1}'.format(template_prefix, ext) + bodies[ext] = render_to_string(template_name, + context).strip() + except TemplateDoesNotExist: + if ext == 'txt' and not bodies: + # We need at least one body + raise + if 'txt' in bodies: + msg = EmailMultiAlternatives(subject, + bodies['txt'], + settings.DEFAULT_FROM_EMAIL, + [email]) + if 'html' in bodies: + msg.attach_alternative(bodies['html'], 'text/html') + else: + msg = EmailMessage(subject, + bodies['html'], + settings.DEFAULT_FROM_EMAIL, + [email]) + msg.content_subtype = 'html' # Main content is now text/html + return msg + + def send_mail(self, template_prefix, email, context): + msg = self.render_mail(template_prefix, email, context) + msg.send() + + def is_open_for_signup(self, request): + if hasattr(request, 'session') and request.session.get( + 'account_verified_email'): + return True + elif app_settings.INVITATION_ONLY is True: + # Site is ONLY open for invites + return False + else: + # Site is open to signup + return True + + def clean_email(self, email): + """ + Validates an email value. You can hook into this if you want to + (dynamically) restrict what email addresses can be chosen. + """ + return email + + def add_message(self, request, level, message_template, + message_context=None, extra_tags=''): + """ + Wrapper of `django.contrib.messages.add_message`, that reads + the message text from a template. + """ + if 'django.contrib.messages' in settings.INSTALLED_APPS: + try: + if message_context is None: + message_context = {} + message = render_to_string(message_template, + message_context).strip() + if message: + messages.add_message(request, level, message, + extra_tags=extra_tags) + except TemplateDoesNotExist: + pass + + +def get_invitations_adapter(): + if hasattr(settings, 'ACCOUNT_ADAPTER'): + if settings.ACCOUNT_ADAPTER == 'invitations.models.InvitationsAdapter': + # defer to allauth + from allauth.account.adapter import get_adapter + return get_adapter() + else: + # load an adapter from elsewhere + return import_attribute(app_settings.ADAPTER)() diff --git a/invitations/app_settings.py b/invitations/app_settings.py index 2c44c9d..9e743ca 100644 --- a/invitations/app_settings.py +++ b/invitations/app_settings.py @@ -32,4 +32,10 @@ def SIGNUP_REDIRECT(self): """ Where to redirect on email confirm of invite """ return self._setting('SIGNUP_REDIRECT', 'account_signup') + @property + def ADAPTER(self): + """ The adapter, setting ACCOUNT_ADAPTER overrides this default """ + return self._setting( + 'ADAPTER', 'invitations.adapters.BaseInvitationsAdapter') + app_settings = AppSettings('INVITATIONS_') diff --git a/invitations/forms.py b/invitations/forms.py index 0e769e1..2c25973 100644 --- a/invitations/forms.py +++ b/invitations/forms.py @@ -2,9 +2,8 @@ from django.utils.translation import ugettext_lazy as _ from django.contrib.auth import get_user_model -from allauth.account.adapter import get_adapter - from .models import Invitation +from .adapters import get_invitations_adapter from .exceptions import AlreadyInvited, AlreadyAccepted, UserRegisteredEmail @@ -24,7 +23,7 @@ def validate_invitation(self, email): def clean_email(self): email = self.cleaned_data["email"] - email = get_adapter().clean_email(email) + email = get_invitations_adapter().clean_email(email) errors = { "already_invited": _("This e-mail address has already been" diff --git a/invitations/models.py b/invitations/models.py index 656020d..d999d37 100644 --- a/invitations/models.py +++ b/invitations/models.py @@ -9,11 +9,9 @@ from django.core.urlresolvers import reverse from django.conf import settings -from allauth.account.adapter import DefaultAccountAdapter -from allauth.account.adapter import get_adapter - from .managers import InvitationManager from .app_settings import app_settings +from .adapters import get_invitations_adapter from . import signals @@ -63,7 +61,7 @@ def send_invitation(self, request, **kwargs): email_template = 'invitations/email/email_invite' - get_adapter().send_mail( + get_invitations_adapter().send_mail( email_template, self.email, ctx) @@ -74,21 +72,26 @@ def send_invitation(self, request, **kwargs): sender=self.__class__, instance=self, invite_url_sent=invite_url, - inviter=request.user) + inviter=self.inviter) def __str__(self): return "Invite: {0}".format(self.email) -class InvitationsAdapter(DefaultAccountAdapter): +# here for backwards compatibility, historic allauth adapter +if hasattr(settings, 'ACCOUNT_ADAPTER'): + if settings.ACCOUNT_ADAPTER == 'invitations.models.InvitationsAdapter': + from allauth.account.adapter import DefaultAccountAdapter + + class InvitationsAdapter(DefaultAccountAdapter): - def is_open_for_signup(self, request): - if hasattr(request, 'session') and request.session.get( - 'account_verified_email'): - return True - elif app_settings.INVITATION_ONLY is True: - # Site is ONLY open for invites - return False - else: - # Site is open to signup - return True + def is_open_for_signup(self, request): + if hasattr(request, 'session') and request.session.get( + 'account_verified_email'): + return True + elif app_settings.INVITATION_ONLY is True: + # Site is ONLY open for invites + return False + else: + # Site is open to signup + return True diff --git a/invitations/tests/__init__.py b/invitations/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invitations/tests/allauth/__init__.py b/invitations/tests/allauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invitations/tests/allauth/test_allauth.py b/invitations/tests/allauth/test_allauth.py new file mode 100644 index 0000000..5d61121 --- /dev/null +++ b/invitations/tests/allauth/test_allauth.py @@ -0,0 +1,84 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.core.urlresolvers import reverse +from django.test.client import RequestFactory +from django.test.utils import override_settings + +from nose_parameterized import parameterized +from allauth.account.models import EmailAddress + +from invitations.models import Invitation, InvitationsAdapter +from invitations.adapters import get_invitations_adapter + + +class AllAuthIntegrationTests(TestCase): + + @classmethod + def setUpClass(cls): + cls.user = get_user_model().objects.create_user( + username='flibble', + password='password') + cls.invitation = Invitation.create( + 'email@example.com', inviter=cls.user) + cls.adapter = get_invitations_adapter() + + @classmethod + def tearDownClass(cls): + get_user_model().objects.all().delete() + Invitation.objects.all().delete() + + @parameterized.expand([ + ('get'), + ('post'), + ]) + def test_accept_invite_allauth(self, method): + client_with_method = getattr(self.client, method) + resp = client_with_method( + reverse('invitations:accept-invite', + kwargs={'key': self.invitation.key}), follow=True) + invite = Invitation.objects.get(email='email@example.com') + self.assertTrue(invite.accepted) + self.assertEqual(invite.inviter, self.user) + self.assertEqual( + resp.request['PATH_INFO'], reverse('account_signup')) + + form = resp.context_data['form'] + self.assertEqual('email@example.com', form.fields['email'].initial) + messages = resp.context['messages'] + message_text = [message.message for message in messages] + self.assertEqual( + message_text, [ + 'Invitation to - email@example.com - has been accepted']) + + resp = self.client.post( + reverse('account_signup'), + {'email': 'email@example.com', + 'username': 'username', + 'password1': 'password', + 'password2': 'password' + }) + + allauth_email_obj = EmailAddress.objects.get( + email='email@example.com') + self.assertTrue(allauth_email_obj.verified) + + def test_fetch_adapter(self): + self.assertIsInstance(self.adapter, InvitationsAdapter) + + def test_allauth_signup_open(self): + signup_request = RequestFactory().get(reverse( + 'account_signup', urlconf='allauth.account.urls')) + self.assertTrue( + self.adapter.is_open_for_signup(signup_request)) + + @override_settings( + INVITATIONS_INVITATION_ONLY=True, + ) + def test_allauth_adapter_invitations_only(self): + signup_request = RequestFactory().get(reverse( + 'account_signup', urlconf='allauth.account.urls')) + self.assertFalse( + self.adapter.is_open_for_signup(signup_request)) + response = self.client.get( + reverse('account_signup')) + self.assertIn('Sign Up Closed', response.content.decode('utf8')) diff --git a/invitations/tests/basic/__init__.py b/invitations/tests/basic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invitations/tests.py b/invitations/tests/basic/tests.py similarity index 78% rename from invitations/tests.py rename to invitations/tests/basic/tests.py index fdda9b8..b00700b 100644 --- a/invitations/tests.py +++ b/invitations/tests/basic/tests.py @@ -12,15 +12,15 @@ from django.core import mail from django.contrib.auth.models import AnonymousUser -from allauth.account.adapter import get_adapter -from allauth.account.models import EmailAddress from freezegun import freeze_time from nose_parameterized import parameterized -from .models import Invitation, InvitationsAdapter -from .app_settings import app_settings -from .views import AcceptInvite, SendJSONInvite -from .forms import InviteForm +from invitations.adapters import ( + get_invitations_adapter, BaseInvitationsAdapter) +from invitations.models import Invitation +from invitations.app_settings import app_settings +from invitations.views import AcceptInvite, SendJSONInvite +from invitations.forms import InviteForm class InvitationModelTests(TestCase): @@ -56,28 +56,14 @@ class InvitationsAdapterTests(TestCase): @classmethod def setUp(cls): - cls.adapter = get_adapter() - cls.signup_request = RequestFactory().get(reverse( - 'account_signup', urlconf='allauth.account.urls')) + cls.adapter = get_invitations_adapter() @classmethod def tearDownClass(cls): del cls.adapter def test_fetch_adapter(self): - self.assertIsInstance(self.adapter, InvitationsAdapter) - - def test_adapter_default_signup(self): - self.assertTrue(self.adapter.is_open_for_signup(self.signup_request)) - - @override_settings( - INVITATIONS_INVITATION_ONLY=True - ) - def test_adapter_invitations_only(self): - self.assertFalse(self.adapter.is_open_for_signup(self.signup_request)) - response = self.client.get( - reverse('account_signup')) - self.assertIn('Sign Up Closed', response.content.decode('utf8')) + self.assertIsInstance(self.adapter, BaseInvitationsAdapter) class InvitationsSendViewTests(TestCase): @@ -102,8 +88,8 @@ def test_auth(self): response = self.client.post( reverse('invitations:send-invite'), {'email': 'valid@example.com'}, follow=True) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.template_name, ['account/login.html']) + + self.assertEqual(response.status_code, 404) @parameterized.expand([ ('invalid@example', 'Enter a valid email address'), @@ -179,6 +165,9 @@ def test_accept_invite_invalid_key(self, method): ('get'), ('post'), ]) + @override_settings( + INVITATIONS_SIGNUP_REDIRECT='/non-existent-url/' + ) def test_accept_invite(self, method): client_with_method = getattr(self.client, method) resp = client_with_method( @@ -187,26 +176,8 @@ def test_accept_invite(self, method): invite = Invitation.objects.get(email='email@example.com') self.assertTrue(invite.accepted) self.assertEqual(invite.inviter, self.user) - self.assertEqual(resp.request['PATH_INFO'], reverse('account_signup')) - - form = resp.context_data['form'] - self.assertEqual('email@example.com', form.fields['email'].initial) - messages = resp.context['messages'] - message_text = [message.message for message in messages] self.assertEqual( - message_text, [ - 'Invitation to - email@example.com - has been accepted']) - - resp = self.client.post( - reverse('account_signup'), - {'email': 'email@example.com', - 'username': 'username', - 'password1': 'password', - 'password2': 'password' - }) - - allauth_email_obj = EmailAddress.objects.get(email='email@example.com') - self.assertTrue(allauth_email_obj.verified) + resp.request['PATH_INFO'], '/non-existent-url/') @override_settings( INVITATIONS_SIGNUP_REDIRECT='/non-existent-url/' @@ -222,39 +193,48 @@ def test_signup_redirect(self): class InvitationsSignalTests(TestCase): + @classmethod + def setUp(cls): + cls.user = get_user_model().objects.create_user( + username='flobble', password='password') + cls.invite = Invitation.create( + email='email@example.com', inviter=cls.user) + + @classmethod + def tearDownClass(cls): + cls.user.delete() + cls.invite.delete() + @patch('invitations.signals.invite_url_sent.send') def test_invite_url_sent_triggered_correctly(self, mock_signal): - invite = Invitation.create('email@example.com') - invite_url = reverse('invitations:accept-invite', args=[invite.key]) - + invite_url = reverse('invitations:accept-invite', + args=[self.invite.key]) request = RequestFactory().get('/') - request.user = 'monkey' invite_url = request.build_absolute_uri(invite_url) - invite.send_invitation(request) + self.invite.send_invitation(request) self.assertTrue(mock_signal.called) self.assertEqual(mock_signal.call_count, 1) mock_signal.assert_called_with( - instance=invite, + instance=self.invite, invite_url_sent=invite_url, - inviter='monkey', + inviter=self.user, sender=Invitation, ) - invite.delete() - + @override_settings( + INVITATIONS_SIGNUP_REDIRECT='/non-existent-url/' + ) @patch('invitations.signals.invite_accepted.send') def test_invite_invite_accepted_triggered_correctly(self, mock_signal): - invite = Invitation.create('email@example.com') request = RequestFactory().get('/') - request.user = 'monkey' - invite.send_invitation(request) + self.invite.send_invitation(request) self.client.post( reverse('invitations:accept-invite', - kwargs={'key': invite.key}), follow=True) + kwargs={'key': self.invite.key}), follow=True) self.assertTrue(mock_signal.called) self.assertEqual(mock_signal.call_count, 1) @@ -264,7 +244,7 @@ def test_invite_invite_accepted_triggered_correctly(self, mock_signal): self.assertEqual( mock_signal.call_args[1]['sender'], AcceptInvite) - invite.delete() + self.invite.delete() class InvitationsFormTests(TestCase): @@ -332,6 +312,14 @@ def test_managers(self): self.assertEqual(sorted(valid), sorted(expected_valid)) self.assertEqual(sorted(expired), sorted(expected_expired)) + def test_delete_all(self): + valid = Invitation.objects.all_valid().values_list( + 'email', flat=True) + Invitation.objects.delete_expired_confirmations() + remaining_invites = Invitation.objects.all().values_list( + 'email', flat=True) + self.assertEqual(sorted(valid), sorted(remaining_invites)) + class InvitationsJSONTests(TestCase): @@ -339,7 +327,8 @@ class InvitationsJSONTests(TestCase): def setUp(cls): cls.user = get_user_model().objects.create_user( username='flibble', - password='password') + password='password', + email='mrflibble@example.com') cls.accepted_invite = Invitation.create('already@accepted.com') cls.accepted_invite.accepted = True cls.accepted_invite.save() @@ -365,6 +354,10 @@ def tearDownClass(cls): {u'valid': [], u'invalid': [{u'email3@example.com': u'pending invite'}]}, 400), + (['mrflibble@example.com'], + {u'valid': [], + u'invalid': [{u'mrflibble@example.com': u'user registered email'}]}, + 400), (['example@example.com'], {u'valid': [{u'example@example.com': u'invited'}], u'invalid': []}, @@ -415,3 +408,46 @@ def test_authenticated_get(self): response = SendJSONInvite.as_view()(request) self.assertEqual(response.status_code, 405) + + +class InvitationsAdminTests(TestCase): + + @classmethod + def setUp(cls): + cls.user = get_user_model().objects.create_superuser( + username='flibble', + password='password', + email='mrflibble@example.com') + cls.invite = Invitation.objects.create(email='example@example.com') + + @classmethod + def tearDownClass(cls): + cls.user.delete() + Invitation.objects.all().delete() + + def test_admin_form_add(self): + self.client.login(username='flibble', password='password') + response = self.client.post( + reverse('admin:invitations_invitation_add'), + {'email': 'valid@example.com', 'inviter': self.user.id}, + follow=True) + invite = Invitation.objects.get(email='valid@example.com') + + self.assertEqual(response.status_code, 200) + self.assertTrue(invite.sent) + self.assertEqual(invite.inviter, self.user) + + invite.delete() + + def test_admin_form_change(self): + self.client.login(username='flibble', password='password') + response = self.client.get( + reverse('admin:invitations_invitation_change', + args=(self.invite.id,)), + follow=True) + + self.assertEqual(response.status_code, 200) + fields = list(response.context_data['adminform'].form.fields.keys()) + expected_fields = ['email', 'accepted', 'created', + 'key', 'sent', 'inviter'] + self.assertEqual(fields, expected_fields) diff --git a/invitations/utils.py b/invitations/utils.py new file mode 100644 index 0000000..2510e3c --- /dev/null +++ b/invitations/utils.py @@ -0,0 +1,13 @@ +from django.utils import six + +try: + import importlib +except: + from django.utils import importlib + + +def import_attribute(path): + assert isinstance(path, six.string_types) + pkg, attr = path.rsplit('.', 1) + ret = getattr(importlib.import_module(pkg), attr) + return ret diff --git a/invitations/views.py b/invitations/views.py index d76fceb..f22708c 100644 --- a/invitations/views.py +++ b/invitations/views.py @@ -9,13 +9,13 @@ from django.core.validators import validate_email from braces.views import LoginRequiredMixin -from allauth.account.adapter import get_adapter from .forms import InviteForm, CleanEmailMixin from .models import Invitation from . import signals -from .exceptions import AlreadyInvited, AlreadyAccepted +from .exceptions import AlreadyInvited, AlreadyAccepted, UserRegisteredEmail from .app_settings import app_settings +from .adapters import get_invitations_adapter class SendInvite(LoginRequiredMixin, FormView): @@ -70,6 +70,9 @@ def post(self, request, *args, **kwargs): except(AlreadyInvited): response['invalid'].append( {invitee: 'pending invite'}) + except(UserRegisteredEmail): + response['invalid'].append( + {invitee: 'user registered email'}) else: invite.send_invitation(request) response['valid'].append({invitee: 'invited'}) @@ -95,16 +98,18 @@ def post(self, *args, **kwargs): self.object = invitation = self.get_object() invitation.accepted = True invitation.save() - get_adapter().stash_verified_email(self.request, invitation.email) + get_invitations_adapter().stash_verified_email( + self.request, invitation.email) signals.invite_accepted.send(sender=self.__class__, request=self.request, email=invitation.email) - get_adapter().add_message(self.request, - messages.SUCCESS, - 'invitations/messages/invite_accepted.txt', - {'email': invitation.email}) + get_invitations_adapter().add_message( + self.request, + messages.SUCCESS, + 'invitations/messages/invite_accepted.txt', + {'email': invitation.email}) return redirect(app_settings.SIGNUP_REDIRECT) diff --git a/requirements.txt b/requirements.txt index d56c8c2..b2ccb2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -django-allauth tox>=2.1.1 mock>=1.3.0 coverage>=3.7.1 diff --git a/setup.py b/setup.py index c8d51ac..7db40d4 100644 --- a/setup.py +++ b/setup.py @@ -6,13 +6,13 @@ package_data={'invitations': ['templates/*.*']}, include_package_data=True, zip_safe=False, - version='1.4', - description='Django invitation integration for django-allauth', + version='1.5', + description='Generic invitations app with support for django-allauth', author='https://github.com/bee-keeper', author_email='none@none.com', url='https://github.com/bee-keeper/django-invitations.git', download_url='https://github.com/' - 'bee-keeper/django-invitations/tarball/1.4', + 'bee-keeper/django-invitations/tarball/1.5', keywords=['django', 'invitation', 'django-allauth', 'invite'], classifiers=[ 'Development Status :: 4 - Beta', @@ -22,13 +22,12 @@ 'Topic :: Internet', 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'Framework :: Django', ], install_requires=[ - 'django-allauth>=0.22', 'django-braces>=1.8.0', ], ) diff --git a/test_allauth_settings.py b/test_allauth_settings.py new file mode 100644 index 0000000..0bd8ba8 --- /dev/null +++ b/test_allauth_settings.py @@ -0,0 +1,22 @@ +from test_settings import * + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.admin', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django_nose', + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'invitations', +) + +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +) + +ACCOUNT_ADAPTER = 'invitations.models.InvitationsAdapter' diff --git a/test_settings.py b/test_settings.py index 06ea950..0287b69 100644 --- a/test_settings.py +++ b/test_settings.py @@ -50,6 +50,9 @@ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) +EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' +# EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + INSTALLED_APPS = ( 'django.contrib.auth', 'django.contrib.admin', @@ -57,19 +60,9 @@ 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - 'invitations', 'django_nose', + 'invitations', ) - -EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' -# EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' - AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', ) - -ACCOUNT_ADAPTER = 'invitations.models.InvitationsAdapter' diff --git a/test_urls.py b/test_urls.py index 3e5cc2b..15c53f3 100644 --- a/test_urls.py +++ b/test_urls.py @@ -1,11 +1,18 @@ from django.conf.urls import patterns, url, include from django.contrib import admin +from django.conf import settings +admin.autodiscover() urlpatterns = patterns( '', url(r'^invitations/', include( 'invitations.urls', namespace='invitations')), - url(r'^accounts/', include('allauth.urls')), url(r'^admin/', include(admin.site.urls)), ) + +if 'allauth' in settings.INSTALLED_APPS: + urlpatterns += patterns( + '', + url(r'^accounts/', include('allauth.urls')) + ) diff --git a/tox.ini b/tox.ini index c35d690..00a9f0a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{27,33,34,35}-django{16,17,18,19} +envlist = py{27,33,34,35}-django{16,17,18,19}-backend{Basic,Allauth} [testenv] deps = @@ -7,18 +7,19 @@ deps = django16: Django<1.7 django17: Django<1.8 django18: Django<1.9 - django19: Django==1.9rc1 - + django19: Django<1.10 + backendAllauth: django-allauth commands = python -V - coverage run manage.py test + backendBasic: coverage run manage.py test invitations/tests/basic + backendAllauth: coverage run manage.py test invitations/tests/allauth --settings=test_allauth_settings coverage report - coverage html [testenv:flake8] basepython=python deps=flake8 -commands=flake8 +commands=flake8 invitations [flake8] exclude = invitations/migrations/* +