#!/usr/bin/env python 
# -*- coding: utf-8 -*- 
#
#
# Copyright 2012 ShopWiki
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#


import bcrypt
import random
import string
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column
from sqlalchemy.types import Integer, Unicode, String, Boolean


class ActivationError(Exception):
    pass


Base = declarative_base()

class UserBase(Base):
    '''
    The :class:`UserBase` class subclasses sqlalchemy's default
    declarative base to provide a schema for basic user authentication.
    :class:`UserBase` provides fields and methods for setting and
    checking a password, generating and checking an activation code, and
    disabling the user entirely.  The :attr:`email` field is unique.
    
    This class **cannot be used on its own**.  In order to use this
    class in your application, subclass it and define the
    :attr:`__tablename__` attribute.
    '''
    
    __abstract__ = True

    id                      = Column(Integer, primary_key=True)
    '''
    The primary key and id which will always be used to reference a user.
    '''

    email                   = Column(Unicode(255), unique=True)
    '''
    The user's email.  The uniqueness is enforced at the database level.
    '''

    password_hash           = Column(String(80))
    '''
    The hash of the user's password, with salt, as generated by bcrypt.
    '''

    password_is_set         = Column(Boolean())
    '''
    :attr:`password_hash` might be empty, so we explicitly record
    whether or not the user has a password set.
    '''

    disabled                = Column(Boolean())
    '''
    Prevent check_password from returning ``True``, regardless of the
    password supplied.
    '''

    activated               = Column(Boolean())
    '''
    :attr:`activated` is ``True`` if the user has completed the e-mail
    verification process using :attr:`activation_code_hash`.
    '''

    activation_code_hash    = Column(String(80))
    '''
    The :attr:`activation_code_hash` is set to a hash of the activation
    code that is generated by :meth:`generate_activation_code` and
    subsequently e-mailed to the user.  This code is immediately
    forgotten by the application; only the bcrypt hashed version of it
    is stored in :attr:`activation_code_hash`.  The activation code can
    be checked using :meth:`.activate`.
    '''

    def set_password(self, password_plaintext):
        '''
        :param password_plaintext: The plaintext of the password to set.

        Given a plaintext password, generate a salted hash using
        bcrypt. Set :attr:`password_is_set` to ``True`` and
        :attr:`password_hash` to the generated hash.  The user can now
        be authenticated with the plain text password and
        :meth:`check_password`.
        '''

        hashed = bcrypt.hashpw(password_plaintext, bcrypt.gensalt())
        self.password_is_set = True
        self.password_hash = hashed

    def check_password(self, password_plaintext):
        '''
        :param password_plaintext: The plaintext of the password to check.

        If :attr:`password_is_set` is ``False`` or :attr:`disabled` is 
        ``True``, return ``False``.  Otherwise, take a plaintext password 
        string and hash it using bcrypt along with the salt for this user.  
        If the resulting hash matches the hash in :attr:`password_hash`, 
        return ``True``.  The session can now be considered authenticated 
        for this user.
        '''

        return (self.password_is_set
                and not self.disabled
                and bcrypt.hashpw(password_plaintext, self.password_hash)
                    == self.password_hash)

    def activate(self, activation_code_plaintext):
        '''
        :param activation_code_plaintext: The activation code to check.
            If its hash matches :attr:`activation_code_hash`, the user
            will be activated.

        Take the plaintext activation code that was sent to the user,
        generally via e-mail.  Hash the plaintext and compare it against
        :attr:`activation_code_hash`. If they are the same, set
        :attr:`activated` to ``True``.  Finally, call
        :meth:`generate_activation_code` in order to prevent
        reactivation of the account using the same code.  Since we don't
        save the new reactivation code, it is useless.  If the account
        becomes inactive in the future for any reason,
        :meth:`generate_activation_code` must be called again in order
        to reactivate.

        If activation fails, an exception is raised and no change is made
        to the model.
        '''

        hashed = bcrypt.hashpw(activation_code_plaintext,
                               self.activation_code_hash)
        if hashed == self.activation_code_hash:
            self.activated = True
            self.generate_activation_code()
        else:
            raise ActivationError('Activation code failed')

    def generate_activation_code(self):
        '''
        Generate a 20 character random code from letters and digits.
        This is the activation code that will be sent to the user
        (presumably via e-mail) and allow them to activate their account.
        The actual code is not stored locally; like a password, it is hashed
        and stored in :attr:`activation_code_hash`.  The plaintext
        activation code is returned to the caller.  In order to activate
        the user with the plaintext activation code, call :meth:`activate`.
        '''

        alphabet = string.ascii_letters + string.digits
        sr = random.SystemRandom()
        activation_code = ''.join(sr.choice(alphabet) for x in range(20))
        self.activation_code_hash = bcrypt.hashpw(activation_code,
                                                  bcrypt.gensalt())
        return activation_code

    def __init__(self, email):
        '''
        :param email: **Required**.  The user's email address.  Validity
            as an email address is not enforced; any string will be
            allowed.
        
        Create a new :class:`UserBase` object.  By default,
        :attr:`password_is_set`, :attr:`disabled`, and
        :attr:`activated` are all initialized to ``False``.
        '''

        self.email = email
        self.password_is_set = False
        self.disabled = False
        self.activated = False

    def __repr__(self):
        return "<User('{email}')>".format(**dict(email=self.email))

