Publish first version

This commit is contained in:
Finn Christiansen 2021-01-24 13:58:29 +01:00
parent e167769041
commit bac59704db
18 changed files with 564 additions and 1 deletions

71
.gitignore vendored Normal file
View file

@ -0,0 +1,71 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# 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/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
# Translations
*.mo
*.pot
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Virtual environment
venv
.Python
[Bb]in
[Ii]nclude
[Ll]ib
[Ll]ib64
[Ll]ocal
[Ss]cripts
pyvenv.cfg
.venv
pip-selfcheck.json
# custom
tags

View file

@ -1 +1,11 @@
# powermeter
# powermeter
Simple Flask based application to receive impulses via cURL from a power meter.
For developing run this (you need to create database and user first):
```
export FLASK_ENV=development
export APP_DEVELOPMENT_DATABASE_URI=postgres://username:passwort@hostname/database
flask run
```

View file

@ -0,0 +1,23 @@
<VirtualHost *:80>
ServerAdmin powermeter@example.com
ServerName powermeter.exmaple.com
ErrorLog ${APACHE_LOG_DIR}/powermeter.example.com_error.log
CustomLog ${APACHE_LOG_DIR}/powermeter.example.com_access.log combined
SetEnv APP_CONFIG "production"
SetEnv APP_PRODUCTION_DATABASE_URI "postgres://username:password@localhost/database"
WSGIDaemonProcess powermeter user=www-data group=www-data threads=5
WSGIScriptAlias / /var/www/vhosts/powermeter.example.com/backend/powermeter.wsgi
WSGIScriptReloading On
WSGIPassAuthorization On
<Directory /var/www/vhosts/powermeter.example.com/backend>
WSGIProcessGroup powermeter
WSGIApplicationGroup %{GLOBAL}
Order deny,allow
Allow from all
</Directory>
</VirtualHost>

34
app/__init__.py Normal file
View file

@ -0,0 +1,34 @@
from flask import Flask
from flask_smorest import Api
from flask_marshmallow import Marshmallow
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
#from flask_jwt_extended import JWTManager
api = Api()
db = SQLAlchemy()
ma = Marshmallow()
#jwt = JWTManager()
def create_app(cfg='default'):
from config import config
app = Flask(__name__)
app.config.from_object(config[cfg])
app.url_map.strict_slashes = False
config[cfg].init_app(app)
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
from . import resources
api.init_app(app)
db.init_app(app)
ma.init_app(app)
#jwt.init_app(app)
Migrate(app, db)
resources.register_blueprints(api)
return app

12
app/models/impulse.py Normal file
View file

