diff --git a/compendium_v2/__init__.py b/compendium_v2/__init__.py index 28dcaa14370e16802a25d735cbaffb3b6a175dc6..6f4c755e4e8328a02003578a23651d1679973231 100644 --- a/compendium_v2/__init__.py +++ b/compendium_v2/__init__.py @@ -8,6 +8,7 @@ from flask import Flask from flask_cors import CORS # for debugging from compendium_v2 import config, environment +from compendium_v2.db import db, db_survey, migrate def create_app(): @@ -27,6 +28,12 @@ def create_app(): app.secret_key = 'super secret session key' app.config['CONFIG_PARAMS'] = app_config + app.config['SQLALCHEMY_DATABASE_URI'] = \ + app_config['SQLALCHEMY_DATABASE_URI'] + + db.init_app(app) + db_survey.init_app(app) + migrate.init_app(app, db) from compendium_v2.routes import default app.register_blueprint(default.routes, url_prefix='/') diff --git a/compendium_v2/db/__init__.py b/compendium_v2/db/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1011335c995ccd82747e61a551da3267b8a3a4b3 --- /dev/null +++ b/compendium_v2/db/__init__.py @@ -0,0 +1,19 @@ +import os +from typing import Any + +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import MetaData +from sqlalchemy.ext.declarative import declarative_base + +MIGRATION_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'migrations')) + +# https://github.com/python/mypy/issues/2477a +base_schema: Any = declarative_base(metadata=MetaData(schema='presentation')) +db = SQLAlchemy(model_class=base_schema) + +base_survey_schema: Any = declarative_base(metadata=MetaData(schema='survey')) +db_survey = SQLAlchemy(model_class=base_survey_schema) + +migrate = Migrate(directory=MIGRATION_DIR) diff --git a/compendium_v2/db/models.py b/compendium_v2/db/models.py new file mode 100644 index 0000000000000000000000000000000000000000..bcd0a897f2a9476c645d3b8b37bbc7234dbec579 --- /dev/null +++ b/compendium_v2/db/models.py @@ -0,0 +1,65 @@ +import enum +import logging + +from sqlalchemy.orm import relationship + +from compendium_v2.db import base_schema, db + +logger = logging.getLogger(__name__) + + +class DataEntrySection(db.Model): + __tablename__ = 'data_entry_sections' + id = db.Column(db.Integer, primary_key=True) + + name = db.Column(db.String(128)) + description = db.Column(db.String(2048)) + + is_active = db.Column(db.Boolean) + sort_order = db.Column(db.Integer) + + items = relationship('DataEntryItem') + + +class DataSourceType(enum.Enum): + BUDGETS_BY_YEAR = 1 + BUDGETS_BY_NREN = 2 + + +data_entry_settings_assoc_table = db.Table( + 'data_entry_settings_assoc_table', + base_schema.metadata, + db.Column('item_id', db.ForeignKey('data_entry_items.id')), + db.Column('setting_id', db.ForeignKey('data_entry_settings.id'))) + + +class DataEntryItem(db.Model): + __tablename__ = 'data_entry_items' + id = db.Column(db.Integer, primary_key=True) + + title = db.Column(db.String(128)) + description = db.Column(db.String(2048)) + + is_active = db.Column(db.Boolean) + sort_order = db.Column(db.Integer) + + data_source = db.Column(db.Enum(DataSourceType)) + + section_id = db.Column(db.Integer, db.ForeignKey('data_entry_sections.id')) + section = relationship('DataEntrySection', back_populates='items') + + settings = relationship('DataEntrySettings', + secondary=data_entry_settings_assoc_table) + + +class SettingType(enum.Enum): + COLOUR_PALETTE = 1 + CHART_TYPE = 2 + HELP_ITEM = 3 + + +class DataEntrySettings(db.Model): + __tablename__ = 'data_entry_settings' + id = db.Column(db.Integer, primary_key=True) + setting_type = db.Column(db.Enum(SettingType)) + setting_value = db.Column(db.String(512)) diff --git a/compendium_v2/db/survey.py b/compendium_v2/db/survey.py new file mode 100644 index 0000000000000000000000000000000000000000..90e6d101eb1a12bebb9d1820bb6692a9d3633226 --- /dev/null +++ b/compendium_v2/db/survey.py @@ -0,0 +1,56 @@ +import logging + +from compendium_v2.db import db_survey + +logger = logging.getLogger(__name__) + + +class AnnualBudgetEntry(db_survey.Model): + __tablename__ = 'budgets' + id = db_survey.Column(db_survey.Integer, primary_key=True) + region = db_survey.Column(db_survey.String(7)) + country = db_survey.Column(db_survey.Text()) + budget = db_survey.Column(db_survey.Text()) + year = db_survey.Column(db_survey.Integer()) + country_code = db_survey.Column(db_survey.Text()) + region_name = db_survey.Column(db_survey.Text()) + + +def get_budget_by_year(): + budget_data = db_survey.session.execute( + db_survey.select(AnnualBudgetEntry) + + ).scalars() + + annual_data = { + + } + seen_countries = set() + + for line_item in budget_data: + li_year = line_item.year + li_country_code = line_item.country_code + if li_year not in annual_data: + annual_data[li_year] = {} + seen_countries.add(li_country_code) + annual_data[li_year][li_country_code] = line_item.budget + + sorted_countries = sorted(seen_countries) + response_data = { + 'labels': sorted_countries, + 'datasets': [] + } + + for year in sorted(annual_data.keys()): + dataset = { + 'label': str(year), + 'data': [] + } + for country in sorted_countries: + budget_amount = annual_data[year].get(country) + dataset['data'].append(float(budget_amount) + if budget_amount else None) + + response_data['datasets'].append(dataset) + + return response_data diff --git a/compendium_v2/migrations/README b/compendium_v2/migrations/README new file mode 100644 index 0000000000000000000000000000000000000000..0e048441597444a7e2850d6d7c4ce15550f79bda --- /dev/null +++ b/compendium_v2/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/compendium_v2/migrations/alembic.ini b/compendium_v2/migrations/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..3bc59edd53dc6ec5d20d16df02243f44ad04a1eb --- /dev/null +++ b/compendium_v2/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(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,flask_migrate + +[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 + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[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/compendium_v2/migrations/env.py b/compendium_v2/migrations/env.py new file mode 100644 index 0000000000000000000000000000000000000000..cc238e276b173b781aad505ecb452f8e819e41c3 --- /dev/null +++ b/compendium_v2/migrations/env.py @@ -0,0 +1,100 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from alembic import context +from flask import current_app + +# 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 +# from compendium_v2.db.models import DataEntryItem, DataEntrySection +from compendium_v2.db import base_schema + +target_metadata = base_schema.metadata + +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_db = current_app.extensions['migrate'].db + +# 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 get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +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=get_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 = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_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/compendium_v2/migrations/script.py.mako b/compendium_v2/migrations/script.py.mako new file mode 100644 index 0000000000000000000000000000000000000000..2c0156303a8df3ffdc9de87765bf801bf6bea4a5 --- /dev/null +++ b/compendium_v2/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/compendium_v2/migrations/versions/20221128_1223_1e8ba780b977_initial_data_entry_models.py b/compendium_v2/migrations/versions/20221128_1223_1e8ba780b977_initial_data_entry_models.py new file mode 100644 index 0000000000000000000000000000000000000000..774d35a429a82538881b74dc8ffb7340f9955261 --- /dev/null +++ b/compendium_v2/migrations/versions/20221128_1223_1e8ba780b977_initial_data_entry_models.py @@ -0,0 +1,69 @@ +"""Initial Data Entry models + +Revision ID: 1e8ba780b977 +Revises: +Create Date: 2022-11-28 12:23:36.478734 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '1e8ba780b977' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('data_entry_sections', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('description', sa.String(length=2048), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id'), + schema='presentation' + ) + op.create_table('data_entry_settings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('setting_type', + sa.Enum('COLOUR_PALLETE', 'CHART_TYPE', 'HELP_ITEM', name='settingtype'), + nullable=True), + sa.Column('setting_value', sa.String(length=512), nullable=True), + sa.PrimaryKeyConstraint('id'), + schema='presentation' + ) + op.create_table('data_entry_items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=128), nullable=True), + sa.Column('description', sa.String(length=2048), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=True), + sa.Column('is_visible', sa.Boolean(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('data_source', + sa.Enum('BUDGETS_BY_YEAR', 'BUDGETS_BY_NREN', name='datasourcetype'), + nullable=True), + sa.Column('section_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['section_id'], ['presentation.data_entry_sections.id'], ), + sa.PrimaryKeyConstraint('id'), + schema='presentation' + ) + op.create_table('data_entry_settings_assoc_table', + sa.Column('item_id', sa.Integer(), nullable=True), + sa.Column('setting_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['item_id'], ['presentation.data_entry_items.id'], ), + sa.ForeignKeyConstraint(['setting_id'], ['presentation.data_entry_settings.id'], ), + schema='presentation' + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('data_entry_settings_assoc_table', schema='presentation') + op.drop_table('data_entry_items', schema='presentation') + op.drop_table('data_entry_settings', schema='presentation') + op.drop_table('data_entry_sections', schema='presentation') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 44ec01cc39cfa390f03b13817b138e032a6dabc7..5b9fb4a4e06eef8261fcdc0d88ebcc09c10408a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,10 @@ +alembic jsonschema Flask Flask-Cors Flask-SQLAlchemy - +Flask-Migrate +psycopg2 Sphinx sphinx-rtd-theme +SQLAlchemy diff --git a/test/conftest.py b/test/conftest.py index 43d9b16a936f7ad89d7ec6602b5592c26564a46f..f055fda3a6231bb6641b953b2c0f246f61e52712 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -5,13 +5,55 @@ import tempfile import pytest import compendium_v2 +from compendium_v2.db import db, db_survey +from compendium_v2.db.models import DataEntryItem, DataEntrySection +from compendium_v2.db.survey import AnnualBudgetEntry + + +def create_test_presentation_data(app): + with app.app_context(): + db.engine.execute("ATTACH DATABASE ':memory:' AS presentation") + db.create_all() + db.session.add( + DataEntrySection(id=1, + name='Section 1', + description='The first section', + is_active=True, + sort_order=1)) + db.session.add( + DataEntryItem(id=1, + title=' Sec:1 Item 1', + description='First Item in the first section', + is_active=True, + sort_order=1, + data_source='BUDGETS_BY_YEAR', + section_id=1), + ) + db.session.commit() + + +def create_test_survey_data(app): + with app.app_context(): + db_survey.engine.execute("ATTACH DATABASE ':memory:' AS survey") + + db_survey.create_all() + db_survey.session.add( + AnnualBudgetEntry(id=1, country_code='AA', budget='1', year=2020)) + db_survey.session.add( + AnnualBudgetEntry(id=2, country_code='AA', budget='2', year=2021)) + db_survey.session.add( + AnnualBudgetEntry(id=3, country_code='AA', budget='3', year=2022)) + db_survey.session.add( + AnnualBudgetEntry(id=4, country_code='BB', budget='5', year=2021)) + db_survey.session.add( + AnnualBudgetEntry(id=5, country_code='BB', budget='6', year=2022)) + db_survey.session.commit() @pytest.fixture def data_config_filename(): test_config_data = { - 'SQLALCHEMY_DATABASE_URI': 'test-uri', - + 'SQLALCHEMY_DATABASE_URI': 'sqlite://', } with tempfile.NamedTemporaryFile() as f: f.write(json.dumps(test_config_data).encode('utf-8')) @@ -22,5 +64,9 @@ def data_config_filename(): @pytest.fixture def client(data_config_filename): os.environ['SETTINGS_FILENAME'] = data_config_filename - with compendium_v2.create_app().test_client() as c: - yield c + app = compendium_v2.create_app() + test_client = app.test_client() + create_test_presentation_data(app) + create_test_survey_data(app) + + yield test_client diff --git a/tox.ini b/tox.ini index b02bc9b33be56dd2970839b2336646ccba7d7eac..74a219d33af3ae0bd808ebf0d7fe2a9f42b5893d 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ envlist = py39 [flake8] -exclude = ./.tox,./webapp +exclude = ./.tox,./webapp,./compendium_v2/migrations [testenv] deps =