Commits

uniqx committed 00d983e Merge

mörtsch

  • Participants
  • Parent commits 21a59e6, 83468b2

Comments (0)

Files changed (22)

File freieit/templates/login_form.html

       Passwort: <input type="password" name="password"><br />
       <input type="submit" value="anmelden">
     </form>
-    Falls Sie noch kein Benutzerkonto haben können sie sich <a href="/accounts/register">hier registieren</a>.
+    
+    {% if INVITE_ONLY %}
+        Um einen neuen Account anzulegen benötigen sie eine Einladung eines Mitglieds.
+    {% else %}
+        Falls Sie noch kein Benutzerkonto haben können sie sich <a href="/accounts/register">hier registieren</a>.
+    {% endif %}
   </div>
 
 </section>

File freieit/urls.py

   url(r'^login$',                       'freieit.views.login.show'),
   url(r'^login$',                       login, name='login'),
   url(r'^accounts/login/$',             login),
-  url(r'^logout$',                      logout, name='logout'),
-  url(r'^accounts/logout/$',            logout),
+  url(r'^logout$',                      logout),
+  url(r'^accounts/logout/$',            logout, name='logout'),
   #url(r'^register$',                    'freieit.views.register.show'),
   url(r'^profile$',                     'freieit.views.profile.show'),
   url(r'^expert/(?P<expert>[\w_\-]+)$', 'freieit.views.expert.show'),

File freieit/views/login.py

 from django.http import HttpResponse
 from django.shortcuts import render_to_response
 from django.contrib.auth import authenticate, login, logout
+from freieit import settings
 
 from django.core.context_processors import csrf
 
       content.update({'login_error': True})
       return render_to_response('login_form.html',content)
 
-  return render_to_response('login_form.html',csrf(request))
+  content = csrf(request)
+  content['INVITE_ONLY'] = settings.INVITE_MODE
+  return render_to_response('login_form.html', content)
 
 def do_logout(request):
   logout(request)

File invitation/admin.py

 from django.contrib import admin
 from invitation.models import InvitationKey, InvitationUser
 
+
 class InvitationKeyAdmin(admin.ModelAdmin):
     list_display = ('__unicode__', 'from_user', 'date_invited', 'key_expired')
 
+
 class InvitationUserAdmin(admin.ModelAdmin):
     list_display = ('inviter', 'invitations_remaining')
 

File invitation/backends.py

 from registration.backends.default import DefaultBackend
 from invitation.models import InvitationKey
 
+
 class InvitationBackend(DefaultBackend):
 
     def post_registration_redirect(self, request, user, *args, **kwargs):

File invitation/forms.py

 from django import forms
 
+
 class InvitationKeyForm(forms.Form):
     email = forms.EmailField()
-    

File invitation/models.py

 from django.utils.http import int_to_base36
 from django.utils.hashcompat import sha_constructor
 from django.utils.translation import ugettext_lazy as _
+from django.utils.timezone import utc
 from django.contrib.auth.models import User
 from django.core.mail import send_mail
 from django.template.loader import render_to_string
 
 from registration.models import SHA1_RE
 
+
+def utcnow_aware():
+    """ Timezone-aware replacement for datetime.datetime.now. """
+    return datetime.datetime.utcnow().replace(tzinfo=utc)
+
+
 class InvitationKeyManager(models.Manager):
     def get_key(self, invitation_key):
         """
         # Don't bother hitting database if invitation_key doesn't match pattern.
         if not SHA1_RE.search(invitation_key):
             return None
-        
+
         try:
             key = self.get(key=invitation_key)
         except self.model.DoesNotExist:
             return None
-        
+
         return key
-        
+
     def is_key_valid(self, invitation_key):
         """
         Check if an ``InvitationKey`` is valid or not, returning a boolean,
     def create_invitation(self, user):
         """
         Create an ``InvitationKey`` and returns it.
-        
-        The key for the ``InvitationKey`` will be a SHA1 hash, generated 
+
+        The key for the ``InvitationKey`` will be a SHA1 hash, generated
         from a combination of the ``User``'s username and a random salt.
         """
         salt = sha_constructor(str(random.random())).hexdigest()[:5]
-        key = sha_constructor("%s%s%s" % (datetime.datetime.now(), salt, user.username)).hexdigest()
+        key = sha_constructor("%s%s%s" % (utcnow_aware(), salt, user.username,)).hexdigest()
         return self.create(from_user=user, key=key)
 
     def remaining_invitations_for_user(self, user):
 
 class InvitationKey(models.Model):
     key = models.CharField(_('invitation key'), max_length=40)
