diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25923b5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index f33fd6d..52771d6 100644 --- a/README.md +++ b/README.md @@ -1 +1,11 @@ -# powermeter \ No newline at end of file +# 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 +``` diff --git a/apache2_sample_config.conf b/apache2_sample_config.conf new file mode 100644 index 0000000..f4c56fe --- /dev/null +++ b/apache2_sample_config.conf @@ -0,0 +1,23 @@ + + 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 + + + WSGIProcessGroup powermeter + WSGIApplicationGroup %{GLOBAL} + Order deny,allow + Allow from all + + + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..5f3251b --- /dev/null +++ b/app/__init__.py @@ -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 + diff --git a/app/models/impulse.py b/app/models/impulse.py new file mode 100644 index 0000000..0799063 --- /dev/null +++ b/app/models/impulse.py @@ -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) diff --git a/app/resources/__init__.py b/app/resources/__init__.py new file mode 100644 index 0000000..3b2634e --- /dev/null +++ b/app/resources/__init__.py @@ -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}') + diff --git a/app/resources/consumption.py b/app/resources/consumption.py new file mode 100644 index 0000000..d649463 --- /dev/null +++ b/app/resources/consumption.py @@ -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} diff --git a/app/resources/impulses.py b/app/resources/impulses.py new file mode 100644 index 0000000..5ad747a --- /dev/null +++ b/app/resources/impulses.py @@ -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('/') +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() + diff --git a/app/schemas/consumption.py b/app/schemas/consumption.py new file mode 100644 index 0000000..febe550 --- /dev/null +++ b/app/schemas/consumption.py @@ -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() diff --git a/app/schemas/impulse.py b/app/schemas/impulse.py new file mode 100644 index 0000000..76a6806 --- /dev/null +++ b/app/schemas/impulse.py @@ -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" diff --git a/app/schemas/namespacedSchema.py b/app/schemas/namespacedSchema.py new file mode 100644 index 0000000..a45b2c1 --- /dev/null +++ b/app/schemas/namespacedSchema.py @@ -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} + diff --git a/config.py b/config.py new file mode 100644 index 0000000..bd23273 --- /dev/null +++ b/config.py @@ -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 +} diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/migrations/alembic.ini @@ -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 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..8b3fb33 --- /dev/null +++ b/migrations/env.py @@ -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() 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/925f94e2acd6_.py b/migrations/versions/925f94e2acd6_.py new file mode 100644 index 0000000..4eeed14 --- /dev/null +++ b/migrations/versions/925f94e2acd6_.py @@ -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 ### diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3bba67f --- /dev/null +++ b/requirements.txt @@ -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