Source

django-registration-plus / registration / models.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
import datetime
import random
import re

from django.conf import settings
from django.contrib.auth.models import User
from django.core.mail import EmailMultiAlternatives
from django.core.mail import get_connection
from django.db import models
from django.db import transaction
from django.template.base import TemplateDoesNotExist
from django.template.loader import render_to_string
from django.utils.hashcompat import sha_constructor
from django.utils.translation import ugettext_lazy as _


SHA1_RE = re.compile('^[a-f0-9]{40}$')


class EmailValidationManager(models.Manager):
    """
    Custom manager for the ``EmailValidation`` model.
    
    The methods defined here provide shortcuts for account creation
    and validation (including generation and emailing of validation
    keys), and for cleaning out expired inactive accounts.
    
    """
    def validate_email(self, validation_key):
        """
        Validate an validation key and validate the corresponding
        ``User`` if valid.
        
        If the key is valid and has not expired, return the ``User``
        after validating.
        
        If the key is not valid or has expired, return ``False``.
        
        If the key is valid but the ``User`` is already active,
        return ``False``.
        
        To prevent revalidation of an account which has been
        devalidated by site administrators, the validation key is
        reset to the string constant ``EmailValidation.VALIDATED``
        after successful validation.
        
        """
        # Make sure the key we're trying conforms to the pattern of a
        # SHA1 hash; if it doesn't, no point trying to look it up in
        # the database.
        if SHA1_RE.search(validation_key):
            try:
                validation = self.get(validation_key=validation_key)
            except self.model.DoesNotExist:
                return False
            if not validation.validation_key_expired():
                user = validation.user
                if not user.is_active:
                    user.is_active = True
                    user.save()
                validation.validation_key = self.model.VALIDATED
                validation.validated = datetime.datetime.now()
                validation.save()
                return user
        return False
    
    def create_inactive_user(self, username, email, password,
                             site, send_email=True):
        """
        Create a new, inactive ``User``, generate a
        ``EmailValidation`` and email its validation key to the
        ``User``, returning the new ``User``.
        
        By default, a validation email will be sent to the new
        user. To disable this, pass ``send_email=False``.
        
        """
        return self.create_user(username, email, password, site,
                                send_email, False)
    
    def create_user(self, username, email, password, site,
                    send_email=True, active=True, invitation_key=None):
        """
        Create a new ``User``, generate a ``EmailValidation`` 
        and email its validation key to the
        ``User``, returning the new ``User``.
        
        By default, an validation email will be sent to the new
        user. To disable this, pass ``send_email=False``.
        
        """
        new_user = User.objects.create_user(username, email, password)
        if not active:
            new_user.is_active = False
            new_user.save()
        
        invitation = None
        if invitation_key:
            invitation = RegistrationInvitation.objects.use_invitation(invitation_key, 
                                                                       new_user)
            if not invitation:
                new_user.delete()
                return None
        
        email_validation = self.create_validation(new_user)
        
        if send_email:
            if invitation and invitation.email == email:
                self.validate_email(email_validation.validation_key)
            else:
                email_validation.send_validation_email(site)
        
        return User.objects.get(username=username)
    create_user = transaction.commit_on_success(create_user)
    
    def create_validation(self, user):
        """
        Create a ``EmailValidation`` for a given
        ``User``, and return the ``EmailValidation``.
        
        The validation key for the ``EmailValidation`` 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]
        username = user.username
        if isinstance(username, unicode):
            username = username.encode('utf-8')
        validation_key = sha_constructor(salt+username).hexdigest()
        return self.create(user=user, email=user.email,
                           validation_key=validation_key)
    
    def delete_expired_users(self):
        """
        Remove expired instances of ``EmailValidation`` and their
        associated ``User``s (if not is_active).
        
        Accounts to be deleted are identified by searching for
        instances of ``EmailValidation`` with expired validation
        keys, and then checking to see if their associated ``User``
        instances have the field ``is_active`` set to ``False``; any
        ``User`` who is both inactive and has an expired validation
        key will be deleted.
        
        It is recommended that this method be executed regularly as
        part of your routine site maintenance; this application
        provides a custom management command which will call this
        method, accessible as ``manage.py cleanupregistration``.
        
        Regularly clearing out accounts which have never been
        validated serves two useful purposes:
        
        1. It alleviates the ocasional need to reset a
           ``EmailValidation`` and/or re-send an validation email
           when a user does not receive or does not act upon the
           initial validation email; since the account will be
           deleted, the user will be able to simply re-register and
           receive a new validation key.
        
        2. It prevents the possibility of a malicious user registering
           one or more accounts and never validating them (thus
           denying the use of those usernames to anyone else); since
           those accounts will be deleted, the usernames will become
           available for use again.
        
        If you have a troublesome ``User`` and wish to disable their
        account while keeping it in the database, simply delete the
        associated ``EmailValidation``; an inactive ``User`` which
        does not have an associated ``EmailValidation`` will not
        be deleted.
        
        """
        for profile in self.all():
            if profile.validation_key_expired():
                user = profile.user
                if not user.is_active:
                    user.delete()
    
    def is_valid(self, user):
        try:
            self.get(user=user, email=user.email, validation_key=self.model.VALIDATED)
            return True
        except self.model.DoesNotExist:
            return False
    

class EmailValidation(models.Model):
    """
    A simple email validation model which stores an validation key for
    use during user account registration.
    
    Generally, you will not want to interact directly with instances
    of this model; the provided manager includes methods
    for creating and validating new accounts, as well as for cleaning
    out accounts which have never been validated.
    
    """
    VALIDATED = u"ALREADY_VALIDATED"
    
    user = models.ForeignKey(User, verbose_name=_('user'))
    email = models.EmailField(_('email'), max_length=255)
    validation_key = models.CharField(_('validation key'), max_length=40)
    created = models.DateTimeField(_('created'), auto_now_add=True)
    validated = models.DateTimeField(_('validated'), null=True, blank=True)
    
    objects = EmailValidationManager()
    
    class Meta:
        verbose_name = _('email validation')
        verbose_name_plural = _('email validation')
    
    def __unicode__(self):
        return u"Email validation for %s/%s" % (self.user, self.email)
    
    def is_validated(self):
        return self.validation_key == self.VALIDATED
    is_validated.boolean = True
        
    def expiration_date(self):
        """
        Return date when email validation expires.
        """
        if self.created:
            return self.created + datetime.timedelta(days=settings.EMAIL_VALIDATION_DAYS)
    
    def validation_key_expired(self):
        """
        Determine whether this ``EmailValidation``'s validation
        key has expired, returning a boolean -- ``True`` if the key
        has expired.
        
        Key expiration is determined by a two-step process:
        
        1. If the user has already validated, the key will have been
           reset to the string constant ``VALIDATED``. Re-validating
           is not permitted, and so this method returns ``True`` in
           this case.
        
        2. Otherwise, the date the validation was created is 
           incremented by the number of days specified in the setting
           ``EMAIL_VALIDATION_DAYS`` (which should be the number of
           days after validation during which a user is allowed to
           validate their email); 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.EMAIL_VALIDATION_DAYS)
        return self.is_validated() or \
               self.expiration_date() <= datetime.datetime.now()
    validation_key_expired.boolean = True
    
    def send_validation_email(self, site, prefix=""):
        """
        Send an validation email to the user associated with this
        ``EmailValidation``.
        
        The validation email will make use of three templates:
        
        ``registration/validation_email_subject.txt``
            This template will be used for the subject line of the
            email. Because it is used as the subject line of an email,
            this template's output **must** be only a single line of
            text; output longer than one line will be forcibly joined
            into only a single line.
        
        ``registration/validation_email.txt``
            This template will be used for the text body of the email.
        
        ``registration/validation_email.html``
            This template will be used for the HTML body of the email.
        
        These templates will each receive the following context
        variables:
        
        ``validation_key``
            The validation key for the new account.
        
        ``expiration_days``
            The number of days remaining during which the account may
            be validaated.
        
        ``user``
            The User that this validation is tied to.
        
        ``site``
            An object representing the site on which the user
            registered; depending on whether ``django.contrib.sites``
            is installed, this may be an instance of either
            ``django.contrib.sites.models.Site`` (if the sites
            application is installed) or
            ``django.contrib.sites.models.RequestSite`` (if
            not). Consult the documentation for the Django sites
            framework for details regarding these objects' interfaces.
        
        """
        if self.validation_key_expired():
            return
        ctx_dict = {'validation_key': self.validation_key,
                    'expiry': self.expiration_date(),
                    'expiration_days': (self.expiration_date() - datetime.datetime.now()).days,
                    'user': self.user,
                    'site': site}
        subject = render_to_string('registration/validation_email_subject.txt',
                                   ctx_dict)
        # Email subject *must not* contain newlines
        subject = prefix + ''.join(subject.splitlines())
        
        txt_message = render_to_string('registration/validation_email.txt',
                                       ctx_dict)
        
        message = EmailMultiAlternatives(subject, txt_message,
                                  settings.DEFAULT_FROM_EMAIL,
                                  [self.user.email],
                                  connection=get_connection())
        
        try:
            html_message = render_to_string('registration/validation_email.html',
                                        ctx_dict)
            message.attach_alternative(html_message, 'text/html')
        except TemplateDoesNotExist: # pragma: no cover
            pass # pragma: no cover
        
        return message.send()
    