-    date_invited = models.DateTimeField(_('date invited'), 
-                                        default=datetime.datetime.now)
-    from_user = models.ForeignKey(User, 
+    date_invited = models.DateTimeField(_('date invited'),
+                                        default=utcnow_aware)
+    from_user = models.ForeignKey(User,
                                   related_name='invitations_sent')
-    registrant = models.ForeignKey(User, null=True, blank=True, 
+    registrant = models.ForeignKey(User, null=True, blank=True,
                                   related_name='invitations_used')
-    
+
     objects = InvitationKeyManager()
-    
+
     def __unicode__(self):
         return u"Invitation from %s on %s" % (self.from_user.username, self.date_invited)
-    
+
     def is_usable(self):
         """
-        Return whether this key is still valid for registering a new user.        
+        Return whether this key is still valid for registering a new user.
         """
         return self.registrant is None and not self.key_expired()
-    
+
     def key_expired(self):
         """
-        Determine whether this ``InvitationKey`` has expired, returning 
+        Determine whether this ``InvitationKey`` has expired, returning
         a boolean -- ``True`` if the key has expired.
-        
-        The date the key has been created is incremented by the number of days 
-        specified in the setting ``ACCOUNT_INVITATION_DAYS`` (which should be 
+
+        The date the key has been created is incremented by the number of days
+        specified in the setting ``ACCOUNT_INVITATION_DAYS`` (which should be
         the number of days after invite during which a user is allowed to
-        create their account); if the result is less than or equal to the 
+        create their account); if the result is less than or equal to the
         current date, the key has expired and this method returns ``True``.
-        
+
         """
         expiration_date = datetime.timedelta(days=settings.ACCOUNT_INVITATION_DAYS)
-        return self.date_invited + expiration_date <= datetime.datetime.now()
+        return self.date_invited + expiration_date <= utcnow_aware()
     key_expired.boolean = True
-    
+
     def mark_used(self, registrant):
         """
         Note that this key has been used to register a new user.
         """
         self.registrant = registrant
         self.save()
-        
+
     def send_to(self, email):
         """
         Send an invitation email to ``email``.
         """
         current_site = Site.objects.get_current()
-        
-        subject = render_to_string('invitation/invitation_email_subject.txt',
-                                   { 'site': current_site, 
-                                     'invitation_key': self })
-        # Email subject *must not* contain newlines
+
+        subject = render_to_string(
+                'invitation/invitation_email_subject.txt',
+                {
+                    'site': current_site,
+                    'invitation_key': self,
+                    })
+        # Email subject *must not* contain newlines.
         subject = ''.join(subject.splitlines())
-        
-        message = render_to_string('invitation/invitation_email.txt',
-                                   { 'invitation_key': self,
-                                     'expiration_days': settings.ACCOUNT_INVITATION_DAYS,
-                                     'site': current_site })
-        
+
+        message = render_to_string(
+                'invitation/invitation_email.txt',
+                {
+                    'invitation_key': self,
+                    'expiration_days': settings.ACCOUNT_INVITATION_DAYS,
+                    'site': current_site,
+                    })
+
         send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [email])
 
-        
+
 class InvitationUser(models.Model):
     inviter = models.ForeignKey(User, unique=True)
     invitations_remaining = models.IntegerField()
     def __unicode__(self):
         return u"InvitationUser for %s" % self.inviter.username
 
-    
+
 def user_post_save(sender, instance, created, **kwargs):
     """Create InvitationUser for user when User is created."""
     if created:
 
 models.signals.post_save.connect(user_post_save, sender=User)
 
+
 def invitation_key_post_save(sender, instance, created, **kwargs):
     """Decrement invitations_remaining when InvitationKey is created."""
     if created:
         invitation_user = InvitationUser.objects.get(inviter=instance.from_user)
         remaining = invitation_user.invitations_remaining
-        invitation_user.invitations_remaining = remaining-1
+        invitation_user.invitations_remaining = remaining - 1
         invitation_user.save()
 
 models.signals.post_save.connect(invitation_key_post_save, sender=InvitationKey)
-

File invitation/tests/__init__.py

+"""
+Unit tests for django-invitation.
+
+These tests assume that you've completed all the prerequisites for
+getting django-invitation running in the default setup, to wit:
+
+1. You have ``invitation`` in your ``INSTALLED_APPS`` setting.
+
+2. You have created all of the templates mentioned in this
+   application's documentation.
+
+3. You have added the setting ``ACCOUNT_INVITATION_DAYS`` to your
+   settings file.
+
+4. You have URL patterns pointing to the invitation views.
+
+"""
+
+import os
+import datetime
+import sha
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.core import mail
+from django.core import management
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+
+from invitation import forms
+from invitation.models import InvitationKey, InvitationUser
+
+
+class InvitationTestCase(TestCase):
+    """
+    Base class for the test cases.
+
+    This sets up one user and two keys -- one expired, one not -- which are
+    used to exercise various parts of the application.
+
+    """
+    def setUp(self):
+
+        self.saved_invite_mode = settings.INVITE_MODE
+        # INVITE_MODE == True is the expected default for invitation
+        # tests. InviteModeOffTests explicitly tests the alternative.
+        settings.INVITE_MODE = True
+        self.saved_template_dirs = getattr(settings, 'TEMPLATE_DIRS')
+        settings.TEMPLATE_DIRS = (os.path.join(os.path.dirname(__file__), 'templates'),)
+
+        self.sample_user = User.objects.create_user(username='alice',
+                                                    password='secret',
+                                                    email='alice@example.com')
+        self.sample_key = InvitationKey.objects.create_invitation(user=self.sample_user)
+        self.expired_key = InvitationKey.objects.create_invitation(user=self.sample_user)
+        self.expired_key.date_invited -= datetime.timedelta(days=settings.ACCOUNT_INVITATION_DAYS + 1)
+        self.expired_key.save()
+
+        self.sample_registration_data = {
+            'invitation_key': self.sample_key.key,
+            'username': 'new_user',
+            'email': 'newbie@example.com',
+            'password1': 'secret',
+            'password2': 'secret',
+            'tos': '1'}
+
+    def tearDown(self):
+        settings.INVITE_MODE = self.saved_invite_mode
+        settings.TEMPLATE_DIRS = self.saved_template_dirs
+        super(InvitationTestCase, self).tearDown()
+
+    def assertRedirect(self, response, viewname):
+        """Assert that response has been redirected to ``viewname``."""
+        self.assertEqual(response.status_code, 302)
+        expected_location = 'http://testserver' + reverse(viewname)
+        self.assertEqual(response['Location'], expected_location)
+
+
+class InvitationModelTests(InvitationTestCase):
+    """
+    Tests for the model-oriented functionality of django-invitation.
+
+    """
+    def test_invitation_key_created(self):
+        """
+        Test that a ``InvitationKey`` is created for a new key.
+
+        """
+        self.assertEqual(InvitationKey.objects.count(), 2)
+
+    def test_invitation_email(self):
+        """
+        Test that ``InvitationKey.send_to`` sends an invitation email.
+
+        """
+        self.sample_key.send_to('bob@example.com')
+        self.assertEqual(len(mail.outbox), 1)
+
+    def test_key_expiration_condition(self):
+        """
+        Test that ``InvitationKey.key_expired()`` returns ``True`` for expired
+        keys, and ``False`` otherwise.
+
+        """
+        # Unexpired user returns False.
+        self.failIf(self.sample_key.key_expired())
+
+        # Expired user returns True.
+        self.failUnless(self.expired_key.key_expired())
+
+    def test_expired_user_deletion(self):
+        """
+        Test ``InvitationKey.objects.delete_expired_keys()``.
+
+        Only keys whose expiration date has passed are deleted by
+        delete_expired_keys.
+
+        """
+        InvitationKey.objects.delete_expired_keys()
+        self.assertEqual(InvitationKey.objects.count(), 1)
+
+    def test_management_command(self):
+        """
+        Test that ``manage.py cleanupinvitation`` functions correctly.
+
+        """
+        management.call_command('cleanupinvitation')
+        self.assertEqual(InvitationKey.objects.count(), 1)
+
+    def test_invitations_remaining(self):
+        """Test InvitationUser calculates remaining invitations properly."""
+        remaining_invites = InvitationKey.objects.remaining_invitations_for_user
+
+        # New user starts with settings.INVITATIONS_PER_USER
+        user = User.objects.create_user(username='newbie',
+                                        password='secret',
+                                        email='newbie@example.com')
+        self.assertEqual(remaining_invites(user), settings.INVITATIONS_PER_USER)
+
+        # After using some, amount remaining is decreased
+        used = InvitationKey.objects.filter(from_user=self.sample_user).count()
+        expected_remaining = settings.INVITATIONS_PER_USER - used
+        remaining = remaining_invites(self.sample_user)
+        self.assertEqual(remaining, expected_remaining)
+
+        # Using Invitationuser via Admin, remaining can be increased
+        invitation_user = InvitationUser.objects.get(inviter=self.sample_user)
+        new_remaining = 2 * settings.INVITATIONS_PER_USER + 1
+        invitation_user.invitations_remaining = new_remaining
+        invitation_user.save()
+        remaining = remaining_invites(self.sample_user)
+        self.assertEqual(remaining, new_remaining)
+
+        # If no InvitationUser (for pre-existing/legacy User), one is created
+        old_sample_user = User.objects.create_user(username='lewis',
+                                                   password='secret',
+                                                   email='lewis@example.com')
+        old_sample_user.invitationuser_set.all().delete()
+        self.assertEqual(old_sample_user.invitationuser_set.count(), 0)
+        remaining = remaining_invites(old_sample_user)
+        self.assertEqual(remaining, settings.INVITATIONS_PER_USER)
+
+
+class InvitationFormTests(InvitationTestCase):
+    """
+    Tests for the forms and custom validation logic included in
+    django-invitation.
+
+    """
+    def test_invitation_form(self):
+        """
+        Test that ``InvitationKeyForm`` enforces email constraints.
+
+        """
+        invalid_data_dicts = [
+            # Invalid email.
+            {
+                'data': {'email': 'example.com'},
+                'error': ('email', [u"Enter a valid e-mail address."])
+            },
+            ]
+
+        for invalid_dict in invalid_data_dicts:
+            form = forms.InvitationKeyForm(data=invalid_dict['data'])
+            self.failIf(form.is_valid())
+            self.assertEqual(form.errors[invalid_dict['error'][0]], invalid_dict['error'][1])
+
+        form = forms.InvitationKeyForm(data={'email': 'foo@example.com'})
+        self.failUnless(form.is_valid())
+
+
+class InvitationViewTests(InvitationTestCase):
+    """
+    Tests for the views included in django-invitation.
+
+    """
+    urls = 'invitation.tests.urls'
+
+    def test_invitation_view(self):
+        """
+        Test that the invitation view rejects invalid submissions,
+        and creates a new key and redirects after a valid submission.
+
+        """
+        # You need to be logged in to send an invite.
+        response = self.client.login(username='alice', password='secret')
+        remaining_invitations = InvitationKey.objects.remaining_invitations_for_user(self.sample_user)
+
+        # Invalid email data fails.
+        response = self.client.post(reverse('invitation_invite'),
+                                    data={'email': 'example.com'})
+        self.assertEqual(response.status_code, 200)
+        self.failUnless(response.context['form'])
+        self.failUnless(response.context['form'].errors)
+
+        # Valid email data succeeds.
+        response = self.client.post(reverse('invitation_invite'),
+                                    data={'email': 'foo@example.com'})
+        self.assertRedirect(response, 'invitation_complete')
+        self.assertEqual(InvitationKey.objects.count(), 3)
+        self.assertEqual(InvitationKey.objects.remaining_invitations_for_user(self.sample_user), remaining_invitations - 1)
+
+        # Once remaining invitations exhausted, you fail again.
+        while InvitationKey.objects.remaining_invitations_for_user(self.sample_user) > 0:
+            self.client.post(reverse('invitation_invite'),
+                             data={'email': 'foo@example.com'})
+        self.assertEqual(InvitationKey.objects.remaining_invitations_for_user(self.sample_user), 0)
+        response = self.client.post(reverse('invitation_invite'),
+                                    data={'email': 'foo@example.com'})
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.context['remaining_invitations'], 0)
+        self.failUnless(response.context['form'])
+
+    def test_invited_view(self):
+        """
+        Test that the invited view invite the user from a valid
+        key and fails if the key is invalid or has expired.
+
+        """
+        # Valid key puts use the invited template.
+        response = self.client.get(reverse('invitation_invited',
+                                           kwargs={'invitation_key': self.sample_key.key}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'invitation/invited.html')
+
+        # Expired key use the wrong key template.
+        response = self.client.get(reverse('invitation_invited',
+                                           kwargs={'invitation_key': self.expired_key.key}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'invitation/wrong_invitation_key.html')
+
+        # Invalid key use the wrong key template.
+        response = self.client.get(reverse('invitation_invited',
+                                           kwargs={'invitation_key': 'foo'}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'invitation/wrong_invitation_key.html')
+
+        # Nonexistent key use the wrong key template.
+        response = self.client.get(reverse('invitation_invited',
+                                           kwargs={'invitation_key': sha.new('foo').hexdigest()}))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'invitation/wrong_invitation_key.html')
+
+    def test_register_view(self):
+        """
+        Test that after registration a key cannot be reused.
+
+        """
+        # The first use of the key to register a new user works.
+        registration_data = self.sample_registration_data.copy()
+        response = self.client.post(reverse('registration_register'),
+                                    data=registration_data)
+        self.assertRedirect(response, 'registration_complete')
+        user = User.objects.get(username='new_user')
+        key = InvitationKey.objects.get_key(self.sample_key.key)
+        self.assertEqual(user, key.registrant)
+
+        # Trying to reuse the same key then fails.
+        registration_data['username'] = 'even_newer_user'
+        response = self.client.post(reverse('registration_register'),
+                                    data=registration_data)
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response,
+                                'invitation/wrong_invitation_key.html')
+        try:
+            even_newer_user = User.objects.get(username='even_newer_user')
+            self.fail("Invitation already used - No user should be created.")
+        except User.DoesNotExist:
+            pass
+
+
+class InviteModeOffTests(InvitationTestCase):
+    """
+    Tests for the case where INVITE_MODE is False.
+
+    (The test cases other than this one generally assume that INVITE_MODE is
+    True.)
+
+    """
+    def setUp(self):
+        super(InviteModeOffTests, self).setUp()
+        settings.INVITE_MODE = False
+
+    def test_invited_view(self):
+        """
+        Test that the invited view redirects to registration_register.
+
+        """
+        response = self.client.get(reverse('invitation_invited',
+                            kwargs={'invitation_key': self.sample_key.key}))
+        self.assertRedirect(response, 'registration_register')
+
+    def test_register_view(self):
+        """
+        Test register view.
+
+        With INVITE_MODE = FALSE, django-invitation just passes this view on to
+        django-registration's register.
+
+        """
+        # get
+        response = self.client.get(reverse('registration_register'))
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'registration/registration_form.html')
+
+        # post
+        response = self.client.post(reverse('registration_register'),
+                                    data=self.sample_registration_data)
+        self.assertRedirect(response, 'registration_complete')

