Initial Commit

This commit is contained in:
2022-07-12 05:44:09 -07:00
commit 57c9c70cbb
31 changed files with 2257 additions and 0 deletions

5
sigl/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
"""Sigl Domain Package.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""

View 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',
)

View 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()

View 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
View 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
View 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
View 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
View 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
View 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
View 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