Source code for registration.models

# -*- coding: utf-8 -*-
from __future__ import unicode_literals
"""
Models of django-inspectional-registration

This is a modification of django-registration_ ``models.py``
The original code is written by James Bennett

.. _django-registration: https://bitbucket.org/ubernostrum/django-registration


Original License::

    Copyright (c) 2007-2011, James Bennett
    All rights reserved.

    Redistribution and use in source and binary forms, with or without
    modification, are permitted provided that the following conditions are
    met:

        * Redistributions of source code must retain the above copyright
        notice, this list of conditions and the following disclaimer.
        * Redistributions in binary form must reproduce the above
        copyright notice, this list of conditions and the following
        disclaimer in the documentation and/or other materials provided
        with the distribution.
        * Neither the name of the author nor the names of other
        contributors may be used to endorse or promote products derived
        from this software without specific prior written permission.

    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
    "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
    LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
    A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
    OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
    SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
    LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
    DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
    THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
    OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""
__author__ = 'Alisue <lambdalisue@hashnote.net>'
__all__ = (
    'ActivationForm', 'RegistrationForm',
    'RegistrationFormNoFreeEmail',
    'RegistrationFormTermsOfService',
    'RegistrationFormUniqueEmail',
)
import re
import sys
import datetime

from django.db import models
from django.template.loader import render_to_string
from django.core.exceptions import ObjectDoesNotExist
from django.utils.text import ugettext_lazy as _
from django.utils.encoding import python_2_unicode_compatible

from registration.conf import settings
from registration.compat import get_user_model
from registration.compat import user_model_label
from registration.compat import datetime_now
from registration.utils import generate_activation_key
from registration.utils import generate_random_password
from registration.utils import send_mail
from registration.supplements import get_supplement_class
from registration.compat import transaction_atomic

from logging import getLogger
logger = getLogger(__name__)

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


class RegistrationManager(models.Manager):

    """Custom manager for the ``RegistrationProfile`` model.

    The methods defined here provide shortcuts for account registration,
    registration acceptance, registration rejection and account activation
    (including generation and emailing of activation keys), and for cleaning out
    expired/rejected inactive accounts.

    """
    @transaction_atomic
    def register(self, username, email, site, send_email=True):
        """register new user with ``username`` and ``email``

        Create a new, inactive ``User``, generate a ``RegistrationProfile``
        and email notification to the ``User``, returning the new ``User``.

        By default, a registration email will be sent to the new user. To
        disable this, pass ``send_email=False``. A registration email will be
        generated by ``registration/registration_email.txt`` and
        ``registration/registration_email_subject.txt``.

        The user created by this method has no usable password and it will
        be set after activation.

        This method is transactional. Thus if some exception has occur in this
        method, the newly created user will be rollbacked.

        """
        User = get_user_model()
        new_user = User.objects.create_user(username, email, 'password')
        new_user.set_unusable_password()
        new_user.is_active = False
        new_user.save()

        profile = self.create(user=new_user)

        if send_email:
            profile.send_registration_email(site)

        return new_user

    @transaction_atomic
    def accept_registration(self, profile, site,
                            send_email=True, message=None, force=False):
        """accept account registration of ``profile``

        Accept account registration and email activation url to the ``User``,
        returning accepted ``User``. 

        By default, an acceptance email will be sent to the new user. To
        disable this, pass ``send_email=False``. An acceptance email will be
        generated by ``registration/acceptance_email.txt`` and
        ``registration/acceptance_email_subject.txt``.

        This method **DOES** works even after ``reject_registration`` has called
        (this mean the account registration has rejected previously) because 
        rejecting user by mistake may occur in this real world :-p If the account 
        registration has already accepted, returning will be ``None``

        The ``date_joined`` attribute of ``User`` updated to now in this
        method and ``activation_key`` of ``RegistrationProfile`` will
        be generated.

        """
        # rejected -> accepted is allowed
        if force or profile.status in ('untreated', 'rejected'):
            if force:
                # removing activation_key will force to create a new one
                profile.activation_key = None
            profile.status = 'accepted'
            profile.save()

            if send_email:
                profile.send_acceptance_email(site, message=message)

            return profile.user
        return None

    @transaction_atomic
    def reject_registration(self, profile, site, send_email=True, message=None):
        """reject account registration of ``profile``

        Reject account registration and email rejection to the ``User``,
        returning accepted ``User``. 

        By default, an rejection email will be sent to the new user. To
        disable this, pass ``send_email=False``. An rejection email will be
        generated by ``registration/rejection_email.txt`` and
        ``registration/rejection_email_subject.txt``.

        This method **DOES NOT** works after ``accept_registration`` has called
        (this mean the account registration has accepted previously).
        If the account registration has already accepted/rejected, returning 
        will be ``None``

        """
        # accepted -> rejected is not allowed
        if profile.status == 'untreated':
            profile.status = 'rejected'
            profile.save()

            if send_email:
                profile.send_rejection_email(site, message=message)

            return profile.user
        return None

    @transaction_atomic
    def activate_user(self, activation_key, site, password=None,
                      send_email=True, message=None, no_profile_delete=False):
        """activate account with ``activation_key`` and ``password``

        Activate account and email notification to the ``User``, returning 
        activated ``User``, ``password`` and ``is_generated``. 

        By default, an activation email will be sent to the new user. To
        disable this, pass ``send_email=False``. An activation email will be
        generated by ``registration/activation_email.txt`` and
        ``registration/activation_email_subject.txt``.

        This method **DOES NOT** works if the account registration has not been
        accepted. You must accept the account registration before activate the
        account. Returning will be ``None`` if the account registration has not
        accepted or activation key has expired.

        if passed ``password`` is ``None`` then random password will be generated
        and set to the ``User``. If the password is generated, ``is_generated``
        will be ``True``

        Use returning value like::

            activated = RegistrationProfile.objects.activate_user(activation_key)

            if activated:
                # Activation has success
                user, password, is_generated = activated
                # user -- a ``User`` instance of account
                # password -- a raw password of ``User``
                # is_generated -- ``True`` if the password is generated

        When activation has success, the ``RegistrationProfile`` of the ``User``
        will be deleted from database because the profile is no longer required.

        """
        try:
            profile = self.get(
                _status='accepted', activation_key=activation_key)
        except self.model.DoesNotExist:
            return None
        if not profile.activation_key_expired():
            is_generated = password is None
            password = password or generate_random_password(
                length=settings.REGISTRATION_DEFAULT_PASSWORD_LENGTH)
            user = profile.user
            user.set_password(password)
            user.is_active = True
            user.save()

            if send_email:
                profile.send_activation_email(site, password,
                                              is_generated, message=message)

            if not no_profile_delete:
                # the profile is no longer required
                profile.delete()
            return user, password, is_generated
        return None

    @transaction_atomic
    def delete_expired_users(self):
        """delete expired users from database

        Remove expired instance of ``RegistrationProfile`` and their associated
        ``User``.

        Accounts to be deleted are identified by searching for instance of
        ``RegistrationProfile`` with expired activation keys, and then checking
        to see if their associated ``User`` instance have the field ``is_active``
        set to ``False`` (it is for compatibility of django-registration); any
        ``User`` who is both inactive and has an expired activation 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 cleanupexpiredregistration`` (for just expired users) or
        ``manage.py cleanupregistration`` (for expired or rejected users).

        Reqularly clearing out accounts which have never been activated servers
        two useful purposes:

        1.  It alleviates the ocasional need to reset a ``RegistrationProfile``
            and/or re-send an activation email when a user does not receive or
            does not act upon the initial activation email; since the account
            will be deleted, the user will be able to simply re-register and
            receive a new activation key (if accepted).

        2.  It prevents the possibility of a malicious user registering one or
            more accounts and never activating them (thus denying the use of
            those username to anyone else); since those accounts will be deleted,
            the username 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 
        ``RegistrationProfile``; an inactive ``User`` which does not have an 
        associated ``RegistrationProfile`` will be deleted.

        """
        for profile in self.all():
            if profile.activation_key_expired():
                try:
                    user = profile.user
                    if not user.is_active:
                        user.delete()
                        profile.delete()    # just in case
                except ObjectDoesNotExist:
                    profile.delete()

    @transaction_atomic
    def delete_rejected_users(self):
        """delete rejected users from database

        Remove rejected instance of ``RegistrationProfile`` and their associated
        ``User``.

        Accounts to be deleted are identified by searching for instance of
        ``RegistrationProfile`` with rejected status, and then checking
        to see if their associated ``User`` instance have the field ``is_active``
        set to ``False`` (it is for compatibility of django-registration); any
        ``User`` who is both inactive and its registration has been rejected 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 cleanuprejectedregistration`` (for just rejected users) or
        ``manage.py cleanupregistration`` (for expired or rejected users).

        Reqularly clearing out accounts which have never been activated servers
        two useful purposes:

        1.  It alleviates the ocasional need to reset a ``RegistrationProfile``
            and/or re-send an activation email when a user does not receive or
            does not act upon the initial activation email; since the account
            will be deleted, the user will be able to simply re-register and
            receive a new activation key (if accepted).

        2.  It prevents the possibility of a malicious user registering one or
            more accounts and never activating them (thus denying the use of
            those username to anyone else); since those accounts will be deleted,
            the username 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 
        ``RegistrationProfile``; an inactive ``User`` which does not have an 
        associated ``RegistrationProfile`` will be deleted.

        """
        for profile in self.all():
            if profile.status == 'rejected':
                try:
                    user = profile.user
                    if not user.is_active:
                        user.delete()
                        profile.delete()  # just in case
                except ObjectDoesNotExist:
                    profile.delete()


@python_2_unicode_compatible
class RegistrationProfile(models.Model):

    """Registration profile model class

    A simple profile which stores an activation key and inspection status for use
    during user account registration/inspection.

    Generally, you will not want to interact directly with instances of this model;
    the provided manager includes method for creating, accepting, rejecting and
    activating, as well as for cleaning out accounts which have never been activated
    or its registration has been rejected.

    While it is possible to use this model as the value of the ``AUTH_PROFILE_MODEL``
    setting, it's not recommended that you do so. This model's sole purpose is to
    store data temporarily during account registration, inspection and activation.

    """
    STATUS_LIST = (
        ('untreated', _('Unprocessed')),
        ('accepted', _('Registration accepted')),
        ('rejected', _('Registration rejected')),
    )
    user = models.OneToOneField(user_model_label, verbose_name=_('user'),
                                related_name='registration_profile',
                                editable=False)
    _status = models.CharField(_('status'), max_length=10, db_column='status',
                               choices=STATUS_LIST, default='untreated',
                               editable=False)
    activation_key = models.CharField(_('activation key'), max_length=40,
                                      null=True, default=None, editable=False)

    objects = RegistrationManager()

    class Meta:
        verbose_name = _('registration profile')
        verbose_name_plural = _('registration profiles')
        permissions = (
            ('accept_registration', 'Can accept registration'),
            ('reject_registration', 'Can reject registration'),
            ('activate_user', 'Can activate user in admin site'),
        )

    def _get_supplement_class(self):
        """get supplement class of this registration"""
        return get_supplement_class()
    supplement_class = property(_get_supplement_class)

    def _get_supplement(self):
        """get supplement information of this registration"""
        supplement_class = self.supplement_class
        if supplement_class:
            app_label = supplement_class._meta.app_label
            class_name = supplement_class.__name__.lower()
            field_name = '_%s_%s_supplement' % (app_label, class_name)
            return getattr(self, field_name, None)
        else:
            return None
    supplement = property(_get_supplement)

    def _get_status(self):
        """get inspection status of this profile

        this will return 'expired' for profile which is accepted but
        activation key has expired

        """
        if self.activation_key_expired():
            return 'expired'
        return self._status

    def _set_status(self, value):
        """set inspection status of this profile

        Setting status to ``'accepted'`` will generate activation key
        and update ``date_joined`` attribute to now of associated ``User``

        Setting status not to ``'accepted'`` will remove activation key
        of this profile.

        """
        self._status = value
        # Automatically generate activation key for accepted profile
        if value == 'accepted' and not self.activation_key:
            username = self.user.username
            self.activation_key = generate_activation_key(username)
            # update user's date_joined
            self.user.date_joined = datetime_now()
            self.user.save()
        elif value != 'accepted' and self.activation_key:
            self.activation_key = None
    status = property(_get_status, _set_status)

    def get_status_display(self):
        """get human readable status"""
        sl = list(self.STATUS_LIST)
        sl.append(('expired', _('Activation key has expired')))
        sl = dict(sl)
        return sl.get(self.status)
    get_status_display.short_description = _("status")

    def __str__(self):
        return "Registration information for %s" % self.user

    def activation_key_expired(self):
        """get whether the activation key of this profile has expired

        Determine whether this ``RegistrationProfiel``'s activation key has
        expired, returning a boolean -- ``True`` if the key has expired.

        Key expiration is determined by a two-step process:

        1.  If the inspection status is not ``'accepted'``, the key is set to
            ``None``. In this case, this method returns ``False`` because these
            profiles are not treated yet or rejected by inspector.

        2.  Otherwise, the date the user signed up (which automatically updated
            in registration acceptance) is incremented by the number of days 
            specified in the setting ``ACCOUNT_ACTIVATION_DAYS`` (which should
            be the number of days after acceptance during which a user is allowed
            to activate their account); if the result is less than or equal to
            the current date, the key has expired and this method return ``True``.

        """
        if self._status != 'accepted':
            return False
        expiration_date = datetime.timedelta(
            days=settings.ACCOUNT_ACTIVATION_DAYS)
        expired = self.user.date_joined + expiration_date <= datetime_now()
        return expired
    activation_key_expired.boolean = True
    activation_key_expired.short_description = _(
        'Activation Key Expired?'
    )

    def _send_email(self, site, action, extra_context=None):
        context = {
            'user': self.user,
            'site': site,
        }
        if action != 'activation':
            # the profile was deleted in 'activation' action
            context['profile'] = self

        if extra_context:
            context.update(extra_context)

        subject = render_to_string(
            'registration/%s_email_subject.txt' % action, context)
        subject = ''.join(subject.splitlines())
        message = render_to_string(
            'registration/%s_email.txt' % action, context)

        mail_from = getattr(settings, 'REGISTRATION_FROM_EMAIL', '') or \
                        settings.DEFAULT_FROM_EMAIL
        send_mail(subject, message, mail_from, [self.user.email])

    def send_registration_email(self, site):
        """send registration email to the user associated with this profile

        Send a registration email to the ``User`` associated with this
        ``RegistrationProfile``.

        The registration email will make use of two templates:

        ``registration/registration_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/registration_email.txt``
            This template will be used for the body of the email

        These templates will each receive the following context variables:

        ``site``
            An object representing the site on which the user registered;this is
            an instance of ``django.contrib.sites.models.Site`` or
            ``django.contrib.sites.models.RequestSite``

        ``user``
            A ``User`` instance of the registration.

        ``profile``
            A ``RegistrationProfile`` instance of the registration

        """
        self._send_email(site, 'registration')

    def send_acceptance_email(self, site, message=None):
        """send acceptance email to the user associated with this profile

        Send an acceptance email to the ``User`` associated with this
        ``RegistrationProfile``.

        The acceptance email will make use of two templates:

        ``registration/acceptance_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/acceptance_email.txt``
            This template will be used for the body of the email

        These templates will each receive the following context variables:

        ``site``
            An object representing the site on which the user registered;this is
            an instance of ``django.contrib.sites.models.Site`` or
            ``django.contrib.sites.models.RequestSite``

        ``user``
            A ``User`` instance of the registration.

        ``profile``
            A ``RegistrationProfile`` instance of the registration

        ``activation_key``
            The activation key for tne new account. Use following code to get
            activation url in the email body::

                http://{{ site.domain }}
                {% url 'registration_activate' activation_key=activation_key %}

        ``expiration_days``
            The number of days remaining during which the account may be activated.

        ``message``
            A message from inspector. In default template, it is not shown.

        """
        extra_context = {
            'activation_key': self.activation_key,
            'expiration_days': settings.ACCOUNT_ACTIVATION_DAYS,
            'message': message,
        }
        self._send_email(site, 'acceptance', extra_context)

    def send_rejection_email(self, site, message=None):
        """send rejection email to the user associated with this profile

        Send a rejection email to the ``User`` associated with this
        ``RegistrationProfile``.

        The rejection email will make use of two templates:

        ``registration/rejection_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/rejection_email.txt``
            This template will be used for the body of the email

        These templates will each receive the following context variables:

        ``site``
            An object representing the site on which the user registered;this is
            an instance of ``django.contrib.sites.models.Site`` or
            ``django.contrib.sites.models.RequestSite``

        ``user``
            A ``User`` instance of the registration.

        ``profile``
            A ``RegistrationProfile`` instance of the registration

        ``message``
            A message from inspector. In default template, it is used for explain
            why the account registration has been rejected.

        """
        extra_context = {
            'message': message,
        }
        self._send_email(site, 'rejection', extra_context)

    def send_activation_email(self, site, password=None, is_generated=False,
                              message=None):
        """send activation email to the user associated with this profile

        Send a activation email to the ``User`` associated with this
        ``RegistrationProfile``.

        The activation email will make use of two templates:

        ``registration/activation_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/activation_email.txt``
            This template will be used for the body of the email

        These templates will each receive the following context variables:

        ``site``
            An object representing the site on which the user registered;this is
            an instance of ``django.contrib.sites.models.Site`` or
            ``django.contrib.sites.models.RequestSite``

        ``user``
            A ``User`` instance of the registration.

        ``password``
            A raw password of ``User``. Use this to tell user to them password
            when the password is generated

        ``is_generated``
            A boolean -- ``True`` if the password is generated. Don't forget to
            tell user to them password when the password is generated

        ``message``
            A message from inspector. In default template, it is not shown.

        """
        extra_context = {
            'password': password,
            'is_generated': is_generated,
            'message': message,
        }
        self._send_email(site, 'activation', extra_context)