File invitation/tests/templates/invitation/invitation_complete.html

Empty file added.

File invitation/tests/templates/invitation/invitation_email.txt

Empty file added.

File invitation/tests/templates/invitation/invitation_email_subject.txt

Empty file added.

File invitation/tests/templates/invitation/invitation_form.html

Empty file added.

File invitation/tests/templates/invitation/invited.html

Empty file added.

File invitation/tests/templates/invitation/wrong_invitation_key.html

Empty file added.

File invitation/tests/templates/registration/activation_email.html

Empty file added.

File invitation/tests/templates/registration/activation_email.txt

Empty file added.

File invitation/tests/templates/registration/activation_email_subject.txt

Empty file added.

File invitation/tests/templates/registration/registration_complete.html

Empty file added.

File invitation/tests/templates/registration/registration_form.html

Empty file added.

File invitation/tests/urls.py

+from django.conf.urls.defaults import *
+from django.views.generic.simple import direct_to_template
+
+from registration.forms import RegistrationFormTermsOfService
+from invitation.views import invite, invited, register
+
+urlpatterns = patterns('',
+    url(r'^invite/complete/$',
+                direct_to_template,
+                {'template': 'invitation/invitation_complete.html'},
+                name='invitation_complete'),
+    url(r'^invite/$',
+                invite,
+                name='invitation_invite'),
+    url(r'^invited/(?P<invitation_key>\w+)/$',
+                invited,
+                name='invitation_invited'),
+    url(r'^register/$',
+                register,
+                {'backend': 'invitation.backends.InvitationBackend'},
+                name='registration_register'),
+    url(r'^register/complete$',
+                direct_to_template,
+                {'template': 'registration/registration_complete.html'},
+                name='registration_complete'),
+)

