parent
510934cd58
commit
85b4230e71
@ -0,0 +1,9 @@ |
||||
# Environment variable overrides for local development |
||||
FLASK_APP=autoapp.py |
||||
FLASK_DEBUG=1 |
||||
FLASK_ENV=development |
||||
DATABASE_URL=sqlite:////tmp/dev.db |
||||
GUNICORN_WORKERS=1 |
||||
LOG_LEVEL=debug |
||||
SECRET_KEY=not-so-secret |
||||
SEND_FILE_MAX_AGE_DEFAULT=0 # In production, set to a higher number, like 31556926 |
@ -0,0 +1,21 @@ |
||||
{ |
||||
"extends": "airbnb-base", |
||||
"parser": "babel-eslint", |
||||
"rules": { |
||||
"no-param-reassign": 0, |
||||
"import/no-extraneous-dependencies": 0, |
||||
"import/prefer-default-export": 0, |
||||
"consistent-return": 0, |
||||
"no-confusing-arrow": 0, |
||||
"no-underscore-dangle": 0 |
||||
}, |
||||
"env": { |
||||
"browser": true, |
||||
"node": true |
||||
}, |
||||
"globals": { |
||||
"__dirname": true, |
||||
"jQuery": true, |
||||
"$": true |
||||
} |
||||
} |
@ -0,0 +1,58 @@ |
||||
*.py[cod] |
||||
|
||||
# C extensions |
||||
*.so |
||||
|
||||
# Packages |
||||
*.egg |
||||
*.egg-info |
||||
build |
||||
eggs |
||||
parts |
||||
bin |
||||
var |
||||
sdist |
||||
develop-eggs |
||||
.installed.cfg |
||||
lib |
||||
lib64 |
||||
|
||||
# Installer logs |
||||
pip-log.txt |
||||
|
||||
# Unit test / coverage reports |
||||
.coverage |
||||
.tox |
||||
nosetests.xml |
||||
|
||||
# Translations |
||||
*.mo |
||||
|
||||
# Mr Developer |
||||
.mr.developer.cfg |
||||
.project |
||||
.pydevproject |
||||
|
||||
# Complexity |
||||
output/*.html |
||||
output/*/index.html |
||||
|
||||
# Sphinx |
||||
docs/_build |
||||
|
||||
.webassets-cache |
||||
|
||||
# Virtualenvs |
||||
env/ |
||||
|
||||
# npm |
||||
/node_modules/ |
||||
|
||||
# webpack-built files |
||||
/harambee_led_bord/static/build/ |
||||
|
||||
# Configuration |
||||
.env |
||||
|
||||
# Development database |
||||
*.db |
@ -0,0 +1,16 @@ |
||||
# Config file for automatic testing at travis-ci.org |
||||
dist: xenial |
||||
language: python |
||||
python: |
||||
- 3.6 |
||||
- 3.7 |
||||
install: |
||||
- pip install -r requirements/dev.txt |
||||
- nvm install 12 |
||||
- nvm use 12 |
||||
- npm install |
||||
before_script: |
||||
- cp .env.example .env |
||||
- npm run lint |
||||
- flask lint --check |
||||
script: flask test |
@ -0,0 +1,47 @@ |
||||
# ==================================== BASE ==================================== |
||||
ARG INSTALL_PYTHON_VERSION=${INSTALL_PYTHON_VERSION:-3.7} |
||||
FROM python:${INSTALL_PYTHON_VERSION}-slim-buster AS base |
||||
|
||||
RUN apt-get update |
||||
RUN apt-get install -y \ |
||||
curl \ |
||||
gcc |
||||
|
||||
ARG INSTALL_NODE_VERSION=${INSTALL_NODE_VERSION:-12} |
||||
RUN curl -sL https://deb.nodesource.com/setup_${INSTALL_NODE_VERSION}.x | bash - |
||||
RUN apt-get install -y \ |
||||
nodejs \ |
||||
&& apt-get -y autoclean |
||||
|
||||
WORKDIR /app |
||||
COPY ["Pipfile", "shell_scripts/auto_pipenv.sh", "./"] |
||||
RUN pip install pipenv |
||||
|
||||
COPY . . |
||||
|
||||
RUN useradd -m sid |
||||
RUN chown -R sid:sid /app |
||||
USER sid |
||||
ENV PATH="/home/sid/.local/bin:${PATH}" |
||||
RUN npm install |
||||
|
||||
# ================================= DEVELOPMENT ================================ |
||||
FROM base AS development |
||||
RUN pipenv install --dev |
||||
EXPOSE 2992 |
||||
EXPOSE 5000 |
||||
CMD [ "pipenv", "run", "npm", "start" ] |
||||
|
||||
# ================================= PRODUCTION ================================= |
||||
FROM base AS production |
||||
RUN pipenv install |
||||
COPY supervisord.conf /etc/supervisor/supervisord.conf |
||||
COPY supervisord_programs /etc/supervisor/conf.d |
||||
EXPOSE 5000 |
||||
ENTRYPOINT ["/bin/bash", "shell_scripts/supervisord_entrypoint.sh"] |
||||
CMD ["-c", "/etc/supervisor/supervisord.conf"] |
||||
|
||||
# =================================== MANAGE =================================== |
||||
FROM base AS manage |
||||
COPY --from=development /home/sid/.local/share/virtualenvs/ /home/sid/.local/share/virtualenvs/ |
||||
ENTRYPOINT [ "pipenv", "run", "flask" ] |
@ -0,0 +1,19 @@ |
||||
Copyright 2019 Yuri van Midden |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in |
||||
all copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
||||
THE SOFTWARE. |
@ -0,0 +1,60 @@ |
||||
[[source]] |
||||
url = "https://pypi.python.org/simple" |
||||
verify_ssl = true |
||||
name = "pypi" |
||||
|
||||
[packages] |
||||
# Flask |
||||
Flask = "==1.1.1" |
||||
Werkzeug = "==0.16.0" |
||||
click = ">=5.0" |
||||
|
||||
# Database |
||||
Flask-SQLAlchemy = "==2.4.1" |
||||
SQLAlchemy = "==1.3.11" |
||||
psycopg2-binary = "==2.8.4" |
||||
|
||||
# Migrations |
||||
Flask-Migrate = "==2.5.2" |
||||
|
||||
# Forms |
||||
Flask-WTF = "==0.14.2" |
||||
WTForms = "==2.2.1" |
||||
|
||||
# Deployment |
||||
gevent = "==1.4.0" |
||||
gunicorn = ">=19.1.1" |
||||
supervisor = "==4.1.0" |
||||
|
||||
# Flask Static Digest |
||||
Flask-Static-Digest = "==0.1.2" |
||||
|
||||
# Auth |
||||
Flask-Login = "==0.4.1" |
||||
Flask-Bcrypt = "==0.7.1" |
||||
|
||||
# Caching |
||||
Flask-Caching = ">=1.0.0" |
||||
|
||||
# Debug toolbar |
||||
Flask-DebugToolbar = "==0.10.1" |
||||
|
||||
# Environment variable parsing |
||||
environs = "==7.1.0" |
||||
|
||||
[dev-packages] |
||||
# Testing |
||||
pytest = "==5.3.1" |
||||
WebTest = "==2.0.33" |
||||
factory-boy = "==2.12.*" |
||||
pdbpp = "==0.10.2" |
||||
|
||||
# Lint and code style |
||||
black = "==19.10b0" |
||||
flake8 = "==3.7.9" |
||||
flake8-blind-except = "==0.1.1" |
||||
flake8-debugger = "==3.2.1" |
||||
flake8-docstrings = "==1.5.0" |
||||
flake8-isort = "==2.8.0" |
||||
isort = "==4.3.21" |
||||
pep8-naming = "==0.9.1" |
@ -0,0 +1,171 @@ |
||||
# Harambee LED bord |
||||
|
||||
Backend voor het Harambee led bord |
||||
|
||||
## Docker Quickstart |
||||
|
||||
This app can be run completely using `Docker` and `docker-compose`. **Using Docker is recommended, as it guarantees the application is run using compatible versions of Python and Node**. |
||||
|
||||
There are three main services: |
||||
|
||||
To run the development version of the app |
||||
|
||||
```bash |
||||
docker-compose up flask-dev |
||||
``` |
||||
|
||||
To run the production version of the app |
||||
|
||||
```bash |
||||
docker-compose up flask-prod |
||||
``` |
||||
|
||||
The list of `environment:` variables in the `docker-compose.yml` file takes precedence over any variables specified in `.env`. |
||||
|
||||
To run any commands using the `Flask CLI` |
||||
|
||||
```bash |
||||
docker-compose run --rm manage <<COMMAND>> |
||||
``` |
||||
|
||||
Therefore, to initialize a database you would run |
||||
|
||||
```bash |
||||
docker-compose run --rm manage db init |
||||
docker-compose run --rm manage db migrate |
||||
docker-compose run --rm manage db upgrade |
||||
``` |
||||
|
||||
A docker volume `node-modules` is created to store NPM packages and is reused across the dev and prod versions of the application. For the purposes of DB testing with `sqlite`, the file `dev.db` is mounted to all containers. This volume mount should be removed from `docker-compose.yml` if a production DB server is used. |
||||
|
||||
### Running locally |
||||
|
||||
Run the following commands to bootstrap your environment if you are unable to run the application using Docker |
||||
|
||||
```bash |
||||
cd harambee_led_bord |
||||
pipenv install --dev |
||||
pipenv shell |
||||
npm install |
||||
npm start # run the webpack dev server and flask server using concurrently |
||||
``` |
||||
|
||||
You will see a pretty welcome screen. |
||||
|
||||
#### Database Initialization (locally) |
||||
|
||||
Once you have installed your DBMS, run the following to create your app's |
||||
database tables and perform the initial migration |
||||
|
||||
```bash |
||||
flask db init |
||||
flask db migrate |
||||
flask db upgrade |
||||
``` |
||||
|
||||
## Deployment |
||||
|
||||
When using Docker, reasonable production defaults are set in `docker-compose.yml` |
||||
|
||||
```text |
||||
FLASK_ENV=production |
||||
FLASK_DEBUG=0 |
||||
``` |
||||
|
||||
Therefore, starting the app in "production" mode is as simple as |
||||
|
||||
```bash |
||||
docker-compose up flask-prod |
||||
``` |
||||
|
||||
If running without Docker |
||||
|
||||
```bash |
||||
export FLASK_ENV=production |
||||
export FLASK_DEBUG=0 |
||||
export DATABASE_URL="<YOUR DATABASE URL>" |
||||
npm run build # build assets with webpack |
||||
flask run # start the flask server |
||||
``` |
||||
|
||||
## Shell |
||||
|
||||
To open the interactive shell, run |
||||
|
||||
```bash |
||||
docker-compose run --rm manage db shell |
||||
flask shell # If running locally without Docker |
||||
``` |
||||
|
||||
By default, you will have access to the flask `app`. |
||||
|
||||
## Running Tests/Linter |
||||
|
||||
To run all tests, run |
||||
|
||||
```bash |
||||
docker-compose run --rm manage test |
||||
flask test # If running locally without Docker |
||||
``` |
||||
|
||||
To run the linter, run |
||||
|
||||
```bash |
||||
docker-compose run --rm manage lint |
||||
flask lint # If running locally without Docker |
||||
``` |
||||
|
||||
The `lint` command will attempt to fix any linting/style errors in the code. If you only want to know if the code will pass CI and do not wish for the linter to make changes, add the `--check` argument. |
||||
|
||||
## Migrations |
||||
|
||||
Whenever a database migration needs to be made. Run the following commands |
||||
|
||||
```bash |
||||
docker-compose run --rm manage db migrate |
||||
flask db migrate # If running locally without Docker |
||||
``` |
||||
|
||||
This will generate a new migration script. Then run |
||||
|
||||
```bash |
||||
docker-compose run --rm manage db upgrade |
||||
flask db upgrade # If running locally without Docker |
||||
``` |
||||
|
||||
To apply the migration. |
||||
|
||||
For a full migration command reference, run `docker-compose run --rm manage db --help`. |
||||
|
||||
If you will deploy your application remotely (e.g on Heroku) you should add the `migrations` folder to version control. |
||||
You can do this after `flask db migrate` by running the following commands |
||||
|
||||
```bash |
||||
git add migrations/* |
||||
git commit -m "Add migrations" |
||||
``` |
||||
|
||||
Make sure folder `migrations/versions` is not empty. |
||||
|
||||
## Asset Management |
||||
|
||||
Files placed inside the `assets` directory and its subdirectories |
||||
(excluding `js` and `css`) will be copied by webpack's |
||||
`file-loader` into the `static/build` directory. In production, the plugin |
||||
`Flask-Static-Digest` zips the webpack content and tags them with a MD5 hash. |
||||
As a result, you must use the `static_url_for` function when including static content, |
||||
as it resolves the correct file name, including the MD5 hash. |
||||
For example |
||||
|
||||
```html |
||||
<link rel="shortcut icon" href="{{static_url_for('static', filename='build/img/favicon.ico') }}"> |
||||
``` |
||||
|
||||
If all of your static files are managed this way, then their filenames will change whenever their |
||||
contents do, and you can ask Flask to tell web browsers that they |
||||
should cache all your assets forever by including the following line |
||||
in ``.env``: |
||||
|
||||
```text |
||||
SEND_FILE_MAX_AGE_DEFAULT=31556926 # one year |
||||
``` |
@ -0,0 +1,86 @@ |
||||
/* ============================================================================= |
||||
App specific CSS file. |
||||
========================================================================== */ |
||||
|
||||
/* universal */ |
||||
|
||||
html { |
||||
overflow-y: scroll; |
||||
} |
||||
|
||||
body { |
||||
padding-top: 60px; |
||||
} |
||||
|
||||
section { |
||||
overflow: auto; |
||||
} |
||||
|
||||
textarea { |
||||
resize: vertical; |
||||
} |
||||
|
||||
.container-narrow{ |
||||
margin: 0 auto; |
||||
max-width: 700px; |
||||
} |
||||
|
||||
/* Forms */ |
||||
|
||||
.navbar-form input[type="text"], |
||||
.navbar-form input[type="password"] { |
||||
width: 180px; |
||||
} |
||||
|
||||
|
||||
.form-register { |
||||
width: 50%; |
||||
} |
||||
|
||||
.form-register .form-control { |
||||
position: relative; |
||||
font-size: 16px; |
||||
height: auto; |
||||
padding: 10px; |
||||
-webkit-box-sizing: border-box; |
||||
-moz-box-sizing: border-box; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
/* footer */ |
||||
|
||||
footer { |
||||
margin-top: 45px; |
||||
padding-top: 5px; |
||||
border-top: 1px solid #eaeaea; |
||||
color: #999999; |
||||
} |
||||
|
||||
footer a { |
||||
color: #999999; |
||||
} |
||||
|
||||
footer p { |
||||
float: right; |
||||
margin-right: 25px; |
||||
} |
||||
|
||||
footer ul { |
||||
list-style: none; |
||||
} |
||||
|
||||
footer ul li { |
||||
float: left; |
||||
margin-left: 10px; |
||||
} |
||||
|
||||
footer .company { |
||||
float: left; |
||||
margin-left: 25px; |
||||
} |
||||
|
||||
footer .footer-nav { |
||||
float: right; |
||||
margin-right: 25px; |
||||
list-style: none; |
||||
} |
After Width: | Height: | Size: 58 KiB |
@ -0,0 +1,21 @@ |
||||
/* |
||||
* Main Javascript file for harambee_led_bord. |
||||
* |
||||
* This file bundles all of your javascript together using webpack. |
||||
*/ |
||||
|
||||
// JavaScript modules
|
||||
require('@fortawesome/fontawesome-free'); |
||||
require('jquery'); |
||||
require('popper.js'); |
||||
require('bootstrap'); |
||||
|
||||
require.context( |
||||
'../img', // context folder
|
||||
true, // include subdirectories
|
||||
/.*/, // RegExp
|
||||
); |
||||
|
||||
// Your own code
|
||||
require('./plugins.js'); |
||||
require('./script.js'); |
@ -0,0 +1 @@ |
||||
// place any jQuery/helper plugins in here, instead of separate, slower script files.
|
@ -0,0 +1 @@ |
||||
// App initialization code goes here
|
@ -0,0 +1,5 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Create an application instance.""" |
||||
from harambee_led_bord.app import create_app |
||||
|
||||
app = create_app() |
@ -0,0 +1,57 @@ |
||||
version: '3.6' |
||||
|
||||
x-build-args: &build_args |
||||
INSTALL_PYTHON_VERSION: 3.8 |
||||
INSTALL_NODE_VERSION: 12 |
||||
|
||||
x-default-volumes: &default_volumes |
||||
volumes: |
||||
- ./:/app |
||||
- node-modules:/app/node_modules |
||||
- ./dev.db:/tmp/dev.db |
||||
|
||||
services: |
||||
flask-dev: |
||||
build: |
||||
context: . |
||||
target: development |
||||
args: |
||||
<<: *build_args |
||||
image: "harambee_led_bord-development" |
||||
ports: |
||||
- "5000:5000" |
||||
- "2992:2992" |
||||
<<: *default_volumes |
||||
|
||||
flask-prod: |
||||
build: |
||||
context: . |
||||
target: production |
||||
args: |
||||
<<: *build_args |
||||
image: "harambee_led_bord-production" |
||||
ports: |
||||
- "5000:5000" |
||||
environment: |
||||
FLASK_ENV: production |
||||
FLASK_DEBUG: 0 |
||||
LOG_LEVEL: info |
||||
GUNICORN_WORKERS: 4 |
||||
<<: *default_volumes |
||||
|
||||
manage: |
||||
build: |
||||
context: . |
||||
target: manage |
||||
environment: |
||||
FLASK_ENV: production |
||||
FLASK_DEBUG: 0 |
||||
image: "harambee_led_bord-manage" |
||||
stdin_open: true |
||||
tty: true |
||||
<<: *default_volumes |
||||
|
||||
volumes: |
||||
node-modules: |
||||
static-build: |
||||
dev-db: |
@ -0,0 +1 @@ |
||||
"""Main application package.""" |
@ -0,0 +1,91 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""The app module, containing the app factory function.""" |
||||
import logging |
||||
import sys |
||||
|
||||
from flask import Flask, render_template |
||||
|
||||
from harambee_led_bord import commands, public, user |
||||
from harambee_led_bord.extensions import ( |
||||
bcrypt, |
||||
cache, |
||||
csrf_protect, |
||||
db, |
||||
debug_toolbar, |
||||
flask_static_digest, |
||||
login_manager, |
||||
migrate, |
||||
) |
||||
|
||||
|
||||
def create_app(config_object="harambee_led_bord.settings"): |
||||
"""Create application factory, as explained here: http://flask.pocoo.org/docs/patterns/appfactories/. |
||||
|
||||
:param config_object: The configuration object to use. |
||||
""" |
||||
app = Flask(__name__.split(".")[0]) |
||||
app.config.from_object(config_object) |
||||
register_extensions(app) |
||||
register_blueprints(app) |
||||
register_errorhandlers(app) |
||||
register_shellcontext(app) |
||||
register_commands(app) |
||||
configure_logger(app) |
||||
return app |
||||
|
||||
|
||||
def register_extensions(app): |
||||
"""Register Flask extensions.""" |
||||
bcrypt.init_app(app) |
||||
cache.init_app(app) |
||||
db.init_app(app) |
||||
csrf_protect.init_app(app) |
||||
login_manager.init_app(app) |
||||
debug_toolbar.init_app(app) |
||||
migrate.init_app(app, db) |
||||
flask_static_digest.init_app(app) |
||||
return None |
||||
|
||||
|
||||
def register_blueprints(app): |
||||
"""Register Flask blueprints.""" |
||||
app.register_blueprint(public.views.blueprint) |
||||
app.register_blueprint(user.views.blueprint) |
||||
return None |
||||
|
||||
|
||||
def register_errorhandlers(app): |
||||
"""Register error handlers.""" |
||||
|
||||
def render_error(error): |
||||
"""Render error template.""" |
||||
# If a HTTPException, pull the `code` attribute; default to 500 |
||||
error_code = getattr(error, "code", 500) |
||||
return render_template(f"{error_code}.html"), error_code |
||||
|
||||
for errcode in [401, 404, 500]: |
||||
app.errorhandler(errcode)(render_error) |
||||
return None |
||||
|
||||
|
||||
def register_shellcontext(app): |
||||
"""Register shell context objects.""" |
||||
|
||||
def shell_context(): |
||||
"""Shell context objects.""" |
||||
return {"db": db, "User": user.models.User} |
||||
|
||||
app.shell_context_processor(shell_context) |
||||
|
||||
|
||||
def register_commands(app): |
||||
"""Register Click commands.""" |
||||
app.cli.add_command(commands.test) |
||||
app.cli.add_command(commands.lint) |
||||
|
||||
|
||||
def configure_logger(app): |
||||
"""Configure loggers.""" |
||||
handler = logging.StreamHandler(sys.stdout) |
||||
if not app.logger.handlers: |
||||
app.logger.addHandler(handler) |
@ -0,0 +1,65 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Click commands.""" |
||||
import os |
||||
from glob import glob |
||||
from subprocess import call |
||||
|
||||
import click |
||||
|
||||
HERE = os.path.abspath(os.path.dirname(__file__)) |
||||
PROJECT_ROOT = os.path.join(HERE, os.pardir) |
||||
TEST_PATH = os.path.join(PROJECT_ROOT, "tests") |
||||
|
||||
|
||||
@click.command() |
||||
def test(): |
||||
"""Run the tests.""" |
||||
import pytest |
||||
|
||||
rv = pytest.main([TEST_PATH, "--verbose"]) |
||||
exit(rv) |
||||
|
||||
|
||||
@click.command() |
||||
@click.option( |
||||
"-f", |
||||
"--fix-imports", |
||||
default=True, |
||||
is_flag=True, |
||||
help="Fix imports using isort, before linting", |
||||
) |
||||
@click.option( |
||||
"-c", |
||||
"--check", |
||||
default=False, |
||||
is_flag=True, |
||||
help="Don't make any changes to files, just confirm they are formatted correctly", |
||||
) |
||||
def lint(fix_imports, check): |
||||
"""Lint and check code style with black, flake8 and isort.""" |
||||
skip = ["node_modules", "requirements", "migrations"] |
||||
root_files = glob("*.py") |
||||
root_directories = [ |
||||
name for name in next(os.walk("."))[1] if not name.startswith(".") |
||||
] |
||||
files_and_directories = [ |
||||
arg for arg in root_files + root_directories if arg not in skip |
||||
] |
||||
|
||||
def execute_tool(description, *args): |
||||
"""Execute a checking tool with its arguments.""" |
||||
command_line = list(args) + files_and_directories |
||||
click.echo(f"{description}: {' '.join(command_line)}") |
||||
rv = call(command_line) |
||||
if rv != 0: |
||||
exit(rv) |
||||
|
||||
isort_args = ["-rc"] |
||||
black_args = [] |
||||
if check: |
||||
isort_args.append("-c") |
||||
black_args.append("--check") |
||||
if fix_imports: |
||||
execute_tool("Fixing import order", "isort", *isort_args) |
||||
execute_tool("Formatting style", "black", *black_args) |
||||
execute_tool("Checking code style", "flake8") |
@ -0,0 +1,18 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Python 2/3 compatibility module.""" |
||||
import sys |
||||
|
||||
PY2 = int(sys.version[0]) == 2 |
||||
|
||||
if PY2: |
||||
text_type = unicode # noqa |
||||
binary_type = str |
||||
string_types = (str, unicode) # noqa |
||||
unicode = unicode # noqa |
||||
basestring = basestring # noqa |
||||
else: |
||||
text_type = str |
||||
binary_type = bytes |
||||
string_types = (str,) |
||||
unicode = str |
||||
basestring = (str, bytes) |
@ -0,0 +1,84 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Database module, including the SQLAlchemy database object and DB-related utilities.""" |
||||
from .compat import basestring |
||||
from .extensions import db |
||||
|
||||
# Alias common SQLAlchemy names |
||||
Column = db.Column |
||||
relationship = db.relationship |
||||
|
||||
|
||||
class CRUDMixin(object): |
||||
"""Mixin that adds convenience methods for CRUD (create, read, update, delete) operations.""" |
||||
|
||||
@classmethod |
||||
def create(cls, **kwargs): |
||||
"""Create a new record and save it the database.""" |
||||
instance = cls(**kwargs) |
||||
return instance.save() |
||||
|
||||
def update(self, commit=True, **kwargs): |
||||
"""Update specific fields of a record.""" |
||||
for attr, value in kwargs.items(): |
||||
setattr(self, attr, value) |
||||
return commit and self.save() or self |
||||
|
||||
def save(self, commit=True): |
||||
"""Save the record.""" |
||||
db.session.add(self) |
||||
if commit: |
||||
db.session.commit() |
||||
return self |
||||
|
||||
def delete(self, commit=True): |
||||
"""Remove the record from the database.""" |
||||
db.session.delete(self) |
||||
return commit and db.session.commit() |
||||
|
||||
|
||||
class Model(CRUDMixin, db.Model): |
||||
"""Base model class that includes CRUD convenience methods.""" |
||||
|
||||
__abstract__ = True |
||||
|
||||
|
||||
# From Mike Bayer's "Building the app" talk |
||||
# https://speakerdeck.com/zzzeek/building-the-app |
||||
class SurrogatePK(object): |
||||
"""A mixin that adds a surrogate integer 'primary key' column named ``id`` to any declarative-mapped class.""" |
||||
|
||||
__table_args__ = {"extend_existing": True} |
||||
|
||||
id = Column(db.Integer, primary_key=True) |
||||
|
||||
@classmethod |
||||
def get_by_id(cls, record_id): |
||||
"""Get record by ID.""" |
||||
if any( |
||||
( |
||||
isinstance(record_id, basestring) and record_id.isdigit(), |
||||
isinstance(record_id, (int, float)), |
||||
) |
||||
): |
||||
return cls.query.get(int(record_id)) |
||||
return None |
||||
|
||||
|
||||
def reference_col( |
||||
tablename, nullable=False, pk_name="id", foreign_key_kwargs=None, column_kwargs=None |
||||
): |
||||
"""Column that adds primary key foreign key reference. |
||||
|
||||
Usage: :: |
||||
|
||||
category_id = reference_col('category') |
||||
category = relationship('Category', backref='categories') |
||||
""" |
||||
foreign_key_kwargs = foreign_key_kwargs or {} |
||||
column_kwargs = column_kwargs or {} |
||||
|
||||
return Column( |
||||
db.ForeignKey(f"{tablename}.{pk_name}", **foreign_key_kwargs), |
||||
nullable=nullable, |
||||
**column_kwargs, |
||||
) |
@ -0,0 +1,19 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Extensions module. Each extension is initialized in the app factory located in app.py.""" |
||||
from flask_bcrypt import Bcrypt |
||||
from flask_caching import Cache |
||||
from flask_debugtoolbar import DebugToolbarExtension |
||||
from flask_login import LoginManager |
||||
from flask_migrate import Migrate |
||||
from flask_sqlalchemy import SQLAlchemy |
||||
from flask_static_digest import FlaskStaticDigest |
||||
from flask_wtf.csrf import CSRFProtect |
||||
|
||||
bcrypt = Bcrypt() |
||||
csrf_protect = CSRFProtect() |
||||
login_manager = LoginManager() |
||||
db = SQLAlchemy() |
||||
migrate = Migrate() |
||||
cache = Cache() |
||||
debug_toolbar = DebugToolbarExtension() |
||||
flask_static_digest = FlaskStaticDigest() |
@ -0,0 +1,3 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""The public module, including the homepage and user auth.""" |
||||
from . import views # noqa |
@ -0,0 +1,39 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Public forms.""" |
||||
from flask_wtf import FlaskForm |
||||
from wtforms import PasswordField, StringField |
||||
from wtforms.validators import DataRequired |
||||
|
||||
from harambee_led_bord.user.models import User |
||||
|
||||
|
||||
class LoginForm(FlaskForm): |
||||
"""Login form.""" |
||||
|
||||
username = StringField("Username", validators=[DataRequired()]) |
||||
password = PasswordField("Password", validators=[DataRequired()]) |
||||
|
||||
def __init__(self, *args, **kwargs): |
||||
"""Create instance.""" |
||||
super(LoginForm, self).__init__(*args, **kwargs) |
||||
self.user = None |
||||
|
||||
def validate(self): |
||||
"""Validate the form.""" |
||||
initial_validation = super(LoginForm, self).validate() |
||||
if not initial_validation: |
||||
return False |
||||
|
||||
self.user = User.query.filter_by(username=self.username.data).first() |
||||
if not self.user: |
||||
self.username.errors.append("Unknown username") |
||||
return False |
||||
|
||||
if not self.user.check_password(self.password.data): |
||||
self.password.errors.append("Invalid password") |
||||
return False |
||||
|
||||
if not self.user.active: |
||||
self.username.errors.append("User not activated") |
||||
return False |
||||
return True |
@ -0,0 +1,77 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Public section, including homepage and signup.""" |
||||
from flask import ( |
||||
Blueprint, |
||||
current_app, |
||||
flash, |
||||
redirect, |
||||
render_template, |
||||
request, |
||||
url_for, |
||||
) |
||||
from flask_login import login_required, login_user, logout_user |
||||
|
||||
from harambee_led_bord.extensions import login_manager |
||||
from harambee_led_bord.public.forms import LoginForm |
||||
from harambee_led_bord.user.forms import RegisterForm |
||||
from harambee_led_bord.user.models import User |
||||
from harambee_led_bord.utils import flash_errors |
||||
|
||||
blueprint = Blueprint("public", __name__, static_folder="../static") |
||||
|
||||
|
||||
@login_manager.user_loader |
||||
def load_user(user_id): |
||||
"""Load user by ID.""" |
||||
return User.get_by_id(int(user_id)) |
||||
|
||||
|
||||
@blueprint.route("/", methods=["GET", "POST"]) |
||||
def home(): |
||||
"""Home page.""" |
||||
form = LoginForm(request.form) |
||||
current_app.logger.info("Hello from the home page!") |
||||
# Handle logging in |
||||
if request.method == "POST": |
||||
if form.validate_on_submit(): |
||||
login_user(form.user) |
||||
flash("You are logged in.", "success") |
||||
redirect_url = request.args.get("next") or url_for("user.members") |
||||
return redirect(redirect_url) |
||||
else: |
||||
flash_errors(form) |
||||
return render_template("public/home.html", form=form) |
||||
|
||||
|
||||
@blueprint.route("/logout/") |
||||
@login_required |
||||
def logout(): |
||||
"""Logout.""" |
||||
logout_user() |
||||
flash("You are logged out.", "info") |
||||
return redirect(url_for("public.home")) |
||||
|
||||
|
||||
@blueprint.route("/register/", methods=["GET", "POST"]) |
||||
def register(): |
||||
"""Register new user.""" |
||||
form = RegisterForm(request.form) |
||||
if form.validate_on_submit(): |
||||
User.create( |
||||
username=form.username.data, |
||||
email=form.email.data, |
||||
password=form.password.data, |
||||
active=True, |
||||
) |
||||
flash("Thank you for registering. You can now log in.", "success") |
||||
return redirect(url_for("public.home")) |
||||
else: |
||||
flash_errors(form) |
||||
return render_template("public/register.html", form=form) |
||||
|
||||
|
||||
@blueprint.route("/about/") |
||||
def about(): |
||||
"""About page.""" |
||||
form = LoginForm(request.form) |
||||
return render_template("public/about.html", form=form) |
@ -0,0 +1,23 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Application configuration. |
||||
|
||||
Most configuration is set via environment variables. |
||||
|
||||
For local development, use a .env file to set |
||||
environment variables. |
||||
""" |
||||
from environs import Env |
||||
|
||||
env = Env() |
||||
env.read_env() |
||||
|
||||
ENV = env.str("FLASK_ENV", default="production") |
||||
DEBUG = ENV == "development" |
||||
SQLALCHEMY_DATABASE_URI = env.str("DATABASE_URL") |
||||
SECRET_KEY = env.str("SECRET_KEY") |
||||
SEND_FILE_MAX_AGE_DEFAULT = env.int("SEND_FILE_MAX_AGE_DEFAULT") |
||||
BCRYPT_LOG_ROUNDS = env.int("BCRYPT_LOG_ROUNDS", default=13) |
||||
DEBUG_TB_ENABLED = DEBUG |
||||
DEBUG_TB_INTERCEPT_REDIRECTS = False |
||||
CACHE_TYPE = "simple" # Can be "memcached", "redis", etc. |
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False |
@ -0,0 +1,16 @@ |
||||
|
||||
{% extends "layout.html" %} |
||||
|
||||
{% block page_title %}Unauthorized{% endblock %} |
||||
|
||||
{% block content %} |
||||
<div class="jumbotron"> |
||||
<div class="text-center"> |
||||
<h1>401</h1> |
||||
<p>You are not authorized to see this page. Please <a href="{{ url_for('public.home')}}">log in</a> or |
||||
<a href="{{ url_for('public.register') }}">create a new account</a>. |
||||
</p> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
|
@ -0,0 +1,15 @@ |
||||
|
||||
{% extends "layout.html" %} |
||||
|
||||
{% block page_title %}Page Not Found{% endblock %} |
||||
|
||||
{% block content %} |
||||
<div class="jumbotron"> |
||||
<div class="text-center"> |
||||
<h1>404</h1> |
||||
<p>Sorry, that page doesn't exist.</p> |
||||
<p>Want to <a href="{{ url_for('public.home') }}">go home</a> instead?</p> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
|
@ -0,0 +1,14 @@ |
||||
|
||||
{% extends "layout.html" %} |
||||
|
||||
{% block page_title %}Server error{% endblock %} |
||||
|
||||
{% block content %} |
||||
<div class="jumbotron"> |
||||
<div class="text-center"> |
||||
<h1>500</h1> |
||||
<p>Sorry, something went wrong on our system. Don't panic, we are fixing it! Please try again later.</p> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
|
@ -0,0 +1,14 @@ |
||||
<footer> |
||||
<small> |
||||
<ul class="company"> |
||||
<li>© Yuri van Midden</li> |
||||
</ul> |
||||
|
||||
<ul class="footer-nav"> |
||||
|
||||
<li><a href="{{ url_for('public.about') }}">About</a></li> |
||||
|
||||
<li><a href="mailto:me@yurivanmidden.nl">Contact</a></li> |
||||
</ul> |
||||
</small> |
||||
</footer> |
@ -0,0 +1,71 @@ |
||||
|
||||
<!DOCTYPE html> |
||||
<!-- paulirish.com/2008/conditional-stylesheets-vs-css-hacks-answer-neither/ --> |
||||
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7" lang="en"> <![endif]--> |
||||
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8" lang="en"> <![endif]--> |
||||
<!--[if IE 8]> <html class="no-js lt-ie9" lang="en"> <![endif]--> |
||||
<!--[if gt IE 8]><!--> |
||||
<html class="no-js" lang="en"> |
||||
<!--<![endif]--> |
||||
<head> |
||||
<meta charset="utf-8" /> |
||||
|
||||
<link rel="shortcut icon" href="{{static_url_for('static', filename='build/img/favicon.ico') }}"> |
||||
<title> |
||||
{% block page_title %} |
||||
Harambee LED bord |
||||
{% endblock %} |
||||
</title> |
||||
<meta |
||||
name="description" |
||||
content="{% block meta_description %}{% endblock %}" |
||||
/> |
||||
<meta name="author" content="{% block meta_author %}{% endblock %}" /> |
||||
|
||||
<!-- Mobile viewport optimized: h5bp.com/viewport --> |
||||
<meta name="viewport" content="width=device-width" /> |
||||
|
||||
<link |
||||
rel="stylesheet" |
||||
type="text/css" |
||||
href="{{ static_url_for('static', filename='build/main_css.bundle.css') }}" |
||||
/> |
||||
|
||||
{% block css %}{% endblock %} |
||||
</head> |
||||
<body class="{% block body_class %}{% endblock %}"> |
||||
{% block body %} {% with form=form %} {% include "nav.html" %} {% endwith %} |
||||
|
||||
<header>{% block header %}{% endblock %}</header> |
||||
|
||||
<main role="main"> |
||||
{% with messages = get_flashed_messages(with_categories=true) %} {% if |
||||
messages %} |
||||
<div class="row"> |
||||
<div class="col-md-12"> |
||||
{% for category, message in messages %} |
||||
<div class="alert alert-{{ category }}"> |
||||
<a class="close" title="Close" href="#" data-dismiss="alert" |
||||
>×</a |
||||
> |
||||
{{ message }} |
||||
</div> |
||||
<!-- end .alert --> |
||||
{% endfor %} |
||||
</div> |
||||
<!-- end col-md --> |
||||
</div> |
||||
<!-- end row --> |
||||
{% endif %} {% endwith %} {% block content %}{% endblock %} |
||||
</main> |
||||
|
||||
{% include "footer.html" %} |
||||
|
||||
<!-- JavaScript at the bottom for fast page loading --> |
||||
<script src="{{ static_url_for('static', filename='build/main_js.bundle.js') }}"></script> |
||||
{% block js %}{% endblock %} |
||||
<!-- end scripts --> |
||||
{% endblock %} |
||||
</body> |
||||
</html> |
||||
|
@ -0,0 +1,49 @@ |
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top"> |
||||
<a class="navbar-brand" href="{{ url_for('public.home') }}"> |
||||
Harambee LED bord |
||||
</a> |
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" |
||||
aria-expanded="false" aria-label="Toggle navigation"> |
||||
<span class="navbar-toggler-icon"></span> |
||||
</button> |
||||
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent"> |
||||
<ul class="navbar-nav mr-auto"> |
||||
<li class="nav-item active"> |
||||
<a class="nav-link" href="{{ url_for('public.home') }}">Home |
||||
<span class="sr-only">(current)</span> |
||||
</a> |
||||
</li> |
||||
<li class="nav-item"> |
||||
<a class="nav-link" href="{{ url_for('public.about') }}">About</a> |
||||
</li> |
||||
</ul> |
||||
{% if current_user and current_user.is_authenticated %} |
||||
<ul class="navbar-nav my-auto"> |
||||
<li class="nav-item active"> |
||||
<a class="nav-link" href="{{ url_for('user.members') }}">Logged in as {{ current_user.username }}</a> |
||||
</li> |
||||
<li class="nav-item"> |
||||
<a class="nav-link" href="{{ url_for('public.logout') }}"> |
||||
<i class="fa fa-sign-out"></i> |
||||
</a> |
||||
</li> |
||||
</ul> |
||||
{% elif form %} |
||||
<form class="form-inline" id="loginForm" method="POST" action="/" role="login"> |
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> |
||||
<div class="input-group mb-2 mr-sm-2"> |
||||
{{ form.username(placeholder="Username", class_="form-control mr-sm-1 my-auto") }} {{ form.password(placeholder="Password", |
||||
class_="form-control mr-sm-1 my-auto") }} |
||||
<button class="btn btn-light btn-primary m-auto" type="submit">Login</button> |
||||
</div> |
||||
</form> |
||||
<ul class="navbar-nav my-auto"> |
||||
<li class="nav-item"> |
||||
<a class="nav-link navbar-text" href="{{ url_for('public.register') }}">Create account</a> |
||||
</li> |
||||
</ul> |
||||
{% endif %} |
||||
</div><!-- /.navbar-collapse --> |
||||
</nav> |
@ -0,0 +1,12 @@ |
||||
|
||||
{% extends "layout.html" %} |
||||
|
||||
{% block content %} |
||||
<div class="container"> |
||||
<h1 class="mt-5">About</h1> |
||||
<div class="row"> |
||||
<p class="lead">This template was created by <a href="http://github.com/sloria/">Steven Loria</a> for use with the <a href="http://github.com/audreyr/cookiecutter/">cookiecutter</a> package by <a href="http://github.com/audreyr/">Audrey Roy</a>.</p> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
|
@ -0,0 +1,35 @@ |
||||
|
||||
{% extends "layout.html" %} |
||||
{% block content %} |
||||
<!-- Main jumbotron for a primary marketing message or call to action --> |
||||
<div class="jumbotron"> |
||||
<div class="container"> |
||||
|
||||
<h1 class="display-3">Welcome to Harambee LED bord</h1> |
||||
|
||||
<p>This is a starter Flask template. It includes Bootstrap 4, jQuery 3, Flask-SQLAlchemy, WTForms, and various testing utilities out of the box.</p> |
||||
<p><a href="https://github.com/cookiecutter-flask/cookiecutter-flask" class="btn btn-primary btn-large">Learn more »</a></p> |
||||
</div> |
||||
</div><!-- /.jumbotron --> |
||||
|
||||
<div class="container"> |
||||
<div class="row"> |
||||
<div class="col-lg-4"> |
||||
<h2><i class="fa fa-code"></i> Bootstrap 4</h2> |
||||
<p>Sleek, intuitive, and powerful mobile-first front-end framework for faster and easier web development.</p> |
||||
<p><a class="btn btn-outline-secondary" href="http://getbootstrap.com/">Official website »</a></p> |
||||
</div> |
||||
<div class="col-lg-4"> |
||||
<h2><i class="fa fa-flask"></i> SQLAlchemy</h2> |
||||
<p>SQLAlchemy is the Python SQL toolkit and Object Relational Mapper that gives application developers the full power and flexibility of SQL.</p> |
||||
<p><a href="http://www.sqlalchemy.org/" class="btn btn-outline-secondary">Official website »</a></p> |
||||
</div> |
||||
<div class="col-lg-4"> |
||||
<h2><i class="fa fa-edit"></i> WTForms</h2> |
||||
<p>WTForms is a flexible forms validation and rendering library for python web development.</p> |
||||
<p><a href="http://wtforms.simplecodes.com/" class="btn btn-outline-secondary">Official website »</a></p> |
||||
</div> |
||||
</div><!-- /.row --> |
||||
</div> |
||||
{% endblock %} |
||||
|
@ -0,0 +1,31 @@ |
||||
|
||||
{% extends "layout.html" %} |
||||
{% block content %} |
||||
<div class="container-narrow"> |
||||
<h1 class="mt-5">Register</h1> |
||||
<br/> |
||||
<form id="registerForm" class="form form-register" method="POST" action="" role="form"> |
||||
{{ form.csrf_token }} |
||||
<div class="form-group"> |
||||
{{form.username.label}} |
||||
{{form.username(placeholder="Username", class_="form-control")}} |
||||
</div> |
||||
<div class="form-group"> |
||||
{{form.email.label}} |
||||
{{form.email(placeholder="Email", class_="form-control")}} |
||||
<small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small> |
||||
</div> |
||||
<div class="form-group"> |
||||
{{form.password.label}} |
||||
{{form.password(placeholder="Password", class_="form-control")}} |
||||
</div> |
||||
<div class="form-group"> |
||||
{{form.confirm.label}} |
||||
{{form.confirm(placeholder="Password (again)", class_="form-control")}} |
||||
</div> |
||||
<p><input class="btn btn-primary" type="submit" value="Register"></p> |
||||
</form> |
||||
<p><em>Already registered?</em> Click <a href="/">here</a> to login.</p> |
||||
</div> |
||||
{% endblock %} |
||||
|
@ -0,0 +1,9 @@ |
||||
|
||||
{% extends "layout.html" %} |
||||
{% block content %} |
||||
<div class="container"> |
||||
<h1>Welcome {{ current_user.username }}</h1> |
||||
<h3>This is the members-only page.</h3> |
||||
</div> |
||||
{% endblock %} |
||||
|
@ -0,0 +1,3 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""The user module.""" |
||||
from . import views # noqa |
@ -0,0 +1,45 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""User forms.""" |
||||
from flask_wtf import FlaskForm |
||||
from wtforms import PasswordField, StringField |
||||
from wtforms.validators import DataRequired, Email, EqualTo, Length |
||||
|
||||
from .models import User |
||||
|
||||
|
||||
class RegisterForm(FlaskForm): |
||||
"""Register form.""" |
||||
|
||||
username = StringField( |
||||
"Username", validators=[DataRequired(), Length(min=3, max=25)] |
||||
) |
||||
email = StringField( |
||||
"Email", validators=[DataRequired(), Email(), Length(min=6, max=40)] |
||||
) |
||||
password = PasswordField( |
||||
"Password", validators=[DataRequired(), Length(min=6, max=40)] |
||||
) |
||||
confirm = PasswordField( |
||||
"Verify password", |
||||
[DataRequired(), EqualTo("password", message="Passwords must match")], |
||||
) |
||||
|
||||
def __init__(self, *args, **kwargs): |
||||
"""Create instance.""" |
||||
super(RegisterForm, self).__init__(*args, **kwargs) |
||||
self.user = None |
||||
|
||||
def validate(self): |
||||
"""Validate the form.""" |
||||
initial_validation = super(RegisterForm, self).validate() |
||||
if not initial_validation: |
||||
return False |
||||
user = User.query.filter_by(username=self.username.data).first() |
||||
if user: |
||||
self.username.errors.append("Username already registered") |
||||
return False |
||||
user = User.query.filter_by(email=self.email.data).first() |
||||
if user: |
||||
self.email.errors.append("Email already registered") |
||||
return False |
||||
return True |
@ -0,0 +1,72 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""User models.""" |
||||
import datetime as dt |
||||
|
||||
from flask_login import UserMixin |
||||
|
||||
from harambee_led_bord.database import ( |
||||
Column, |
||||
Model, |
||||
SurrogatePK, |
||||
db, |
||||
reference_col, |
||||
relationship, |
||||
) |
||||
from harambee_led_bord.extensions import bcrypt |
||||
|
||||
|
||||
class Role(SurrogatePK, Model): |
||||
"""A role for a user.""" |
||||
|
||||
__tablename__ = "roles" |
||||
name = Column(db.String(80), unique=True, nullable=False) |
||||
user_id = reference_col("users", nullable=True) |
||||
user = relationship("User", backref="roles") |
||||
|
||||
def __init__(self, name, **kwargs): |
||||
"""Create instance.""" |
||||
db.Model.__init__(self, name=name, **kwargs) |
||||
|
||||
def __repr__(self): |
||||
"""Represent instance as a unique string.""" |
||||
return f"<Role({self.name})>" |
||||
|
||||
|
||||
class User(UserMixin, SurrogatePK, Model): |
||||
"""A user of the app.""" |
||||
|
||||
__tablename__ = "users" |
||||
username = Column(db.String(80), unique=True, nullable=False) |
||||
email = Column(db.String(80), unique=True, nullable=False) |
||||
#: The hashed password |
||||
password = Column(db.LargeBinary(128), nullable=True) |
||||
created_at = Column(db.DateTime, nullable=False, default=dt.datetime.utcnow) |
||||
first_name = Column(db.String(30), nullable=True) |
||||
last_name = Column(db.String(30), nullable=True) |
||||
active = Column(db.Boolean(), default=False) |
||||
is_admin = Column(db.Boolean(), default=False) |
||||
|
||||
def __init__(self, username, email, password=None, **kwargs): |
||||
"""Create instance.""" |
||||
db.Model.__init__(self, username=username, email=email, **kwargs) |
||||
if password: |
||||
self.set_password(password) |
||||
else: |
||||
self.password = None |
||||
|
||||
def set_password(self, password): |
||||
"""Set password.""" |
||||
self.password = bcrypt.generate_password_hash(password) |
||||
|
||||
def check_password(self, value): |
||||
"""Check password.""" |
||||
return bcrypt.check_password_hash(self.password, value) |
||||
|
||||
@property |
||||
def full_name(self): |
||||
"""Full user name.""" |
||||
return f"{self.first_name} {self.last_name}" |
||||
|
||||
def __repr__(self): |
||||
"""Represent instance as a unique string.""" |
||||
return f"<User({self.username!r})>" |
@ -0,0 +1,13 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""User views.""" |
||||
from flask import Blueprint, render_template |
||||
from flask_login import login_required |
||||
|
||||
blueprint = Blueprint("user", __name__, url_prefix="/users", static_folder="../static") |
||||
|
||||
|
||||
@blueprint.route("/") |
||||
@login_required |
||||
def members(): |
||||
"""List members.""" |
||||
return render_template("users/members.html") |
@ -0,0 +1,10 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Helper utilities and decorators.""" |
||||
from flask import flash |
||||
|
||||
|
||||
def flash_errors(form, category="warning"): |
||||
"""Flash all errors for a form.""" |
||||
for field, errors in form.errors.items(): |
||||
for error in errors: |
||||
flash(f"{getattr(form, field).label.text} - {error}", category) |
@ -0,0 +1 @@ |
||||
Generic single-database configuration. |
@ -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 = console |
||||
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 |
@ -0,0 +1,96 @@ |
||||
from __future__ import with_statement |
||||
|
||||
import logging |
||||
from logging.config import fileConfig |
||||
|
||||
from sqlalchemy import engine_from_config |
||||
from sqlalchemy import pool |
||||
|
||||
from alembic import context |
||||
|
||||
# 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) |
||||
logger = logging.getLogger('alembic.env') |
||||
|
||||
# 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').replace('%', '%%')) |
||||
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, target_metadata=target_metadata, literal_binds=True |
||||
) |
||||
|
||||
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. |
||||
|
||||
""" |
||||
|
||||
# this callback is used to prevent an auto-migration from being generated |
||||
# when there are no changes to the schema |
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html |
||||
def process_revision_directives(context, revision, directives): |
||||
if getattr(config.cmd_opts, 'autogenerate', False): |
||||
script = directives[0] |
||||
if script.upgrade_ops.is_empty(): |
||||
directives[:] = [] |
||||
logger.info('No changes in schema detected.') |
||||
|
||||
connectable = engine_from_config( |
||||
config.get_section(config.config_ini_section), |
||||
prefix='sqlalchemy.', |
||||
poolclass=pool.NullPool, |
||||
) |
||||
|
||||
with connectable.connect() as connection: |
||||
context.configure( |
||||
connection=connection, |
||||
target_metadata=target_metadata, |
||||
process_revision_directives=process_revision_directives, |
||||
**current_app.extensions['migrate'].configure_args |
||||
) |
||||
|
||||
with context.begin_transaction(): |
||||
context.run_migrations() |
||||
|
||||
|
||||
if context.is_offline_mode(): |
||||
run_migrations_offline() |
||||
else: |
||||
run_migrations_online() |
@ -0,0 +1,24 @@ |
||||
"""${message} |
||||
|
||||
Revision ID: ${up_revision} |
||||
Revises: ${down_revision | comma,n} |
||||
Create Date: ${create_date} |
||||
|
||||
""" |
||||
from alembic import op |
||||
import sqlalchemy as sa |
||||
${imports if imports else ""} |
||||
|
||||
# revision identifiers, used by Alembic. |
||||
revision = ${repr(up_revision)} |
||||
down_revision = ${repr(down_revision)} |
||||
branch_labels = ${repr(branch_labels)} |
||||
depends_on = ${repr(depends_on)} |
||||
|
||||
|
||||
def upgrade(): |
||||
${upgrades if upgrades else "pass"} |
||||
|
||||
|
||||
def downgrade(): |
||||
${downgrades if downgrades else "pass"} |
@ -0,0 +1,50 @@ |
||||
"""empty message |
||||
|
||||
Revision ID: 6741bbbc111a |
||||
Revises: |
||||
Create Date: 2019-12-13 15:00:37.768224 |
||||
|
||||
""" |
||||
from alembic import op |
||||
import sqlalchemy as sa |
||||
|
||||
|
||||
# revision identifiers, used by Alembic. |
||||
revision = '6741bbbc111a' |
||||
down_revision = None |
||||
branch_labels = None |
||||
depends_on = None |
||||
|
||||
|
||||
def upgrade(): |
||||
# ### commands auto generated by Alembic - please adjust! ### |
||||
op.create_table('users', |
||||
sa.Column('id', sa.Integer(), nullable=False), |
||||
sa.Column('username', sa.String(length=80), nullable=False), |
||||
sa.Column('email', sa.String(length=80), nullable=False), |
||||
sa.Column('password', sa.LargeBinary(length=128), nullable=True), |
||||
sa.Column('created_at', sa.DateTime(), nullable=False), |
||||
sa.Column('first_name', sa.String(length=30), nullable=True), |
||||
sa.Column('last_name', sa.String(length=30), nullable=True), |
||||
sa.Column('active', sa.Boolean(), nullable=True), |
||||
sa.Column('is_admin', sa.Boolean(), nullable=True), |
||||
sa.PrimaryKeyConstraint('id'), |
||||
sa.UniqueConstraint('email'), |
||||
sa.UniqueConstraint('username') |
||||
) |
||||
op.create_table('roles', |
||||
sa.Column('id', sa.Integer(), nullable=False), |
||||
sa.Column('name', sa.String(length=80), nullable=False), |
||||
sa.Column('user_id', sa.Integer(), nullable=True), |
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), |
||||
sa.PrimaryKeyConstraint('id'), |
||||
sa.UniqueConstraint('name') |
||||
) |
||||
# ### end Alembic commands ### |
||||
|
||||
|
||||
def downgrade(): |
||||
# ### commands auto generated by Alembic - please adjust! ### |
||||
op.drop_table('roles') |
||||
op.drop_table('users') |
||||
# ### end Alembic commands ### |
@ -0,0 +1,52 @@ |
||||
{ |
||||
"name": "harambee_led_bord", |
||||
"version": "1.0.0", |
||||
"description": "Backend voor het Harambee led bord", |
||||
"scripts": { |
||||
"build": "NODE_ENV=production webpack --progress --colors -p && npm run flask-static-digest", |
||||
"start": "concurrently -n \"WEBPACK,FLASK\" -c \"bgBlue.bold,bgMagenta.bold\" \"npm run webpack-watch\" \"npm run flask-server\"", |
||||
"webpack-watch": "NODE_ENV=debug webpack --mode development --watch", |
||||
"flask-server": "pipenv run flask run --host=0.0.0.0", |
||||
"flask-static-digest": "pipenv run flask digest compile", |
||||
"lint": "eslint \"assets/js/*.js\"" |
||||
}, |
||||
"repository": { |
||||
"type": "git", |
||||
"url": "git+https://github.com/yurivanmidden/harambee_led_bord.git" |
||||
}, |
||||
"author": "Yuri van Midden", |
||||
"license": "MIT", |
||||
"engines": { |
||||
"node": ">=12" |
||||
}, |
||||
"bugs": { |
||||
"url": "https://github.com/yurivanmidden/harambee_led_bord/issues" |
||||
}, |
||||
"homepage": "https://github.com/yurivanmidden/harambee_led_bord#readme", |
||||
"dependencies": { |
||||
"@fortawesome/fontawesome-free": "^5.9.0", |
||||
"bootstrap": "^4.3.1", |
||||
"font-awesome": "^4.7.0", |
||||
"jquery": "^3.4.1", |
||||
"popper.js": "^1.15.0" |
||||
}, |
||||
"devDependencies": { |
||||
"@babel/core": "^7.4.5", |
||||
"babel-eslint": "^10.0.1", |
||||
"babel-loader": "^8.0.6", |
||||
"babel-preset-env": "^1.7.0", |
||||
"concurrently": "^5.0.0", |
||||
"css-loader": "^3.0.0", |
||||
"eslint": "^6.2.2", |
||||
"eslint-config-airbnb-base": "^14.0.0", |
||||
"eslint-plugin-import": "^2.17.3", |
||||
"file-loader": "^5.0.2", |
||||
"less": "^3.9.0", |
||||
"less-loader": "^5.0.0", |
||||
"mini-css-extract-plugin": "^0.8.0", |
||||
"raw-loader": "^4.0.0", |
||||
"url-loader": "^3.0.0", |
||||
"webpack": "^4.33.0", |
||||
"webpack-cli": "^3.3.2" |
||||
} |
||||
} |
@ -0,0 +1,11 @@ |
||||
[flake8] |
||||
ignore = D401,D202,E226,E302,E41 |
||||
max-line-length=120 |
||||
exclude = migrations/* |
||||
max-complexity = 10 |
||||
|
||||
[isort] |
||||
line_length=88 |
||||
multi_line_output=3 |
||||
skip=migrations/* |
||||
include_trailing_comma=true |
@ -0,0 +1,7 @@ |
||||
#!/usr/bin/env sh |
||||
|
||||
function auto_pipenv_shell { |
||||
if [ -f "Pipfile" ] ; then |
||||
source "$(pipenv --venv)/bin/activate" |
||||
fi |
||||
} |
@ -0,0 +1,12 @@ |
||||
#!/usr/bin/env sh |
||||
set -e |
||||
|
||||
npm run build |
||||
source ./shell_scripts/auto_pipenv.sh |
||||
auto_pipenv_shell |
||||
|
||||
if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then |
||||
set -- supervisord "$@" |
||||
fi |
||||
|
||||
exec "$@" |
@ -0,0 +1,21 @@ |
||||
[unix_http_server] |
||||
file=/tmp/supervisor.sock ; path to your socket file |
||||
|
||||
[supervisord] |
||||
logfile=/tmp/supervisord.log ; supervisord log file |
||||
logfile_maxbytes=50MB ; maximum size of logfile before rotation |
||||
logfile_backups=10 ; number of backed up logfiles |
||||
loglevel=%(ENV_LOG_LEVEL)s ; info, debug, warn, trace |
||||
pidfile=/tmp/supervisord.pid ; pidfile location |
||||
nodaemon=true ; run supervisord as a daemon |
||||
minfds=1024 ; number of startup file descriptors |
||||
minprocs=200 ; number of process descriptors |
||||
|
||||
[rpcinterface:supervisor] |
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface |
||||
|
||||
[supervisorctl] |
||||
serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket |
||||
|
||||
[include] |
||||
files = /etc/supervisor/conf.d/*.conf |
@ -0,0 +1,18 @@ |
||||
[program:gunicorn] |
||||
directory=/app |
||||
command=gunicorn |
||||
harambee_led_bord.app:create_app() |
||||
-b :5000 |
||||
-w %(ENV_GUNICORN_WORKERS)s |
||||
-k gevent |
||||
--max-requests=5000 |
||||
--max-requests-jitter=500 |
||||
--log-level=%(ENV_LOG_LEVEL)s |
||||
stdout_logfile=/dev/stdout |
||||
stdout_logfile_maxbytes=0 |
||||
autostart=true |
||||
autorestart=true |
||||
stdout_logfile=/dev/stdout |
||||
stdout_logfile_maxbytes=0 |
||||
stderr_logfile=/dev/stderr |
||||
stderr_logfile_maxbytes=0 |
@ -0,0 +1 @@ |
||||
"""Tests for the app.""" |
@ -0,0 +1,53 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Defines fixtures available to all tests.""" |
||||
|
||||
import logging |
||||
|
||||
import pytest |
||||
from webtest import TestApp |
||||
|
||||
from harambee_led_bord.app import create_app |
||||
from harambee_led_bord.database import db as _db |
||||
|
||||
from .factories import UserFactory |
||||
|
||||
|
||||
@pytest.fixture |
||||
def app(): |
||||
"""Create application for the tests.""" |
||||
_app = create_app("tests.settings") |
||||
_app.logger.setLevel(logging.CRITICAL) |
||||
ctx = _app.test_request_context() |
||||
ctx.push() |
||||
|
||||
yield _app |
||||
|
||||
ctx.pop() |
||||
|
||||
|
||||
@pytest.fixture |
||||
def testapp(app): |
||||
"""Create Webtest app.""" |
||||
return TestApp(app) |
||||
|
||||
|
||||
@pytest.fixture |
||||
def db(app): |
||||
"""Create database for the tests.""" |
||||
_db.app = app |
||||
with app.app_context(): |
||||
_db.create_all() |
||||
|
||||
yield _db |
||||
|
||||
# Explicitly close DB connection |
||||
_db.session.close() |
||||
_db.drop_all() |
||||
|
||||
|
||||
@pytest.fixture |
||||
def user(db): |
||||
"""Create user for the tests.""" |
||||
user = UserFactory(password="myprecious") |
||||
db.session.commit() |
||||
return user |
@ -0,0 +1,31 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Factories to help in tests.""" |
||||
from factory import PostGenerationMethodCall, Sequence |
||||
from factory.alchemy import SQLAlchemyModelFactory |
||||
|
||||
from harambee_led_bord.database import db |
||||
from harambee_led_bord.user.models import User |
||||
|
||||
|
||||
class BaseFactory(SQLAlchemyModelFactory): |
||||
"""Base factory.""" |
||||
|
||||
class Meta: |
||||
"""Factory configuration.""" |
||||
|
||||
abstract = True |
||||
sqlalchemy_session = db.session |
||||
|
||||
|
||||
class UserFactory(BaseFactory): |
||||
"""User factory.""" |
||||
|
||||
username = Sequence(lambda n: f"user{n}") |
||||
email = Sequence(lambda n: f"user{n}@example.com") |
||||
password = PostGenerationMethodCall("set_password", "example") |
||||
active = True |
||||
|
||||
class Meta: |
||||
"""Factory configuration.""" |
||||
|
||||
model = User |
@ -0,0 +1,12 @@ |
||||
"""Settings module for test app.""" |
||||
ENV = "development" |
||||
TESTING = True |
||||
SQLALCHEMY_DATABASE_URI = "sqlite://" |
||||
SECRET_KEY = "not-so-secret-in-tests" |
||||
BCRYPT_LOG_ROUNDS = ( |
||||
4 # For faster tests; needs at least 4 to avoid "ValueError: Invalid rounds" |
||||
) |
||||
DEBUG_TB_ENABLED = False |
||||
CACHE_TYPE = "simple" # Can be "memcached", "redis", etc. |
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False |
||||
WTF_CSRF_ENABLED = False # Allows form testing |
@ -0,0 +1,77 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Test forms.""" |
||||
|
||||
from harambee_led_bord.public.forms import LoginForm |
||||
from harambee_led_bord.user.forms import RegisterForm |
||||
|
||||
|
||||
class TestRegisterForm: |
||||
"""Register form.""" |
||||
|
||||
def test_validate_user_already_registered(self, user): |
||||
"""Enter username that is already registered.""" |
||||
form = RegisterForm( |
||||
username=user.username, |
||||
email="foo@bar.com", |
||||
password="example", |
||||
confirm="example", |
||||
) |
||||
|
||||
assert form.validate() is False |
||||
assert "Username already registered" in form.username.errors |
||||
|
||||
def test_validate_email_already_registered(self, user): |
||||
"""Enter email that is already registered.""" |
||||
form = RegisterForm( |
||||
username="unique", email=user.email, password="example", confirm="example" |
||||
) |
||||
|
||||
assert form.validate() is False |
||||
assert "Email already registered" in form.email.errors |
||||
|
||||
def test_validate_success(self, db): |
||||
"""Register with success.""" |
||||
form = RegisterForm( |
||||
username="newusername", |
||||
email="new@test.test", |
||||
password="example", |
||||
confirm="example", |
||||
) |
||||
assert form.validate() is True |
||||
|
||||
|
||||
class TestLoginForm: |
||||
"""Login form.""" |
||||
|
||||
def test_validate_success(self, user): |
||||
"""Login successful.""" |
||||
user.set_password("example") |
||||
user.save() |
||||
form = LoginForm(username=user.username, password="example") |
||||
assert form.validate() is True |
||||
assert form.user == user |
||||
|
||||
def test_validate_unknown_username(self, db): |
||||
"""Unknown username.""" |
||||
form = LoginForm(username="unknown", password="example") |
||||
assert form.validate() is False |
||||
assert "Unknown username" in form.username.errors |
||||
assert form.user is None |
||||
|
||||
def test_validate_invalid_password(self, user): |
||||
"""Invalid password.""" |
||||
user.set_password("example") |
||||
user.save() |
||||
form = LoginForm(username=user.username, password="wrongpassword") |
||||
assert form.validate() is False |
||||
assert "Invalid password" in form.password.errors |
||||
|
||||
def test_validate_inactive_user(self, user): |
||||
"""Inactive user.""" |
||||
user.active = False |
||||
user.set_password("example") |
||||
user.save() |
||||
# Correct username and password, but user is not activated |
||||
form = LoginForm(username=user.username, password="example") |
||||
assert form.validate() is False |
||||
assert "User not activated" in form.username.errors |
@ -0,0 +1,120 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Functional tests using WebTest. |
||||
|
||||
See: http://webtest.readthedocs.org/ |
||||
""" |
||||
from flask import url_for |
||||
|
||||
from harambee_led_bord.user.models import User |
||||
|
||||
from .factories import UserFactory |
||||
|
||||
|
||||
class TestLoggingIn: |
||||
"""Login.""" |
||||
|
||||
def test_can_log_in_returns_200(self, user, testapp): |
||||
"""Login successful.""" |
||||
# Goes to homepage |
||||
res = testapp.get("/") |
||||
# Fills out login form in navbar |
||||
form = res.forms["loginForm"] |
||||
form["username"] = user.username |
||||
form["password"] = "myprecious" |
||||
# Submits |
||||
res = form.submit().follow() |
||||
assert res.status_code == 200 |
||||
|
||||
def test_sees_alert_on_log_out(self, user, testapp): |
||||
"""Show alert on logout.""" |
||||
res = testapp.get("/") |
||||
# Fills out login form in navbar |
||||
form = res.forms["loginForm"] |
||||
form["username"] = user.username |
||||
form["password"] = "myprecious" |
||||
# Submits |
||||
res = form.submit().follow() |
||||
res = testapp.get(url_for("public.logout")).follow() |
||||
# sees alert |
||||
assert "You are logged out." in res |
||||
|
||||
def test_sees_error_message_if_password_is_incorrect(self, user, testapp): |
||||
"""Show error if password is incorrect.""" |
||||
# Goes to homepage |
||||
res = testapp.get("/") |
||||
# Fills out login form, password incorrect |
||||
form = res.forms["loginForm"] |
||||
form["username"] = user.username |
||||
form["password"] = "wrong" |
||||
# Submits |
||||
res = form.submit() |
||||
# sees error |
||||
assert "Invalid password" in res |
||||
|
||||
def test_sees_error_message_if_username_doesnt_exist(self, user, testapp): |
||||
"""Show error if username doesn't exist.""" |
||||
# Goes to homepage |
||||
res = testapp.get("/") |
||||
# Fills out login form, password incorrect |
||||
form = res.forms["loginForm"] |
||||
form["username"] = "unknown" |
||||
form["password"] = "myprecious" |
||||
# Submits |
||||
res = form.submit() |
||||
# sees error |
||||
assert "Unknown user" in res |
||||
|
||||
|
||||
class TestRegistering: |
||||
"""Register a user.""" |
||||
|
||||
def test_can_register(self, user, testapp): |
||||
"""Register a new user.""" |
||||
old_count = len(User.query.all()) |
||||
# Goes to homepage |
||||
res = testapp.get("/") |
||||
# Clicks Create Account button |
||||
res = res.click("Create account") |
||||
# Fills out the form |
||||
form = res.forms["registerForm"] |
||||
form["username"] = "foobar" |
||||
form["email"] = "foo@bar.com" |
||||
form["password"] = "secret" |
||||
form["confirm"] = "secret" |
||||
# Submits |
||||
res = form.submit().follow() |
||||
assert res.status_code == 200 |
||||
# A new user was created |
||||
assert len(User.query.all()) == old_count + 1 |
||||
|
||||
def test_sees_error_message_if_passwords_dont_match(self, user, testapp): |
||||
"""Show error if passwords don't match.""" |
||||
# Goes to registration page |
||||
res = testapp.get(url_for("public.register")) |
||||
# Fills out form, but passwords don't match |
||||
form = res.forms["registerForm"] |
||||
form["username"] = "foobar" |
||||
form["email"] = "foo@bar.com" |
||||
form["password"] = "secret" |
||||
form["confirm"] = "secrets" |
||||
# Submits |
||||
res = form.submit() |
||||
# sees error message |
||||
assert "Passwords must match" in res |
||||
|
||||
def test_sees_error_message_if_user_already_registered(self, user, testapp): |
||||
"""Show error if user already registered.""" |
||||
user = UserFactory(active=True) # A registered user |
||||
user.save() |
||||
# Goes to registration page |
||||
res = testapp.get(url_for("public.register")) |
||||
# Fills out form, but username is already registered |
||||
form = res.forms["registerForm"] |
||||
form["username"] = user.username |
||||
form["email"] = "foo@bar.com" |
||||
form["password"] = "secret" |
||||
form["confirm"] = "secret" |
||||
# Submits |
||||
res = form.submit() |
||||
# sees error |
||||
assert "Username already registered" in res |
@ -0,0 +1,66 @@ |
||||
# -*- coding: utf-8 -*- |
||||
"""Model unit tests.""" |
||||
import datetime as dt |
||||
|
||||
import pytest |
||||
|
||||
from harambee_led_bord.user.models import Role, User |
||||
|
||||
from .factories import UserFactory |
||||
|
||||
|
||||
@pytest.mark.usefixtures("db") |
||||
class TestUser: |
||||
"""User tests.""" |
||||
|
||||
def test_get_by_id(self): |
||||
"""Get user by ID.""" |
||||
user = User("foo", "foo@bar.com") |
||||
user.save() |
||||
|
||||
retrieved = User.get_by_id(user.id) |
||||
assert retrieved == user |
||||
|
||||
def test_created_at_defaults_to_datetime(self): |
||||
"""Test creation date.""" |
||||
user = User(username="foo", email="foo@bar.com") |
||||
user.save() |
||||
assert bool(user.created_at) |
||||
assert isinstance(user.created_at, dt.datetime) |
||||
|
||||
def test_password_is_nullable(self): |
||||
"""Test null password.""" |
||||
user = User(username="foo", email="foo@bar.com") |
||||
user.save() |
||||
assert user.password is None |
||||
|
||||
def test_factory(self, db): |
||||
"""Test user factory.""" |
||||
user = UserFactory(password="myprecious") |
||||
db.session.commit() |
||||
assert bool(user.username) |
||||
assert bool(user.email) |
||||
assert bool(user.created_at) |
||||
assert user.is_admin is False |
||||
assert user.active is True |
||||
assert user.check_password("myprecious") |
||||
|
||||
def test_check_password(self): |
||||
"""Check password.""" |
||||
user = User.create(username="foo", email="foo@bar.com", password="foobarbaz123") |
||||
assert user.check_password("foobarbaz123") is True |
||||
assert user.check_password("barfoobaz") is False |
||||
|
||||
def test_full_name(self): |
||||
"""User full name.""" |
||||
user = UserFactory(first_name="Foo", last_name="Bar") |
||||
assert user.full_name == "Foo Bar" |
||||
|
||||
def test_roles(self): |
||||
"""Add a role to a user.""" |
||||
role = Role(name="admin") |
||||
role.save() |
||||
user = UserFactory() |
||||
user.roles.append(role) |
||||
user.save() |
||||
assert role in user.roles |
@ -0,0 +1,82 @@ |
||||
const path = require('path'); |
||||
const webpack = require('webpack'); |
||||
|
||||
/* |
||||
* Webpack Plugins |
||||
*/ |
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); |
||||
|
||||
const ProductionPlugins = [ |
||||
// production webpack plugins go here
|
||||
new webpack.DefinePlugin({ |
||||
"process.env": { |
||||
NODE_ENV: JSON.stringify("production") |
||||
} |
||||
}) |
||||
] |
||||
|
||||
const debug = (process.env.NODE_ENV !== 'production'); |
||||
const rootAssetPath = path.join(__dirname, 'assets'); |
||||
|
||||
module.exports = { |
||||
// configuration
|
||||
context: __dirname, |
||||
entry: { |
||||
main_js: './assets/js/main', |
||||
main_css: [ |
||||
path.join(__dirname, 'node_modules', 'font-awesome', 'css', 'font-awesome.css'), |
||||
path.join(__dirname, 'node_modules', 'bootstrap', 'dist', 'css', 'bootstrap.css'), |
||||
path.join(__dirname, 'assets', 'css', 'style.css'), |
||||
], |
||||
}, |
||||
mode: debug, |
||||
output: { |
||||
chunkFilename: "[id].js", |
||||
filename: "[name].bundle.js", |
||||
path: path.join(__dirname, "harambee_led_bord", "static", "build"), |
||||
publicPath: "/static/build/" |
||||
}, |
||||
resolve: { |
||||
extensions: [".js", ".jsx", ".css"] |
||||
}, |
||||
devtool: debug ? "eval-source-map" : false, |
||||
plugins: [ |
||||
new MiniCssExtractPlugin({ filename: "[name].bundle.css" }), |
||||
new webpack.ProvidePlugin({ $: "jquery", jQuery: "jquery" }) |
||||
].concat(debug ? [] : ProductionPlugins), |
||||
module: { |
||||
rules: [ |
||||
{ |
||||
test: /\.less$/, |
||||
use: [ |
||||
{ |
||||
loader: MiniCssExtractPlugin.loader, |
||||
options: { |
||||
hmr: debug, |
||||
}, |
||||
}, |
||||
'css-loader!less-loader', |
||||
], |
||||
}, |
||||
{ |
||||
test: /\.css$/, |
||||
use: [ |
||||
{ |
||||
loader: MiniCssExtractPlugin.loader, |
||||
options: { |
||||
hmr: debug, |
||||
}, |
||||
}, |
||||
'css-loader', |
||||
], |
||||
}, |
||||
{ test: /\.html$/, loader: 'raw-loader' }, |
||||
{ test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: 'url-loader', options: { limit: 10000, mimetype: 'application/font-woff' } }, |
||||
{ |
||||
test: /\.(ttf|eot|svg|png|jpe?g|gif|ico)(\?.*)?$/i, |
||||
loader: `file-loader?context=${rootAssetPath}&name=[path][name].[ext]` |
||||
}, |
||||
{ test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader', query: { presets: ['env'], cacheDirectory: true } }, |
||||
], |
||||
} |
||||
}; |
Loading…
Reference in new issue