108536234: created users and roles data and domain model.

You will need to run the /scripts/bootstrap.sh to create the database for test and the app.
This commit is contained in:
Rebecca Law
2015-11-25 15:29:12 +00:00
parent be6b89eea7
commit abe1d8ae17
24 changed files with 519 additions and 89 deletions

13
.gitignore vendored
View File

@@ -56,11 +56,14 @@ docs/_build/
# PyBuilder # PyBuilder
target/ target/
.idea/ .idea/
.DS_Store
# cache and static rebuild files # cache and static rebuild files
assets/stylesheets/govuk_template/.sass-cache/ app/assets/stylesheets/govuk_template/.sass-cache/
.sass-cache/ .sass-cache/
cache/ .cache/
static/stylesheets/govuk-template* app/static/stylesheets/govuk-template*
static/css* app/static/css*
static/css_all.css app/static/css_all.css
app/static/.webassets-cache/

View File

@@ -1,4 +1,4 @@
[![Build Status](https://api.travis-ci.org/alphagov/noworlktifications-admin.svg?branch=master)](https://api.travis-ci.org/alphagov/notifications-admin.svg?branch=master) [![Build Status](https://api.travis-ci.org/alphagov/notifications-admin.svg?branch=master)](https://api.travis-ci.org/alphagov/notifications-admin.svg?branch=master)
# notifications-admin # notifications-admin
@@ -16,21 +16,32 @@ Application to handle the admin functions of the notifications application.
### Create a virtual environment for this project ### Create a virtual environment for this project
mkvirtualenv -p /usr/local/bin/python3 notifications-admin mkvirtualenv -p /usr/local/bin/python3 notifications-admin
### GOV.UK frontend toolkit ### GOV.UK frontend toolkit
The GOV.UK frontend toolkit is a submodule of this project. The GOV.UK frontend toolkit is a submodule of this project.
To get the content of the toolkit run the following two commands To get the content of the toolkit run the following two commands
git submodule init git submodule init
git submodule update git submodule update
### To run the sample application run: ### To run the sample application run:
pip install -r requirements.txt pip install -r requirements.txt
./scripts/run_app.sh ./scripts/run_app.sh
url to test app: localhost:6012/helloworld url to test app:
localhost:6012/helloworld
### Database
Run the following command to create the database
python app.py db upgrade
### Domain model
All the domain models are defined in the [models.py](https://github.com/alphagov/notifications-admin/blob/master/app/models.py) file.

73
app.py
View File

@@ -1,82 +1,17 @@
import os import os
from flask.ext import assets from flask.ext import assets
from flask.ext.script import Manager, Server from flask.ext.script import Manager, Server
from flask_migrate import Migrate, MigrateCommand
from webassets.filter import get_filter from webassets.filter import get_filter
from app import create_app from app import create_app, db
application = create_app(os.getenv('NOTIFICATIONS_ADMIN_ENVIRONMENT') or 'development') application = create_app(os.getenv('NOTIFICATIONS_ADMIN_ENVIRONMENT') or 'development')
manager = Manager(application) manager = Manager(application)
port = int(os.environ.get('PORT', 6012)) port = int(os.environ.get('PORT', 6012))
manager.add_command("runserver", Server(host='0.0.0.0', port=port)) manager.add_command("runserver", Server(host='0.0.0.0', port=port))
migrate = Migrate(application, db)
env = assets.Environment(application) manager.add_command('db', MigrateCommand)
# Tell flask-assets where to look for our sass files.
env.load_path = [
os.path.join(os.path.dirname(__file__), 'app/assets/stylesheets'),
os.path.join(os.path.dirname(__file__), 'app/assets'),
os.path.join(os.path.dirname(__file__), 'app/assets/stylesheets/stylesheets/govuk_frontend_toolkit'),
os.path.join(os.path.dirname(__file__), 'app/assets/stylesheets/govuk_template')
]
scss = get_filter('scss', as_output=True)
env.register(
'css_all',
assets.Bundle(
'main.scss',
filters='scss',
output='css_all.css'
)
)
env.register(
'css_govuk-template',
assets.Bundle(
'govuk_template/govuk-template.scss',
filters='scss',
output='stylesheets/govuk-template.css'
)
)
env.register(
'css_govuk-template-ie6',
assets.Bundle(
'govuk_template/govuk-template-ie6.scss',
filters='scss',
output='stylesheets/govuk-template-ie6.css'
)
)
env.register(
'css_govuk-template-ie7',
assets.Bundle(
'govuk_template/govuk-template-ie7.scss',
filters='scss',
output='stylesheets/govuk-template-ie7.css'
)
)
env.register(
'css_govuk-template-ie8',
assets.Bundle(
'govuk_template/govuk-template-ie8.scss',
filters='scss',
output='stylesheets/govuk-template-ie8.css'
)
)
env.register(
'css_govuk-template-print',
assets.Bundle(
'govuk_template/govuk-template-print.scss',
filters='scss',
output='stylesheets/govuk-template-print.css'
)
)
@manager.command @manager.command

View File

@@ -1,7 +1,14 @@
import os import os
from flask import Flask from flask import Flask
from config import configs
from flask._compat import string_types from flask._compat import string_types
from flask.ext import assets
from flask.ext.sqlalchemy import SQLAlchemy
from webassets.filter import get_filter
from config import configs
db = SQLAlchemy()
def create_app(config_name): def create_app(config_name):
@@ -9,6 +16,7 @@ def create_app(config_name):
application.config['NOTIFY_API_ENVIRONMENT'] = config_name application.config['NOTIFY_API_ENVIRONMENT'] = config_name
application.config.from_object(configs[config_name]) application.config.from_object(configs[config_name])
db.init_app(application)
init_app(application) init_app(application)
from app.main import main as main_blueprint from app.main import main as main_blueprint
@@ -22,6 +30,77 @@ def init_app(app):
if key in os.environ: if key in os.environ:
app.config[key] = convert_to_boolean(os.environ[key]) app.config[key] = convert_to_boolean(os.environ[key])
init_asset_environment(app)
def init_asset_environment(app):
env = assets.Environment(app)
# Tell flask-assets where to look for our sass files.
env.load_path = [
os.path.join(os.path.dirname(__file__), 'assets/stylesheets'),
os.path.join(os.path.dirname(__file__), 'assets'),
os.path.join(os.path.dirname(__file__), 'assets/stylesheets/stylesheets/govuk_frontend_toolkit'),
os.path.join(os.path.dirname(__file__), 'assets/stylesheets/govuk_template')
]
scss = get_filter('scss', as_output=True)
env.register(
'css_all',
assets.Bundle(
'main.scss',
filters='scss',
output='css_all.css'
)
)
env.register(
'css_govuk-template',
assets.Bundle(
'govuk_template/govuk-template.scss',
filters='scss',
output='stylesheets/govuk-template.css'
)
)
env.register(
'css_govuk-template-ie6',
assets.Bundle(
'govuk_template/govuk-template-ie6.scss',
filters='scss',
output='stylesheets/govuk-template-ie6.css'
)
)
env.register(
'css_govuk-template-ie7',
assets.Bundle(
'govuk_template/govuk-template-ie7.scss',
filters='scss',
output='stylesheets/govuk-template-ie7.css'
)
)
env.register(
'css_govuk-template-ie8',
assets.Bundle(
'govuk_template/govuk-template-ie8.scss',
filters='scss',
output='stylesheets/govuk-template-ie8.css'
)
)
env.register(
'css_govuk-template-print',
assets.Bundle(
'govuk_template/govuk-template-print.scss',
filters='scss',
output='stylesheets/govuk-template-print.css'
)
)
def convert_to_boolean(value): def convert_to_boolean(value):
if isinstance(value, string_types): if isinstance(value, string_types):

0
app/main/dao/__init__.py Normal file
View File

11
app/main/dao/roles_dao.py Normal file
View File

@@ -0,0 +1,11 @@
from app import db
from app.models import Roles
def insert_role(role):
db.session.add(role)
db.session.commit()
def get_role_by_id(id):
return Roles.query.filter_by(id=id).first()

11
app/main/dao/users_dao.py Normal file
View File

@@ -0,0 +1,11 @@
from app import db
from app.models import Users
def insert_user(user):
db.session.add(user)
db.session.commit()
def get_user_by_id(id):
return Users.query.filter_by(id=id).first()

50
app/models.py Normal file
View File

@@ -0,0 +1,50 @@
from app import db
from flask import current_app
DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
DATE_FORMAT = "%Y-%m-%d"
class Roles(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
role = db.Column(db.String, nullable=False, unique=True)
class Users(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, nullable=False, index=True)
email_address = db.Column(db.String(255), nullable=False, index=True)
password = db.Column(db.String, index=False, unique=False, nullable=False)
mobile_number = db.Column(db.String, index=False, unique=False, nullable=False)
created_at = db.Column(db.DateTime, index=False, unique=False, nullable=False)
updated_at = db.Column(db.DateTime, index=False, unique=False, nullable=True)
password_changed_at = db.Column(db.DateTime, index=False, unique=False, nullable=True)
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'), index=True, unique=False, nullable=False)
logged_in_at = db.Column(db.DateTime, nullable=True)
failed_login_count = db.Column(db.Integer, nullable=False, default=0)
state = db.Column(db.String, nullable=False, default='pending')
def serialize(self):
serialized = {
'id': self.id,
'name': self.name,
'emailAddress': self.email_address,
'locked': self.failed_login_count > current_app.config['MAX_FAILED_LOGIN_COUNT'],
'createdAt': self.created_at.strftime(DATETIME_FORMAT),
'updatedAt': self.updated_at.strftime(DATETIME_FORMAT),
'role': self.role,
'passwordChangedAt': self.password_changed_at.strftime(DATETIME_FORMAT),
'failedLoginCount': self.failed_login_count
}
return filter_null_value_fields(serialized)
def filter_null_value_fields(obj):
return dict(
filter(lambda x: x[1] is not None, obj.items())
)

View File

@@ -5,6 +5,11 @@ class Config(object):
cache = False cache = False
manifest = True manifest = True
SQLALCHEMY_COMMIT_ON_TEARDOWN = False
SQLALCHEMY_RECORD_QUERIES = True
SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/notifications_admin'
MAX_FAILED_LOGIN_COUNT = 10
class Development(Config): class Development(Config):
DEBUG = True DEBUG = True
@@ -12,9 +17,10 @@ class Development(Config):
class Test(Config): class Test(Config):
DEBUG = False DEBUG = False
SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/test_notifications_admin'
configs = { configs = {
'development': Development, 'development': Development,
'TEST': Test 'test': Test
} }

1
migrations/README Executable file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

45
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers =
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

73
migrations/env.py Normal file
View File

@@ -0,0 +1,73 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option('sqlalchemy.url', current_app.config.get('SQLALCHEMY_DATABASE_URI'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
engine = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
connection = engine.connect()
context.configure(
connection=connection,
target_metadata=target_metadata
)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

22
migrations/script.py.mako Executable file
View File

@@ -0,0 +1,22 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision}
Create Date: ${create_date}
"""
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,41 @@
"""empty message
Revision ID: create_users
Revises: None
Create Date: 2015-11-24 10:39:19.827534
"""
# revision identifiers, used by Alembic.
revision = 'create_users'
down_revision = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table('roles',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('role', sa.String, nullable=False, unique=True)
)
op.create_table('users',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('name', sa.String, nullable=False),
sa.Column('email_address', sa.String(length=255), nullable=False),
sa.Column('password', sa.String, nullable=False),
sa.Column('mobile_number', sa.String, nullable=False),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('updated_at', sa.DateTime),
sa.Column('password_changed_at', sa.DateTime),
sa.Column('role_id', sa.Integer, nullable=False),
sa.Column('logged_in_at', sa.DateTime),
sa.Column('failed_login_count', sa.Integer, nullable=False),
sa.Column('state', sa.String, default='pending'),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'])
)
def downgrade():
op.drop_table('users')
op.drop_table('roles')

View File

@@ -1,3 +1,8 @@
Flask==0.10.1 Flask==0.10.1
Flask-Script==2.0.5 Flask-Script==2.0.5
Flask-Assets==0.11 Flask-Assets==0.11
Flask-Migrate==1.3.1
Flask-SQLAlchemy==2.0
psycopg2==2.6.1
SQLAlchemy==1.0.5
SQLAlchemy-Utils==0.30.5

36
scripts/bootstrap.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
#
# Bootstrap virtualenv environment and postgres databases locally.
#
# NOTE: This script expects to be run from the project root with
# ./scripts/bootstrap.sh
set -o pipefail
function display_result {
RESULT=$1
EXIT_STATUS=$2
TEST=$3
if [ $RESULT -ne 0 ]; then
echo -e "\033[31m$TEST failed\033[0m"
exit $EXIT_STATUS
else
echo -e "\033[32m$TEST passed\033[0m"
fi
}
if [ ! $VIRTUAL_ENV ]; then
virtualenv ./venv
. ./venv/bin/activate
fi
# Install Python development dependencies
pip3 install -r requirements_for_test.txt
# Create Postgres databases
createdb notifications_admin
createdb test_notifications_admin
# Upgrade databases
python app.py db upgrade

View File

@@ -1,2 +1,3 @@
[pep8] [pep8]
max-line-length = 120 max-line-length = 120
exclude = ./migrations,./venv,./venv3

0
tests/app/__init__.py Normal file
View File

View File

View File

View File

@@ -0,0 +1,21 @@
import pytest
import sqlalchemy
from app.models import Roles
from app.main.dao import roles_dao
def test_insert_role_should_be_able_to_get_role(notifications_admin, notifications_admin_db):
role = Roles(id=1000, role='some role for test')
roles_dao.insert_role(role)
saved_role = roles_dao.get_role_by_id(role.id)
assert saved_role == role
def test_insert_role_will_throw_error_if_role_already_exists():
role = Roles(id=1, role='cannot create a duplicate')
with pytest.raises(sqlalchemy.exc.IntegrityError) as error:
roles_dao.insert_role(role)
assert 'duplicate key value violates unique constraint "roles_pkey"' in str(error.value)

View File

@@ -0,0 +1,32 @@
from datetime import datetime
import pytest
import sqlalchemy
from app.models import Users
from app.main.dao import users_dao
def test_insert_user_should_add_user(notifications_admin, notifications_admin_db):
user = Users(name='test insert',
password='somepassword',
email_address='test@insert.gov.uk',
mobile_number='+441234123412',
created_at=datetime.now(),
role_id=1)
users_dao.insert_user(user)
saved_user = users_dao.get_user_by_id(user.id)
assert saved_user == user
def test_insert_user_with_role_that_does_not_exist_fails(notifications_admin, notifications_admin_db):
user = Users(name='test insert',
password='somepassword',
email_address='test@insert.gov.uk',
mobile_number='+441234123412',
created_at=datetime.now(),
role_id=100)
with pytest.raises(sqlalchemy.exc.IntegrityError) as error:
users_dao.insert_user(user)
assert 'insert or update on table "users" violates foreign key constraint "users_role_id_fkey"' in str(error.value)

39
tests/conftest.py Normal file
View File

@@ -0,0 +1,39 @@
import pytest
from sqlalchemy.schema import MetaData, DropConstraint
from app import create_app, db
from app.models import Roles
@pytest.fixture(scope='module')
def notifications_admin(request):
app = create_app('test')
ctx = app.app_context()
ctx.push()
def teardown():
ctx.pop()
request.addfinalizer(teardown)
return app
@pytest.fixture(scope='module')
def notifications_admin_db(notifications_admin, request):
metadata = MetaData(db.engine)
metadata.reflect()
for table in metadata.tables.values():
for fk in table.foreign_keys:
db.engine.execute(DropConstraint(fk.constraint))
metadata.drop_all()
# Create the tables based on the current model
db.create_all()
# Add base data here
role = Roles(id=1, role='test_role')
db.session.add(role)
db.session.commit()
db.session.flush()
db.session.expunge_all()
db.session.commit()

View File

@@ -1,2 +1,10 @@
def test_app(): def test_index_returns_200(notifications_admin):
assert 1 == 1 response = notifications_admin.test_client().get('/index')
assert response.status_code == 200
assert response.data.decode('utf-8') == 'Hello from notifications-admin'
def test_helloworld_returns_200(notifications_admin):
response = notifications_admin.test_client().get('/helloworld')
assert response.status_code == 200
assert 'Hello world!' in response.data.decode('utf-8')