Initial Commit
This commit is contained in:
5
sigl/__init__.py
Normal file
5
sigl/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Sigl Application Package.
|
||||
|
||||
Simple Grocery List (Sigl) | sigl.app
|
||||
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||
"""
|
||||
12
sigl/__main__.py
Normal file
12
sigl/__main__.py
Normal file
@@ -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)
|
||||
39
sigl/cli.py
Normal file
39
sigl/cli.py
Normal file
@@ -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')
|
||||
84
sigl/database/__init__.py
Normal file
84
sigl/database/__init__.py
Normal file
@@ -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
|
||||
14
sigl/database/globals.py
Normal file
14
sigl/database/globals.py
Normal file
@@ -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()
|
||||
34
sigl/database/orm.py
Normal file
34
sigl/database/orm.py
Normal file
@@ -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)
|
||||
56
sigl/database/tables.py
Normal file
56
sigl/database/tables.py
Normal file
@@ -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),
|
||||
)
|
||||
78
sigl/database/util.py
Normal file
78
sigl/database/util.py
Normal file
@@ -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'],
|
||||
)
|
||||
5
sigl/domain/__init__.py
Normal file
5
sigl/domain/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Sigl Domain Package.
|
||||
|
||||
Simple Grocery List (Sigl) | sigl.app
|
||||
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||
"""
|
||||
11
sigl/domain/models/__init__.py
Normal file
11
sigl/domain/models/__init__.py
Normal file
@@ -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',
|
||||
)
|
||||
41
sigl/domain/models/mixins.py
Normal file
41
sigl/domain/models/mixins.py
Normal file
@@ -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()
|
||||
42
sigl/domain/models/product.py
Normal file
42
sigl/domain/models/product.py
Normal file
@@ -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
|
||||
35
sigl/exc.py
Normal file
35
sigl/exc.py
Normal file
@@ -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
|
||||
149
sigl/factory.py
Normal file
149
sigl/factory.py
Normal file
@@ -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
|
||||
335
sigl/logging.py
Normal file
335
sigl/logging.py
Normal file
@@ -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)
|
||||
69
sigl/mail.py
Normal file
69
sigl/mail.py
Normal file
@@ -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)
|
||||
8
sigl/settings.py
Normal file
8
sigl/settings.py
Normal file
@@ -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
|
||||
27
sigl/socketio.py
Normal file
27
sigl/socketio.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user