class RegistrationInvitationManager(models.Manager):
    """
    Custom manager for the ``RegistrationInvitation`` model.
    
    The methods defined here provide shortcuts for invitation creation
    and usage (including emailing of invitations keys), and for cleaning
    out expired inactive invitations.
    
    """
    def create_invitation(self, user):
        """
        Create a ``RegistrationInvitation`` for a given
        ``User``, and return the ``RegistrationInvitation``.
        
        """
        return self.create(owner=user)
    
    def send_invitation(self, owner, email, site):
        """
        Send an existing ``RegistrationInvitation`` for a given
        ``User``, update it with specified email, send the invitation
         email and return the ``RegistrationInvitation``.
        
        The invitation key for the ``RegistrationInvitation`` will be a
        SHA1 hash, generated from a combination of the specified email
        and a random salt.
        
        """
        invitation = RegistrationInvitation.objects.filter(owner=owner, email=None).order_by("created")
        if len(invitation):
            invitation = invitation[0]
            salt = sha_constructor(str(random.random())).hexdigest()[:5]
            if isinstance(email, unicode):
                email = email.encode('utf-8')
            invitation.invitation_key = sha_constructor(salt+email).hexdigest()
            invitation.email = email
            invitation.sent = datetime.datetime.now()
            invitation.save()
            invitation.send_invitation_email(site)
            return invitation
        return None
    
    def use_invitation(self, invitation_key, user):
        """
        Use an outstanding ``RegistrationInvitation`` to register a new
        ``User``, update it with the given ``User`` and exercised date
        and return the ``User``.
        
        """
        try:
            invitation = RegistrationInvitation.objects.get(invitation_key=invitation_key)
        except RegistrationInvitation.DoesNotExist:
            return None
        if not invitation.invitation_key_expired():
            invitation.user = user
            invitation.exercised = datetime.datetime.now()
            invitation.invitation_key = self.model.EXERCISED
            invitation.save()
            return invitation
        return None
    
    def has_invitations(self, user):
        """
        Return the number of ``RegistrationInvitation`` a given
        ``User`` has available to send.
        
        """
        return RegistrationInvitation.objects.filter(owner=user, email=None).count()
    

