diff --git a/fbone/admin/forms.py b/fbone/admin/forms.py index 7a1f2c9..32d1101 100644 --- a/fbone/admin/forms.py +++ b/fbone/admin/forms.py @@ -1,18 +1,24 @@ # -*- coding: utf-8 -*- from flask.ext.wtf import Form -from wtforms import HiddenField, SubmitField, RadioField, DateField -from wtforms.validators import AnyOf +from wtforms import HiddenField, SubmitField, RadioField, DateField, SelectMultipleField, TextField, BooleanField +from wtforms.validators import AnyOf, Required -from ..user import USER_ROLE, USER_STATUS +from ..user import USER_STATUS class UserForm(Form): next = HiddenField() - role_code = RadioField(u"Role", [AnyOf([str(val) for val in USER_ROLE.keys()])], - choices=[(str(val), label) for val, label in USER_ROLE.items()]) + role_code_select = SelectMultipleField(u"Role", choices=[]) status_code = RadioField(u"Status", [AnyOf([str(val) for val in USER_STATUS.keys()])], choices=[(str(val), label) for val, label in USER_STATUS.items()]) # A demo of datepicker. created_time = DateField(u'Created time') + confirmed = BooleanField(u'Confirmed') + submit = SubmitField(u'Save') + +class RoleForm(Form): + next = HiddenField() + name = TextField(u"Role Code", [Required()]) + description = TextField(u"Description", default=u"") submit = SubmitField(u'Save') diff --git a/fbone/admin/views.py b/fbone/admin/views.py index a1267fe..d1bd166 100644 --- a/fbone/admin/views.py +++ b/fbone/admin/views.py @@ -6,9 +6,9 @@ from ..extensions import db from ..decorators import admin_required -from ..user import User -from .forms import UserForm - +from ..user import User, Role +from .forms import UserForm, RoleForm +from datetime import datetime admin = Blueprint('admin', __name__, url_prefix='/admin') @@ -18,7 +18,9 @@ @admin_required def index(): users = User.query.all() - return render_template('admin/index.html', users=users, active='index') + roles = Role.query.all() + return render_template('admin/index.html', users=users, roles=roles, + active='index') @admin.route('/users') @@ -28,20 +30,56 @@ def users(): users = User.query.all() return render_template('admin/users.html', users=users, active='users') - @admin.route('/user/', methods=['GET', 'POST']) @login_required @admin_required def user(user_id): user = User.query.filter_by(id=user_id).first_or_404() form = UserForm(obj=user, next=request.args.get('next')) - + form.role_code_select.choices = [(r.name, r.description) for r in Role.query.order_by('name')] if form.validate_on_submit(): form.populate_obj(user) + user.empty_roles() + for rolename in form.role_code_select.data: + user.add_role(rolename) + + if form.confirmed.data == True: + user.confirmed_at = datetime.utcnow() + else: + user.confirmed_at = None + db.session.add(user) db.session.commit() flash('User updated.', 'success') + else: + form.role_code_select.data = [r.name for r in user.roles] + if user.confirmed_at: + form.confirmed.data = True return render_template('admin/user.html', user=user, form=form) + +@admin.route('/roles') +@login_required +@admin_required +def roles(): + roles = Role.query.all() + return render_template('admin/roles.html', roles=roles, active='roles') + +@admin.route('/role/', methods=['GET', 'POST']) +@login_required +@admin_required +def role(role_id): + role = Role.query.filter_by(id=role_id).first_or_404() + form = RoleForm(obj=role, next=request.args.get('next')) + if form.validate_on_submit(): + form.populate_obj(role) + + db.session.add(role) + db.session.commit() + + flash('Role updated.', 'success') + + return render_template('admin/role.html', role=role, form=form) + diff --git a/fbone/app.py b/fbone/app.py index e115e26..2f47630 100644 --- a/fbone/app.py +++ b/fbone/app.py @@ -6,14 +6,19 @@ from flask.ext.babel import Babel from .config import DefaultConfig -from .user import User, user +from .user import User, Role, SocialConnection, user from .settings import settings -from .frontend import frontend +from .frontend import frontend, forms from .api import api from .admin import admin from .extensions import db, mail, cache, login_manager, oid from .utils import INSTANCE_FOLDER_PATH +from flask.ext.security import ( Security, SQLAlchemyUserDatastore ) +from flask.ext.social import ( Social ) +from flask.ext.social.datastore import ( SQLAlchemyConnectionDatastore ) +from flask.ext.principal import ( Principal ) + # For import * __all__ = ['create_app'] @@ -93,6 +98,21 @@ def load_user(id): # flask-openid oid.init_app(app) + security_ds = SQLAlchemyUserDatastore(db, User, Role) + social_ds = SQLAlchemyConnectionDatastore(db, SocialConnection ) + app.security = Security(app, security_ds, + login_form=forms.LoginForm, + register_form=forms.SignupForm, + confirm_register_form=forms.SignupForm, +# reset_password_form=ResetPasswordForm, +# send_confirmation_form=SendConfirmationForm, + forgot_password_form=forms.RecoverPasswordForm, + change_password_form=forms.ChangePasswordForm, + ) + app.social = Social(app, social_ds) + + app.principal = Principal(app) + def configure_blueprints(app, blueprints): """Configure blueprints in views.""" @@ -102,6 +122,8 @@ def configure_blueprints(app, blueprints): def configure_template_filters(app): + # add jinja extensions + app.jinja_env.add_extension("jinja2.ext.do") @app.template_filter() def pretty_date(value): @@ -111,6 +133,9 @@ def pretty_date(value): def format_date(value, format='%Y-%m-%d'): return value.strftime(format) + @app.template_filter() + def is_classname(value, clsstr): + return (value.__class__.__name__ == clsstr) def configure_logging(app): """Configure file(info) and email(error) logging.""" diff --git a/fbone/config.py b/fbone/config.py index 38c961a..26f9280 100644 --- a/fbone/config.py +++ b/fbone/config.py @@ -67,6 +67,39 @@ class DefaultConfig(BaseConfig): OPENID_FS_STORE_PATH = os.path.join(INSTANCE_FOLDER_PATH, 'openid') make_dir(OPENID_FS_STORE_PATH) + # Flask-Security options + SECURITY_CONFIRMABLE = True + SECURITY_REGISTERABLE = True + SECURITY_RECOVERABLE = True + SECURITY_TRACKABLE = True + SECURITY_CHANGEABLE = True +# SECURITY_PASSWORD_HASH = 'bcrypt' +# SECURITY_PASSWORD_SALT = 'add your salt here' + + + SOCIAL_TWITTER = { + 'consumer_key': 'twitter consumer key', + 'consumer_secret': 'twitter consumer secret' + } + + SOCIAL_FACEBOOK = { + #'consumer_key': 'facebook app id', + 'consumer_key': '212817878843088', + #'consumer_secret': 'faceboo app secret', + 'consumer_secret': '43f451ab755274ac8f6efb3be82be7ba', + } + + SOCIAL_FOURSQUARE = { + 'consumer_key': 'client id', + 'consumer_secret': 'client secret' + } + + SOCIAL_GOOGLE = { + 'consumer_key': 'xxxx', + 'consumer_secret': 'xxxx' + } + + class TestConfig(BaseConfig): TESTING = True diff --git a/fbone/forms.py b/fbone/forms.py new file mode 100644 index 0000000..006e6c1 --- /dev/null +++ b/fbone/forms.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +""" + Misc stuff for working with forms +""" + +class FieldDescription(object): + """ + Holds additional field information for form fields + """ + def __init__(self, *args, **kwargs): + self.description = None + self.placeholder = None + self.title = None + + if args and len(args) > 0: + self.description = args[0] + + if kwargs: + for key,val in kwargs.iteritems(): + if not hasattr(self, key): + msg = "'%s' object has no attribute '%s'" % ( self.__class__.__name__, key ) + raise AttributeError(msg) + setattr(self, key, val) + + def __str__(self): + return self.description + + def __repr__(self): + return self.description + + def __unicode__(self): + return self.description diff --git a/fbone/frontend/forms.py b/fbone/frontend/forms.py index d1dd64e..021b988 100644 --- a/fbone/frontend/forms.py +++ b/fbone/frontend/forms.py @@ -12,26 +12,23 @@ from ..utils import (PASSWORD_LEN_MIN, PASSWORD_LEN_MAX, USERNAME_LEN_MIN, USERNAME_LEN_MAX) +from flask_security import forms -class LoginForm(Form): - next = HiddenField() - login = TextField(u'Username or email', [Required()]) - password = PasswordField('Password', [Required(), Length(PASSWORD_LEN_MIN, PASSWORD_LEN_MAX)]) - remember = BooleanField('Remember me') - submit = SubmitField('Sign in') +class LoginForm(forms.LoginForm): + pass -class SignupForm(Form): - next = HiddenField() - email = EmailField(u'Email', [Required(), Email()], - description=u"What's your email address?") - password = PasswordField(u'Password', [Required(), Length(PASSWORD_LEN_MIN, PASSWORD_LEN_MAX)], - description=u'%s characters or more! Be tricky.' % PASSWORD_LEN_MIN) +class RecoverPasswordForm(forms.ForgotPasswordForm): + pass + +class ChangePasswordForm(forms.ChangePasswordForm): + pass + +class SignupForm(forms.RegisterForm): name = TextField(u'Choose your username', [Required(), Length(USERNAME_LEN_MIN, USERNAME_LEN_MAX)], description=u"Don't worry. you can change it later.") agree = BooleanField(u'Agree to the ' + Markup('Terms of Service'), [Required()]) - submit = SubmitField('Sign up') def validate_name(self, field): if User.query.filter_by(name=field.data).first() is not None: @@ -41,19 +38,6 @@ def validate_email(self, field): if User.query.filter_by(email=field.data).first() is not None: raise ValidationError(u'This email is taken') - -class RecoverPasswordForm(Form): - email = EmailField(u'Your email', [Email()]) - submit = SubmitField('Send instructions') - - -class ChangePasswordForm(Form): - activation_key = HiddenField() - password = PasswordField(u'Password', [Required()]) - password_again = PasswordField(u'Password again', [EqualTo('password', message="Passwords don't match")]) - submit = SubmitField('Save') - - class ReauthForm(Form): next = HiddenField() password = PasswordField(u'Password', [Required(), Length(PASSWORD_LEN_MIN, PASSWORD_LEN_MAX)]) diff --git a/fbone/frontend/views.py b/fbone/frontend/views.py index 0c1c9f5..55d69e5 100644 --- a/fbone/frontend/views.py +++ b/fbone/frontend/views.py @@ -16,7 +16,7 @@ frontend = Blueprint('frontend', __name__) -@frontend.route('/login/openid', methods=['GET', 'POST']) +#@frontend.route('/login/openid', methods=['GET', 'POST']) @oid.loginhandler def login_openid(): if current_user.is_authenticated(): @@ -86,7 +86,7 @@ def search(): return render_template('frontend/search.html', pagination=pagination, keywords=keywords) -@frontend.route('/login', methods=['GET', 'POST']) +#@frontend.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated(): return redirect(url_for('user.index')) @@ -109,7 +109,7 @@ def login(): return render_template('frontend/login.html', form=form) -@frontend.route('/reauth', methods=['GET', 'POST']) +#@frontend.route('/reauth', methods=['GET', 'POST']) @login_required def reauth(): form = ReauthForm(next=request.args.get('next')) @@ -127,7 +127,7 @@ def reauth(): return render_template('frontend/reauth.html', form=form) -@frontend.route('/logout') +#@frontend.route('/logout') @login_required def logout(): logout_user() @@ -135,7 +135,7 @@ def logout(): return redirect(url_for('frontend.index')) -@frontend.route('/signup', methods=['GET', 'POST']) +#@frontend.route('/signup', methods=['GET', 'POST']) def signup(): if current_user.is_authenticated(): return redirect(url_for('user.index')) @@ -156,7 +156,7 @@ def signup(): return render_template('frontend/signup.html', form=form) -@frontend.route('/change_password', methods=['GET', 'POST']) +#@frontend.route('/change_password', methods=['GET', 'POST']) def change_password(): user = None if current_user.is_authenticated(): @@ -187,7 +187,7 @@ def change_password(): return render_template("frontend/change_password.html", form=form) -@frontend.route('/reset_password', methods=['GET', 'POST']) +#@frontend.route('/reset_password', methods=['GET', 'POST']) def reset_password(): form = RecoverPasswordForm() diff --git a/fbone/models.py b/fbone/models.py new file mode 100755 index 0000000..3e878d5 --- /dev/null +++ b/fbone/models.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from sqlalchemy import ( Column, Integer, DateTime, Unicode ) +from sqlalchemy.sql import ( select, func ) +from sqlalchemy.types import ( TypeDecorator, TEXT ) +from sqlalchemy.ext.declarative import ( declared_attr ) +from sqlalchemy.ext.mutable import ( Mutable ) +from datetime import datetime + +from extensions import ( db ) +from utils import ( get_current_time ) + +__all__ = [ 'IdMixin', 'TimestampMixin', 'IdTimestampMixin' ] + +class IdMixin(object): + """ + Provides the :attr:`id` primary key column + """ + id = db.Column(db.Integer, primary_key=True) + +class TimestampMixin(object): + """ + Provides the :attr:`created_at` and :attr:`updated_at` audit timestamps + """ + created_at = db.Column(db.DateTime, default=get_current_time, nullable=False) + updated_at = db.Column(db.DateTime, default=get_current_time, onupdate=datetime.utcnow, nullable=False) + deleted_at = db.Column(db.DateTime, default=None, nullable=True) + + @property + def deleted(self): + if self.deleted_at is not None: + return True + return False + +class IdTimestampMixin(IdMixin, TimestampMixin): + """ + Base mixin class for all tables that adds id and timestamp columns and includes + stub :meth:`permissions` and :meth:`url_for` methods + """ + pass + +class TimestampMixin(object): + """ + Provides the :attr:`created_at` and :attr:`updated_at` audit timestamps + """ + created_at = db.Column(db.DateTime, default=get_current_time, nullable=False) + updated_at = db.Column(db.DateTime, default=get_current_time, onupdate=datetime.utcnow, nullable=False) + + diff --git a/fbone/settings/forms.py b/fbone/settings/forms.py index 76b1195..dba33e7 100644 --- a/fbone/settings/forms.py +++ b/fbone/settings/forms.py @@ -13,17 +13,17 @@ from ..utils import PASSWORD_LEN_MIN, PASSWORD_LEN_MAX, AGE_MIN, AGE_MAX, DEPOSIT_MIN, DEPOSIT_MAX from ..utils import allowed_file, ALLOWED_AVATAR_EXTENSIONS from ..utils import SEX_TYPE - +from ..forms import FieldDescription class ProfileForm(Form): multipart = True next = HiddenField() - email = EmailField(u'Email', [Required(), Email()]) + email = EmailField(u'Email', [Required(), Email()], description=u"Your email address") # Don't use the same name as model because we are going to use populate_obj(). avatar_file = FileField(u"Avatar", [Optional()]) sex_code = RadioField(u"Sex", [AnyOf([str(val) for val in SEX_TYPE.keys()])], choices=[(str(val), label) for val, label in SEX_TYPE.items()]) age = IntegerField(u'Age', [Optional(), NumberRange(AGE_MIN, AGE_MAX)]) - phone = TelField(u'Phone', [Length(max=64)]) + phone = TelField(u'Phone', [Length(max=64)], description=FieldDescription(u"Required for account verification", placeholder=u"+1 (AREA) XXX-XXXX", title=u"Phone must be SMS capable")) url = URLField(u'URL', [Optional(), URL()]) deposit = DecimalField(u'Deposit', [Optional(), NumberRange(DEPOSIT_MIN, DEPOSIT_MAX)]) location = TextField(u'Location', [Length(max=64)]) diff --git a/fbone/settings/views.py b/fbone/settings/views.py index 4d379d7..ae299f8 100644 --- a/fbone/settings/views.py +++ b/fbone/settings/views.py @@ -23,7 +23,6 @@ def profile(): user = User.query.filter_by(name=current_user.name).first_or_404() form = ProfileForm(obj=user.user_detail, email=current_user.email, - role_code=current_user.role_code, status_code=current_user.status_code, next=request.args.get('next')) diff --git a/fbone/templates/admin/layout.html b/fbone/templates/admin/layout.html index d69d7fc..1729823 100644 --- a/fbone/templates/admin/layout.html +++ b/fbone/templates/admin/layout.html @@ -6,4 +6,5 @@ {% set tabs = [ ("index", url_for('admin.index')), ("users", url_for('admin.users')), + ("roles", url_for('admin.roles')), ]%} diff --git a/fbone/templates/admin/role.html b/fbone/templates/admin/role.html new file mode 100644 index 0000000..aad4b02 --- /dev/null +++ b/fbone/templates/admin/role.html @@ -0,0 +1,10 @@ +{% from "macros/_form.html" import render_form %} + +{% extends "layouts/base.html" %} + +{% block body %} +
+

