From 57c9c70cbb2f1b189814f4642f87f21d1082ee5f Mon Sep 17 00:00:00 2001 From: Jonathan Krauss Date: Tue, 12 Jul 2022 05:44:09 -0700 Subject: [PATCH] Initial Commit --- .gitignore | 215 +++++++++ LICENSE.md | 13 + Makefile | 33 ++ README.md | 3 + config/test.py | 38 ++ migrations/README | 1 + migrations/alembic.ini | 50 +++ migrations/env.py | 91 ++++ migrations/script.py.mako | 24 + migrations/versions/b3639db87e06_.py | 56 +++ poetry.lock | 649 +++++++++++++++++++++++++++ pyproject.toml | 35 ++ sigl/__init__.py | 5 + sigl/__main__.py | 12 + sigl/cli.py | 39 ++ sigl/database/__init__.py | 84 ++++ sigl/database/globals.py | 14 + sigl/database/orm.py | 34 ++ sigl/database/tables.py | 56 +++ sigl/database/util.py | 78 ++++ sigl/domain/__init__.py | 5 + sigl/domain/models/__init__.py | 11 + sigl/domain/models/mixins.py | 41 ++ sigl/domain/models/product.py | 42 ++ sigl/exc.py | 35 ++ sigl/factory.py | 149 ++++++ sigl/logging.py | 335 ++++++++++++++ sigl/mail.py | 69 +++ sigl/settings.py | 8 + sigl/socketio.py | 27 ++ tox.ini | 5 + 31 files changed, 2257 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 config/test.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/b3639db87e06_.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 sigl/__init__.py create mode 100644 sigl/__main__.py create mode 100644 sigl/cli.py create mode 100644 sigl/database/__init__.py create mode 100644 sigl/database/globals.py create mode 100644 sigl/database/orm.py create mode 100644 sigl/database/tables.py create mode 100644 sigl/database/util.py create mode 100644 sigl/domain/__init__.py create mode 100644 sigl/domain/models/__init__.py create mode 100644 sigl/domain/models/mixins.py create mode 100644 sigl/domain/models/product.py create mode 100644 sigl/exc.py create mode 100644 sigl/factory.py create mode 100644 sigl/logging.py create mode 100644 sigl/mail.py create mode 100644 sigl/settings.py create mode 100644 sigl/socketio.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f783b14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,215 @@ +# ---> Sigl +assets/dist/* +config/* +docs/_build/* + +!config/dev.example.py +!config/test.py +!.gitkeep + +# MacOS +.DS_Store + +# Databases +*.db + +# Logging +log/* + +# IDE +.vscode + +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# FuseBox cache +.fusebox/ + +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..37b7568 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,13 @@ +BSD 3-Clause License + +Copyright (c) 2022, Asymworks, LLC All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + + Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5ed0c8e --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +# Sigl Build and Test Scripts + +export FLASK_APP := sigl.factory + +shell serve db-init db-migrate db-upgrade db-downgrade db-populate : export FLASK_ENV := development +shell serve db-init db-migrate db-upgrade db-downgrade db-populate : export SIGL_CONFIG := ../config/dev.py +coverage coverage-html coverage-report test test-wip test-x : export FLASK_ENV := production + +shell-psql serve-psql export : export FLASK_ENV := development +shell-psql serve-psql export : export SIGL_CONFIG := ../config/dev-docker.py + +db-init : + poetry run flask db init + +db-migrate : + poetry run flask db migrate + +db-upgrade : + poetry run flask db upgrade + +db-downgrade : + poetry run flask db downgrade + +lint : + poetry run flake8 + +serve : + poetry run python -m sigl + +shell : + poetry run flask shell + +.PHONY : lint shell serve \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c868ba --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Simple Grocery List (Sigl) + +Simple Grocery List, or **Sigl** (pronounced "sigil") is a lightweight shopping list manager designed for shared grocery lists (or any other shopping list). Sigl is designed to be intuitive and flexible, emphasizing an ad-hoc approach to shopping lists rather than "recipe" based lists. \ No newline at end of file diff --git a/config/test.py b/config/test.py new file mode 100644 index 0000000..13492d8 --- /dev/null +++ b/config/test.py @@ -0,0 +1,38 @@ +"""Sigl Test Configuration. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +# Default Jade Tree Test Configuration +SERVER_NAME = 'test.sigl.local' + +# Session and Token Keys +APP_SESSION_KEY = 'sigl-test-session-key' +APP_TOKEN_KEY = 'sigl-test-token-key' + +APP_TOKEN_ISSUER = 'urn:sigl.test' +APP_TOKEN_AUDIENCE = 'urn:sigl.test' +APP_TOKEN_VALIDITY = 7200 + +# Development Database Settings (overridden by PyTest app_config Fixture) +DB_DRIVER = 'sqlite' +DB_FILE = 'sigl-test.db' + +# Mail Configuration +MAIL_ENABLED = True +MAIL_SERVER = 'localhost' +MAIL_SENDER = 'test@localhost' +MAIL_SUPPRESS_SEND = True + +# Frontend Configuration +FRONTEND_HOST = 'http://localhost' +FRONTEND_LOGIN_PATH = '/login' +FRONTEND_LOGO_PATH = '/logo.png' +FRONTEND_REG_CONFIRM_PATH = '/register/confirm' +FRONTEND_REG_CANCEL_PATH = '/register/cancel' +FRONTEND_REG_RESEND_PATH = '/register/resend' + +# Email Site Information +SITE_ABUSE_MAILBOX = 'abuse@localhost' +SITE_HELP_MAILBOX = 'help@localhost' diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# 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,flask_migrate + +[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 + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[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 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..68feded --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,91 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +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 +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).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 = current_app.extensions['migrate'].db.get_engine() + + 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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/b3639db87e06_.py b/migrations/versions/b3639db87e06_.py new file mode 100644 index 0000000..9b99bdd --- /dev/null +++ b/migrations/versions/b3639db87e06_.py @@ -0,0 +1,56 @@ +"""empty message + +Revision ID: b3639db87e06 +Revises: +Create Date: 2022-07-11 17:09:20.502213 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b3639db87e06' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('products', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('category', sa.String(length=128), nullable=False), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('modified_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_products_category'), 'products', ['category'], unique=False) + op.create_table('sigl_config', + sa.Column('key', sa.String(), nullable=False), + sa.Column('value', sa.String(length=128), nullable=True), + sa.PrimaryKeyConstraint('key') + ) + op.create_table('product_locations', + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('store', sa.String(length=128), nullable=False), + sa.Column('aisle', sa.String(length=64), nullable=False), + sa.Column('bin', sa.String(length=64), nullable=True), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('modified_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.PrimaryKeyConstraint('product_id', 'store') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('product_locations') + op.drop_table('sigl_config') + op.drop_index(op.f('ix_products_category'), table_name='products') + op.drop_table('products') + # ### end Alembic commands ### diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..c59a87f --- /dev/null +++ b/poetry.lock @@ -0,0 +1,649 @@ +[[package]] +name = "alembic" +version = "1.8.0" +description = "A database migration tool for SQLAlchemy." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" + +[package.extras] +tz = ["python-dateutil"] + +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "bidict" +version = "0.22.0" +description = "The bidirectional mapping library for Python." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "blinker" +version = "1.4" +description = "Fast, simple object-to-object and broadcast signaling" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "codespell" +version = "2.1.0" +description = "Codespell" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["check-manifest", "flake8", "pytest", "pytest-cov", "pytest-dependency"] +hard-encoding-detection = ["chardet"] + +[[package]] +name = "colorama" +version = "0.4.5" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "coverage" +version = "6.4.1" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "dnspython" +version = "2.2.1" +description = "DNS toolkit" +category = "main" +optional = false +python-versions = ">=3.6,<4.0" + +[package.extras] +dnssec = ["cryptography (>=2.6,<37.0)"] +curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] +doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"] +idna = ["idna (>=2.1,<4.0)"] +trio = ["trio (>=0.14,<0.20)"] +wmi = ["wmi (>=1.5.1,<2.0.0)"] + +[[package]] +name = "eventlet" +version = "0.33.1" +description = "Highly concurrent networking library" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +dnspython = ">=1.15.0" +greenlet = ">=0.3" +six = ">=1.10.0" + +[[package]] +name = "flake8" +version = "4.0.1" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.8.0,<2.9.0" +pyflakes = ">=2.4.0,<2.5.0" + +[[package]] +name = "flask" +version = "2.1.2" +description = "A simple framework for building complex web applications." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.0" +importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} +itsdangerous = ">=2.0" +Jinja2 = ">=3.0" +Werkzeug = ">=2.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "flask-cors" +version = "3.0.10" +description = "A Flask extension adding a decorator for CORS support" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +Flask = ">=0.9" +Six = "*" + +[[package]] +name = "flask-mail" +version = "0.9.1" +description = "Flask extension for sending email" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +blinker = "*" +Flask = "*" + +[[package]] +name = "flask-migrate" +version = "3.1.0" +description = "SQLAlchemy database migrations for Flask applications using Alembic." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +alembic = ">=0.7" +Flask = ">=0.9" +Flask-SQLAlchemy = ">=1.0" + +[[package]] +name = "flask-socketio" +version = "5.2.0" +description = "Socket.IO integration for Flask applications" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Flask = ">=0.9" +python-socketio = ">=5.0.2" + +[[package]] +name = "flask-sqlalchemy" +version = "2.5.1" +description = "Adds SQLAlchemy support to your Flask application." +category = "main" +optional = false +python-versions = ">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*" + +[package.dependencies] +Flask = ">=0.10" +SQLAlchemy = ">=0.8.0" + +[[package]] +name = "greenlet" +version = "1.1.2" +description = "Lightweight in-process concurrent programming" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" + +[package.extras] +docs = ["sphinx"] + +[[package]] +name = "importlib-metadata" +version = "4.12.0" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "isort" +version = "5.10.1" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] + +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "mako" +version = "1.2.1" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycodestyle" +version = "2.8.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyflakes" +version = "2.4.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "7.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "python-engineio" +version = "4.3.3" +description = "Engine.IO server and client for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +asyncio_client = ["aiohttp (>=3.4)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] + +[[package]] +name = "python-socketio" +version = "5.7.0" +description = "Socket.IO server and client for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +bidict = ">=0.21.0" +python-engineio = ">=4.3.0" + +[package.extras] +asyncio_client = ["aiohttp (>=3.4)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "sqlalchemy" +version = "1.4.39" +description = "Database Abstraction Library" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} + +[package.extras] +aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] +aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"] +mariadb_connector = ["mariadb (>=1.0.1)"] +mssql = ["pyodbc"] +mssql_pymssql = ["pymssql"] +mssql_pyodbc = ["pyodbc"] +mypy = ["sqlalchemy2-stubs", "mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0,<2)", "mysqlclient (>=1.4.0)"] +mysql_connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"] +postgresql_pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"] +postgresql_psycopg2binary = ["psycopg2-binary"] +postgresql_psycopg2cffi = ["psycopg2cffi"] +pymysql = ["pymysql (<1)", "pymysql"] +sqlcipher = ["sqlcipher3-binary"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "werkzeug" +version = "2.1.2" +description = "The comprehensive WSGI web application library." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +watchdog = ["watchdog"] + +[[package]] +name = "zipp" +version = "3.8.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.9" +content-hash = "6a8db1863ac0cd67efd7dc3acadea21dd345fc45f8d01b4ad28aa65543dc146b" + +[metadata.files] +alembic = [ + {file = "alembic-1.8.0-py3-none-any.whl", hash = "sha256:b5ae4bbfc7d1302ed413989d39474d102e7cfa158f6d5969d2497955ffe85a30"}, + {file = "alembic-1.8.0.tar.gz", hash = "sha256:a2d4d90da70b30e70352cd9455e35873a255a31402a438fe24815758d7a0e5e1"}, +] +atomicwrites = [] +attrs = [] +bidict = [ + {file = "bidict-0.22.0-py3-none-any.whl", hash = "sha256:415126d23a0c81e1a8c584a8fb1f6905ea090c772571803aeee0a2242e8e7ba0"}, + {file = "bidict-0.22.0.tar.gz", hash = "sha256:5c826b3e15e97cc6e615de295756847c282a79b79c5430d3bfc909b1ac9f5bd8"}, +] +blinker = [] +click = [] +codespell = [] +colorama = [] +coverage = [] +dnspython = [ + {file = "dnspython-2.2.1-py3-none-any.whl", hash = "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"}, + {file = "dnspython-2.2.1.tar.gz", hash = "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e"}, +] +eventlet = [ + {file = "eventlet-0.33.1-py2.py3-none-any.whl", hash = "sha256:a085922698e5029f820cf311a648ac324d73cec0e4792877609d978a4b5bbf31"}, + {file = "eventlet-0.33.1.tar.gz", hash = "sha256:afbe17f06a58491e9aebd7a4a03e70b0b63fd4cf76d8307bae07f280479b1515"}, +] +flake8 = [ + {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, + {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, +] +flask = [] +flask-cors = [ + {file = "Flask-Cors-3.0.10.tar.gz", hash = "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de"}, + {file = "Flask_Cors-3.0.10-py2.py3-none-any.whl", hash = "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438"}, +] +flask-mail = [] +flask-migrate = [ + {file = "Flask-Migrate-3.1.0.tar.gz", hash = "sha256:57d6060839e3a7f150eaab6fe4e726d9e3e7cffe2150fb223d73f92421c6d1d9"}, + {file = "Flask_Migrate-3.1.0-py3-none-any.whl", hash = "sha256:a6498706241aba6be7a251078de9cf166d74307bca41a4ca3e403c9d39e2f897"}, +] +flask-socketio = [ + {file = "Flask-SocketIO-5.2.0.tar.gz", hash = "sha256:19c3d0cea49c53505fa457fedc133b32cb6eeaaa30d28cdab9d6ca8f16045427"}, + {file = "Flask_SocketIO-5.2.0-py3-none-any.whl", hash = "sha256:c82672b843fa271ec2961d356c608bc94a730660ac73a623bddb66c4b3d72215"}, +] +flask-sqlalchemy = [] +greenlet = [ + {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"}, + {file = "greenlet-1.1.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a"}, + {file = "greenlet-1.1.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d"}, + {file = "greenlet-1.1.2-cp27-cp27m-win32.whl", hash = "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713"}, + {file = "greenlet-1.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40"}, + {file = "greenlet-1.1.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d"}, + {file = "greenlet-1.1.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8"}, + {file = "greenlet-1.1.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"}, + {file = "greenlet-1.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965"}, + {file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"}, + {file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"}, + {file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"}, + {file = "greenlet-1.1.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c"}, + {file = "greenlet-1.1.2-cp35-cp35m-win32.whl", hash = "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963"}, + {file = "greenlet-1.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e"}, + {file = "greenlet-1.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"}, + {file = "greenlet-1.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f"}, + {file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"}, + {file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"}, + {file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"}, + {file = "greenlet-1.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe"}, + {file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"}, + {file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"}, + {file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"}, + {file = "greenlet-1.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2"}, + {file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"}, + {file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"}, + {file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"}, + {file = "greenlet-1.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3"}, + {file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"}, + {file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"}, + {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, +] +importlib-metadata = [] +iniconfig = [] +isort = [ + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, +] +itsdangerous = [] +jinja2 = [] +mako = [ + {file = "Mako-1.2.1-py3-none-any.whl", hash = "sha256:df3921c3081b013c8a2d5ff03c18375651684921ae83fd12e64800b7da923257"}, + {file = "Mako-1.2.1.tar.gz", hash = "sha256:f054a5ff4743492f1aa9ecc47172cb33b42b9d993cffcc146c9de17e717b0307"}, +] +markupsafe = [] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +packaging = [] +pluggy = [] +py = [] +pycodestyle = [ + {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, + {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, +] +pyflakes = [ + {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, + {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, +] +pyparsing = [] +pytest = [] +python-engineio = [ + {file = "python-engineio-4.3.3.tar.gz", hash = "sha256:18474c452894c60590b2d2339d6c81b93fb9857f1be271a2e91fb2707eb4095d"}, + {file = "python_engineio-4.3.3-py3-none-any.whl", hash = "sha256:e660fcbac7497f105310d933987d3a82d2e677240a6b493c0a514aa7f91d3b07"}, +] +python-socketio = [ + {file = "python-socketio-5.7.0.tar.gz", hash = "sha256:82e3c45baa51f2180f176e5e1c4232a4452f7545a3fe3156d093fa7e5890e816"}, + {file = "python_socketio-5.7.0-py3-none-any.whl", hash = "sha256:874eeba29129618b30189ecb277e2d5e646c39e50b1a997289c0473ee6c1f5c8"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +sqlalchemy = [] +tomli = [] +werkzeug = [] +zipp = [] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6bd816d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[tool.poetry] +name = "sigl" +version = "0.1.0" +description = "Simple Grocery List" +authors = ["Jonathan Krauss "] +license = "BSD-3-Clause" + +[tool.poetry.dependencies] +python = "^3.9" +Flask = "^2.1.2" +Flask-SQLAlchemy = "^2.5.1" +Flask-Mail = "^0.9.1" +Flask-SocketIO = "^5.2.0" +eventlet = "^0.33.1" +Flask-Cors = "^3.0.10" +alembic = "^1.8.0" +SQLAlchemy = "^1.4.39" +Flask-Migrate = "^3.1.0" + +[tool.poetry.dev-dependencies] +coverage = "^6.4.1" +flake8 = "^4.0.1" +pytest = "^7.1.2" +isort = "^5.10.1" +codespell = "^2.1.0" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.isort] +# https://github.com/PyCQA/isort/wiki/isort-Settings +profile = "black" +combine_as_imports = true +force_sort_within_sections = true \ No newline at end of file diff --git a/sigl/__init__.py b/sigl/__init__.py new file mode 100644 index 0000000..f1cb02d --- /dev/null +++ b/sigl/__init__.py @@ -0,0 +1,5 @@ +"""Sigl Application Package. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" diff --git a/sigl/__main__.py b/sigl/__main__.py new file mode 100644 index 0000000..dbae0d7 --- /dev/null +++ b/sigl/__main__.py @@ -0,0 +1,12 @@ +"""Sigl Application Entry Point. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from .factory import create_app +from .socketio import socketio + +app = create_app() + +socketio.run(app) diff --git a/sigl/cli.py b/sigl/cli.py new file mode 100644 index 0000000..dd32808 --- /dev/null +++ b/sigl/cli.py @@ -0,0 +1,39 @@ +"""Sigl Flask Shell Helpers. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + + +def init_shell(): # pragma: no cover + """Initialize the Flask Shell Context.""" + import datetime + import sqlalchemy as sa + + from sigl.database import db + from sigl.domain.models import ( + Product, + ProductLocation, + ) + + return { + # Imports + 'datetime': datetime, + 'sa': sa, + + # Globals + 'db': db, + 'session': db.session, + + # Models + 'Product': Product, + 'ProductLocation': ProductLocation, + } + + +def init_cli(app): + """Register the Shell Helpers with the Application.""" + app.shell_context_processor(init_shell) + + # Notify Initialization Complete + app.logger.debug('CLI Initialized') diff --git a/sigl/database/__init__.py b/sigl/database/__init__.py new file mode 100644 index 0000000..427316d --- /dev/null +++ b/sigl/database/__init__.py @@ -0,0 +1,84 @@ +"""Sigl Database Package. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +import re + +from alembic.script import ScriptDirectory +from flask import current_app +from sqlalchemy import inspect + +from sigl.exc import Error + +from .globals import db, migrate +from .util import make_uri + +__all__ = ('db', 'migrate', 'init_db') + + +def check_database(): + """Check that the database schema is initialized.""" + if current_app.config.get('_SIGL_DB_NEEDS_INIT'): + raise Error('Database is not initialized') + + +def init_db(app): + """Initialize the Global Database Object for the Application.""" + # Set SQLalchemy Defaults (suppresses warning) + app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False) + + # Load SQLalchemy URI + if 'SQLALCHEMY_DATABASE_URI' not in app.config: + if 'DB_URI' in app.config: + app.config['SQLALCHEMY_DATABASE_URI'] = app.config['DB_URI'] + else: + app.config['SQLALCHEMY_DATABASE_URI'] = make_uri(app) + + # Initialize Object Relational Mapping + from .orm import init_orm + init_orm() + + # Log the URI (masked) + app.logger.debug( + 'Starting SQLalchemy with URI: "%s"', re.sub( + r':([.+]|[0-9a-z\'_-~]|%[0-9A-F]{2})+@', + ':(masked)@', + app.config['SQLALCHEMY_DATABASE_URI'], + flags=re.I + ) + ) + + # Initialize Database + db.init_app(app) + migrate.init_app(app, db) + + # Check the Database Revision + app.config['_SIGL_DB_NEEDS_INIT'] = True + app.config['_SIGL_DB_NEEDS_UPGRADE'] = False + app.config['_SIGL_DB_HEAD_REV'] = None + app.config['_SIGL_DB_CUR_REV'] = None + with app.app_context(): + if inspect(db.engine).has_table('alembic_version'): + app.config['_SIGL_DB_NEEDS_INIT'] = False + + db_version_sql = db.text('select version_num from alembic_version') + db_version = db.engine.execute(db_version_sql).scalar() + app.config['_SIGL_DB_CUR_REV'] = db_version + + script_dir = ScriptDirectory.from_config(migrate.get_config()) + for rev in script_dir.walk_revisions(): + if rev.is_head: + app.config['_SIGL_DB_HEAD_REV'] = rev.revision + if rev.revision == db_version: + app.config['_SIGL_DB_NEEDS_UPGRADE'] = not rev.is_head + + # Register Database Setup Hook + app.before_request(check_database) + + # Notify Initialization + app.logger.debug('Database Initialized') + + # Return Success + return True diff --git a/sigl/database/globals.py b/sigl/database/globals.py new file mode 100644 index 0000000..45dd72a --- /dev/null +++ b/sigl/database/globals.py @@ -0,0 +1,14 @@ +"""Sigl Database Global Objects. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + +#: Global Database Object for Models +db = SQLAlchemy() + +#: Global Alembic Migration Object +migrate = Migrate() diff --git a/sigl/database/orm.py b/sigl/database/orm.py new file mode 100644 index 0000000..8bab9c4 --- /dev/null +++ b/sigl/database/orm.py @@ -0,0 +1,34 @@ +"""Sigl Database ORM Setup. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from sigl.domain.models import ( + Product, + ProductLocation, +) + +from .globals import db +from .tables import ( + product_locations, + products, +) + +__all__ = ('init_orm', ) + + +def init_orm(): + """Initialize the Sigl ORM.""" + + # Products + db.mapper(Product, products, properties={ + 'locations': db.relationship( + ProductLocation, + backref='product', + cascade='all, delete-orphan', + ), + }) + + # Product Locations + db.mapper(ProductLocation, product_locations) diff --git a/sigl/database/tables.py b/sigl/database/tables.py new file mode 100644 index 0000000..e0fdf0d --- /dev/null +++ b/sigl/database/tables.py @@ -0,0 +1,56 @@ +"""Sigl Database Tables. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from .globals import db + +#: Sigl Server Configuration Table +sigl_config = db.Table( + 'sigl_config', + + # Key/Value Store + db.Column('key', db.String, primary_key=True), + db.Column('value', db.String(128)), +) + +#: Product Table +products = db.Table( + 'products', + + # Primary Key + db.Column('id', db.Integer, primary_key=True), + + # Product Attributes + db.Column('name', db.String(128), nullable=False), + db.Column('category', db.String(128), nullable=False, index=True), + + # Mixin Columns + db.Column('notes', db.String(), default=None), + db.Column('created_at', db.DateTime(), default=None), + db.Column('modified_at', db.DateTime(), default=None), +) + +#: Product Location Table +product_locations = db.Table( + 'product_locations', + + # Primary Keys + db.Column( + 'product_id', + db.ForeignKey('products.id'), + nullable=False, + primary_key=True, + ), + db.Column('store', db.String(128), nullable=False, primary_key=True), + + # Location Attributes + db.Column('aisle', db.String(64), nullable=False), + db.Column('bin', db.String(64), default=None), + + # Mixin Columns + db.Column('notes', db.String(), default=None), + db.Column('created_at', db.DateTime(), default=None), + db.Column('modified_at', db.DateTime(), default=None), +) diff --git a/sigl/database/util.py b/sigl/database/util.py new file mode 100644 index 0000000..e64b138 --- /dev/null +++ b/sigl/database/util.py @@ -0,0 +1,78 @@ +"""Sigl Database Utilities. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +import os +from urllib.parse import quote_plus + +from sigl.exc import ConfigError + +__all__ = ('make_uri', ) + + +def make_uri(app): + ''' + Assembles the Database URI from Application Configuration values. + + :param app: :class:`Flask` application object + ''' + if 'DB_DRIVER' not in app.config: + raise ConfigError( + 'DB_DRIVER must be defined in application configuration', + config_key='DB_DRIVER' + ) + + # SQLite + if app.config['DB_DRIVER'] == 'sqlite': + if 'DB_FILE' not in app.config: + raise ConfigError( + 'DB_FILE must be defined in application configuration for ' + 'SQLite Database', + config_key='DB_FILE' + ) + return 'sqlite:///{db_file}'.format( + db_file=os.path.abspath(app.config['DB_FILE']), + ) + + # Database Servers (note only MySQL and PostgreSQL are tested) + uri_auth = '' + uri_port = '' + + for k in ('DB_HOST', 'DB_NAME'): + if k not in app.config: + raise ConfigError( + f'{k} must be defined in application configuration', + config_key=k + ) + + # Assemble Port Suffix + if 'DB_PORT' in app.config: + uri_port = ':{}'.format(app.config['DB_PORT']) + + # Assemble Authentication Portion + if 'DB_USERNAME' in app.config: + if 'DB_PASSWORD' in app.config: + uri_auth = '{username}:{password}@'.format( + username=quote_plus(app.config['DB_USERNAME']), + password=quote_plus(app.config['DB_PASSWORD']), + ) + else: + uri_auth = '{username}@'.format( + username=quote_plus(app.config['DB_USERNAME']), + ) + elif 'DB_PASSWORD' in app.config: + raise ConfigError( + 'DB_USERNAME must be defined if DB_PASSWORD is set in ' + 'application configuration', + config_key='DB_USERNAME' + ) + + return '{driver}://{auth}{host}{port}/{name}'.format( + driver=app.config['DB_DRIVER'], + auth=uri_auth, + host=app.config['DB_HOST'], + port=uri_port, + name=app.config['DB_NAME'], + ) diff --git a/sigl/domain/__init__.py b/sigl/domain/__init__.py new file mode 100644 index 0000000..91039db --- /dev/null +++ b/sigl/domain/__init__.py @@ -0,0 +1,5 @@ +"""Sigl Domain Package. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" diff --git a/sigl/domain/models/__init__.py b/sigl/domain/models/__init__.py new file mode 100644 index 0000000..48e6706 --- /dev/null +++ b/sigl/domain/models/__init__.py @@ -0,0 +1,11 @@ +"""Sigl Domain Models. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from .product import Product, ProductLocation + +__all__ = ( + 'Product', 'ProductLocation', +) diff --git a/sigl/domain/models/mixins.py b/sigl/domain/models/mixins.py new file mode 100644 index 0000000..89d54e5 --- /dev/null +++ b/sigl/domain/models/mixins.py @@ -0,0 +1,41 @@ +"""Sigl Domain Model Mixin Classes. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class NotesMixin: + ''' + Domain Model Mixin Class for objects with Notes + .. :py:attr:: notes + :property: + Adds a column to the database object with type + :py:class:`~sqlalchemy.types.Text` which is intended for the user to + add notes associated with the object. + ''' + notes: str = None + + +@dataclass +class TimestampMixin: + ''' + Domain Model Mixin Class for Timestamped Objects + .. :py:attr:: created_at + :property: + Adds a ``created_at`` column to the database object with type + :py:class:`datetime.datetime` + .. :py:attr:: modified_at + :property: + Adds a ``modified_at`` column to the database object with type + :py:class:`datetime.datetime` + ''' + created_at: datetime = datetime.utcnow() + modified_at: datetime = None + + def set_modified_at(self): + self.modified_at = datetime.utcnow() diff --git a/sigl/domain/models/product.py b/sigl/domain/models/product.py new file mode 100644 index 0000000..78e0643 --- /dev/null +++ b/sigl/domain/models/product.py @@ -0,0 +1,42 @@ +"""Sigl Product Domain Model. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from dataclasses import dataclass +from typing import List + +from .mixins import NotesMixin, TimestampMixin + +__all__ = ('Product', 'ProductLocation') + + +@dataclass +class Product(NotesMixin, TimestampMixin): + """Information about a single Product. + + This class contains information about a single Product that can be on a + shopping list, including the Product Name, Category, Notes, and Product + Location in one or more stores. + """ + name: str = None + category: str = None + + # Populated by ORM + # locations: List['ProductLocation'] = [] + + +@dataclass +class ProductLocation(NotesMixin): + """Location of a Product in a Store. + + Stores the location of a Product within a store using an aisle and bin + location system. + """ + store: str = None + aisle: str = None + bin: str = None + + # Relationship Fields + product: 'Product' = None diff --git a/sigl/exc.py b/sigl/exc.py new file mode 100644 index 0000000..40fba76 --- /dev/null +++ b/sigl/exc.py @@ -0,0 +1,35 @@ +"""Sigl Exception Classes. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +__all__ = ( + 'ConfigError', + 'Error', +) + + +class Error(Exception): + """Base Class for Sigl Errors.""" + default_code = 500 + + def __init__(self, *args, **kwargs): + """Class Constructor.""" + self.status_code = kwargs.pop('status_code', self.__class__.default_code) + super().__init__(*args, **kwargs) + + +class ConfigError(Error): + """Exception raised for invalid or missing configuration values. + + .. attribute:: config_key + :type: str + + Configuration key which is missing or invalid + + """ + def __init__(self, *args, config_key=None): + """Class Constructor.""" + super().__init__(*args) + self.config_key = config_key diff --git a/sigl/factory.py b/sigl/factory.py new file mode 100644 index 0000000..9c5e9b5 --- /dev/null +++ b/sigl/factory.py @@ -0,0 +1,149 @@ +"""Sigl Application Factory. + +The :mod:`sigl.factory` module contains a method :func:`create_app` which +implements the Flask `Application Factory`_ pattern to create the application +object dynamically. + +Application default configuration values are found in the +:mod:`sigl.settings` module. + +.. _`Application Factory`: + https://flask.palletsprojects.com/en/2.1.x/patterns/appfactories/ + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +import configparser +import os +import pathlib + +from flask import Flask + + +def read_version(): + """Read the Name and Version String from pyproject.toml.""" + # Search for pyproject.toml + d = pathlib.Path(__file__) + name = None + version = None + while d.parent != d: + d = d.parent + path = d / 'pyproject.toml' + if path.exists(): + # Use configparser to parse toml like INI to avoid dependency on + # tomlkit or similar + config = configparser.ConfigParser() + config.read(str(path)) + if 'tool.poetry' in config: + name = config['tool.poetry'].get('name').strip('"\'') + version = config['tool.poetry'].get('version').strip('"\'') + return (name, version) + + return (None, None) + + +def create_app(app_config=None, app_name=None): + """Create and Configure the Flask Application. + + Create and Configure the Application with the Flask `Application Factory`_ + pattern. The `app_config` dictionary will override configuration keys set + via other methods, and is intended primarily for use in test frameworks to + provide a predictable configuration for testing. + + Args: + app_config: Configuration override values which are applied after all + other configuration sources have been loaded + app_name: Application name override + + Returns: + Flask application + """ + app = Flask( + 'sigl', + template_folder='templates' + ) + + # Load Application Name and Version from pyproject.toml + pkg_name, pkg_version = read_version() + app.config['APP_NAME'] = pkg_name + app.config['APP_VERSION'] = pkg_version + + # Load Default Settings + app.config.from_object('sigl.settings') + + # Load Configuration File from Environment + if 'SIGL_CONFIG' in os.environ: + app.config.from_envvar('SIGL_CONFIG') + + # Load Configuration Variables from Environment + for k, v in os.environ.items(): + if k.startswith('SIGL_') and k != 'SIGL_CONFIG': + app.config[k[5:]] = v + + # Load Factory Configuration + if app_config is not None: + if isinstance(app_config, dict): + app.config.update(app_config) + elif app_config.endswith('.py'): + app.config.from_pyfile(app_config) + + # Setup Secret Keys + app.config['SECRET_KEY'] = app.config.get('APP_SESSION_KEY', None) + + # Override Application Name + if app_name is not None: + app.name = app_name + + try: + # Initialize Logging + from .logging import init_logging + init_logging(app) + + app.logger.info('{} starting up as "{}"'.format( + app.config['APP_NAME'], + app.name + )) + + # Initialize Database + from .database import init_db + init_db(app) + + if app.config.get('_SIGL_DB_NEEDS_INIT', True): + message = 'The database schema has not been initialized or the ' \ + 'database is corrupt. Please initialize the database using ' \ + "'flask db upgrade' and restart the server." + app.logger.error(message) + + if app.config.get('_SIGL_DB_NEEDS_UPGRADE'): + message = 'The database schema is outdated (current version: {}, ' \ + 'newest version: {}). Please update the database using ' \ + "'flask db upgrade' and restart the server." + app.logger.warning(message) + + # Setup E-mail + from .mail import init_mail + init_mail(app) + + # Initialize CLI + from .cli import init_cli + init_cli(app) + + # Initialize Web Sockets + from .socketio import init_socketio + init_socketio(app) + + # Startup Complete + app.logger.info('{} startup complete'.format(app.config['APP_NAME'])) + + # Return Application + return app + + except Exception as e: + # Ensure startup exceptions get logged + app.logger.exception( + 'Startup Error (%s): %s', + e.__class__.__name__, + str(e) + ) + raise e diff --git a/sigl/logging.py b/sigl/logging.py new file mode 100644 index 0000000..c62c41c --- /dev/null +++ b/sigl/logging.py @@ -0,0 +1,335 @@ +"""Sigl Application Logging. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +import logging +import sys +import traceback + +from flask import has_request_context, render_template, request +from flask.logging import default_handler, wsgi_errors_stream +from flask_mail import Message + +from sigl.exc import ConfigError +from sigl.mail import mail + +__all__ = ( + 'MailHandler', + 'NoTraceFormatter', + 'RequestFormatter', + 'init_logging', +) + +LEVELS = { + 'PANIC': logging.CRITICAL, # noqa: E241 + 'ALERT': logging.CRITICAL, # noqa: E241 + 'CRITICAL': logging.CRITICAL, # noqa: E241 + 'CRIT': logging.CRITICAL, # noqa: E241 + 'ERROR': logging.ERROR, # noqa: E241 + 'ERR': logging.ERROR, # noqa: E241 + 'WARNING': logging.WARNING, # noqa: E241 + 'WARN': logging.WARNING, # noqa: E241 + 'NOTICE': logging.INFO, # noqa: E241 + 'INFO': logging.INFO, # noqa: E241 + 'DEBUG': logging.DEBUG, # noqa: E241 +} + + +def lookup_level(level): + '''Lookup a Logging Level by Python Name or syslog Name''' + if isinstance(level, int): + return level + return LEVELS.get(str(level).upper(), logging.NOTSET) + + +class MailHandler(logging.Handler): + ''' + Custom Log Handler class which acts like :class:`logging.SMTPHandler` but + uses :mod:`flask_mail` and :func:`flask.render_template` on the back end + to send Jinja templated HTML messages. The :obj:`sigl.mail.mail` object + must be available and initialized, so this handler should not be used prior + to calling :meth:`flask_mail.Mail.init_app`. + ''' + def __init__(self, sender, recipients, subject, text_template=None, + html_template=None, **kwargs): + ''' + Initialize the Handler. + + Initialize the instance with the sender, recipient list, and subject + for the email. To use Jinja templates, pass a template path to the + ``text_template`` and ``html_template`` parameters. The templates + will be called with the following context items set: + + .. data:: record + :type: :class:`python:logging.LogRecord` + + Log Record which was sent to the logger + + .. data:: formatted_record + :type: str + + Formatted log record from :meth:`python:logging.Handler.format` + + .. data:: request + :type: :class:`flask.Request` + + Flask request object (or None if not available) + + .. data:: user + :type: :class:`sigl.models.User` + + User object (or :class:`flask_login.AnonymousUserMixin`) + + Additional template context variables may be passed to the handler + constructor as keyword arguments. + + :param sender: Email sender address + :type sender: str + :param recipients: Email recipient addresses + :type recipients: str or list + :param subject: Email subject + :type subject: str + :param text_template: Path to Jinja2 Template for plain-text message + :type text_template: str + :param html_template: Path to Jinja2 Template for HTML message + :type html_template: str + :param kwargs: Additional variables to pass to templates + :type kwargs: dict + ''' + super(MailHandler, self).__init__() + self.sender = sender + if isinstance(recipients, str): + recipients = [recipients] + self.recipients = recipients + self.subject = subject + self.text_template = text_template + self.html_template = html_template + self.template_context = kwargs + + def getSubject(self): + ''' + Determine the subject for the email. If you want to override the + subject line with something record-dependent, override this method. + ''' + return self.subject + + def emit(self, record): + '''Format the record and send it to the specified recipients''' + try: + request_obj = None + if has_request_context(): + request_obj = request + + # Load stack trace (if an exception is being processed) + exc_type, exc_msg, stack_trace = sys.exc_info() + + # Format Record + formatted_record = self.format(record) + text_body = formatted_record + html_body = None + + # Render Text Body + if self.text_template is not None: + text_body = render_template( + self.text_template, + record=record, + request=request_obj, + formatted_record=formatted_record, + exc_class='None' if not exc_type else exc_type.__name__, + exc_msg=exc_msg, + stack_trace=traceback.extract_tb(stack_trace), + **self.template_context, + ) + + # Render HTML Body + if self.html_template is not None: + html_body = render_template( + self.html_template, + record=record, + request=request_obj, + formatted_record=formatted_record, + exc_class='None' if not exc_type else exc_type.__name__, + exc_msg=exc_msg, + stack_trace=traceback.extract_tb(stack_trace), + **self.template_context, + ) + + # Send the message with Flask-Mail + msg = Message( + self.getSubject(), + sender=self.sender, + recipients=self.recipients, + ) + msg.body = text_body + msg.html = html_body + + mail.send(msg) + + except Exception: # pragma: no cover + self.handleError(record) + + +class RequestInjectorMixin(object): + ''' + Inject the Flask :attr:`flask.Flask.request` object into the logger record + as well as shortcut getters for :attr:`flask.Request.url` and + :attr:`flask.Request.remote_addr` + ''' + def _injectRequest(self, record): + if has_request_context(): + record.request = request + record.remote_addr = request.remote_addr + record.url = request.url + else: + record.request = None + record.remote_addr = None + record.url = None + + return record + + +class NoTraceFormatter(logging.Formatter, RequestInjectorMixin): + ''' + Custom Log Formatter which suppresses automatic printing of stack trace + information even when :attr:`python:logging.LogRecord.exc_info` is set. + This wholly overrides :meth:`python:logging.Formatter.format`. + + The Flask :class:`~flask.Flask.Request` object is also included into the + logger record as well as shortcut getters for :attr:`flask.Request.url` + and :attr:`flask.Request.remote_addr` + ''' + def format(self, record): + ''' + Format the specified record as text. + + The record's attribute dictionary is used as the operand to a string + formatting operation which yields the returned string. Before + formatting the dictionary, a couple of preparatory steps are carried + out. The message attribute of the record is computed using + :meth:`python:logging.LogRecord.getMessage`. If the formatting string + uses the time (as determined by a call to + :meth:`python:logging.LogRecord.usesTime`), + :meth:`python:logging.LogRecord.formatTime` is called to format the + event time. + ''' + record = self._injectRequest(record) + record.message = record.getMessage() + if self.usesTime(): + record.asctime = self.formatTime(record, self.datefmt) + return self.formatMessage(record) + + +class RequestFormatter(logging.Formatter, RequestInjectorMixin): + ''' + Custom Log Formatter which includes the Flask :attr:`flask.Flask.request` + object into the logger record as well as shortcut getters for + :attr:`flask.Request.url` and :attr:`flask.Request.remote_addr` + ''' + def format(self, record): + ''' + Format the specified record as text. + + The record's attribute dictionary is used as the operand to a string + formatting operation which yields the returned string. Before + formatting the dictionary, a couple of preparatory steps are carried + out. The message attribute of the record is computed using + :meth:`python:logging.LogRecord.getMessage`. If the formatting string + uses the time (as determined by a call to + :meth:`python:logging.LogRecord.usesTime`), + :meth:`python:logging.LogRecord.formatTime` is called to format the + event time. + ''' + record = self._injectRequest(record) + return super(RequestFormatter, self).format(record) + + +def init_email_logger(app, root_logger): + '''Install the Email Log Handler to the Root Logger''' + for k in ('MAIL_SERVER', 'MAIL_SENDER', 'LOG_EMAIL_ADMINS', + 'LOG_EMAIL_SUBJECT'): + if k not in app.config: + raise ConfigError( + '{} must be defined in the application configuration in ' + 'order to send logs to an email recipient.'.format(k), + config_key=k + ) + + mail_handler = MailHandler( + sender=app.config.get('MAIL_SENDER', None), + recipients=app.config['LOG_EMAIL_ADMINS'], + subject=app.config['LOG_EMAIL_SUBJECT'], + text_template=app.config.get('LOG_EMAIL_BODY_TMPL', None), + html_template=app.config.get('LOG_EMAIL_HTML_TMPL', None), + ) + mail_handler.setLevel( + lookup_level(app.config.get('LOG_EMAIL_LEVEL', logging.ERROR)) + ) + mail_handler.setFormatter(NoTraceFormatter( + app.config.get('LOG_EMAIL_FORMAT', None) + )) + root_logger.addHandler(mail_handler) + + # Dump Logger Configuration to Debug + app.logger.debug('Installed Email Log Handler') + app.logger.debug( + 'Email Log Recipients: %s', ', '.join(mail_handler.recipients) + ) + app.logger.debug('Email Log Subject: %s', mail_handler.subject) + app.logger.debug( + 'Email Log Templates: %s, %s', + mail_handler.text_template, + mail_handler.html_template, + ) + + +def init_logging(app): + ''' + This sets up the application logging infrastructure, including console or + WSGI server logging as well as email logging for production server errors. + ''' + logging_dest = app.config.get('LOGGING_DEST', 'wsgi').lower() + logging_stream = wsgi_errors_stream + if logging_dest == 'stdout': + logging_stream = sys.stdout + elif logging_dest == 'stderr': + logging_stream = sys.stderr + elif logging_dest != 'wsgi': + raise ConfigError( + 'LOGGING_DEST must be set to wsgi, stdout, or stderr (is set ' + 'to {})'.format(logging_dest) + , config_key='LOGGING_DEST' + ) + + logging_level = lookup_level(app.config.get('LOGGING_LEVEL', None)) + logging_format = app.config.get('LOGGING_FORMAT', None) + logging_backtrace = app.config.get('LOGGING_BACKTRACE', True) + if not isinstance(logging_backtrace, bool): + raise ConfigError( + 'LOGGING_BACKTRACE must be a boolean value, found {}' + .format(type(logging_backtrace).__name__), + config_key='LOGGING_BACKTRACE' + ) + + fmt_class = RequestFormatter if logging_backtrace else NoTraceFormatter + + app_handler = logging.StreamHandler(logging_stream) + app_handler.setFormatter(fmt_class(logging_format)) + app_handler.setLevel(logging_level) + + # Install Log Handler and Formatter on Flask Logger + app.logger.setLevel(logging_level) + app.logger.addHandler(app_handler) + + # Remove Flask Default Log Handler + app.logger.removeHandler(default_handler) + + # Notify Logging Initialized + app.logger.debug('Logging Boostrapped') + app.logger.debug('Installed %s Log Handler', logging_dest) + + # Setup the Mail Log Handler if configured + if not app.debug or app.config.get('DBG_FORCE_LOGGERS', False): + if app.config.get('LOG_EMAIL', False): + init_email_logger(app, app.logger) diff --git a/sigl/mail.py b/sigl/mail.py new file mode 100644 index 0000000..33cfe94 --- /dev/null +++ b/sigl/mail.py @@ -0,0 +1,69 @@ +"""Sigl Email Support. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from flask import current_app +from flask_mail import Mail, Message + +from sigl.exc import ConfigError + +mail = Mail() + +__all__ = ('init_mail', 'mail', 'send_email') + + +def init_mail(app): + """Register the Flask-Mail object with the Application.""" + if not app.config.get('MAIL_ENABLED', False): + app.logger.debug('Skipping mail setup (disabled)') + return + + required_keys = ( + 'MAIL_SERVER', 'MAIL_SENDER', 'SITE_ABUSE_MAILBOX', 'SITE_HELP_MAILBOX' + ) + for k in required_keys: + if k not in app.config: + raise ConfigError( + '{} must be defined in the application configuration' + .format(k), + config_key=k + ) + + mail.init_app(app) + + # Add Site Mailboxes to templates + app.jinja_env.globals.update( + site_abuse_mailbox=app.config['SITE_ABUSE_MAILBOX'], + site_help_mailbox=app.config['SITE_HELP_MAILBOX'], + ) + + # Dump Configuration to Debug + app.logger.debug( + 'Mail Initialized with server %s and sender %s', + app.config['MAIL_SERVER'], + app.config['MAIL_SENDER'], + ) + + +def send_email(subject, recipients, body, html=None, sender=None): + """Send an Email Message using Flask-Mail.""" + recip_list = [recipients] if isinstance(recipients, str) else recipients + + if not current_app.config.get('MAIL_ENABLED', False): + current_app.logger.debug( + 'Skipping send_email({}) to {} (email disabled)'.format( + subject, + ', '.join(recip_list) + ) + ) + return + + if sender is None: + sender = current_app.config['MAIL_SENDER'] + + msg = Message(subject, sender=sender, recipients=recip_list) + msg.body = body + msg.html = html + mail.send(msg) diff --git a/sigl/settings.py b/sigl/settings.py new file mode 100644 index 0000000..176f245 --- /dev/null +++ b/sigl/settings.py @@ -0,0 +1,8 @@ +"""Sigl Default Settings. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +# Email Disabled +MAIL_ENABLED = False diff --git a/sigl/socketio.py b/sigl/socketio.py new file mode 100644 index 0000000..8e89e91 --- /dev/null +++ b/sigl/socketio.py @@ -0,0 +1,27 @@ +"""Sigl Websockets Entry Point. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from flask_socketio import SocketIO + +socketio = SocketIO() + +__all__ = ('socketio', 'init_socketio') + + +def init_socketio(app): + '''Initialize the Web Socket Handler''' + socketio_opts = { + 'cors_allowed_origins': '*', + 'logger': app.config.get('SOCKETIO_LOGGING', False), + 'engineio_logger': app.config.get('ENGINEIO_LOGGING', False), + } + socketio.init_app(app, **socketio_opts) + + # Notify Initialization + app.logger.debug('Web Sockets Initialized') + + # Return Success + return True diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..eb2fff4 --- /dev/null +++ b/tox.ini @@ -0,0 +1,5 @@ +[flake8] +ignore = D300,D400,E201,E202,E203,E241 +exclude = config docs migrations node_modules scripts +extend-ignore = E501 sigl/database/orm.py, E124 E127 E128 sigl/database/views.py +docstring-convention = google