@ -0,0 +1,12 @@
from .. import db
from datetime import datetime
class Impulse(db.Model):
id = db.Column(db.Integer, primary_key=True)
power = db.Column(db.Integer, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
def __repr__(self):
return 'Impulse {} with power of {} watts at {}'.format(self.name, self.power, self.created_at)

14
app/resources/__init__.py Normal file
View file

@ -0,0 +1,14 @@
def register_blueprints(api):
from . import impulses, consumption
resources = (impulses, consumption, )
for resource_blp in (res.bp for res in resources):
# Here we can register common handlers to all resources
#
# resource_blp.before_request(before_request_handler)
# resource_blp.after_request(after_request_handler)
api.register_blueprint(resource_blp,
url_prefix=f'/{resource_blp.url_prefix}')

View file

@ -0,0 +1,24 @@
from .. import db
import datetime
from flask.views import MethodView
from flask_smorest import Blueprint
from ..schemas.consumption import ConsumptionSchema, ConsumptionQuerySchema
from ..models.impulse import Impulse
from sqlalchemy.sql import func
bp = Blueprint('consumption', 'consumptions', url_prefix='consumption',
description='Operations on consumptions')
@bp.route('/')
class Consumptions(MethodView):
@bp.arguments(ConsumptionQuerySchema, location="query")
@bp.response(ConsumptionSchema(many=False))
def get(self, args):
if "timeperiod" in args:
timeperiod = int(args['timeperiod'])
filterdate = datetime.datetime.now() - datetime.timedelta(seconds=timeperiod)
consumption = db.session.query(func.sum(Impulse.power)).filter(Impulse.created_at >= filterdate).first()[0]
else:
consumption = db.session.query(func.sum(Impulse.power)).first()[0]
return {'consumption': consumption}

35
app/resources/impulses.py Normal file
View file

@ -0,0 +1,35 @@
from .. import db
from flask.views import MethodView
from flask_smorest import Blueprint
from ..schemas.impulse import ImpulseSchema, ImpulseQuerySchema
from ..models.impulse import Impulse
bp = Blueprint('impulse', 'impulses', url_prefix='impulses',
description='Operations on impulses')
@bp.route('/')
class Impulses(MethodView):
@bp.arguments(ImpulseQuerySchema, location="query")
@bp.response(ImpulseSchema(many=True))
def get(self, args):
impulses = Impulse.query.all()
return impulses
@bp.arguments(ImpulseSchema)
@bp.response(ImpulseSchema, code=201)
def post(self, new_impulse):
db.session.add(new_impulse)
db.session.commit()
return new_impulse
@bp.route('/<impulse_id>')
class ImpulseById(MethodView):
@bp.response(code=204)
def delete(self, impulse_id):
impulse = Impulse.query.filter_by(id=impulse_id).first()
db.session.delete(impulse)
db.session.commit()

View file

@ -0,0 +1,13 @@
from .. import ma
from marshmallow import fields
class ConsumptionQuerySchema(ma.Schema):
class Meta:
strict = True
timeperiod = fields.String()
class ConsumptionSchema(ma.Schema):
consumption = fields.Number()

20
app/schemas/impulse.py Normal file
View file

@ -0,0 +1,20 @@
from .. import ma
from ..models.impulse import Impulse
from marshmallow import fields
from .namespacedSchema import NamespacedSchema
class ImpulseQuerySchema(ma.Schema):
class Meta:
strict = True
timeperiod = fields.String()
class ImpulseSchema(NamespacedSchema):
class Meta:
strict = True
model = Impulse
name = "impulse"
plural_name = "impulses"

View file

@ -0,0 +1,45 @@
from marshmallow import SchemaOpts
from marshmallow import pre_load, post_dump
from marshmallow_sqlalchemy import ModelSchemaOpts, ModelConverter, ModelSchema
from .. import ma
from .. import db
class NamespaceOpts(ModelSchemaOpts):
"""Same as the default class Meta options, but adds "name" and
"plural_name" options for enveloping.
"""
def __init__(self, meta, **kwargs):
SchemaOpts.__init__(self, meta, **kwargs)
self.name = getattr(meta, "name", None)
self.plural_name = getattr(meta, "plural_name", self.name)
self.model = getattr(meta, "model", None)
self.model_converter = getattr(meta, "model_converter", ModelConverter)
self.include_fk = getattr(meta, "include_fk", False)
self.transient = getattr(meta, "transient", False)
self.sqla_session = db.session
self.load_instance = True
self.include_relationships = True
#class NamespacedSchema(ma.SQLAlchemySchema):
class NamespacedSchema(ModelSchema):
OPTIONS_CLASS = NamespaceOpts
@pre_load(pass_many=True)
def unwrap_envelope(self, data, many, **kwargs):
key = self.opts.plural_name if many else self.opts.name
if key in data:
return data[key]
else:
return data
@post_dump(pass_many=True)
def wrap_with_envelope(self, data, many, **kwargs):
if 'noenvelope' in self.context and self.context['noenvelope']:
return data
else:
key = self.opts.plural_name if many else self.opts.name
return {key: data}

42
config.py Normal file
View file

@ -0,0 +1,42 @@
import os
class Config:
# SQLALCHEMY_TRACK_MODIFICATIONS = False
OPENAPI_VERSION = '3.0.2'
OPENAPI_URL_PREFIX = 'openapi'
DEBUG = True
SECRET_KEY = 'secret'
API_TITLE = 'powermeter api'
API_VERSION = '0.1'
@staticmethod
def init_app(app):
pass
class DevelopmentConfig(Config):
SQLALCHEMY_DATABASE_URI = os.getenv('APP_DEVELOPMENT_DATABASE_URI')
OPENAPI_REDOC_PATH = 'redoc'
OPENAPI_REDOC_VERSION = 'next'
OPENAPI_SWAGGER_UI_PATH = 'swagger-ui'
OPENAPI_SWAGGER_UI_VERSION = '3.18.3'
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = os.getenv('APP_TESTING_DATABASE_URI')
class ProductionConfig(Config):
SQLALCHEMY_DATABASE_URI = os.getenv('APP_PRODUCTION_DATABASE_URI')
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Generic single-database configuration.

45
migrations/alembic.ini Normal file
View file

@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

96
migrations/env.py Normal file
View file

@ -0,0 +1,96 @@
from __future__ import with_statement
import logging
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from 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.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 = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View file

@ -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"}

View file

@ -0,0 +1,33 @@
"""empty message
Revision ID: 925f94e2acd6
Revises:
Create Date: 2021-01-23 21:09:25.068512
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '925f94e2acd6'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('impulse',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('power', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('impulse')
# ### end Alembic commands ###

21
requirements.txt Normal file
View file

@ -0,0 +1,21 @@
alembic==1.5.2
apispec==4.0.0
click==7.1.2
Flask==1.1.2
flask-marshmallow==0.14.0
Flask-Migrate==2.6.0
flask-smorest==0.27.0
Flask-SQLAlchemy==2.4.4
itsdangerous==1.1.0
Jinja2==2.11.2
Mako==1.1.4
MarkupSafe==1.1.1
marshmallow==3.10.0
marshmallow-sqlalchemy==0.24.1
psycopg2==2.8.6
python-dateutil==2.8.1
python-editor==1.0.4
six==1.15.0
SQLAlchemy==1.3.22
webargs==7.0.1
Werkzeug==1.0.1