File invitation/urls.py

     url(r'^invite/$',
                 invite,
                 name='invitation_invite'),
-    url(r'^invited/(?P<invitation_key>\w+)/$', 
+    url(r'^invited/(?P<invitation_key>\w+)/$',
                 invited,
                 name='invitation_invited'),
     url(r'^register/$',
                 register,
-                { 'backend': 'registration.backends.default.DefaultBackend' },
+                {'backend': 'registration.backends.default.DefaultBackend'},
                 name='registration_register'),
 )

File invitation/views.py

 is_key_valid = InvitationKey.objects.is_key_valid
 remaining_invitations_for_user = InvitationKey.objects.remaining_invitations_for_user
 
+
 def invited(request, invitation_key=None, extra_context=None):
     if getattr(settings, 'INVITE_MODE', False):
         if invitation_key and is_key_valid(invitation_key):
     else:
         return HttpResponseRedirect(reverse('registration_register'))
 
+
 def register(request, backend, success_url=None,
             form_class=RegistrationForm,
             disallowed_url='registration_disallowed',
         return registration_register(request, backend, success_url, form_class,
                                      disallowed_url, template_name, extra_context)
 
+
 def invite(request, success_url=None,
             form_class=InvitationKeyForm,
             template_name='invitation/invitation_form.html',