Skip to content

Commit

Permalink
Merge 1be785a into b0bc4bd
Browse files Browse the repository at this point in the history
  • Loading branch information
bee-keeper committed Dec 7, 2015
2 parents b0bc4bd + 1be785a commit a802d4b
Show file tree
Hide file tree
Showing 19 changed files with 456 additions and 134 deletions.
41 changes: 28 additions & 13 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 36 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,55 @@
[![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)

###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.

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.

Invitation flow:

* Priviledged user invites prospective user by email (either via Django admin or via dedicated form post)
* 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, 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
* User clicks link, their email is confirmed and they are redirected to a preconfigured url (default is accounts/signup)
* Upon signing up with their password 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 make sure invitations comes 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`
Expand All @@ -52,6 +76,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

Expand All @@ -65,8 +92,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
118 changes: 118 additions & 0 deletions invitations/adapters.py
Original file line number Diff line number Diff line change
@@ -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)()
6 changes: 6 additions & 0 deletions invitations/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_')
5 changes: 2 additions & 3 deletions invitations/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"
Expand Down
35 changes: 19 additions & 16 deletions invitations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand All @@ -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
Empty file added invitations/tests/__init__.py
Empty file.
Empty file.
Loading

0 comments on commit a802d4b

Please sign in to comment.