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