diff --git a/config/local_settings_test.py b/config/local_settings_test.py index 1521a02..eae1812 100644 --- a/config/local_settings_test.py +++ b/config/local_settings_test.py @@ -1,3 +1,5 @@ +from os import path + GITHUB_CONFIG = { 'repositories': [ ('pretenders', 'deploystream'), @@ -19,3 +21,7 @@ GIT_CONFIG = { } + +CSRF_ENABLED = False +TEST_DB_LOCATION = path.join(path.abspath(path.dirname(__file__)), 'test.db') +SQLALCHEMY_DATABASE_URI = ('sqlite:///' + TEST_DB_LOCATION) diff --git a/config/settings.py b/config/settings.py index 33843ed..9b521e2 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,12 +1,28 @@ from os import path, environ import traceback +_basedir = path.abspath(path.dirname(__file__)) -APP_PACKAGE = path.basename(path.dirname(__file__)) +APP_PACKAGE = path.basename(_basedir) + +# Default settings, overridden by the python file pointed to by CONFIG variable +SQLALCHEMY_DATABASE_URI = 'sqlite:///' + path.join(_basedir, 'app.db') +DATABASE_CONNECT_OPTIONS = {} + +# THREADS_PER_PAGE = 8 + +CSRF_ENABLED = True +CSRF_SESSION_KEY = "somethingimpossibletoguess" + +# RECAPTCHA_USE_SSL = False +# RECAPTCHA_PUBLIC_KEY = 'blahblahblahblahblahblahblahblahblah' +# RECAPTCHA_PRIVATE_KEY = 'blahblahblahblahblahblahprivate' +# RECAPTCHA_OPTIONS = {'theme': 'white'} + +GITHUB_CONFIG = GIT_CONFIG = SPRINTLY_CONFIG = JIRA_CONFIG = None # The following is the programmatic equivalent of # from deploystream.local_settings_ import * -GITHUB_CONFIG = GIT_CONFIG = SPRINTLY_CONFIG = JIRA_CONFIG = None try: CONFIG = environ.get('CONFIG', 'sample') diff --git a/deploystream/__init__.py b/deploystream/__init__.py index 263a040..cafa438 100644 --- a/deploystream/__init__.py +++ b/deploystream/__init__.py @@ -3,6 +3,7 @@ from os import environ from os.path import join, dirname from flask import Flask +from flask.ext.sqlalchemy import SQLAlchemy APP_DIR = dirname(__file__) CONFIG_DIR = join(dirname(APP_DIR), 'config') @@ -51,6 +52,7 @@ # something that is actually a secret app.secret_key = 'mysecret' +db = SQLAlchemy(app) # Initialise the providers. from providers import init_providers classes = init_providers(app.config['PROVIDERS']) @@ -62,3 +64,6 @@ # Import any views we want to register here at the bottom of the file: import deploystream.views # NOQA import deploystream.apps.feature.views # NOQA + +from deploystream.apps.users.views import mod as usersModule +app.register_blueprint(usersModule) diff --git a/deploystream/apps/oauth/views.py b/deploystream/apps/oauth/views.py index 5a31280..b265864 100644 --- a/deploystream/apps/oauth/views.py +++ b/deploystream/apps/oauth/views.py @@ -1,8 +1,11 @@ from flask import session, redirect, flash, request, url_for from flask_oauth import OAuth -from deploystream import app +from deploystream import app, db from deploystream.apps.oauth import get_token, set_token +from deploystream.apps.users.models import User, OAuth as UserOAuth +from deploystream.apps.users.lib import (load_user_to_session, + get_user_id_from_session) from deploystream.providers.interfaces import class_implements, IOAuthProvider @@ -62,18 +65,79 @@ def oauth_authorized(resp): set_token(session, oauth_name, resp['access_token']) - if request.args.get('islogin'): - user = OAUTH_OBJECTS[oauth_name].get('/user') - username = user.data['login'] - session['username'] = username + # If registering, add a User and UserOAuth and load to session. + # If Logging in, then just loading to session + # If linking, then just adding a userOauth. + + current_user_id = get_user_id_from_session(session) + remote_user = OAUTH_OBJECTS[oauth_name].get('/user') + remote_user_id = remote_user.data['id'] + + user = get_or_create_user_oauth(current_user_id, remote_user_id, + oauth_name, remote_user.data['login']) + + load_user_to_session(session, user) return redirect(next_url) -@app.route('/login') -def login(): +def get_or_create_user_oauth(user_id, service_user_id, service_name, + service_username): + """ + Get or create OAuth information and Users with the given information. + + Handles a number of scenarios: + - No user id is known. (ie user is logged out) + - OAuth object exists: return the associated user. + - OAuth object doesn't exist: create it, along with a user and link + them + - User id is known (ie user is logged in) + - Create and link the OAuth data to the account. + + :param user_id: + The id of the currently logged in user. Or ``None`` if logged out. + :param service_user_id: + The id of the user according to the external service. + :param service_name: + The name used internally to reference the external service. + :param service_username: + The username that the service knows this user by. This will be used to + create the account in deploystream if logging in for the first time. + """ + if not user_id: + # We're either logging in or registering + oauth_obj = UserOAuth.query.filter_by(service_user_id=service_user_id, + service=service_name).first() + if not oauth_obj: + # Create a user and an OAuth linked to it. + current_user = User(username=service_username) + oauth = UserOAuth(service_user_id=service_user_id, + service=service_name, + service_username=service_username) + oauth.user = current_user + db.session.add(current_user) + db.session.add(oauth) + db.session.commit() + user_id = current_user.id + else: + user_id = oauth_obj.user.id + + else: + # We're linking the account + oauth = UserOAuth(service_user_id=service_user_id, + service=service_name, + service_username=service_username, + user_id=user_id) + db.session.add(oauth) + db.session.commit() + + return user_id + + +@app.route('/oauth/') +def link_up(oauth_name): "Handler for calls to login via github." - return start_token_processing('github', islogin=True) + return start_token_processing(oauth_name, islogin=True) def start_token_processing(oauth_name, islogin=None): @@ -83,4 +147,5 @@ def start_token_processing(oauth_name, islogin=None): oauth_name=oauth_name, islogin=islogin, _external=True) + return OAUTH_OBJECTS[oauth_name].authorize(callback=url) diff --git a/deploystream/apps/users/__init__.py b/deploystream/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deploystream/apps/users/constants.py b/deploystream/apps/users/constants.py new file mode 100644 index 0000000..3635f24 --- /dev/null +++ b/deploystream/apps/users/constants.py @@ -0,0 +1,22 @@ +# User role +ADMIN = 0 +STAFF = 1 +USER = 2 +ROLE = { + ADMIN: 'admin', + STAFF: 'staff', + USER: 'user', +} + +# user status +INACTIVE = 0 +NEW = 1 +ACTIVE = 2 +STATUS = { + INACTIVE: 'inactive', + NEW: 'new', + ACTIVE: 'active', +} + +#OAUTH keys supported: +OAUTHS = ['github'] diff --git a/deploystream/apps/users/decorators.py b/deploystream/apps/users/decorators.py new file mode 100644 index 0000000..aa0e4e1 --- /dev/null +++ b/deploystream/apps/users/decorators.py @@ -0,0 +1,13 @@ +from functools import wraps + +from flask import g, flash, redirect, url_for, request + + +def requires_login(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user is None: + flash(u'You need to be signed in for this page.') + return redirect(url_for('users.login', next=request.path)) + return f(*args, **kwargs) + return decorated_function diff --git a/deploystream/apps/users/forms.py b/deploystream/apps/users/forms.py new file mode 100644 index 0000000..d00c389 --- /dev/null +++ b/deploystream/apps/users/forms.py @@ -0,0 +1,35 @@ +from flask.ext.wtf import ( + Form, TextField, PasswordField, BooleanField, RecaptchaField +) +from flask.ext.wtf import Required, Email, EqualTo + +from .models import User + + +class LoginForm(Form): + username = TextField('Username', [Required()]) + password = PasswordField('Password', [Required()]) + + +class RegisterForm(Form): + username = TextField('Username', [Required()]) + email = TextField('Email address', [Email()]) + password = PasswordField('Password', [Required()]) + confirm = PasswordField('Repeat Password', [ + Required(), + EqualTo('password', message='Passwords must match') + ]) + accept_tos = BooleanField('I accept the TOS', [Required()]) + #recaptcha = RecaptchaField() + + def validate(self): + rv = Form.validate(self) + if not rv: + return False + + uname_clash = User.query.filter_by(username=self.username.data).first() + if uname_clash: + self.username.errors.append("This username is already in use.") + return False + + return True diff --git a/deploystream/apps/users/lib.py b/deploystream/apps/users/lib.py new file mode 100644 index 0000000..a909eff --- /dev/null +++ b/deploystream/apps/users/lib.py @@ -0,0 +1,10 @@ +def load_user_to_session(session, user): + session['user_id'] = user.id + session['user_name'] = user.username + + +def get_user_id_from_session(session): + try: + return session['user_id'] + except KeyError: + return None diff --git a/deploystream/apps/users/models.py b/deploystream/apps/users/models.py new file mode 100644 index 0000000..bea995d --- /dev/null +++ b/deploystream/apps/users/models.py @@ -0,0 +1,48 @@ +from datetime import datetime + +from deploystream import db +from deploystream.apps.users import constants as USERS + + +class User(db.Model): + + __tablename__ = 'users_user' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(50), unique=True) + email = db.Column(db.String(120)) + password = db.Column(db.String(20)) + role = db.Column(db.SmallInteger, default=USERS.USER) + status = db.Column(db.SmallInteger, default=USERS.NEW) + created = db.Column(db.DateTime, default=datetime.now) + + oauth_keys = db.relationship('OAuth', backref='user') + + def __init__(self, username=None, email=None, password=None): + self.username = username + self.email = email + self.password = password + + def getStatus(self): + return USERS.STATUS[self.status] + + def getRole(self): + return USERS.ROLE[self.role] + + def __repr__(self): + return '' % (self.username) + + +class OAuth(db.Model): + + __tablename__ = 'users_oauth' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users_user.id')) + service = db.Column(db.String(20)) + service_user_id = db.Column(db.String(120)) + service_username = db.Column(db.String(50)) + + # A user on an external service should only exist once in deploystream. + db.UniqueConstraint('service', 'service_user_id', name='service_user') + + # Each user in deploystream should only have each service oauth'd once. + db.UniqueConstraint('user_id', 'service', name='user_service') diff --git a/deploystream/apps/users/template_helpers.py b/deploystream/apps/users/template_helpers.py new file mode 100644 index 0000000..a7183af --- /dev/null +++ b/deploystream/apps/users/template_helpers.py @@ -0,0 +1,27 @@ +from . import constants +from .models import OAuth +from deploystream import app + + +@app.template_filter('all_oauths') +def all_oauths(user): + """Return all available oauths for the User. + + Returns all oauth information including those that the user hasn't + connected to yet. + """ + all_oauth = [] + for oauth_service in constants.OAUTHS: + user_oauth = OAuth.query.filter_by(user_id=user.id, + service=oauth_service).first() + if user_oauth: + all_oauth.append((oauth_service, user_oauth.service_username)) + else: + all_oauth.append((oauth_service, None)) + + return all_oauth + + +@app.template_filter('humanize_time') +def humanize_time(datetime): + return datetime.strftime("%B %d, %Y") diff --git a/deploystream/apps/users/views.py b/deploystream/apps/users/views.py new file mode 100644 index 0000000..ed39573 --- /dev/null +++ b/deploystream/apps/users/views.py @@ -0,0 +1,76 @@ +from flask import (Blueprint, request, render_template, flash, g, session, + redirect, url_for) +from werkzeug import check_password_hash, generate_password_hash + +from deploystream import db +from .forms import RegisterForm, LoginForm +from .models import User +from .lib import load_user_to_session +from .decorators import requires_login +from . import template_helpers + +mod = Blueprint('users', __name__, url_prefix='/users') + + +@mod.route('/me/') +@requires_login +def home(): + return render_template("users/profile.html", user=g.user) + + +@mod.before_request +def before_request(): + """ + pull user's profile from the database before every request are treated + """ + g.user = None + if 'user_id' in session: + g.user = User.query.get(session['user_id']) + + +@mod.route('/login/', methods=['GET', 'POST']) +def login(): + """ + Login form + """ + form = LoginForm(request.form) + # make sure data are valid, but doesn't validate password is right + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data).first() + # we use werzeug to validate user's password + if user and check_password_hash(user.password, form.password.data): + load_user_to_session(session, user) + + flash('Welcome %s' % user.username) + return redirect(url_for('users.home')) + flash('Wrong email or password', 'error-message') + if request.method == 'POST': + suffix = ".html" + else: + suffix = "_ajax.html" + return render_template("users/login" + suffix, form=form) + + +@mod.route('/register/', methods=['GET', 'POST']) +def register(): + """ + Registration Form + """ + form = RegisterForm(request.form) + if form.validate_on_submit(): + # create a user instance not yet stored in the database + user = User(form.username.data, form.email.data, + generate_password_hash(form.password.data)) + # Insert the record in our database and commit it + db.session.add(user) + db.session.commit() + + # Log the user in, as he now has an id + load_user_to_session(session, user) + + # flash will display a message to the user + flash('Thanks for registering') + # redirect user to the 'home' method of the user module. + return redirect(url_for('users.home')) + + return render_template("users/register.html", form=form) diff --git a/deploystream/static/favicon.ico b/deploystream/static/favicon.ico new file mode 100644 index 0000000..6848441 Binary files /dev/null and b/deploystream/static/favicon.ico differ diff --git a/deploystream/static/font/FontAwesome.otf b/deploystream/static/font/FontAwesome.otf new file mode 100644 index 0000000..64049bf Binary files /dev/null and b/deploystream/static/font/FontAwesome.otf differ diff --git a/deploystream/static/font/fontawesome-webfont.eot b/deploystream/static/font/fontawesome-webfont.eot new file mode 100755 index 0000000..7d81019 Binary files /dev/null and b/deploystream/static/font/fontawesome-webfont.eot differ diff --git a/deploystream/static/font/fontawesome-webfont.svg b/deploystream/static/font/fontawesome-webfont.svg new file mode 100755 index 0000000..ba0afe5 --- /dev/null +++ b/deploystream/static/font/fontawesome-webfont.svg @@ -0,0 +1,284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/deploystream/static/font/fontawesome-webfont.ttf b/deploystream/static/font/fontawesome-webfont.ttf new file mode 100755 index 0000000..d461724 Binary files /dev/null and b/deploystream/static/font/fontawesome-webfont.ttf differ diff --git a/deploystream/static/font/fontawesome-webfont.woff b/deploystream/static/font/fontawesome-webfont.woff new file mode 100755 index 0000000..3c89ae0 Binary files /dev/null and b/deploystream/static/font/fontawesome-webfont.woff differ diff --git a/deploystream/static/less/bootstrap-modules.less b/deploystream/static/less/bootstrap-modules.less index 798c5e0..8afb5bf 100644 --- a/deploystream/static/less/bootstrap-modules.less +++ b/deploystream/static/less/bootstrap-modules.less @@ -13,7 +13,7 @@ // Base CSS @import "bootstrap/type.less"; @import "bootstrap/code.less"; -// @import "bootstrap/forms.less"; +@import "bootstrap/forms.less"; @import "bootstrap/tables.less"; // Components: common diff --git a/deploystream/static/partials/home.html b/deploystream/static/partials/home.html index 4248b82..0dcf07d 100644 --- a/deploystream/static/partials/home.html +++ b/deploystream/static/partials/home.html @@ -10,7 +10,7 @@

Welcome to Ployst!

from various tools.

- + Sign in via Github

diff --git a/deploystream/templates/base-angular.html b/deploystream/templates/base-angular.html new file mode 100644 index 0000000..14535f0 --- /dev/null +++ b/deploystream/templates/base-angular.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block extrahead %} + +{% endblock %} diff --git a/deploystream/templates/base.html b/deploystream/templates/base.html index e36bb90..bb39707 100644 --- a/deploystream/templates/base.html +++ b/deploystream/templates/base.html @@ -6,18 +6,16 @@ DeployStream {% block extratitle %}{% endblock %} - - + + + + + {% block extrahead %} {% endblock %} {% endblock %} @@ -43,10 +41,22 @@ - - - - + {% block scripts %}{% endblock %} diff --git a/deploystream/templates/forms/macros.html b/deploystream/templates/forms/macros.html new file mode 100644 index 0000000..af8b188 --- /dev/null +++ b/deploystream/templates/forms/macros.html @@ -0,0 +1,14 @@ +{% macro render_field(field) %} +
+ {{ field.label(class="control-label") }} +
+ {% if field.errors %} + {% set css_class = 'has_error ' + kwargs.pop('class', 'input-xlarge') %} + {{ field(class=css_class, **kwargs) }} +
    {% for error in field.errors %}
  • {{ error|e }}
  • {% endfor %}
+ {% else %} + {{ field(**kwargs) }} + {% endif %} +
+
+{% endmacro %} diff --git a/deploystream/templates/index-async.html b/deploystream/templates/index-async.html index f8ac747..5e8fa8f 100644 --- a/deploystream/templates/index-async.html +++ b/deploystream/templates/index-async.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "base-angular.html" %} {% block scripts %} diff --git a/deploystream/templates/navbar.html b/deploystream/templates/navbar.html index b2a31db..89f779c 100644 --- a/deploystream/templates/navbar.html +++ b/deploystream/templates/navbar.html @@ -7,26 +7,34 @@ diff --git a/deploystream/templates/users/login.html b/deploystream/templates/users/login.html new file mode 100644 index 0000000..c002d09 --- /dev/null +++ b/deploystream/templates/users/login.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block content %} + + {% from "forms/macros.html" import render_field %} +
+ {{ form.csrf_token }} + {{ render_field(form.username, class="input text") }} + {{ render_field(form.password, class="input text") }} +
+ +
+
+{% endblock %} diff --git a/deploystream/templates/users/login_ajax.html b/deploystream/templates/users/login_ajax.html new file mode 100644 index 0000000..3bd37d9 --- /dev/null +++ b/deploystream/templates/users/login_ajax.html @@ -0,0 +1,9 @@ +{% from "forms/macros.html" import render_field %} +
+ {{ form.csrf_token }} + {{ render_field(form.username, class="input text", autofocus="autofocus") }} + {{ render_field(form.password, class="input text") }} +
+ +
+
diff --git a/deploystream/templates/users/profile.html b/deploystream/templates/users/profile.html new file mode 100644 index 0000000..7b010e6 --- /dev/null +++ b/deploystream/templates/users/profile.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block content %} + +
+
+ +

