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