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