{{ user.username }}

+
    +
  • Joined on {{ user.created | humanize_time }}
  • +
+ +

Linked Accounts

+
    + + {% for oauth_service, oauth_username in user | all_oauths %} +
  • + {% if oauth_username %} + {{ oauth_username }} + {% else %} + Add + {% endif %} +
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/deploystream/templates/users/register.html b/deploystream/templates/users/register.html new file mode 100644 index 0000000..29cb248 --- /dev/null +++ b/deploystream/templates/users/register.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block content %} + + {% from "forms/macros.html" import render_field %} +
+ {{ form.csrf_token }} + {{ render_field(form.username, class="input text") }} + {{ render_field(form.email, class="input text") }} + {{ render_field(form.password, class="input text") }} + {{ render_field(form.confirm, class="input text") }} + {{ render_field(form.accept_tos, class="input checkbox") }} +
+ +
+
+{% endblock %} diff --git a/requirements/runtime.txt b/requirements/runtime.txt index 59abf03..954f000 100644 --- a/requirements/runtime.txt +++ b/requirements/runtime.txt @@ -1,6 +1,8 @@ docopt # create command-line interface from docstring Flask # web framework Flask-OAuth # Oauth library for flask +flask-sqlalchemy # SQLAlchemy support for flask +Flask-WTF # Handle User's data submission github3.py # client for the GitHub v3 API GitPython # access git repositories zope.interface # define and enforce interfaces diff --git a/scripts/create_db.sh b/scripts/create_db.sh new file mode 100755 index 0000000..7fc8391 --- /dev/null +++ b/scripts/create_db.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +`dirname $0`/manage.py syncdb $* diff --git a/scripts/manage.py b/scripts/manage.py new file mode 100755 index 0000000..e6e6ca4 --- /dev/null +++ b/scripts/manage.py @@ -0,0 +1,59 @@ +# A module for doing all the things. +# Bares a vague resemblance to django's manage.py +# +# You can use it to access the shell, create the db and run the server. + +import os +import readline +from pprint import pprint +from flask import * +from deploystream import * +from deploystream import __version__ + +USAGE = """ +deploystream {version}: manage.py + +Do things with the application from the command line. + +Use this to create the database, access the shell and run the server in debug +mode. When deploying to a server you should use the .wsgi file. + +TODO: create the .wsgi file. + +Usage: + manage.py runserver + manage.py syncdb + manage.py shell + manage.py -h | --help + manage.py --version + +Options: + -h --help Show this screen. + --version Show version. +""".format(version=__version__) + + +def runserver(**kwargs): + app.run(debug=True, **kwargs) + + +def syncdb(): + db.create_all() + + +def shell(): + os.environ['PYTHONINSPECT'] = 'True' + +if __name__ == '__main__': + from docopt import docopt + arguments = docopt(USAGE, version=__version__) + kwargs = {} + if arguments['runserver']: + host, port = arguments[''].split(':') + kwargs['host'] = host + kwargs['port'] = int(port) + runserver(**kwargs) + elif arguments['syncdb']: + syncdb() + elif arguments['shell']: + shell() diff --git a/scripts/runserver.py b/scripts/runserver.py deleted file mode 100755 index 9c583f7..0000000 --- a/scripts/runserver.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python -from deploystream import __version__, app - -USAGE = """ -deploystream {version}: runserver.py - -Run the application from the command line. For debugging purposes -only. When deploying to a server you should use the .wsgi file. - -TODO: create the .wsgi file. - -Usage: - runserver.py - runserver.py - runserver.py -h | --help - runserver.py --version - -Options: - -h --help Show this screen. - --version Show version. -""".format(version=__version__) - -if __name__ == '__main__': - from docopt import docopt - arguments = docopt(USAGE, version=__version__) - kwargs = {} - if arguments['']: - host, port = arguments[''].split(':') - kwargs['host'] = host - kwargs['port'] = int(port) - - app.run(debug=True, **kwargs) diff --git a/scripts/runserver.sh b/scripts/runserver.sh index 68ccf83..f9c0234 100755 --- a/scripts/runserver.sh +++ b/scripts/runserver.sh @@ -1,3 +1,3 @@ #!/bin/bash -`dirname $0`/runserver.py $* +`dirname $0`/manage.py runserver $* diff --git a/scripts/shell.sh b/scripts/shell.sh new file mode 100755 index 0000000..ad9e716 --- /dev/null +++ b/scripts/shell.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +`dirname $0`/manage.py shell $* diff --git a/tests/__init__.py b/tests/__init__.py index 0491cd1..6dc7505 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,9 @@ +import os import os.path +import deploystream + + TEST_DATA = os.path.join(os.path.dirname(__file__), 'data') @@ -7,3 +11,9 @@ def load_fixture(filename): with file(os.path.join(TEST_DATA, filename)) as f: contents = f.read() return contents + + +def setup(): + if os.path.exists(deploystream.app.config['TEST_DB_LOCATION']): + os.remove(deploystream.app.config['TEST_DB_LOCATION']) + deploystream.db.create_all() diff --git a/tests/test_oauth/test_get_or_create_user_oauth.py b/tests/test_oauth/test_get_or_create_user_oauth.py new file mode 100644 index 0000000..32cdb4e --- /dev/null +++ b/tests/test_oauth/test_get_or_create_user_oauth.py @@ -0,0 +1,75 @@ +from nose.tools import assert_equal + +from sqlalchemy.exc import IntegrityError + +from deploystream import db +from deploystream.apps.oauth.views import get_or_create_user_oauth +from deploystream.apps.users.models import User, OAuth + + +def test_logged_out_first_time_oauth_use(): + "Test when first signing in with external service" + get_or_create_user_oauth(user_id=None, + service_user_id='101', + service_name='my-oauth-service', + service_username='testuser') + + created = User.query.filter_by(username='testuser').first() + assert_equal('testuser', created.username) + assert_equal(1, len(created.oauth_keys)) + assert_equal('testuser', created.oauth_keys[0].service_username) + assert_equal('my-oauth-service', created.oauth_keys[0].service) + + +def test_logged_out_first_time_oauth_name_clash(): + """ + Test when first signing in with external service with name clash. + + Tests the case where a user exists in deploystream with the same username + as that found in the external service. + """ + u = User(username='testclash') + db.session.add(u) + db.session.commit() + try: + get_or_create_user_oauth(user_id=None, + service_user_id='102', + service_name='my-oauth-service', + service_username='testclash') + + created = User.query.filter_by(username='testclash').first() + except IntegrityError: + db.session.rollback() + raise AssertionError("Not sure what we want to do in this case. " + "Create a random username? Prompt for one?") + + +def test_logged_out_return_oauth_access(): + "Test that correct user is retrieved and logged in." + first_created_id = get_or_create_user_oauth(user_id=None, + service_user_id='103', + service_name='my-oauth-service', + service_username='test_return_user') + # Now let's return logged out and see what happens. + user_id = get_or_create_user_oauth(user_id=None, + service_user_id='103', + service_name='my-oauth-service', + service_username='test_return_user') + + assert_equal(user_id, first_created_id) + + +def test_logged_in_and_attach_additional_oauth_information(): + "Test that an additional OAuth is added to the account" + u = User(username='additional') + db.session.add(u) + db.session.commit() + + user_id = get_or_create_user_oauth(user_id=u.id, + service_user_id='109', + service_name='my-oauth-service', + service_username='add-github') + + assert_equal(user_id, u.id) + original_user = User.query.filter_by(username='additional').first() + assert_equal('add-github', original_user.oauth_keys[0].service_username) diff --git a/tests/test_providers/test_git_provider.py b/tests/test_providers/test_git_provider.py index 116b02f..4e72224 100644 --- a/tests/test_providers/test_git_provider.py +++ b/tests/test_providers/test_git_provider.py @@ -1,7 +1,7 @@ import os from os.path import join, exists, dirname -from nose.tools import assert_equal, with_setup +from nose.tools import assert_equal, with_setup, assert_items_equal from deploystream.providers.git_provider import GitProvider @@ -19,7 +19,10 @@ def ensure_dummy_clone_available(): os.system('git clone git://github.com/pretenders/dummyrepo.git {0}' .format(folder_name)) else: - os.system('git --git-dir={0} fetch'.format(folder_name)) + cmd = 'git --git-dir={0}/.git fetch'.format(folder_name) + ans = os.system(cmd) + if ans != 0: + raise Exception("Git fetch failed") @with_setup(ensure_dummy_clone_available) @@ -37,9 +40,11 @@ def test_git_provider_finds_branches_across_repos(): branch_finder_template=".*(?i){project}.*") branches = provider.get_repo_branches_involved('FeAtUrE-99') - assert_equal([ + assert_items_equal([ ('dummyrepo', 'my/feature_branch', 'cf9130d3c07b061a88569153f10a7c7779338cfa'), + ('dummyrepo', 'my/feature-99', + '7098fa31bf9663343c723d9d155c0dc6e6e28174'), ], branches) @@ -48,7 +53,7 @@ def test_git_provider_feature_breakup_regex(): Test that GitProvider breaks up feature ids into appropriate parts. """ provider = GitProvider( - feature_breakup_regex="(?P[a-zA-Z]+)-?(?P[0-9]+)") + feature_breakup_regex="(?P[a-zA-Z]+)-?(?P[0-9]+)") for feature, expected in [ ('DD-334', {'id': '334', 'project':'DD'}), ('DD334', {'id': '334', 'project':'DD'}), diff --git a/tests/test_users/__init__.py b/tests/test_users/__init__.py new file mode 100644 index 0000000..103e566 --- /dev/null +++ b/tests/test_users/__init__.py @@ -0,0 +1,9 @@ +import os + +import deploystream + + +def setup(): + if os.path.exists(deploystream.app.config['TEST_DB_LOCATION']): + os.remove(deploystream.app.config['TEST_DB_LOCATION']) + deploystream.db.create_all() diff --git a/tests/test_users/test_views.py b/tests/test_users/test_views.py new file mode 100644 index 0000000..3392607 --- /dev/null +++ b/tests/test_users/test_views.py @@ -0,0 +1,55 @@ +from mock import Mock, patch +from nose.tools import assert_true, assert_equal + +import deploystream +from deploystream.apps.users.models import User + + +class UserTestMixin(object): + + def setup(self): + self.client = deploystream.app.test_client() + + def send_register_post(self, username, email="a@a.com", password='123', + confirm_password='123'): + return self.client.post('/users/register/', + data={'username': username, 'email': email, 'password': password, + 'confirm': confirm_password, 'accept_tos': True}) + + def send_login_post(self, username, password): + return self.client.post('/users/login/', + data={'username': username, 'password': password}) + + +class TestRegister(UserTestMixin): + + def test_adds_user_to_the_database(self): + response = self.send_register_post( + email='test@test.com', username='test1') + assert_equal(response.status_code, 302) + assert_true("/users/me" in response.location) + u = User.query.filter_by(username="test1").first() + assert_equal(u.email, 'test@test.com') + + def test_incomplete_when_username_already_exists(self): + response = self.send_register_post(username='fred') + assert_true("/users/me" in response.location) + + response = self.send_register_post(username='fred') + assert_true("This username is already in use." in response.data) + + def test_incomplete_when_passwords_do_not_match(self): + response = self.send_register_post("james", + 'test3@test.com', '123', '111') + + assert_true("Passwords must match" in response.data) + + +class TestLogin(UserTestMixin): + + def test_login_to_existing_user_account(self): + response = self.send_register_post('phil', 'test_login@test.com') + self.client.get('/logout') + response = self.send_login_post('phil', '123') + assert_equal(response.status_code, 302) + assert_true("/users/me" in response.location)