Edit Role

+ {{ render_form(url_for('admin.role', role_id=role.id), form) }} +
+{% endblock %} diff --git a/fbone/templates/admin/roles.html b/fbone/templates/admin/roles.html new file mode 100644 index 0000000..4630dc9 --- /dev/null +++ b/fbone/templates/admin/roles.html @@ -0,0 +1,23 @@ +{% extends "admin/layout.html" %} + +{% block body %} +
+

Roles

+ + + + + + + + + {% for role in roles %} + + + + + + {% endfor %} +
NameDescription
{{ role.name }}{{ role.description }}Edit
+
+{% endblock %} diff --git a/fbone/templates/admin/users.html b/fbone/templates/admin/users.html index 616e583..a1b5a88 100644 --- a/fbone/templates/admin/users.html +++ b/fbone/templates/admin/users.html @@ -10,6 +10,7 @@

Users

Status Role Created Time + Confirmed Time @@ -17,8 +18,13 @@

Users

{{ user.name }} {{ user.status }} - {{ user.role }} + {{ ",".join(user.get_role_names()) }} {{ user.created_time|format_date }} + {% if user.confirmed_at %} + {{ user.confirmed_at|format_date }} + {% else %} + + {% endif %} Edit {% endfor %} diff --git a/fbone/templates/macros/_form.html b/fbone/templates/macros/_form.html index 1215737..046974e 100644 --- a/fbone/templates/macros/_form.html +++ b/fbone/templates/macros/_form.html @@ -81,8 +81,14 @@ for="{{ field.id }}"> {{ field.label }} -
- {{ field }} +
+ {% set attrs = {} %} + {# if not field.description is string #} + {% if field.description | is_classname('FieldDescription') %} + {% do attrs.update({'placeholder': field.description.placeholder }) if field.description.placeholder %} + {% do attrs.update({'title': field.description.title }) %} + {% endif %} + {{ field(**attrs) }} {{ field.description }} {% if field.errors -%}
    @@ -134,13 +140,11 @@ {% endif %} {% set focus = True %} {% for field in form %} - {% if field.type != "HiddenField" and field.type != "CSRFTokenField" %} + {% if field.type != "HiddenField" and field.type != "CSRFTokenField" and field.type != "SubmitField" %} {% if field.type == "RadioField" %} {{ render_radio(field) }} {% elif field.type == "BooleanField" %} {{ render_checkbox(field) }} - {% elif field.type == "SubmitField" %} - {{ render_action(field) }} {% elif field.type == "TextAreaField" %} {{ render_textarea(field) }} {% elif field.type == "DateField" %} @@ -159,5 +163,17 @@ {% endif %} {% endif %} {% endfor %} +<<<<<<< HEAD + {% for field in form %} + {% if field.type == "SubmitField" %} + {% if field.type == "SubmitField" %} + {{ render_action(field) }} + {% endif %} + {% endif %} + {% endfor %} + + +======= +>>>>>>> upstream/master {% endmacro %} diff --git a/fbone/templates/macros/_social.html b/fbone/templates/macros/_social.html new file mode 100644 index 0000000..927ad84 --- /dev/null +++ b/fbone/templates/macros/_social.html @@ -0,0 +1,25 @@ +{% macro social_register(provider_id, display_name) %} +
    + +
    +{% endmacro %} + + +{% macro social_login(provider_id, display_name) %} +
    + +
    +{% endmacro %} + +{% macro social_connect_button(provider_id, display_name, conn) %} + {% if conn %} +
    + +
    + {% else %} +
    + +
    + {% endif %} +{% endmacro %} + diff --git a/fbone/templates/security/_macros.html b/fbone/templates/security/_macros.html new file mode 100644 index 0000000..8575f3d --- /dev/null +++ b/fbone/templates/security/_macros.html @@ -0,0 +1,16 @@ +{% macro render_field_with_errors(field) %} +

    + {{ field.label }} {{ field(**kwargs)|safe }} + {% if field.errors %} +

      + {% for error in field.errors %} +
    • {{ error }}
    • + {% endfor %} +
    + {% endif %} +

    +{% endmacro %} + +{% macro render_field(field) %} +

    {{ field(**kwargs)|safe }}

    +{% endmacro %} \ No newline at end of file diff --git a/fbone/templates/security/change_password.html b/fbone/templates/security/change_password.html new file mode 100644 index 0000000..37b9995 --- /dev/null +++ b/fbone/templates/security/change_password.html @@ -0,0 +1,17 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% from "macros/_form.html" import render_form %} + +{% set page_title = "Change password" %} + +{% extends "layouts/base.html" %} +{% block body %} + +

    Change password

    +{{ render_form(url_for_security('change_password'), change_password_form)}} + + {% if security.recoverable %} +
  • Forgot password
  • + {% endif %} + +{% endblock %} + diff --git a/fbone/templates/security/email/change_notice.html b/fbone/templates/security/email/change_notice.html new file mode 100644 index 0000000..d1224cf --- /dev/null +++ b/fbone/templates/security/email/change_notice.html @@ -0,0 +1,4 @@ +

    Your password has been changed.

    +{% if security.recoverable %} +

    If you did not change your password, click here to reset it.

    +{% endif %} diff --git a/fbone/templates/security/email/change_notice.txt b/fbone/templates/security/email/change_notice.txt new file mode 100644 index 0000000..e74bd80 --- /dev/null +++ b/fbone/templates/security/email/change_notice.txt @@ -0,0 +1,5 @@ +Your password has been changed +{% if security.recoverable %} +If you did not change your password, click the link below to reset it. +{{ url_for_security('forgot_password', _external=True) }} +{% endif %} diff --git a/fbone/templates/security/email/confirmation_instructions.html b/fbone/templates/security/email/confirmation_instructions.html new file mode 100644 index 0000000..5082a9a --- /dev/null +++ b/fbone/templates/security/email/confirmation_instructions.html @@ -0,0 +1,3 @@ +

    Please confirm your email through the link below:

    + +

    Confirm my account

    \ No newline at end of file diff --git a/fbone/templates/security/email/confirmation_instructions.txt b/fbone/templates/security/email/confirmation_instructions.txt new file mode 100644 index 0000000..fb435b5 --- /dev/null +++ b/fbone/templates/security/email/confirmation_instructions.txt @@ -0,0 +1,3 @@ +Please confirm your email through the link below: + +{{ confirmation_link }} \ No newline at end of file diff --git a/fbone/templates/security/email/login_instructions.html b/fbone/templates/security/email/login_instructions.html new file mode 100644 index 0000000..45a7cb5 --- /dev/null +++ b/fbone/templates/security/email/login_instructions.html @@ -0,0 +1,5 @@ +

    Welcome {{ user.email }}!

    + +

    You can log into your through the link below:

    + +

    Login now

    \ No newline at end of file diff --git a/fbone/templates/security/email/login_instructions.txt b/fbone/templates/security/email/login_instructions.txt new file mode 100644 index 0000000..1364ed6 --- /dev/null +++ b/fbone/templates/security/email/login_instructions.txt @@ -0,0 +1,5 @@ +Welcome {{ user.email }}! + +You can log into your through the link below: + +{{ login_link }} \ No newline at end of file diff --git a/fbone/templates/security/email/reset_instructions.html b/fbone/templates/security/email/reset_instructions.html new file mode 100644 index 0000000..fd0b48d --- /dev/null +++ b/fbone/templates/security/email/reset_instructions.html @@ -0,0 +1 @@ +

    Click here to reset your password

    \ No newline at end of file diff --git a/fbone/templates/security/email/reset_instructions.txt b/fbone/templates/security/email/reset_instructions.txt new file mode 100644 index 0000000..91ac288 --- /dev/null +++ b/fbone/templates/security/email/reset_instructions.txt @@ -0,0 +1,3 @@ +Click the link below to reset your password: + +{{ reset_link }} \ No newline at end of file diff --git a/fbone/templates/security/email/reset_notice.html b/fbone/templates/security/email/reset_notice.html new file mode 100644 index 0000000..536e296 --- /dev/null +++ b/fbone/templates/security/email/reset_notice.html @@ -0,0 +1 @@ +

    Your password has been reset

    \ No newline at end of file diff --git a/fbone/templates/security/email/reset_notice.txt b/fbone/templates/security/email/reset_notice.txt new file mode 100644 index 0000000..a3fa0b4 --- /dev/null +++ b/fbone/templates/security/email/reset_notice.txt @@ -0,0 +1 @@ +Your password has been reset \ No newline at end of file diff --git a/fbone/templates/security/email/welcome.html b/fbone/templates/security/email/welcome.html new file mode 100644 index 0000000..55eaed6 --- /dev/null +++ b/fbone/templates/security/email/welcome.html @@ -0,0 +1,7 @@ +

    Welcome {{ user.email }}!

    + +{% if security.confirmable %} +

    You can confirm your email through the link below:

    + +

    Confirm my account

    +{% endif %} \ No newline at end of file diff --git a/fbone/templates/security/email/welcome.txt b/fbone/templates/security/email/welcome.txt new file mode 100644 index 0000000..fb6ee5b --- /dev/null +++ b/fbone/templates/security/email/welcome.txt @@ -0,0 +1,7 @@ +Welcome {{ user.email }}! + +{% if security.confirmable %} +You can confirm your email through the link below: + +{{ confirmation_link }} +{% endif %} \ No newline at end of file diff --git a/fbone/templates/security/forgot_password.html b/fbone/templates/security/forgot_password.html new file mode 100644 index 0000000..6233829 --- /dev/null +++ b/fbone/templates/security/forgot_password.html @@ -0,0 +1,14 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% from "macros/_form.html" import render_form %} + +{% set page_title = "Forgot Password" %} + +{% extends "layouts/base.html" %} +{% block body %} + +

    Send password reset instructions

    + +{{ render_form(url_for_security('forgot_password'), forgot_password_form)}} + + +{% endblock %} diff --git a/fbone/templates/security/login_user.html b/fbone/templates/security/login_user.html new file mode 100644 index 0000000..8ea89eb --- /dev/null +++ b/fbone/templates/security/login_user.html @@ -0,0 +1,22 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% from "macros/_form.html" import render_form %} +{% from "macros/_social.html" import social_login %} + +{% set page_title = "Sign in" %} + +{% extends "layouts/base.html" %} +{% block body %} + +

    Login

    +{{ render_form(url_for_security('login'), login_user_form)}} + +{{ social_login('facebook', 'Facebook') }} +{{ social_login('twitter', 'Twitter') }} +{{ social_login('google', 'Google') }} +{{ social_login('foursquare', 'foursquare') }} + +{% if security.recoverable %} +
  • Forgot password
  • +{% endif %} + +{% endblock %} diff --git a/fbone/templates/security/register_user.html b/fbone/templates/security/register_user.html new file mode 100644 index 0000000..cab6bc6 --- /dev/null +++ b/fbone/templates/security/register_user.html @@ -0,0 +1,30 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% from "macros/_form.html" import render_form %} +{% from "macros/_social.html" import social_register %} + +{% set page_title = "Register" %} + +{% extends "layouts/base.html" %} +{% block body %} +
    +

    Register

    +
    + +
    +
    +{{ render_form(url_for_security('register'), register_user_form)}} +
    + +
    +

    OR

    +
    +
    + +{{ social_register('facebook', 'Facebook') }} +{{ social_register('twitter', 'Twitter') }} +{{ social_register('google', 'Google') }} +{{ social_register('foursquare', 'foursquare') }} +
    + +
    +{% endblock %} diff --git a/fbone/templates/security/reset_password.html b/fbone/templates/security/reset_password.html new file mode 100644 index 0000000..e14ec47 --- /dev/null +++ b/fbone/templates/security/reset_password.html @@ -0,0 +1,12 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% from "macros/_form.html" import render_form %} + +{% set page_title = "Reset password" %} + +{% extends "layouts/base.html" %} +{% block body %} + +

    Reset password

    +{{ render_form(url_for_security('reset_password'), reset_password_form)}} + +{% endblock %} diff --git a/fbone/templates/security/send_confirmation.html b/fbone/templates/security/send_confirmation.html new file mode 100644 index 0000000..38d6f91 --- /dev/null +++ b/fbone/templates/security/send_confirmation.html @@ -0,0 +1,12 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% from "macros/_form.html" import render_form %} + +{% set page_title = "Resend confirmation" %} + +{% extends "layouts/base.html" %} +{% block body %} + +

    Resend confirmation instructions

    +{{ render_form(url_for_security('send_confirmation'), send_confirmation_form)}} + +{% endblock %} diff --git a/fbone/templates/security/send_login.html b/fbone/templates/security/send_login.html new file mode 100644 index 0000000..a6f2a18 --- /dev/null +++ b/fbone/templates/security/send_login.html @@ -0,0 +1,12 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% from "macros/_form.html" import render_form %} + +{% set page_title = "Send Login" %} + +{% extends "layouts/base.html" %} +{% block body %} + +

    Login

    +{{ render_form(url_for_security('login'), send_login_form)}} + +{% endblock %} diff --git a/fbone/user/__init__.py b/fbone/user/__init__.py index a95322c..1d023b2 100644 --- a/fbone/user/__init__.py +++ b/fbone/user/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from .models import UserDetail, User +from .models import UserDetail, User, Role, SocialConnection from .views import user -from .constants import USER_ROLE, ADMIN, USER, USER_STATUS, NEW, ACTIVE +from .constants import USER_STATUS, NEW, ACTIVE diff --git a/fbone/user/constants.py b/fbone/user/constants.py index e700542..fc597a7 100644 --- a/fbone/user/constants.py +++ b/fbone/user/constants.py @@ -1,15 +1,5 @@ # -*- coding: utf-8 -*- -# User role -ADMIN = 0 -STAFF = 1 -USER = 2 -USER_ROLE = { - ADMIN: 'admin', - STAFF: 'staff', - USER: 'user', -} - # User status INACTIVE = 0 NEW = 1 diff --git a/fbone/user/models.py b/fbone/user/models.py index 2a5b6e7..a1c5c7b 100644 --- a/fbone/user/models.py +++ b/fbone/user/models.py @@ -2,12 +2,16 @@ from sqlalchemy import Column, types from sqlalchemy.ext.mutable import Mutable -from werkzeug import generate_password_hash, check_password_hash -from flask.ext.login import UserMixin +from werkzeug import generate_password_hash +from flask.ext.security import ( UserMixin, RoleMixin, login_required ) +from flask.ext.security.utils import ( encrypt_password, verify_and_update_password ) +from flask.ext.principal import ( RoleNeed ) +from flask import current_app from ..extensions import db from ..utils import get_current_time, SEX_TYPE, STRING_LEN -from .constants import USER, USER_ROLE, ADMIN, INACTIVE, USER_STATUS +from ..models import IdTimestampMixin +from .constants import INACTIVE, USER_STATUS class DenormalizedText(Mutable, types.TypeDecorator): @@ -44,6 +48,27 @@ def process_result_value(self, value, dialect): def copy_value(self, value): return set(value) +class SocialConnection(IdTimestampMixin, db.Model): + __tablename__ = 'social_connections' + user_id = db.Column(db.Integer, db.ForeignKey('users.id')) + provider_id = db.Column(db.String(255)) + provider_user_id = db.Column(db.String(255)) + access_token = db.Column(db.String(255)) + secret = db.Column(db.String(255)) + display_name = db.Column(db.String(255)) + profile_url = db.Column(db.String(512)) + image_url = db.Column(db.String(512)) + rank = db.Column(db.Integer) + +# Define models +roles_users = db.Table('roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('users.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('roles.id'))) + +class Role(IdTimestampMixin, db.Model, RoleMixin): + __tablename__ = 'roles' + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(STRING_LEN)) class UserDetail(db.Model): @@ -78,6 +103,19 @@ class User(db.Model, UserMixin): activation_key = Column(db.String(STRING_LEN)) created_time = Column(db.DateTime, default=get_current_time) + active = db.Column(db.Boolean()) + confirmed_at = db.Column(db.DateTime()) + current_login_at = db.Column(db.DateTime()) + last_login_at = db.Column(db.DateTime()) + current_login_ip = db.Column(db.String(STRING_LEN)) + last_login_ip = db.Column(db.String(STRING_LEN)) + login_count = db.Column(db.Integer()) + + roles = db.relationship( + 'Role', + secondary=roles_users, + backref=db.backref('users', lazy='dynamic') + ) avatar = Column(db.String(STRING_LEN)) _password = Column('password', db.String(STRING_LEN), nullable=False) @@ -86,7 +124,8 @@ def _get_password(self): return self._password def _set_password(self, password): - self._password = generate_password_hash(password) + self._password = encrypt_password(password) + # Hide password encryption by exposing password field only. password = db.synonym('_password', descriptor=property(_get_password, @@ -95,17 +134,38 @@ def _set_password(self, password): def check_password(self, password): if self.password is None: return False - return check_password_hash(self.password, password) + return verify_and_update_password(password, self) # ================================================================ - role_code = Column(db.SmallInteger, default=USER, nullable=False) + def is_admin(self): + return self.has_role(u'admin') + + def is_staff(self): + return self.has_role(u'staff') + + def get_role_names(self): + return [ r.name for r in self.roles ] + + def has_role(self, role_name): + return ( current_app.security.datastore.find_role(role_name) \ + in self.roles ) + + def add_role(self, role_name): + current_app.security.datastore.add_role_to_user(self, role_name) + + def remove_role(self, role_name): + role_names = self.get_role_names() + if role_name in role_names: + current_app.security.datastore.remove_role_from_user( + self, role_name) + + def empty_roles(self): + role_names = self.get_role_names() + for role_name in role_names: + current_app.security.datastore.remove_role_from_user( + self, role_name) - @property - def role(self): - return USER_ROLE[self.role_code] - def is_admin(self): - return self.role_code == ADMIN # ================================================================ # One-to-many relationship between users and user_statuses. diff --git a/manage.py b/manage.py index 624250d..ac6d09d 100644 --- a/manage.py +++ b/manage.py @@ -4,9 +4,9 @@ from fbone import create_app from fbone.extensions import db -from fbone.user import User, UserDetail, ADMIN, ACTIVE +from fbone.user import User, UserDetail, Role, ACTIVE from fbone.utils import MALE - +from datetime import datetime app = create_app() manager = Manager(app) @@ -16,7 +16,7 @@ def run(): """Run in local machine.""" - app.run() + app.run(host="0.0.0.0") @manager.command @@ -26,19 +26,40 @@ def initdb(): db.drop_all() db.create_all() - admin = User( - name=u'admin', - email=u'admin@example.com', - password=u'123456', - role_code=ADMIN, - status_code=ACTIVE, - user_detail=UserDetail( + db.session.add(Role( + name=u'admin', + description='Administrator role' + )) + + db.session.add(Role( + name=u'staff', + description='Staff role' + )) + + db.session.add(Role( + name=u'user', + description='User role' + )) + db.session.commit() + + + ds = app.security.datastore + admin = ds.create_user( + email='admin', + name='Administrator', + password=u'123456', + roles=['admin', 'user'], + status_code=ACTIVE, + confirmed_at=datetime.utcnow(), + user_detail=UserDetail( sex_code=MALE, age=10, url=u'http://admin.example.com', deposit=100.00, location=u'Hangzhou', - bio=u'admin Guy is ... hmm ... just a admin guy.')) + bio=u'admin Guy is ... hmm ... just a admin guy.') + ) + db.session.add(admin) db.session.commit() diff --git a/setup.py b/setup.py index 035714a..71708f9 100644 --- a/setup.py +++ b/setup.py @@ -25,8 +25,15 @@ 'Flask-Cache', 'Flask-Login', 'Flask-OpenID', + 'Flask-Principal', + 'Flask-Social', 'nose', 'mysql-python', + 'facebook', + 'python-twitter', + 'foursquare', + 'oauth2client', + 'google-api-python-client', 'fabric', ], test_suite='tests',