class RegistrationInvitation(models.Model):
    """
    A simple model that stores invitation data.
    
    Generally, you will not want to interact directly with instances
    of this model; the provided manager includes methods
    for creating and sending invitations, as well as for cleaning
    out expired invitations.
    
    """
    EXERCISED = u"ALREADY_EXERCISED"
    
    owner = models.ForeignKey(User, verbose_name=_('owner'), related_name="owner")
    created = models.DateTimeField(_('created'), auto_now_add=True)
    email = models.EmailField(_('email'), max_length=255, null=True, blank=True)
    invitation_key = models.CharField(_('invitation key'), max_length=40, null=True, blank=True)
    sent = models.DateTimeField(_('sent'), null=True, blank=True)
    exercised = models.DateTimeField(_('used'), null=True, blank=True)
    user = models.ForeignKey(User, verbose_name=_('user'), null=True, blank=True)
    
    objects = RegistrationInvitationManager()
    
    class Meta:
        verbose_name = _('registration invitation')
        verbose_name_plural = _('registration invitation')
    
    def __unicode__(self):
        return u"Registration invitation by %s for %s" % (self.owner, self.email)
    
    def status(self):
        if self.exercised:
            return "Exercised by %s" % self.user
        elif self.sent:
            return "Sent to %s" % self.email
        else:
            return "Open"
    
    def expiration_date(self):
        if self.sent and not self.exercised:
            return self.sent + datetime.timedelta(days=settings.EMAIL_INVITATION_DAYS)
    
    def invitation_key_expired(self):
        """
        Determine whether this ``RegistrationInvitation``'s invitation
        key has expired, returning a boolean -- ``True`` if the key
        has expired.
        
        Key expiration is determined by a three-step process:
        
        1. If the invitation has not yet been send the key is not expired.
        
        2. If the user has already used the key, the key will have been
           reset to the string constant ``EXERCISED``. Reusing
           is not permitted, and so this method returns ``True`` in
           this case.
        
        3. Otherwise, the date the invitation was sent is
           incremented by the number of days specified in the setting
           ``EMAIL_INVITATION_DAYS`` (which should be the number of
           days after invitation is sent during which a user is allowed
           to register); if the result is less than or
           equal to the current date, the key has expired and this
           method returns ``True``.
        
        """
        return self.sent and (self.invitation_key == self.EXERCISED or \
               self.expiration_date() <= datetime.datetime.now())
    invitation_key_expired.boolean = True
    
    def send_invitation_email(self, site):
        """
        Send an invitation email to the email associated with this
        ``RegistrationInvitation``.
        
        The invitation email will make use of three templates:
        
        ``registration/invitation_email_subject.txt``
            This template will be used for the subject line of the
            email. Because it is used as the subject line of an email,
            this template's output **must** be only a single line of
            text; output longer than one line will be forcibly joined
            into only a single line.
        
        ``registration/invitation_email.txt``
            This template will be used for the text body of the email.
        
        ``registration/invitation_email.html``
            This template will be used for the HTML body of the email.
        
        These templates will each receive the following context
        variables:
        
        ``invitation_key``
            The invitation key for the new account.
        
        ``expiration_days``
            The number of days remaining during which the user may
            register.
        
        ``owner``
            The User that this invitation is sent by.
        
        ``site``
            An object representing the site on which the user
            registered; depending on whether ``django.contrib.sites``
            is installed, this may be an instance of either
            ``django.contrib.sites.models.Site`` (if the sites
            application is installed) or
            ``django.contrib.sites.models.RequestSite`` (if
            not). Consult the documentation for the Django sites
            framework for details regarding these objects' interfaces.
        
        """
        if self.invitation_key_expired():
            return
        ctx_dict = {'invitation_key': self.invitation_key,
                    'expiry': self.expiration_date(),
                    'expiration_days': (self.expiration_date() - datetime.datetime.now()).days,
                    'user': self.owner,
                    'site': site}
        subject = render_to_string('registration/invitation_email_subject.txt',
                                   ctx_dict)
        # Email subject *must not* contain newlines
        subject = ''.join(subject.splitlines())
        
        txt_message = render_to_string('registration/invitation_email.txt',
                                       ctx_dict)
        
        message = EmailMultiAlternatives(subject, txt_message,
                                  settings.DEFAULT_FROM_EMAIL,
                                  [self.email],
                                  connection=get_connection())
        
        try:
            html_message = render_to_string('registration/invitation_email.html',
                                        ctx_dict)
            message.attach_alternative(html_message, 'text/html')
        except TemplateDoesNotExist: # pragma: no cover
            pass # pragma: no cover
        
        return message.send()