├── .gitignore ├── AUTHORS.txt ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── invitation ├── __init__.py ├── admin.py ├── app_settings.py ├── forms.py ├── locale │ └── tr │ │ └── LC_MESSAGES │ │ └── django.po ├── management │ └── __init__.py ├── models.py ├── signals.py ├── templates │ └── admin │ │ └── invitation │ │ └── invitationstats │ │ ├── _reward_link.html │ │ └── change_list.html ├── templatetags │ ├── __init__.py │ └── invitation_tags.py ├── tests │ ├── __init__.py │ ├── invite_only_urls.py │ ├── invite_optional_urls.py │ ├── models.py │ ├── templates │ │ ├── invitation │ │ │ ├── invitation_complete.html │ │ │ ├── invitation_email.txt │ │ │ ├── invitation_email_subject.txt │ │ │ ├── invitation_form.html │ │ │ ├── invitation_home.html │ │ │ ├── invitation_registered.html │ │ │ ├── invitation_unavailable.html │ │ │ ├── invite_only.html │ │ │ └── wrong_invitation_key.html │ │ └── registration │ │ │ ├── registration_complete.html │ │ │ ├── registration_form.html │ │ │ └── registration_register.html │ ├── urls.py │ ├── utils.py │ └── views.py ├── urls.py └── views.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[oc] 2 | *.mo 3 | *.db 4 | cache/ 5 | *.kdevelop 6 | *.kdevses 7 | *.egg-info 8 | MANIFEST 9 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Atamert Ölçgen 2 | lhon 3 | Ales Zabala Alava (Shagi) 4 | Brad Montgomery 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, Atamert Ölçgen (http://www.muhuk.com) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of django-inviting nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.txt 3 | recursive-include invitation/locale/*/LC_MESSAGES *.po *.mo 4 | recursive-include invitation/templates *.html 5 | recursive-include invitation/tests/templates *.html 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | .. attention:: This package is no longer maintained. 3 | 4 | 5 | Built on top of ``django-registration``, **django-inviting** handles registration through invitations. 6 | 7 | 8 | Features 9 | ======== 10 | 11 | - Invitations can be optional or required to be registered. 12 | - Admin integration 13 | - Adding available invitations with custom performance and rewarding 14 | algorithms. (for invite only mode) 15 | 16 | 17 | Installation 18 | ============ 19 | 20 | This application depends on ``django-registration``. 21 | 22 | #. Add ``"django-inviting"`` directory to your Python path. 23 | #. Add ``"invitation"`` to your ``INSTALLED_APPS`` tuple found in 24 | your settings file. 25 | #. Include ``"invitation.urls"`` to your URLconf. 26 | 27 | 28 | Testing & Example 29 | ================= 30 | 31 | TODO 32 | 33 | 34 | Usage 35 | ===== 36 | 37 | You can configure ``django-inviting`` app's behaviour with the following 38 | settings: 39 | 40 | :INVITATION_INVITE_ONLY: 41 | Set this to True if you want registration to be only possible via 42 | invitations. Default value is ``False``. 43 | 44 | :INVITATION_EXPIRE_DAYS: 45 | How many days before an invitation is expired. Default value is ``15``. 46 | 47 | :INVITATION_INITIAL_INVITATIONS: 48 | How many invitations are available to new users. If 49 | ``INVITATION_INVITE_ONLY`` is ``False`` this setting 50 | has no effect. Default value is ``10``. 51 | 52 | :INVITATION_PERFORMANCE_FUNC: 53 | A method that takes an ``InvitationStats`` instance and returns a 54 | ``float`` between ``0.0`` and ``1.0``. You can supply a custom 55 | performance method by reference or by import path as a string. 56 | Default value is ``None``. If a custom performance function is not 57 | supplied one of the default performance functions in ``invitation.models`` 58 | will be used according to ``INVITATION_INVITE_ONLY`` value. 59 | 60 | :INVITATION_REWARD_THRESHOLD: 61 | A ``float`` that determines which users are rewarded. Default value 62 | is ``0.75``. 63 | 64 | 65 | See Also 66 | ======== 67 | 68 | - `django-invitation `_ 69 | - `django-invite `_ 70 | -------------------------------------------------------------------------------- /invitation/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = u'Atamert \xd6l\xe7gen' 2 | __copyright__ = u'Copyright 2010, \xd6l\xe7gen Bili\u015fim' 3 | __credits__ = [u'Atamert \xd6l\xe7gen'] 4 | 5 | 6 | __license__ = 'BSD' 7 | __version__ = '0.6.1' 8 | __maintainer__ = u'Atamert \xd6l\xe7gen' 9 | __email__ = 'muhuk@muhuk.com' 10 | __status__ = 'Production' 11 | -------------------------------------------------------------------------------- /invitation/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from models import Invitation, InvitationStats 3 | 4 | 5 | class InvitationAdmin(admin.ModelAdmin): 6 | list_display = ('user', 'email', 'expiration_date') 7 | admin.site.register(Invitation, InvitationAdmin) 8 | 9 | 10 | class InvitationStatsAdmin(admin.ModelAdmin): 11 | list_display = ('user', 'available', 'sent', 'accepted', 'performance') 12 | 13 | def performance(self, obj): 14 | return '%0.2f' % obj.performance 15 | admin.site.register(InvitationStats, InvitationStatsAdmin) 16 | -------------------------------------------------------------------------------- /invitation/app_settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.utils.importlib import import_module 4 | 5 | 6 | def get_performance_func(settings): 7 | performance_func = getattr(settings, 'INVITATION_PERFORMANCE_FUNC', None) 8 | if isinstance(performance_func, (str, unicode)): 9 | module_name, func_name = performance_func.rsplit('.', 1) 10 | try: 11 | performance_func = getattr(import_module(module_name), func_name) 12 | except ImportError: 13 | raise ImproperlyConfigured('Can\'t import performance function ' \ 14 | '`%s` from `%s`' % (func_name, 15 | module_name)) 16 | if performance_func and not callable(performance_func): 17 | raise ImproperlyConfigured('INVITATION_PERFORMANCE_FUNC must be a ' \ 18 | 'callable or an import path string ' \ 19 | 'pointing to a callable.') 20 | 21 | 22 | INVITE_ONLY = getattr(settings, 'INVITATION_INVITE_ONLY', False) 23 | EXPIRE_DAYS = getattr(settings, 'INVITATION_EXPIRE_DAYS', 15) 24 | INITIAL_INVITATIONS = getattr(settings, 'INVITATION_INITIAL_INVITATIONS', 10) 25 | REWARD_THRESHOLD = getattr(settings, 'INVITATION_REWARD_THRESHOLD', 0.75) 26 | PERFORMANCE_FUNC = get_performance_func(settings) 27 | -------------------------------------------------------------------------------- /invitation/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth.models import User 3 | from registration.forms import RegistrationForm 4 | 5 | 6 | def save_user(form_instance): 7 | """ 8 | Create a new **active** user from form data. 9 | 10 | This method is intended to replace the ``save`` of 11 | ``django-registration``s ``RegistrationForm``. Required form fields 12 | are ``username``, ``email`` and ``password1``. 13 | """ 14 | username = form_instance.cleaned_data['username'] 15 | email = form_instance.cleaned_data['email'] 16 | password = form_instance.cleaned_data['password1'] 17 | new_user = User.objects.create_user(username, email, password) 18 | new_user.save() 19 | return new_user 20 | 21 | 22 | class InvitationForm(forms.Form): 23 | email = forms.EmailField() 24 | 25 | 26 | class RegistrationFormInvitation(RegistrationForm): 27 | """ 28 | Subclass of ``registration.RegistrationForm`` that create an **active** 29 | user. 30 | 31 | Since registration is (supposedly) done via invitation, no further 32 | activation is required. For this reason ``email`` field always return 33 | the value of ``email`` argument given the constructor. 34 | """ 35 | def __init__(self, email, *args, **kwargs): 36 | super(RegistrationFormInvitation, self).__init__(*args, **kwargs) 37 | self._make_email_immutable(email) 38 | 39 | def _make_email_immutable(self, email): 40 | self._email = self.initial['email'] = email 41 | if 'email' in self.data: 42 | self.data = self.data.copy() 43 | self.data['email'] = email 44 | self.fields['email'].widget.attrs.update({'readonly': True}) 45 | 46 | def clean_email(self): 47 | return self._email 48 | 49 | save = save_user 50 | -------------------------------------------------------------------------------- /invitation/locale/tr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-inviting\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2010-02-25 13:36+0200\n" 11 | "PO-Revision-Date: 2009-10-13 17:47+0200\n" 12 | "Last-Translator: Atamert Ölçgen \n" 13 | "Language-Team: muhuk \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "X-Poedit-Language: Turkish\n" 18 | 19 | #: models.py:70 20 | msgid "e-mail" 21 | msgstr "e-posta" 22 | 23 | #: models.py:71 24 | msgid "invitation key" 25 | msgstr "davetiye kodu" 26 | 27 | #: models.py:72 28 | msgid "date invited" 29 | msgstr "davet edildilği tarih" 30 | 31 | #: models.py:78 32 | msgid "invitation" 33 | msgstr "davetiye" 34 | 35 | #: models.py:79 36 | msgid "invitations" 37 | msgstr "davetiyeler" 38 | 39 | #: models.py:83 40 | #, python-format 41 | msgid "%(username)s invited %(email)s on %(date)s" 42 | msgstr "%(username)s, %(email)s'i %(date)s tarihinde davet etti" 43 | 44 | #: models.py:102 45 | msgid "expiration date" 46 | msgstr "son kullanma tarihi" 47 | 48 | #: models.py:160 49 | msgid "available invitations" 50 | msgstr "kullanılabilir davetiyeler" 51 | 52 | #: models.py:162 53 | msgid "invitations sent" 54 | msgstr "gönderilmiş davetiyeler" 55 | 56 | #: models.py:163 57 | msgid "invitations accepted" 58 | msgstr "kabul edilmiş davetiyeler" 59 | 60 | #: models.py:168 61 | msgid "invitation stats" 62 | msgstr "davetiye istatistikleri" 63 | 64 | #: models.py:172 65 | #, python-format 66 | msgid "invitation stats for %(username)s" 67 | msgstr "%(username)s için davetiye istatistikleri" 68 | 69 | #: views.py:79 70 | #, python-format 71 | msgid "%(users)s users are given a total of %(invitations)s invitations." 72 | msgstr "%(users)s kullanıcıya toplam %(invitations)s davetiye verildi." 73 | 74 | #: views.py:84 75 | msgid "No user has performance above threshold, no invitations awarded." 76 | msgstr "" 77 | "Belirlenen performans eşiğinin üstünde kullanıcı olmadığı " 78 | "için hiç davetiye tanımlanmadı." 79 | 80 | #: templates/admin/invitation/invitationstats/_reward_link.html:3 81 | msgid "Reward users with good performance" 82 | msgstr "Aktif kullanıcılara davetiye tanımla" 83 | 84 | #: templates/admin/invitation/invitationstats/change_list.html:10 85 | #, python-format 86 | msgid "Add %(name)s" 87 | msgstr "%(name)s ekle" 88 | -------------------------------------------------------------------------------- /invitation/management/__init__.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_syncdb 2 | from django.contrib.auth.models import User 3 | from invitation import models 4 | 5 | 6 | def create_stats_for_existing_users(sender, **kwargs): 7 | """ 8 | Create `InvitationStats` objects for all users after a `sycndb` 9 | 10 | """ 11 | count = 0 12 | for user in User.objects.filter(invitation_stats__isnull=True): 13 | models.InvitationStats.objects.create(user=user) 14 | count += 1 15 | if count > 0: 16 | print "Created InvitationStats for %s existing Users" % count 17 | 18 | 19 | post_syncdb.connect(create_stats_for_existing_users, sender=models) 20 | -------------------------------------------------------------------------------- /invitation/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import random 3 | from django.db import models 4 | from django.core.mail import send_mail 5 | from django.conf import settings 6 | from django.template.loader import render_to_string 7 | from django.utils.translation import ugettext_lazy as _ 8 | from django.utils.hashcompat import sha_constructor 9 | from django.contrib.auth.models import User 10 | from django.contrib.sites.models import Site, RequestSite 11 | import app_settings 12 | import signals 13 | 14 | 15 | def performance_calculator_invite_only(invitation_stats): 16 | """Calculate a performance score between ``0.0`` and ``1.0``. 17 | """ 18 | if app_settings.INVITE_ONLY: 19 | total = invitation_stats.available + invitation_stats.sent 20 | try: 21 | send_ratio = float(invitation_stats.sent) / total 22 | except ZeroDivisionError: 23 | send_ratio = 0.0 24 | accept_ratio = performance_calculator_invite_optional(invitation_stats) 25 | return min((send_ratio + accept_ratio) * 0.6, 1.0) 26 | 27 | 28 | def performance_calculator_invite_optional(invitation_stats): 29 | try: 30 | accept_ratio = float(invitation_stats.accepted) / invitation_stats.sent 31 | return min(accept_ratio, 1.0) 32 | except ZeroDivisionError: 33 | return 0.0 34 | 35 | 36 | DEFAULT_PERFORMANCE_CALCULATORS = { 37 | True: performance_calculator_invite_only, 38 | False: performance_calculator_invite_optional, 39 | } 40 | 41 | 42 | class InvitationError(Exception): 43 | pass 44 | 45 | 46 | class InvitationManager(models.Manager): 47 | def invite(self, user, email): 48 | """ 49 | Get or create an invitation for ``email`` from ``user``. 50 | 51 | This method doesn't an send email. You need to call ``send_email()`` 52 | method on returned ``Invitation`` instance. 53 | """ 54 | invitation = None 55 | try: 56 | # It is possible that there is more than one invitation fitting 57 | # the criteria. Normally this means some older invitations are 58 | # expired or an email is invited consequtively. 59 | invitation = self.filter(user=user, email=email)[0] 60 | if not invitation.is_valid(): 61 | invitation = None 62 | except (Invitation.DoesNotExist, IndexError): 63 | pass 64 | if invitation is None: 65 | user.invitation_stats.use() 66 | key = '%s%0.16f%s%s' % (settings.SECRET_KEY, 67 | random.random(), 68 | user.email, 69 | email) 70 | key = sha_constructor(key).hexdigest() 71 | invitation = self.create(user=user, email=email, key=key) 72 | return invitation 73 | invite.alters_data = True 74 | 75 | def find(self, invitation_key): 76 | """ 77 | Find a valid invitation for the given key or raise 78 | ``Invitation.DoesNotExist``. 79 | 80 | This function always returns a valid invitation. If an invitation is 81 | found but not valid it will be automatically deleted. 82 | """ 83 | try: 84 | invitation = self.filter(key=invitation_key)[0] 85 | except IndexError: 86 | raise Invitation.DoesNotExist 87 | if not invitation.is_valid(): 88 | invitation.delete() 89 | raise Invitation.DoesNotExist 90 | return invitation 91 | 92 | def valid(self): 93 | """Filter valid invitations. 94 | """ 95 | expiration = datetime.datetime.now() - datetime.timedelta( 96 | app_settings.EXPIRE_DAYS) 97 | return self.get_query_set().filter(date_invited__gte=expiration) 98 | 99 | def invalid(self): 100 | """Filter invalid invitation. 101 | """ 102 | expiration = datetime.datetime.now() - datetime.timedelta( 103 | app_settings.EXPIRE_DAYS) 104 | return self.get_query_set().filter(date_invited__le=expiration) 105 | 106 | 107 | class Invitation(models.Model): 108 | user = models.ForeignKey(User, related_name='invitations') 109 | email = models.EmailField(_(u'e-mail')) 110 | key = models.CharField(_(u'invitation key'), max_length=40, unique=True) 111 | date_invited = models.DateTimeField(_(u'date invited'), 112 | default=datetime.datetime.now) 113 | 114 | objects = InvitationManager() 115 | 116 | class Meta: 117 | verbose_name = _(u'invitation') 118 | verbose_name_plural = _(u'invitations') 119 | ordering = ('-date_invited',) 120 | 121 | def __unicode__(self): 122 | return _('%(username)s invited %(email)s on %(date)s') % { 123 | 'username': self.user.username, 124 | 'email': self.email, 125 | 'date': str(self.date_invited.date()), 126 | } 127 | 128 | @models.permalink 129 | def get_absolute_url(self): 130 | return ('invitation_register', (), {'invitation_key': self.key}) 131 | 132 | @property 133 | def _expires_at(self): 134 | return self.date_invited + datetime.timedelta(app_settings.EXPIRE_DAYS) 135 | 136 | def is_valid(self): 137 | """ 138 | Return ``True`` if the invitation is still valid, ``False`` otherwise. 139 | """ 140 | return datetime.datetime.now() < self._expires_at 141 | 142 | def expiration_date(self): 143 | """Return a ``datetime.date()`` object representing expiration date. 144 | """ 145 | return self._expires_at.date() 146 | expiration_date.short_description = _(u'expiration date') 147 | expiration_date.admin_order_field = 'date_invited' 148 | 149 | def send_email(self, email=None, site=None, request=None): 150 | """ 151 | Send invitation email. 152 | 153 | Both ``email`` and ``site`` parameters are optional. If not supplied 154 | instance's ``email`` field and current site will be used. 155 | 156 | **Templates:** 157 | 158 | :invitation/invitation_email_subject.txt: 159 | Template used to render the email subject. 160 | 161 | **Context:** 162 | 163 | :invitation: ``Invitation`` instance ``send_email`` is called on. 164 | :site: ``Site`` instance to be used. 165 | 166 | :invitation/invitation_email.txt: 167 | Template used to render the email body. 168 | 169 | **Context:** 170 | 171 | :invitation: ``Invitation`` instance ``send_email`` is called on. 172 | :expiration_days: ``INVITATION_EXPIRE_DAYS`` setting. 173 | :site: ``Site`` instance to be used. 174 | 175 | **Signals:** 176 | 177 | ``invitation.signals.invitation_sent`` is sent on completion. 178 | """ 179 | email = email or self.email 180 | if site is None: 181 | if Site._meta.installed: 182 | site = Site.objects.get_current() 183 | elif request is not None: 184 | site = RequestSite(request) 185 | subject = render_to_string('invitation/invitation_email_subject.txt', 186 | {'invitation': self, 'site': site}) 187 | # Email subject *must not* contain newlines 188 | subject = ''.join(subject.splitlines()) 189 | message = render_to_string('invitation/invitation_email.txt', { 190 | 'invitation': self, 191 | 'expiration_days': app_settings.EXPIRE_DAYS, 192 | 'site': site 193 | }) 194 | send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [email]) 195 | signals.invitation_sent.send(sender=self) 196 | 197 | def mark_accepted(self, new_user): 198 | """ 199 | Update sender's invitation statistics and delete self. 200 | 201 | ``invitation.signals.invitation_accepted`` is sent just before the 202 | instance is deleted. 203 | """ 204 | self.user.invitation_stats.mark_accepted() 205 | signals.invitation_accepted.send(sender=self, 206 | inviting_user=self.user, 207 | new_user=new_user) 208 | self.delete() 209 | mark_accepted.alters_data = True 210 | 211 | 212 | class InvitationStatsManager(models.Manager): 213 | def give_invitations(self, user=None, count=None): 214 | rewarded_users = 0 215 | invitations_given = 0 216 | if not isinstance(count, int) and not callable(count): 217 | raise TypeError('Count must be int or callable.') 218 | if user is None: 219 | qs = self.get_query_set() 220 | else: 221 | qs = self.filter(user=user) 222 | for instance in qs: 223 | if callable(count): 224 | c = count(instance.user) 225 | else: 226 | c = count 227 | if c: 228 | instance.add_available(c) 229 | rewarded_users += 1 230 | invitations_given += c 231 | return rewarded_users, invitations_given 232 | 233 | def reward(self, user=None, reward_count=app_settings.INITIAL_INVITATIONS): 234 | def count(user): 235 | if user.invitation_stats.performance >= \ 236 | app_settings.REWARD_THRESHOLD: 237 | return reward_count 238 | return 0 239 | return self.give_invitations(user, count) 240 | 241 | 242 | class InvitationStats(models.Model): 243 | """Store invitation statistics for ``user``. 244 | """ 245 | user = models.OneToOneField(User, 246 | related_name='invitation_stats') 247 | available = models.IntegerField(_(u'available invitations'), 248 | default=app_settings.INITIAL_INVITATIONS) 249 | sent = models.IntegerField(_(u'invitations sent'), default=0) 250 | accepted = models.IntegerField(_(u'invitations accepted'), default=0) 251 | 252 | objects = InvitationStatsManager() 253 | 254 | class Meta: 255 | verbose_name = verbose_name_plural = _(u'invitation stats') 256 | ordering = ('-user',) 257 | 258 | def __unicode__(self): 259 | return _(u'invitation stats for %(username)s') % { 260 | 'username': self.user.username} 261 | 262 | @property 263 | def performance(self): 264 | if app_settings.PERFORMANCE_FUNC: 265 | return app_settings.PERFORMANCE_FUNC(self) 266 | return DEFAULT_PERFORMANCE_CALCULATORS[app_settings.INVITE_ONLY](self) 267 | 268 | def add_available(self, count=1): 269 | """ 270 | Add usable invitations. 271 | 272 | **Optional arguments:** 273 | 274 | :count: 275 | Number of invitations to add. Default is ``1``. 276 | 277 | ``invitation.signals.invitation_added`` is sent at the end. 278 | """ 279 | self.available = models.F('available') + count 280 | self.save() 281 | signals.invitation_added.send(sender=self, user=self.user, count=count) 282 | add_available.alters_data = True 283 | 284 | def use(self, count=1): 285 | """ 286 | Mark invitations used. 287 | 288 | Raises ``InvitationError`` if ``INVITATION_INVITE_ONLY`` is True or 289 | ``count`` is more than available invitations. 290 | 291 | **Optional arguments:** 292 | 293 | :count: 294 | Number of invitations to mark used. Default is ``1``. 295 | """ 296 | if app_settings.INVITE_ONLY: 297 | if self.available - count >= 0: 298 | self.available = models.F('available') - count 299 | else: 300 | raise InvitationError('No available invitations.') 301 | self.sent = models.F('sent') + count 302 | self.save() 303 | use.alters_data = True 304 | 305 | def mark_accepted(self, count=1): 306 | """ 307 | Mark invitations accepted. 308 | 309 | Raises ``InvitationError`` if more invitations than possible is 310 | being accepted. 311 | 312 | **Optional arguments:** 313 | 314 | :count: 315 | Optional. Number of invitations to mark accepted. Default is ``1``. 316 | """ 317 | if self.accepted + count > self.sent: 318 | raise InvitationError('There can\'t be more accepted ' \ 319 | 'invitations than sent invitations.') 320 | self.accepted = models.F('accepted') + count 321 | self.save() 322 | mark_accepted.alters_data = True 323 | 324 | 325 | def create_stats(sender, instance, created, raw, **kwargs): 326 | if created and not raw: 327 | InvitationStats.objects.create(user=instance) 328 | models.signals.post_save.connect(create_stats, 329 | sender=User, 330 | dispatch_uid='invitation.models.create_stats') 331 | -------------------------------------------------------------------------------- /invitation/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | invitation_added = Signal(providing_args=['user', 'count']) 5 | 6 | invitation_sent = Signal() 7 | 8 | invitation_accepted = Signal(providing_args=['inviting_user', 'new_user']) 9 | -------------------------------------------------------------------------------- /invitation/templates/admin/invitation/invitationstats/_reward_link.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if INVITE_ONLY %}
  • 3 | {% blocktrans %}Reward users with good performance{% endblocktrans %} 4 |
  • {% endif %} 5 | -------------------------------------------------------------------------------- /invitation/templates/admin/invitation/invitationstats/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load i18n invitation_tags %} 3 | 4 | {% block object-tools %} 5 | {% if has_add_permission %} 6 | 14 | {% endif %} 15 | {% endblock %} -------------------------------------------------------------------------------- /invitation/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /invitation/templatetags/invitation_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from invitation.app_settings import INVITE_ONLY 3 | 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.inclusion_tag('admin/invitation/invitationstats/_reward_link.html') 9 | def admin_reward_link(): 10 | """ 11 | Adds a reward action if INVITE_ONLY is ``True``. 12 | 13 | Usage:: 14 | 15 | {% admin_reward_link %} 16 | """ 17 | return {'INVITE_ONLY': INVITE_ONLY} 18 | -------------------------------------------------------------------------------- /invitation/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from views import InviteOnlyModeTestCase 2 | from views import InviteOptionalModeTestCase 3 | from models import InvitationTestCase 4 | from models import InvitationStatsInviteOnlyTestCase 5 | from models import InvitationStatsInviteOptionalTestCase 6 | -------------------------------------------------------------------------------- /invitation/tests/invite_only_urls.py: -------------------------------------------------------------------------------- 1 | from django.utils.importlib import import_module 2 | from invitation import app_settings 3 | 4 | 5 | app_settings.INVITE_ONLY = True 6 | reload(import_module('invitation.urls')) 7 | reload(import_module('invitation.tests.urls')) 8 | from invitation.tests.urls import urlpatterns 9 | -------------------------------------------------------------------------------- /invitation/tests/invite_optional_urls.py: -------------------------------------------------------------------------------- 1 | from django.utils.importlib import import_module 2 | from invitation import app_settings 3 | 4 | 5 | app_settings.INVITE_ONLY = False 6 | reload(import_module('invitation.urls')) 7 | reload(import_module('invitation.tests.urls')) 8 | from invitation.tests.urls import urlpatterns 9 | -------------------------------------------------------------------------------- /invitation/tests/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from django.core import mail 3 | from django.contrib.auth.models import User 4 | from utils import BaseTestCase 5 | from invitation import app_settings 6 | from invitation.models import InvitationError, Invitation, InvitationStats 7 | from invitation.models import performance_calculator_invite_only 8 | from invitation.models import performance_calculator_invite_optional 9 | 10 | 11 | EXPIRE_DAYS = app_settings.EXPIRE_DAYS 12 | INITIAL_INVITATIONS = app_settings.INITIAL_INVITATIONS 13 | 14 | 15 | class InvitationTestCase(BaseTestCase): 16 | def setUp(self): 17 | super(InvitationTestCase, self).setUp() 18 | user = self.user() 19 | user.invitation_stats.use() 20 | self.invitation = Invitation.objects.create(user=user, 21 | email=u'test@example.com', 22 | key=u'F' * 40) 23 | 24 | def make_invalid(self, invitation=None): 25 | invitation = invitation or self.invitation 26 | invitation.date_invited = datetime.datetime.now() - \ 27 | datetime.timedelta(EXPIRE_DAYS + 10) 28 | invitation.save() 29 | return invitation 30 | 31 | def test_send_email(self): 32 | self.invitation.send_email() 33 | self.assertEqual(len(mail.outbox), 1) 34 | self.assertEqual(mail.outbox[0].recipients()[0], u'test@example.com') 35 | self.invitation.send_email(u'other@email.org') 36 | self.assertEqual(len(mail.outbox), 2) 37 | self.assertEqual(mail.outbox[1].recipients()[0], u'other@email.org') 38 | 39 | def test_mark_accepted(self): 40 | new_user = User.objects.create_user('test', 'test@example.com', 'test') 41 | pk = self.invitation.pk 42 | self.invitation.mark_accepted(new_user) 43 | self.assertRaises(Invitation.DoesNotExist, 44 | Invitation.objects.get, pk=pk) 45 | 46 | def test_invite(self): 47 | self.user().invitation_stats.add_available(10) 48 | Invitation.objects.all().delete() 49 | invitation = Invitation.objects.invite(self.user(), 'test@example.com') 50 | self.assertEqual(invitation.user, self.user()) 51 | self.assertEqual(invitation.email, 'test@example.com') 52 | self.assertEqual(len(invitation.key), 40) 53 | self.assertEqual(invitation.is_valid(), True) 54 | self.assertEqual(type(invitation.expiration_date()), datetime.date) 55 | # Test if existing valid record is returned 56 | # when we try with the same credentials 57 | self.assertEqual(Invitation.objects.invite(self.user(), 58 | 'test@example.com'), invitation) 59 | # Try with an invalid invitation 60 | invitation = self.make_invalid(invitation) 61 | new_invitation = Invitation.objects.invite(self.user(), 62 | 'test@example.com') 63 | self.assertEqual(new_invitation.is_valid(), True) 64 | self.assertNotEqual(new_invitation, invitation) 65 | 66 | def test_find(self): 67 | self.assertEqual(Invitation.objects.find(self.invitation.key), 68 | self.invitation) 69 | invitation = self.make_invalid() 70 | self.assertEqual(invitation.is_valid(), False) 71 | self.assertRaises(Invitation.DoesNotExist, 72 | Invitation.objects.find, invitation.key) 73 | self.assertEqual(Invitation.objects.all().count(), 0) 74 | self.assertRaises(Invitation.DoesNotExist, 75 | Invitation.objects.find, '') 76 | 77 | 78 | class InvitationStatsBaseTestCase(BaseTestCase): 79 | def stats(self, user=None): 80 | user = user or self.user() 81 | return (user.invitation_stats.available, 82 | user.invitation_stats.sent, 83 | user.invitation_stats.accepted) 84 | 85 | class MockInvitationStats(object): 86 | def __init__(self, available, sent, accepted): 87 | self.available = available 88 | self.sent = sent 89 | self.accepted = accepted 90 | 91 | 92 | class InvitationStatsInviteOnlyTestCase(InvitationStatsBaseTestCase): 93 | def setUp(self): 94 | super(InvitationStatsInviteOnlyTestCase, self).setUp() 95 | app_settings.INVITE_ONLY = True 96 | 97 | def test_default_performance_func(self): 98 | self.assertAlmostEqual(performance_calculator_invite_only( 99 | self.MockInvitationStats(5, 5, 1)), 0.42) 100 | self.assertAlmostEqual(performance_calculator_invite_only( 101 | self.MockInvitationStats(0, 10, 10)), 1.0) 102 | self.assertAlmostEqual(performance_calculator_invite_only( 103 | self.MockInvitationStats(10, 0, 0)), 0.0) 104 | 105 | def test_add_available(self): 106 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS, 0, 0)) 107 | self.user().invitation_stats.add_available() 108 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS + 1, 0, 0)) 109 | self.user().invitation_stats.add_available(10) 110 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS + 11, 0, 0)) 111 | 112 | def test_use(self): 113 | self.user().invitation_stats.add_available(10) 114 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS + 10, 0, 0)) 115 | self.user().invitation_stats.use() 116 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS + 9, 1, 0)) 117 | self.user().invitation_stats.use(5) 118 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS + 4, 6, 0)) 119 | self.assertRaises(InvitationError, 120 | self.user().invitation_stats.use, 121 | INITIAL_INVITATIONS + 5) 122 | 123 | def test_mark_accepted(self): 124 | if INITIAL_INVITATIONS < 10: 125 | i = 10 126 | self.user().invitation_stats.add_available(10-INITIAL_INVITATIONS) 127 | else: 128 | i = INITIAL_INVITATIONS 129 | self.user().invitation_stats.use(i) 130 | self.user().invitation_stats.mark_accepted() 131 | self.assertEqual(self.stats(), (0, i, 1)) 132 | self.user().invitation_stats.mark_accepted(5) 133 | self.assertEqual(self.stats(), (0, i, 6)) 134 | self.assertRaises(InvitationError, 135 | self.user().invitation_stats.mark_accepted, i) 136 | 137 | def test_give_invitations(self): 138 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS, 0, 0)) 139 | InvitationStats.objects.give_invitations(count=3) 140 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS + 3, 0, 0)) 141 | InvitationStats.objects.give_invitations(self.user(), count=3) 142 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS + 6, 0, 0)) 143 | InvitationStats.objects.give_invitations(self.user(), 144 | count=lambda u: 4) 145 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS + 10, 0, 0)) 146 | 147 | def test_reward(self): 148 | self.assertAlmostEqual(self.user().invitation_stats.performance, 0.0) 149 | InvitationStats.objects.reward() 150 | self.assertEqual(self.user().invitation_stats.available, 151 | INITIAL_INVITATIONS) 152 | self.user().invitation_stats.use(INITIAL_INVITATIONS) 153 | self.user().invitation_stats.mark_accepted(INITIAL_INVITATIONS) 154 | InvitationStats.objects.reward() 155 | invitation_stats = self.user().invitation_stats 156 | self.assertEqual(invitation_stats.performance > 0.5, True) 157 | self.assertEqual(invitation_stats.available, INITIAL_INVITATIONS) 158 | 159 | 160 | class InvitationStatsInviteOptionalTestCase(InvitationStatsBaseTestCase): 161 | def setUp(self): 162 | super(InvitationStatsInviteOptionalTestCase, self).setUp() 163 | app_settings.INVITE_ONLY = False 164 | 165 | def test_default_performance_func(self): 166 | self.assertAlmostEqual(performance_calculator_invite_optional( 167 | self.MockInvitationStats(5, 5, 1)), 0.2) 168 | self.assertAlmostEqual(performance_calculator_invite_optional( 169 | self.MockInvitationStats(20, 5, 1)), 0.2) 170 | self.assertAlmostEqual(performance_calculator_invite_optional( 171 | self.MockInvitationStats(0, 5, 1)), 0.2) 172 | self.assertAlmostEqual(performance_calculator_invite_optional( 173 | self.MockInvitationStats(0, 10, 10)), 1.0) 174 | self.assertAlmostEqual(performance_calculator_invite_optional( 175 | self.MockInvitationStats(10, 0, 0)), 0.0) 176 | 177 | def test_use(self): 178 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS, 0, 0)) 179 | self.user().invitation_stats.use() 180 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS, 1, 0)) 181 | self.user().invitation_stats.use(5) 182 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS, 6, 0)) 183 | self.user().invitation_stats.use(INITIAL_INVITATIONS + 5) 184 | self.assertEqual(self.stats(), (INITIAL_INVITATIONS, 185 | INITIAL_INVITATIONS + 11, 186 | 0)) 187 | 188 | def test_mark_accepted(self): 189 | if INITIAL_INVITATIONS < 10: 190 | i = 10 191 | self.user().invitation_stats.add_available(10-INITIAL_INVITATIONS) 192 | else: 193 | i = INITIAL_INVITATIONS 194 | self.user().invitation_stats.use(i) 195 | self.user().invitation_stats.mark_accepted() 196 | self.assertEqual(self.stats(), (i, i, 1)) 197 | self.user().invitation_stats.mark_accepted(5) 198 | self.assertEqual(self.stats(), (i, i, 6)) 199 | self.assertRaises(InvitationError, 200 | self.user().invitation_stats.mark_accepted, i) 201 | self.user().invitation_stats.mark_accepted(4) 202 | self.assertEqual(self.stats(), (i, i, 10)) 203 | 204 | def test_reward(self): 205 | self.assertAlmostEqual(self.user().invitation_stats.performance, 0.0) 206 | InvitationStats.objects.reward() 207 | self.assertEqual(self.user().invitation_stats.available, 208 | INITIAL_INVITATIONS) 209 | self.user().invitation_stats.use(INITIAL_INVITATIONS) 210 | self.user().invitation_stats.mark_accepted(INITIAL_INVITATIONS) 211 | InvitationStats.objects.reward() 212 | invitation_stats = self.user().invitation_stats 213 | self.assertEqual( 214 | invitation_stats.performance > app_settings.REWARD_THRESHOLD, True) 215 | self.assertEqual(invitation_stats.available, INITIAL_INVITATIONS * 2) 216 | -------------------------------------------------------------------------------- /invitation/tests/templates/invitation/invitation_complete.html: -------------------------------------------------------------------------------- 1 | Invitation Complete 2 | -------------------------------------------------------------------------------- /invitation/tests/templates/invitation/invitation_email.txt: -------------------------------------------------------------------------------- 1 | Invitation Email 2 | 3 | http://{{ site.domain }}{{ invitation.get_absolute_url }} 4 | -------------------------------------------------------------------------------- /invitation/tests/templates/invitation/invitation_email_subject.txt: -------------------------------------------------------------------------------- 1 | Invitation 2 | Subject 3 | -------------------------------------------------------------------------------- /invitation/tests/templates/invitation/invitation_form.html: -------------------------------------------------------------------------------- 1 | Invitation Form 2 | 3 | {{ form }} 4 | -------------------------------------------------------------------------------- /invitation/tests/templates/invitation/invitation_home.html: -------------------------------------------------------------------------------- 1 | Invitation Home 2 | -------------------------------------------------------------------------------- /invitation/tests/templates/invitation/invitation_registered.html: -------------------------------------------------------------------------------- 1 | Invitation Home 2 | -------------------------------------------------------------------------------- /invitation/tests/templates/invitation/invitation_unavailable.html: -------------------------------------------------------------------------------- 1 | Invitation Unavailable 2 | -------------------------------------------------------------------------------- /invitation/tests/templates/invitation/invite_only.html: -------------------------------------------------------------------------------- 1 | Invite Only -------------------------------------------------------------------------------- /invitation/tests/templates/invitation/wrong_invitation_key.html: -------------------------------------------------------------------------------- 1 | Wrong Invitation Key -------------------------------------------------------------------------------- /invitation/tests/templates/registration/registration_complete.html: -------------------------------------------------------------------------------- 1 | Registration Complete 2 | -------------------------------------------------------------------------------- /invitation/tests/templates/registration/registration_form.html: -------------------------------------------------------------------------------- 1 | Registration Form 2 | 3 | {{ form }} 4 | -------------------------------------------------------------------------------- /invitation/tests/templates/registration/registration_register.html: -------------------------------------------------------------------------------- 1 | Registration Register 2 | -------------------------------------------------------------------------------- /invitation/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | import invitation.urls as invitation_urls 3 | 4 | 5 | urlpatterns = invitation_urls.urlpatterns + patterns('', 6 | url(r'^register/$', 7 | 'django.views.generic.simple.direct_to_template', 8 | {'template': 'registration/registration_register.html'}, 9 | name='registration_register'), 10 | url(r'^register/complete/$', 11 | 'django.views.generic.simple.direct_to_template', 12 | {'template': 'registration/registration_complete.html'}, 13 | name='registration_complete'), 14 | ) 15 | -------------------------------------------------------------------------------- /invitation/tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.conf import settings 3 | from django.test import TestCase 4 | from django.contrib.auth.models import User 5 | 6 | 7 | class BaseTestCase(TestCase): 8 | def setUp(self): 9 | super(BaseTestCase, self).setUp() 10 | settings.TEMPLATE_DIRS = ( 11 | os.path.join(os.path.dirname(__file__), 'templates'), 12 | ) 13 | User.objects.create_user('testuser', 14 | 'testuser@example.com', 15 | 'testuser') 16 | 17 | def user(self): 18 | return User.objects.get(username='testuser') 19 | -------------------------------------------------------------------------------- /invitation/tests/views.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import reverse 2 | from django.core import mail 3 | from django.contrib.auth.models import User 4 | from utils import BaseTestCase 5 | from invitation.models import Invitation 6 | 7 | 8 | class InviteOnlyModeTestCase(BaseTestCase): 9 | urls = 'invitation.tests.invite_only_urls' 10 | 11 | def test_invation_mode(self): 12 | # Normal registration view should redirect 13 | response = self.client.get(reverse('registration_register')) 14 | self.assertRedirects(response, reverse('invitation_invite_only')) 15 | # But registration after invitation view should work 16 | response = self.client.get(reverse('invitation_register', 17 | args=('A' * 40,))) 18 | self.assertEqual(response.status_code, 200) 19 | 20 | def test_invitation(self): 21 | available = self.user().invitation_stats.available 22 | self.client.login(username='testuser', password='testuser') 23 | response = self.client.post(reverse('invitation_invite'), 24 | {'email': 'friend@example.com'}) 25 | self.assertRedirects(response, reverse('invitation_complete')) 26 | self.assertEqual(self.user().invitation_stats.available, available-1) 27 | # Delete previously created invitation and 28 | # set available invitations count to 0. 29 | Invitation.objects.all().delete() 30 | invitation_stats = self.user().invitation_stats 31 | invitation_stats.available = 0 32 | invitation_stats.save() 33 | del(invitation_stats) 34 | response = self.client.post(reverse('invitation_invite'), 35 | {'email': 'friend@example.com'}) 36 | self.assertRedirects(response, reverse('invitation_unavailable')) 37 | 38 | def test_registration(self): 39 | # Make sure error message is shown in 40 | # case of an invalid invitation key 41 | response = self.client.get(reverse('invitation_register', 42 | args=('A' * 40,))) 43 | self.assertEqual(response.status_code, 200) 44 | self.assertTemplateUsed(response, 45 | 'invitation/wrong_invitation_key.html') 46 | # Registration with an invitation 47 | invitation = Invitation.objects.invite(self.user(), 48 | 'friend@example.com') 49 | register_url = reverse('invitation_register', args=(invitation.key,)) 50 | response = self.client.get(register_url) 51 | self.assertEqual(response.status_code, 200) 52 | self.assertTemplateUsed(response, 53 | 'registration/registration_form.html') 54 | self.assertContains(response, invitation.email) 55 | # We are posting a different email than the 56 | # invitation.email but the form should just 57 | # ignore it and register with invitation.email 58 | response = self.client.post(register_url, 59 | {'username': u'friend', 60 | 'email': u'noone@example.com', 61 | 'password1': u'friend', 62 | 'password2': u'friend'}) 63 | self.assertRedirects(response, reverse('invitation_registered')) 64 | self.assertEqual(len(mail.outbox), 0) # No confirmation email 65 | self.assertEqual(self.user().invitation_stats.accepted, 1) 66 | new_user = User.objects.get(username='friend') 67 | self.assertEqual(new_user.is_active, True) 68 | self.assertRaises(Invitation.DoesNotExist, 69 | Invitation.objects.get, 70 | user=self.user(), 71 | email='friend@example.com') 72 | 73 | 74 | class InviteOptionalModeTestCase(BaseTestCase): 75 | urls = 'invitation.tests.invite_optional_urls' 76 | 77 | def test_invation_mode(self): 78 | # Normal registration view should work 79 | response = self.client.get(reverse('registration_register')) 80 | self.assertEqual(response.status_code, 200) 81 | self.assertTemplateUsed(response, 82 | 'registration/registration_register.html') 83 | # So as registration after invitation view 84 | response = self.client.get(reverse('invitation_register', 85 | args=('A' * 40,))) 86 | self.assertEqual(response.status_code, 200) 87 | 88 | def test_invitation(self): 89 | self.client.login(username='testuser', password='testuser') 90 | response = self.client.get(reverse('invitation_invite')) 91 | self.assertEqual(response.status_code, 200) 92 | response = self.client.post(reverse('invitation_invite'), 93 | {'email': 'friend@example.com'}) 94 | self.assertRedirects(response, reverse('invitation_complete')) 95 | invitation_query = Invitation.objects.filter(user=self.user(), 96 | email='friend@example.com') 97 | self.assertEqual(invitation_query.count(), 1) 98 | self.assertEqual(len(mail.outbox), 1) 99 | self.assertEqual(self.user().invitation_stats.sent, 1) 100 | -------------------------------------------------------------------------------- /invitation/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | from django.views.generic.simple import direct_to_template 3 | from django.contrib.auth.decorators import login_required 4 | from app_settings import INVITE_ONLY 5 | 6 | 7 | login_required_direct_to_template = login_required(direct_to_template) 8 | 9 | 10 | urlpatterns = patterns('', 11 | url(r'^invitation/$', 12 | login_required_direct_to_template, 13 | {'template': 'invitation/invitation_home.html'}, 14 | name='invitation_home'), 15 | url(r'^invitation/invite/$', 16 | 'invitation.views.invite', 17 | name='invitation_invite'), 18 | url(r'^invitation/invite/complete/$', 19 | login_required_direct_to_template, 20 | {'template': 'invitation/invitation_complete.html'}, 21 | name='invitation_complete'), 22 | url(r'^invitation/invite/unavailable/$', 23 | login_required_direct_to_template, 24 | {'template': 'invitation/invitation_unavailable.html'}, 25 | name='invitation_unavailable'), 26 | url(r'^invitation/accept/complete/$', 27 | direct_to_template, 28 | {'template': 'invitation/invitation_registered.html'}, 29 | name='invitation_registered'), 30 | url(r'^invitation/accept/(?P\w+)/$', 31 | 'invitation.views.register', 32 | name='invitation_register'), 33 | ) 34 | 35 | 36 | if INVITE_ONLY: 37 | urlpatterns += patterns('', 38 | url(r'^register/$', 39 | 'django.views.generic.simple.redirect_to', 40 | {'url': '../invitation/invite_only/', 'permanent': False}, 41 | name='registration_register'), 42 | url(r'^invitation/invite_only/$', 43 | direct_to_template, 44 | {'template': 'invitation/invite_only.html'}, 45 | name='invitation_invite_only'), 46 | url(r'^invitation/reward/$', 47 | 'invitation.views.reward', 48 | name='invitation_reward'), 49 | ) 50 | -------------------------------------------------------------------------------- /invitation/views.py: -------------------------------------------------------------------------------- 1 | from django.core.urlresolvers import reverse 2 | from django.http import HttpResponseRedirect 3 | from django.template import RequestContext 4 | from django.shortcuts import render_to_response 5 | from django.utils.translation import ugettext 6 | from django.contrib.auth.decorators import login_required 7 | from django.contrib.admin.views.decorators import staff_member_required 8 | from models import InvitationError, Invitation, InvitationStats 9 | from forms import InvitationForm, RegistrationFormInvitation 10 | from registration.signals import user_registered 11 | 12 | 13 | def apply_extra_context(context, extra_context=None): 14 | if extra_context is None: 15 | extra_context = {} 16 | for key, value in extra_context.items(): 17 | context[key] = callable(value) and value() or value 18 | return context 19 | 20 | 21 | @login_required 22 | def invite(request, success_url=None, 23 | form_class=InvitationForm, 24 | template_name='invitation/invitation_form.html', 25 | extra_context=None): 26 | """ 27 | Create an invitation and send invitation email. 28 | 29 | Send invitation email and then redirect to success URL if the 30 | invitation form is valid. Redirect named URL ``invitation_unavailable`` 31 | on InvitationError. Render invitation form template otherwise. 32 | 33 | **Required arguments:** 34 | 35 | None. 36 | 37 | **Optional arguments:** 38 | 39 | :success_url: 40 | The URL to redirect to on successful registration. Default value is 41 | ``None``, ``invitation_complete`` will be resolved in this case. 42 | 43 | :form_class: 44 | A form class to use for invitation. Takes ``request.user`` as first 45 | argument to its constructor. Must have an ``email`` field. Custom 46 | validation can be implemented here. 47 | 48 | :template_name: 49 | A custom template to use. Default value is 50 | ``invitation/invitation_form.html``. 51 | 52 | :extra_context: 53 | A dictionary of variables to add to the template context. Any 54 | callable object in this dictionary will be called to produce 55 | the end result which appears in the context. 56 | 57 | **Template:** 58 | 59 | ``invitation/invitation_form.html`` or ``template_name`` keyword 60 | argument. 61 | 62 | **Context:** 63 | 64 | A ``RequestContext`` instance is used rendering the template. Context, 65 | in addition to ``extra_context``, contains: 66 | 67 | :form: 68 | The invitation form. 69 | """ 70 | if request.method == 'POST': 71 | form = form_class(request.POST, request.FILES) 72 | if form.is_valid(): 73 | try: 74 | invitation = Invitation.objects.invite( 75 | request.user, form.cleaned_data["email"]) 76 | except InvitationError: 77 | return HttpResponseRedirect(reverse('invitation_unavailable')) 78 | invitation.send_email(request=request) 79 | return HttpResponseRedirect(success_url or \ 80 | reverse('invitation_complete')) 81 | else: 82 | form = form_class() 83 | context = apply_extra_context(RequestContext(request), extra_context) 84 | return render_to_response(template_name, 85 | {'form': form}, 86 | context_instance=context) 87 | 88 | 89 | def register(request, 90 | invitation_key, 91 | wrong_key_template='invitation/wrong_invitation_key.html', 92 | redirect_to_if_authenticated='/', 93 | success_url=None, 94 | form_class=RegistrationFormInvitation, 95 | template_name='registration/registration_form.html', 96 | extra_context=None): 97 | """ 98 | Allow a new user to register via invitation. 99 | 100 | Send invitation email and then redirect to success URL if the 101 | invitation form is valid. Redirect named URL ``invitation_unavailable`` 102 | on InvitationError. Render invitation form template otherwise. Sends 103 | registration.signals.user_registered after creating the user. 104 | 105 | **Required arguments:** 106 | 107 | :invitation_key: 108 | An invitation key in the form of ``[\da-e]{40}`` 109 | 110 | **Optional arguments:** 111 | 112 | :wrong_key_template: 113 | Template to be used when an invalid invitation key is supplied. 114 | Default value is ``invitation/wrong_invitation_key.html``. 115 | 116 | :redirect_to_if_authenticated: 117 | URL to be redirected when an authenticated user calls this view. 118 | Defaults value is ``/`` 119 | 120 | :success_url: 121 | The URL to redirect to on successful registration. Default value is 122 | ``None``, ``invitation_registered`` will be resolved in this case. 123 | 124 | :form_class: 125 | A form class to use for registration. Takes the invited email as first 126 | argument to its constructor. 127 | 128 | :template_name: 129 | A custom template to use. Default value is 130 | ``registration/registration_form.html``. 131 | 132 | :extra_context: 133 | A dictionary of variables to add to the template context. Any 134 | callable object in this dictionary will be called to produce 135 | the end result which appears in the context. 136 | 137 | **Templates:** 138 | 139 | ``invitation/invitation_form.html`` or ``template_name`` keyword 140 | argument as the *main template*. 141 | 142 | ``invitation/wrong_invitation_key.html`` or ``wrong_key_template`` keyword 143 | argument as the *wrong key template*. 144 | 145 | **Context:** 146 | 147 | ``RequestContext`` instances are used rendering both templates. Context, 148 | in addition to ``extra_context``, contains: 149 | 150 | For wrong key template 151 | :invitation_key: supplied invitation key 152 | 153 | For main template 154 | :form: 155 | The registration form. 156 | """ 157 | if request.user.is_authenticated(): 158 | return HttpResponseRedirect(redirect_to_if_authenticated) 159 | try: 160 | invitation = Invitation.objects.find(invitation_key) 161 | except Invitation.DoesNotExist: 162 | context = apply_extra_context(RequestContext(request), extra_context) 163 | return render_to_response(wrong_key_template, 164 | {'invitation_key': invitation_key}, 165 | context_instance=context) 166 | if request.method == 'POST': 167 | form = form_class(invitation.email, request.POST, request.FILES) 168 | if form.is_valid(): 169 | new_user = form.save() 170 | invitation.mark_accepted(new_user) 171 | user_registered.send(sender="invitation", 172 | user=new_user, 173 | request=request) 174 | return HttpResponseRedirect(success_url or \ 175 | reverse('invitation_registered')) 176 | else: 177 | form = form_class(invitation.email) 178 | context = apply_extra_context(RequestContext(request), extra_context) 179 | return render_to_response(template_name, 180 | {'form': form}, 181 | context_instance=context) 182 | 183 | 184 | @staff_member_required 185 | def reward(request): 186 | """ 187 | Add invitations to users with high invitation performance and redirect 188 | refferring page. 189 | """ 190 | rewarded_users, invitations_given = InvitationStats.objects.reward() 191 | if rewarded_users: 192 | message = ugettext(u'%(users)s users are given a total of ' \ 193 | u'%(invitations)s invitations.') % { 194 | 'users': rewarded_users, 195 | 'invitations': invitations_given} 196 | else: 197 | message = ugettext(u'No user has performance above ' \ 198 | u'threshold, no invitations awarded.') 199 | request.user.message_set.create(message=message) 200 | return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/')) 201 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from distutils.core import setup 4 | from invitation import __version__, __maintainer__, __email__ 5 | 6 | 7 | def compile_translations(): 8 | try: 9 | from django.core.management.commands.compilemessages \ 10 | import compile_messages 11 | except ImportError: 12 | return None 13 | curdir = os.getcwdu() 14 | os.chdir(os.path.join(os.path.dirname(__file__), 'invitation')) 15 | try: 16 | compile_messages(stderr=sys.stderr) 17 | except TypeError: 18 | # compile_messages doesn't accept stderr parameter prior to 1.2.4 19 | compile_messages() 20 | os.chdir(curdir) 21 | compile_translations() 22 | 23 | 24 | license_text = open('LICENSE.txt').read() 25 | long_description = open('README.rst').read() 26 | 27 | 28 | setup( 29 | name = 'django-inviting', 30 | version = __version__, 31 | url = 'http://github.com/muhuk/django-inviting', 32 | author = __maintainer__, 33 | author_email = __email__, 34 | license = license_text, 35 | packages = ['invitation', 36 | 'invitation.tests', 37 | 'invitation.templatetags'], 38 | package_data= { 39 | 'invitation': ['templates/admin/invitation/invitationstats/*', 40 | 'tests/templates/invitations/*', 41 | 'tests/templates/registration/*', 42 | 'locale/*/LC_MESSAGES/django.*'] 43 | }, 44 | data_files=[('', ['LICENSE.txt', 45 | 'README.rst'])], 46 | description = 'Registration through invitations', 47 | long_description=long_description, 48 | classifiers = ['Development Status :: 5 - Production/Stable', 49 | 'Environment :: Web Environment', 50 | 'Framework :: Django', 51 | 'Intended Audience :: Developers', 52 | 'License :: OSI Approved :: BSD License', 53 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content'] 54 | ) 55 | --------------------------------------------------------------------------------