From a593ebdafa964a64b3b038af944a569723714d1f Mon Sep 17 00:00:00 2001 From: Flix6x Date: Tue, 20 Jul 2021 13:38:02 +0000 Subject: [PATCH 01/18] Create draft PR for #155 From f5723abd7104acc760ad8262460082dee8abc52d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 20 Jul 2021 21:22:06 +0200 Subject: [PATCH 02/18] Two db migrations introducing the new tables GenericAssetType and GenericAsset. Check out the revision files for details on how to set up user defined table contents while migrating. --- ...5e_introduce_the_GenericAssetType_table.py | 170 +++++++++++++++ ...d7cceb_introduce_the_GenericAsset_table.py | 195 ++++++++++++++++++ flexmeasures/data/models/assets.py | 55 ++++- flexmeasures/data/models/time_series.py | 9 + 4 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 flexmeasures/data/migrations/versions/565e092a6c5e_introduce_the_GenericAssetType_table.py create mode 100644 flexmeasures/data/migrations/versions/b6d49ed7cceb_introduce_the_GenericAsset_table.py diff --git a/flexmeasures/data/migrations/versions/565e092a6c5e_introduce_the_GenericAssetType_table.py b/flexmeasures/data/migrations/versions/565e092a6c5e_introduce_the_GenericAssetType_table.py new file mode 100644 index 000000000..ea1a47cd9 --- /dev/null +++ b/flexmeasures/data/migrations/versions/565e092a6c5e_introduce_the_GenericAssetType_table.py @@ -0,0 +1,170 @@ +"""introduce the GenericAssetType table + +Revision ID: 565e092a6c5e +Revises: 04f0e2d2924a +Create Date: 2021-07-20 16:16:50.872449 + +""" +import json + +from alembic import context, op +from sqlalchemy import orm +import sqlalchemy as sa + +from flexmeasures.data.models.assets import GenericAssetType + + +# revision identifiers, used by Alembic. +revision = "565e092a6c5e" +down_revision = "04f0e2d2924a" +branch_labels = None +depends_on = None + + +def upgrade(): + """Add GenericAssetType table + + A GenericAssetType is created for each AssetType, MarketType and WeatherSensorType. + Optionally, additional GenericAssetTypes can be created using: + + flexmeasures db upgrade +1 -x '{"name": "waste power plant"}' -x '{"name": "EVSE", "hover_label": "Electric Vehicle Supply Equipment"}' + + The +1 makes sure we only upgrade by 1 revision, as these arguments are only meant to be used by this upgrade function. + """ + + upgrade_schema() + upgrade_data() + + +def downgrade(): + op.drop_table("generic_asset_type") + + +def upgrade_data(): + """Data migration adding 1 generic asset type for each user defined generic asset type, + plus 1 generic asset type for each AssetType, MarketType and WeatherSensorType. + """ + + # Get user defined generic asset types + generic_asset_types = context.get_x_argument() + + # Declare ORM table views + t_asset_types = sa.Table( + "asset_type", + sa.MetaData(), + sa.Column("name", sa.String(80)), + sa.Column("display_name", sa.String(80)), + ) + t_market_types = sa.Table( + "market_type", + sa.MetaData(), + sa.Column("name", sa.String(80)), + sa.Column("display_name", sa.String(80)), + ) + t_weather_sensor_types = sa.Table( + "weather_sensor_type", + sa.MetaData(), + sa.Column("name", sa.String(80)), + sa.Column("display_name", sa.String(80)), + ) + + # Use SQLAlchemy's connection and transaction to go through the data + connection = op.get_bind() + session = orm.Session(bind=connection) + + # Select all existing ids that need migrating, while keeping names intact + asset_type_results = connection.execute( + sa.select( + [ + t_asset_types.c.name, + t_asset_types.c.display_name, + ] + ) + ).fetchall() + market_type_results = connection.execute( + sa.select( + [ + t_market_types.c.name, + t_market_types.c.display_name, + ] + ) + ).fetchall() + weather_sensor_type_results = connection.execute( + sa.select( + [ + t_weather_sensor_types.c.name, + t_weather_sensor_types.c.display_name, + ] + ) + ).fetchall() + + # Prepare to build a list of new generic assets + new_generic_asset_types = [] + + # Construct generic asset type for each user defined generic asset type + asset_type_results_dict = {k: v for k, v in asset_type_results} + market_type_results_dict = {k: v for k, v in market_type_results} + weather_sensor_type_results_dict = {k: v for k, v in weather_sensor_type_results} + for i, generic_asset_type in enumerate(generic_asset_types): + generic_asset_type_dict = json.loads(generic_asset_type) + print( + f"Constructing one generic asset type according to: {generic_asset_type_dict}" + ) + if generic_asset_type_dict["name"] in asset_type_results_dict.keys(): + raise ValueError( + f"User defined generic asset type named '{generic_asset_type_dict['name']}' already exists as asset type." + ) + if generic_asset_type_dict["name"] in market_type_results_dict.keys(): + raise ValueError( + f"User defined generic asset type named '{generic_asset_type_dict['name']}' already exists as market type." + ) + if generic_asset_type_dict["name"] in weather_sensor_type_results_dict.keys(): + raise ValueError( + f"User defined generic asset type named '{generic_asset_type_dict['name']}' already exists as weather sensor type." + ) + new_generic_asset_type = GenericAssetType( + name=generic_asset_type_dict["name"], + hover_label=generic_asset_type_dict.get("hover_label", None), + ) + new_generic_asset_types.append(new_generic_asset_type) + + # Construct generic asset types for each AssetType + print( + f"Constructing generic asset types for each of the following asset types: {asset_type_results_dict}" + ) + for name, display_name in asset_type_results_dict.items(): + # Create new GenericAssets with matching names + new_generic_asset_type = GenericAssetType(name=name, hover_label=display_name) + new_generic_asset_types.append(new_generic_asset_type) + + # Construct generic asset types for each MarketType + print( + f"Constructing generic asset types for each of the following market types: {market_type_results_dict}" + ) + for name, display_name in market_type_results_dict.items(): + # Create new GenericAssets with matching names + new_generic_asset_type = GenericAssetType(name=name, hover_label=display_name) + new_generic_asset_types.append(new_generic_asset_type) + + # Construct generic asset types for each WeatherSensorType + print( + f"Constructing generic asset types for each of the following market types: {weather_sensor_type_results_dict}" + ) + for name, display_name in weather_sensor_type_results_dict.items(): + # Create new GenericAssets with matching names + new_generic_asset_type = GenericAssetType(name=name, hover_label=display_name) + new_generic_asset_types.append(new_generic_asset_type) + + # Add the new generic asset types + session.add_all(new_generic_asset_types) + session.commit() + + +def upgrade_schema(): + op.create_table( + "generic_asset_type", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=True), + sa.Column("hover_label", sa.String(length=80), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("generic_asset_type_pkey")), + ) diff --git a/flexmeasures/data/migrations/versions/b6d49ed7cceb_introduce_the_GenericAsset_table.py b/flexmeasures/data/migrations/versions/b6d49ed7cceb_introduce_the_GenericAsset_table.py new file mode 100644 index 000000000..27a49d6d3 --- /dev/null +++ b/flexmeasures/data/migrations/versions/b6d49ed7cceb_introduce_the_GenericAsset_table.py @@ -0,0 +1,195 @@ +"""introduce the GenericAsset table + +Revision ID: b6d49ed7cceb +Revises: 565e092a6c5e +Create Date: 2021-07-20 20:15:28.019102 + +""" +import json + +from alembic import context, op +from sqlalchemy import orm +import sqlalchemy as sa + +from flexmeasures.data.models.assets import Asset, GenericAsset, GenericAssetType +from flexmeasures.data.models.markets import Market +from flexmeasures.data.models.time_series import Sensor +from flexmeasures.data.models.weather import WeatherSensor + + +# revision identifiers, used by Alembic. +revision = "b6d49ed7cceb" +down_revision = "565e092a6c5e" +branch_labels = None +depends_on = None + + +def upgrade(): + """Add GenericAsset table and link with Sensor table + + For Sensors with corresponding Assets, Markets or WeatherSensors, a GenericAsset is created with matching name. + For Sensors without, a GenericAsset is created with matching name. + Optionally, sensors that do not correspond to an existing Asset, Market or WeatherSensor can be grouped using + + flexmeasures db upgrade +1 -x '{"asset_type_name": "waste power plant", "sensor_ids": [2, 4], "asset_name": "Afval Energie Centrale", "owner_id": 2}' -x '{"asset_type_name": "EVSE", "sensor_ids": [7, 8], "asset_name": "Laadstation Rijksmuseum - charger 2", "owner_id": 2}' + + The +1 makes sure we only upgrade by 1 revision, as these arguments are only meant to be used by this upgrade function. + """ + + upgrade_schema() + upgrade_data() + op.alter_column("generic_asset", "generic_asset_type_id", nullable=False) + op.alter_column("sensor", "generic_asset_id", nullable=False) + + +def downgrade(): + op.drop_constraint( + op.f("sensor_generic_asset_id_generic_asset_fkey"), "sensor", type_="foreignkey" + ) + op.drop_column("sensor", "generic_asset_id") + op.drop_table("generic_asset") + + +def upgrade_data(): + """Data migration adding 1 generic asset for each user defined group of sensors, + plus 1 generic asset for each remaining sensor (i.e. those not part of a user defined group). + """ + + # Get user defined sensor groups + sensor_groups = context.get_x_argument() + + # Declare ORM table views + t_sensors = sa.Table( + "sensor", + sa.MetaData(), + sa.Column("id", sa.Integer), + sa.Column("name", sa.String(80)), + ) + + # Use SQLAlchemy's connection and transaction to go through the data + connection = op.get_bind() + session = orm.Session(bind=connection) + + # Select all existing ids that need migrating, while keeping names intact + sensor_results = connection.execute( + sa.select( + [ + t_sensors.c.id, + t_sensors.c.name, + ] + ) + ).fetchall() + sensors = session.query(Sensor).all() + + # Prepare to build a list of new generic assets + new_generic_assets = [] + + # Construct generic asset for each user defined sensor group + sensor_results_dict = {k: v for k, v in sensor_results} + for i, sensor_group in enumerate(sensor_groups): + sensor_group_dict = json.loads(sensor_group) + print(f"Constructing one generic asset according to: {sensor_group_dict}") + if not set(sensor_group_dict["sensor_ids"]).issubset( + set(sensor_results_dict.keys()) + ): + raise ValueError( + f"At least some of these sensor ids {sensor_group_dict['sensor_ids']} do not exist." + ) + generic_asset_type = ( + session.query(GenericAssetType) + .filter_by(name=sensor_group_dict["asset_type_name"]) + .one_or_none() + ) + if generic_asset_type is None: + raise ValueError( + f"Asset type name '{sensor_group_dict['asset_type_name']}' does not exist." + ) + group_sensors = [ + sensor for sensor in sensors if sensor.id in sensor_group_dict["sensor_ids"] + ] + new_generic_asset = GenericAsset( + name=sensor_group_dict["asset_name"], + generic_asset_type=generic_asset_type, + sensors=group_sensors, + owner_id=sensor_group_dict["owner_id"], + ) + new_generic_assets.append(new_generic_asset) + for id in sensor_group_dict["sensor_ids"]: + sensor_results_dict.pop(id) + + # Construct generic assets for all remaining sensors + print( + f"Constructing generic assets for each of the following sensors: {sensor_results_dict}" + ) + for id_, name in sensor_results_dict.items(): + _sensors = [sensor for sensor in sensors if sensor.id == id_] + + asset = session.query(Asset).filter_by(id=id_).one_or_none() + if asset is not None: + asset_type_name = asset.asset_type_name + else: + market = session.query(Market).filter_by(id=id_).one_or_none() + if market is not None: + asset_type_name = market.market_type_name + else: + weather_sensor = ( + session.query(WeatherSensor).filter_by(id=id_).one_or_none() + ) + if weather_sensor is not None: + asset_type_name = weather_sensor.weather_sensor_type_name + else: + raise ValueError( + f"Cannot find an Asset, Market or WeatherSensor with id {id_}" + ) + + generic_asset_type = ( + session.query(GenericAssetType) + .filter_by(name=asset_type_name) + .one_or_none() + ) + + # Create new GenericAssets with matching names + new_generic_asset = GenericAsset( + name=name, generic_asset_type=generic_asset_type, sensors=_sensors + ) + new_generic_assets.append(new_generic_asset) + + # Add the new generic assets + session.add_all(new_generic_assets) + session.commit() + + +def upgrade_schema(): + op.create_table( + "generic_asset", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=True), + sa.Column("latitude", sa.Float(), nullable=True), + sa.Column("longitude", sa.Float(), nullable=True), + sa.Column( + "generic_asset_type_id", sa.Integer(), nullable=True + ), # we set nullable=False after data migration + sa.Column("owner_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["generic_asset_type_id"], + ["generic_asset_type.id"], + name=op.f("generic_asset_generic_asset_type_id_generic_asset_type_fkey"), + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["fm_user.id"], + name=op.f("generic_asset_owner_id_fm_user_fkey"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("generic_asset_pkey")), + ) + op.add_column( + "sensor", sa.Column("generic_asset_id", sa.Integer(), nullable=True) + ) # we set nullable=False after data migration + op.create_foreign_key( + op.f("sensor_generic_asset_id_generic_asset_fkey"), + "sensor", + "generic_asset", + ["generic_asset_id"], + ["id"], + ) diff --git a/flexmeasures/data/models/assets.py b/flexmeasures/data/models/assets.py index 7dd30712e..06c3f3768 100644 --- a/flexmeasures/data/models/assets.py +++ b/flexmeasures/data/models/assets.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union import isodate import timely_beliefs as tb @@ -67,6 +67,59 @@ def __repr__(self): return "" % self.name +class GenericAssetType(db.Model): + """An asset type defines what type an asset belongs to. + + Examples of asset types: WeatherStation, Market, CP, EVSE, WindTurbine, SolarPanel, Building. + """ + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), default="") + hover_label = db.Column(db.String(80), nullable=True, unique=False) + + +class GenericAsset(db.Model): + """An asset is something that has economic value. + + Examples of tangible assets: a house, a ship, a weather station. + Examples of intangible assets: a market, a country, a copyright. + """ + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), default="") + latitude = db.Column(db.Float, nullable=True) # if null, asset is virtual + longitude = db.Column(db.Float, nullable=True) # if null, asset is virtual + + generic_asset_type_id = db.Column( + db.Integer, db.ForeignKey("generic_asset_type.id"), nullable=False + ) + generic_asset_type = db.relationship( + "GenericAssetType", + foreign_keys=[generic_asset_type_id], + backref=db.backref("generic_assets", lazy=True), + ) + + owner_id = db.Column( + db.Integer, db.ForeignKey("fm_user.id", ondelete="CASCADE"), nullable=True + ) # if null, asset is public + owner = db.relationship( + "User", + backref=db.backref( + "generic_assets", + foreign_keys=[owner_id], + lazy=True, + cascade="all, delete-orphan", + passive_deletes=True, + ), + ) + + @property + def location(self) -> Optional[Tuple[float, float]]: + if None not in (self.latitude, self.longitude): + return self.latitude, self.longitude + return None + + class Asset(db.Model, tb.SensorDBMixin): """Each asset is an energy- consuming or producing hardware. """ diff --git a/flexmeasures/data/models/time_series.py b/flexmeasures/data/models/time_series.py index 5f2a4d601..a8a50324b 100644 --- a/flexmeasures/data/models/time_series.py +++ b/flexmeasures/data/models/time_series.py @@ -25,6 +25,15 @@ class Sensor(db.Model, tb.SensorDBMixin): """A sensor measures events. """ + generic_asset_id = db.Column( + db.Integer, db.ForeignKey("generic_asset.id"), nullable=False + ) + generic_asset = db.relationship( + "GenericAsset", + foreign_keys=[generic_asset_id], + backref=db.backref("sensors", lazy=True), + ) + def __init__(self, name: str, **kwargs): tb.SensorDBMixin.__init__(self, name, **kwargs) tb_utils.remove_class_init_kwargs(tb.SensorDBMixin, kwargs) From 6554d6c41aa45a39c26165f4473ea2a505142eba Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 27 Jul 2021 14:00:36 +0200 Subject: [PATCH 03/18] Fix tests --- .../api/common/schemas/tests/test_sensors.py | 2 +- flexmeasures/api/dev/tests/conftest.py | 17 +++- flexmeasures/api/v1/tests/conftest.py | 4 + flexmeasures/api/v1_1/tests/conftest.py | 2 + flexmeasures/conftest.py | 39 ++++++++- ...5e_introduce_the_GenericAssetType_table.py | 3 +- ...d7cceb_introduce_the_GenericAsset_table.py | 3 +- flexmeasures/data/models/assets.py | 59 +------------ flexmeasures/data/models/generic_assets.py | 86 +++++++++++++++++++ flexmeasures/data/models/markets.py | 4 +- flexmeasures/data/models/time_series.py | 6 +- flexmeasures/data/models/weather.py | 4 +- flexmeasures/data/tests/conftest.py | 2 + flexmeasures/ui/tests/conftest.py | 2 + 14 files changed, 163 insertions(+), 70 deletions(-) create mode 100644 flexmeasures/data/models/generic_assets.py diff --git a/flexmeasures/api/common/schemas/tests/test_sensors.py b/flexmeasures/api/common/schemas/tests/test_sensors.py index 829daf91b..e4f22f82f 100644 --- a/flexmeasures/api/common/schemas/tests/test_sensors.py +++ b/flexmeasures/api/common/schemas/tests/test_sensors.py @@ -14,7 +14,7 @@ build_entity_address(dict(sensor_id=1), "sensor"), "sensor", "fm1", - "my daughter's height", + "height", ), ( build_entity_address( diff --git a/flexmeasures/api/dev/tests/conftest.py b/flexmeasures/api/dev/tests/conftest.py index 1fe88e2b7..00f2d49c5 100644 --- a/flexmeasures/api/dev/tests/conftest.py +++ b/flexmeasures/api/dev/tests/conftest.py @@ -3,6 +3,7 @@ from flask_security import SQLAlchemySessionUserDatastore import pytest +from flexmeasures.data.models.generic_assets import GenericAssetType, GenericAsset from flexmeasures.data.models.time_series import Sensor @@ -28,13 +29,25 @@ def setup_api_fresh_test_data(fresh_db, setup_roles_users_fresh_db): give_prosumer_the_MDC_role(fresh_db) -def add_gas_sensor(the_db, test_supplier): +def add_gas_sensor(db, test_supplier): + incineration_type = GenericAssetType( + name="waste incinerator", + ) + db.session.add(incineration_type) + db.session.flush() + incineration_asset = GenericAsset( + name="incineration line", + generic_asset_type=incineration_type, + ) + db.session.add(incineration_asset) + db.session.flush() gas_sensor = Sensor( name="some gas sensor", unit="m³/h", event_resolution=timedelta(minutes=10), + generic_asset=incineration_asset, ) - the_db.session.add(gas_sensor) + db.session.add(gas_sensor) gas_sensor.owner = test_supplier diff --git a/flexmeasures/api/v1/tests/conftest.py b/flexmeasures/api/v1/tests/conftest.py index adb09523f..234fe1b5d 100644 --- a/flexmeasures/api/v1/tests/conftest.py +++ b/flexmeasures/api/v1/tests/conftest.py @@ -17,6 +17,7 @@ def setup_api_test_data(db, setup_roles_users, add_market_prices): print("Setting up data for API v1 tests on %s" % db.engine) from flexmeasures.data.models.assets import Asset, AssetType, Power + from flexmeasures.data.models.generic_assets import GenericAssetType from flexmeasures.data.models.data_sources import DataSource # Create an anonymous user @@ -33,6 +34,7 @@ def setup_api_test_data(db, setup_roles_users, add_market_prices): # Create 1 test asset for the anonymous user test_asset_type = AssetType(name="test-type") db.session.add(test_asset_type) + db.session.add(GenericAssetType(name="test-type")) asset_names = ["CS 0"] assets: List[Asset] = [] for asset_name in asset_names: @@ -115,11 +117,13 @@ def setup_fresh_api_test_data(fresh_db, setup_roles_users_fresh_db): db = fresh_db setup_roles_users = setup_roles_users_fresh_db from flexmeasures.data.models.assets import Asset, AssetType + from flexmeasures.data.models.generic_assets import GenericAssetType # Create 5 test assets for the test_prosumer user test_prosumer = setup_roles_users["Test Prosumer"] test_asset_type = AssetType(name="test-type") db.session.add(test_asset_type) + db.session.add(GenericAssetType(name="test-type")) asset_names = ["CS 1", "CS 2", "CS 3", "CS 4", "CS 5"] assets: List[Asset] = [] for asset_name in asset_names: diff --git a/flexmeasures/api/v1_1/tests/conftest.py b/flexmeasures/api/v1_1/tests/conftest.py index 6fa73f7a2..55860cd5c 100644 --- a/flexmeasures/api/v1_1/tests/conftest.py +++ b/flexmeasures/api/v1_1/tests/conftest.py @@ -20,6 +20,7 @@ def setup_api_test_data(db, setup_roles_users, add_market_prices): from flexmeasures.data.models.user import User, Role from flexmeasures.data.models.assets import Asset, AssetType + from flexmeasures.data.models.generic_assets import GenericAssetType user_datastore = SQLAlchemySessionUserDatastore(db.session, User, Role) @@ -43,6 +44,7 @@ def setup_api_test_data(db, setup_roles_users, add_market_prices): test_prosumer = setup_roles_users["Test Prosumer"] test_asset_type = AssetType(name="test-type") db.session.add(test_asset_type) + db.session.add(GenericAssetType(name="test-type")) asset_names = ["CS 1", "CS 2", "CS 3"] assets: List[Asset] = [] for asset_name in asset_names: diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index c937cc17c..7719107c0 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -23,6 +23,7 @@ from flexmeasures.utils.time_utils import as_server_time from flexmeasures.data.services.users import create_user from flexmeasures.data.models.assets import AssetType, Asset, Power +from flexmeasures.data.models.generic_assets import GenericAssetType, GenericAsset from flexmeasures.data.models.data_sources import DataSource from flexmeasures.data.models.weather import WeatherSensor, WeatherSensorType from flexmeasures.data.models.markets import Market, MarketType, Price @@ -157,6 +158,11 @@ def create_test_markets(db) -> Dict[str, Market]: yearly_seasonality=True, ) db.session.add(day_ahead) + db.session.add( + GenericAssetType( + name="day_ahead", + ) + ) epex_da = Market( name="epex_da", market_type=day_ahead, @@ -186,6 +192,27 @@ def setup_asset_types_fresh_db(fresh_db) -> Dict[str, AssetType]: return create_test_asset_types(fresh_db) +@pytest.fixture(scope="module") +def setup_generic_asset(db, setup_generic_asset_type) -> Dict[str, AssetType]: + """Make some generic assets used throughout.""" + troposphere = GenericAsset( + name="troposphere", generic_asset_type=setup_generic_asset_type["public_good"] + ) + db.session.add(troposphere) + return dict(troposphere=troposphere) + + +@pytest.fixture(scope="module") +def setup_generic_asset_type(db) -> Dict[str, AssetType]: + """Make some generic asset types used throughout.""" + + public_good = GenericAssetType( + name="public good", + ) + db.session.add(public_good) + return dict(public_good=public_good) + + def create_test_asset_types(db) -> Dict[str, AssetType]: """Make some asset types used throughout.""" @@ -197,6 +224,7 @@ def create_test_asset_types(db) -> Dict[str, AssetType]: yearly_seasonality=True, ) db.session.add(solar) + db.session.add(GenericAssetType(name="solar")) wind = AssetType( name="wind", is_producer=True, @@ -205,6 +233,7 @@ def create_test_asset_types(db) -> Dict[str, AssetType]: yearly_seasonality=True, ) db.session.add(wind) + db.session.add(GenericAssetType(name="wind")) return dict(solar=solar, wind=wind) @@ -336,6 +365,7 @@ def create_test_battery_assets( yearly_seasonality=True, ) ) + db.session.add(GenericAssetType(name="battery")) test_battery = Asset( name="Test battery", @@ -395,6 +425,7 @@ def add_charging_station_assets( yearly_seasonality=True, ) ) + db.session.add(GenericAssetType(name="one-way_evse")) db.session.add( AssetType( name="two-way_evse", @@ -407,6 +438,7 @@ def add_charging_station_assets( yearly_seasonality=True, ) ) + db.session.add(GenericAssetType(name="two-way_evse")) charging_station = Asset( name="Test charging station", @@ -464,6 +496,7 @@ def create_weather_sensors(db: SQLAlchemy): test_sensor_type = WeatherSensorType(name="wind_speed") db.session.add(test_sensor_type) + db.session.add(GenericAssetType(name="wind_speed")) wind_sensor = WeatherSensor( name="wind_speed_sensor", weather_sensor_type_name="wind_speed", @@ -475,6 +508,7 @@ def create_weather_sensors(db: SQLAlchemy): db.session.add(wind_sensor) test_sensor_type = WeatherSensorType(name="temperature") + db.session.add(GenericAssetType(name="temperature")) db.session.add(test_sensor_type) temp_sensor = WeatherSensor( name="temperature_sensor", @@ -489,11 +523,10 @@ def create_weather_sensors(db: SQLAlchemy): @pytest.fixture(scope="module") -def add_sensors(db: SQLAlchemy): +def add_sensors(db: SQLAlchemy, setup_generic_asset): """Add some generic sensors.""" height_sensor = Sensor( - name="my daughter's height", - unit="m", + name="height", unit="m", generic_asset=setup_generic_asset["troposphere"] ) db.session.add(height_sensor) return height_sensor diff --git a/flexmeasures/data/migrations/versions/565e092a6c5e_introduce_the_GenericAssetType_table.py b/flexmeasures/data/migrations/versions/565e092a6c5e_introduce_the_GenericAssetType_table.py index ea1a47cd9..a52429fc8 100644 --- a/flexmeasures/data/migrations/versions/565e092a6c5e_introduce_the_GenericAssetType_table.py +++ b/flexmeasures/data/migrations/versions/565e092a6c5e_introduce_the_GenericAssetType_table.py @@ -11,8 +11,7 @@ from sqlalchemy import orm import sqlalchemy as sa -from flexmeasures.data.models.assets import GenericAssetType - +from flexmeasures.data.models.generic_assets import GenericAssetType # revision identifiers, used by Alembic. revision = "565e092a6c5e" diff --git a/flexmeasures/data/migrations/versions/b6d49ed7cceb_introduce_the_GenericAsset_table.py b/flexmeasures/data/migrations/versions/b6d49ed7cceb_introduce_the_GenericAsset_table.py index 27a49d6d3..a12da2133 100644 --- a/flexmeasures/data/migrations/versions/b6d49ed7cceb_introduce_the_GenericAsset_table.py +++ b/flexmeasures/data/migrations/versions/b6d49ed7cceb_introduce_the_GenericAsset_table.py @@ -11,7 +11,8 @@ from sqlalchemy import orm import sqlalchemy as sa -from flexmeasures.data.models.assets import Asset, GenericAsset, GenericAssetType +from flexmeasures.data.models.assets import Asset +from flexmeasures.data.models.generic_assets import GenericAssetType, GenericAsset from flexmeasures.data.models.markets import Market from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.models.weather import WeatherSensor diff --git a/flexmeasures/data/models/assets.py b/flexmeasures/data/models/assets.py index 06c3f3768..d8059813a 100644 --- a/flexmeasures/data/models/assets.py +++ b/flexmeasures/data/models/assets.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Tuple, Union import isodate import timely_beliefs as tb @@ -6,6 +6,7 @@ from flexmeasures.data.config import db from flexmeasures.data.models.time_series import Sensor, TimedValue +from flexmeasures.data.models.generic_assets import create_generic_asset from flexmeasures.utils.entity_address_utils import build_entity_address from flexmeasures.utils.flexmeasures_inflection import humanize, pluralize @@ -67,59 +68,6 @@ def __repr__(self): return "" % self.name -class GenericAssetType(db.Model): - """An asset type defines what type an asset belongs to. - - Examples of asset types: WeatherStation, Market, CP, EVSE, WindTurbine, SolarPanel, Building. - """ - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(80), default="") - hover_label = db.Column(db.String(80), nullable=True, unique=False) - - -class GenericAsset(db.Model): - """An asset is something that has economic value. - - Examples of tangible assets: a house, a ship, a weather station. - Examples of intangible assets: a market, a country, a copyright. - """ - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(80), default="") - latitude = db.Column(db.Float, nullable=True) # if null, asset is virtual - longitude = db.Column(db.Float, nullable=True) # if null, asset is virtual - - generic_asset_type_id = db.Column( - db.Integer, db.ForeignKey("generic_asset_type.id"), nullable=False - ) - generic_asset_type = db.relationship( - "GenericAssetType", - foreign_keys=[generic_asset_type_id], - backref=db.backref("generic_assets", lazy=True), - ) - - owner_id = db.Column( - db.Integer, db.ForeignKey("fm_user.id", ondelete="CASCADE"), nullable=True - ) # if null, asset is public - owner = db.relationship( - "User", - backref=db.backref( - "generic_assets", - foreign_keys=[owner_id], - lazy=True, - cascade="all, delete-orphan", - passive_deletes=True, - ), - ) - - @property - def location(self) -> Optional[Tuple[float, float]]: - if None not in (self.latitude, self.longitude): - return self.latitude, self.longitude - return None - - class Asset(db.Model, tb.SensorDBMixin): """Each asset is an energy- consuming or producing hardware. """ @@ -155,7 +103,8 @@ def __init__(self, **kwargs): # Create a new Sensor with unique id across assets, markets and weather sensors if "id" not in kwargs: - new_sensor = Sensor(name=kwargs["name"]) + new_generic_asset = create_generic_asset("asset", **kwargs) + new_sensor = Sensor(name=kwargs["name"], generic_asset=new_generic_asset) db.session.add(new_sensor) db.session.flush() # generates the pkey for new_sensor sensor_id = new_sensor.id diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py new file mode 100644 index 000000000..d14e5145d --- /dev/null +++ b/flexmeasures/data/models/generic_assets.py @@ -0,0 +1,86 @@ +from typing import Optional, Tuple + +from flexmeasures.data import db + + +class GenericAssetType(db.Model): + """An asset type defines what type an asset belongs to. + + Examples of asset types: WeatherStation, Market, CP, EVSE, WindTurbine, SolarPanel, Building. + """ + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), default="") + hover_label = db.Column(db.String(80), nullable=True, unique=False) + + +class GenericAsset(db.Model): + """An asset is something that has economic value. + + Examples of tangible assets: a house, a ship, a weather station. + Examples of intangible assets: a market, a country, a copyright. + """ + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), default="") + latitude = db.Column(db.Float, nullable=True) + longitude = db.Column(db.Float, nullable=True) + + generic_asset_type_id = db.Column( + db.Integer, db.ForeignKey("generic_asset_type.id"), nullable=False + ) + generic_asset_type = db.relationship( + "GenericAssetType", + foreign_keys=[generic_asset_type_id], + backref=db.backref("generic_assets", lazy=True), + ) + + owner_id = db.Column( + db.Integer, db.ForeignKey("fm_user.id", ondelete="CASCADE"), nullable=True + ) # if null, asset is public + owner = db.relationship( + "User", + backref=db.backref( + "generic_assets", + foreign_keys=[owner_id], + lazy=True, + cascade="all, delete-orphan", + passive_deletes=True, + ), + ) + + @property + def location(self) -> Optional[Tuple[float, float]]: + if None not in (self.latitude, self.longitude): + return self.latitude, self.longitude + return None + + +def create_generic_asset(generic_asset_type: str, **kwargs) -> GenericAsset: + """Create a GenericAsset and assigns it an id. + + :param generic_asset_type: "asset", "market" or "weather_sensor" + :param kwargs: should have values for keys "name", and: + - "asset_type_name" or "asset_type" when generic_asset_type is "asset" + - "market_type_name" or "market_type" when generic_asset_type is "market" + - "weather_sensor_type_name" or "weather_sensor_type" when generic_asset_type is "weather_sensor" + - alternatively, "sensor_type" is also fine + :returns: the created GenericAsset + """ + asset_type_name = kwargs.pop(f"{generic_asset_type}_type_name", None) + if asset_type_name is None: + if f"{generic_asset_type}_type" in kwargs: + asset_type_name = kwargs.pop(f"{generic_asset_type}_type").name + else: + asset_type_name = kwargs.pop("sensor_type").name + generic_asset_type = GenericAssetType.query.filter_by( + name=asset_type_name + ).one_or_none() + if generic_asset_type is None: + raise ValueError(f"Cannot find GenericAssetType {asset_type_name} in database.") + new_generic_asset = GenericAsset( + name=kwargs["name"], generic_asset_type_id=generic_asset_type.id + ) + db.session.add(new_generic_asset) + db.session.flush() # generates the pkey for new_generic_asset + return new_generic_asset diff --git a/flexmeasures/data/models/markets.py b/flexmeasures/data/models/markets.py index dd32431c2..436276ff5 100644 --- a/flexmeasures/data/models/markets.py +++ b/flexmeasures/data/models/markets.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Query from flexmeasures.data.config import db +from flexmeasures.data.models.generic_assets import create_generic_asset from flexmeasures.data.models.time_series import Sensor, TimedValue from flexmeasures.utils.entity_address_utils import build_entity_address from flexmeasures.utils.flexmeasures_inflection import humanize @@ -65,7 +66,8 @@ def __init__(self, **kwargs): # Create a new Sensor with unique id across assets, markets and weather sensors if "id" not in kwargs: - new_sensor = Sensor(name=kwargs["name"]) + new_generic_asset = create_generic_asset("market", **kwargs) + new_sensor = Sensor(name=kwargs["name"], generic_asset=new_generic_asset) db.session.add(new_sensor) db.session.flush() # generates the pkey for new_sensor new_sensor_id = new_sensor.id diff --git a/flexmeasures/data/models/time_series.py b/flexmeasures/data/models/time_series.py index a8a50324b..523d1f681 100644 --- a/flexmeasures/data/models/time_series.py +++ b/flexmeasures/data/models/time_series.py @@ -34,10 +34,8 @@ class Sensor(db.Model, tb.SensorDBMixin): backref=db.backref("sensors", lazy=True), ) - def __init__(self, name: str, **kwargs): - tb.SensorDBMixin.__init__(self, name, **kwargs) - tb_utils.remove_class_init_kwargs(tb.SensorDBMixin, kwargs) - db.Model.__init__(self, **kwargs) + def __init__(self, **kwargs): + super(Sensor, self).__init__(**kwargs) @property def entity_address(self) -> str: diff --git a/flexmeasures/data/models/weather.py b/flexmeasures/data/models/weather.py index 42c68b856..ebc9d2c39 100644 --- a/flexmeasures/data/models/weather.py +++ b/flexmeasures/data/models/weather.py @@ -9,6 +9,7 @@ from flexmeasures.data.config import db from flexmeasures.data.models.time_series import Sensor, TimedValue +from flexmeasures.data.models.generic_assets import create_generic_asset from flexmeasures.utils.geo_utils import parse_lat_lng from flexmeasures.utils.entity_address_utils import build_entity_address from flexmeasures.utils.flexmeasures_inflection import humanize @@ -67,7 +68,8 @@ def __init__(self, **kwargs): # Create a new Sensor with unique id across assets, markets and weather sensors if "id" not in kwargs: - new_sensor = Sensor(name=kwargs["name"]) + new_generic_asset = create_generic_asset("weather_sensor", **kwargs) + new_sensor = Sensor(name=kwargs["name"], generic_asset=new_generic_asset) db.session.add(new_sensor) db.session.flush() # generates the pkey for new_sensor new_sensor_id = new_sensor.id diff --git a/flexmeasures/data/tests/conftest.py b/flexmeasures/data/tests/conftest.py index 969d34bc8..6b87fcc5f 100644 --- a/flexmeasures/data/tests/conftest.py +++ b/flexmeasures/data/tests/conftest.py @@ -10,6 +10,7 @@ from flexmeasures.data.models.assets import Asset, Power from flexmeasures.data.models.data_sources import DataSource +from flexmeasures.data.models.generic_assets import GenericAssetType from flexmeasures.data.models.weather import WeatherSensorType, WeatherSensor, Weather from flexmeasures.data.models.forecasting import model_map from flexmeasures.data.models.forecasting.model_spec_factory import ( @@ -112,6 +113,7 @@ def add_test_weather_sensor_and_forecasts(db: SQLAlchemy): for sensor_name in ("radiation", "wind_speed"): sensor_type = WeatherSensorType.query.filter_by(name=sensor_name).one_or_none() if sensor_type is None: + db.session.add(GenericAssetType(name=sensor_name)) sensor_type = WeatherSensorType(name=sensor_name) sensor = WeatherSensor( name=sensor_name, sensor_type=sensor_type, latitude=100, longitude=100 diff --git a/flexmeasures/ui/tests/conftest.py b/flexmeasures/ui/tests/conftest.py index f29d34b5a..c4b761acc 100644 --- a/flexmeasures/ui/tests/conftest.py +++ b/flexmeasures/ui/tests/conftest.py @@ -5,6 +5,7 @@ from flexmeasures.data.services.users import create_user from flexmeasures.data.models.assets import Asset from flexmeasures.data.models.weather import WeatherSensor, WeatherSensorType +from flexmeasures.data.models.generic_assets import GenericAssetType from flexmeasures.ui.tests.utils import login, logout @@ -71,6 +72,7 @@ def setup_ui_test_data( # Create 1 weather sensor test_sensor_type = WeatherSensorType(name="radiation") db.session.add(test_sensor_type) + db.session.add(GenericAssetType(name="radiation")) sensor = WeatherSensor( name="radiation_sensor", weather_sensor_type_name="radiation", From fd4770fe1b9b6eb63dae0d8c0826e7a1f02f48a8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 27 Jul 2021 14:15:44 +0200 Subject: [PATCH 04/18] Update CLI command for adding a sensor --- flexmeasures/data/schemas/sensors.py | 13 ++++++++++++- flexmeasures/data/scripts/cli_tasks/data_add.py | 9 +++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/schemas/sensors.py b/flexmeasures/data/schemas/sensors.py index 80fb23ecd..2641645fa 100644 --- a/flexmeasures/data/schemas/sensors.py +++ b/flexmeasures/data/schemas/sensors.py @@ -1,6 +1,7 @@ -from marshmallow import Schema, fields +from marshmallow import Schema, fields, validates, ValidationError from flexmeasures.data import ma +from flexmeasures.data.models.generic_assets import GenericAsset from flexmeasures.data.models.time_series import Sensor @@ -29,5 +30,15 @@ class SensorSchema(SensorSchemaMixin, ma.SQLAlchemySchema): Sensor schema, with validations. """ + generic_asset_id = fields.Integer(required=True) + + @validates("generic_asset_id") + def validate_generic_asset(self, generic_asset_id: int): + generic_asset = GenericAsset.query.get(generic_asset_id) + if not generic_asset: + raise ValidationError( + f"Generic asset with id {generic_asset_id} doesn't exist." + ) + class Meta: model = Sensor diff --git a/flexmeasures/data/scripts/cli_tasks/data_add.py b/flexmeasures/data/scripts/cli_tasks/data_add.py index 96f0221b6..2b0a223ef 100644 --- a/flexmeasures/data/scripts/cli_tasks/data_add.py +++ b/flexmeasures/data/scripts/cli_tasks/data_add.py @@ -94,6 +94,12 @@ def new_user(username: str, email: str, roles: List[str], timezone: str): required=True, help="timezone as string, e.g. 'UTC' or 'Europe/Amsterdam'", ) +@click.option( + "--generic-asset-id", + required=True, + type=int, + help="Generic asset to assign this sensor to", +) def add_sensor(**args): """Add a sensor.""" check_timezone(args["timezone"]) @@ -103,8 +109,7 @@ def add_sensor(**args): app.db.session.add(sensor) app.db.session.commit() print(f"Successfully created sensor with ID {sensor.id}") - # TODO: uncomment when #66 has landed - # print(f"You can access it at its entity address {sensor.entity_address}") + print(f"You can access it at its entity address {sensor.entity_address}") @fm_add_data.command("asset") From 5482343380cd5ffdaeb21832c8b1b5f47d5ecc90 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 27 Jul 2021 14:26:44 +0200 Subject: [PATCH 05/18] Resolve type warnings --- .../data/scripts/cli_tasks/data_add.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/flexmeasures/data/scripts/cli_tasks/data_add.py b/flexmeasures/data/scripts/cli_tasks/data_add.py index 2b0a223ef..6844afb26 100644 --- a/flexmeasures/data/scripts/cli_tasks/data_add.py +++ b/flexmeasures/data/scripts/cli_tasks/data_add.py @@ -1,7 +1,7 @@ """CLI Tasks for (de)populating the database - most useful in development""" from datetime import timedelta -from typing import List, Optional +from typing import Dict, List, Optional import pandas as pd import pytz @@ -75,7 +75,7 @@ def new_user(username: str, email: str, roles: List[str], timezone: str): user_roles=roles, check_deliverability=False, ) - app.db.session.commit() + db.session.commit() print(f"Successfully created user {created_user}") @@ -106,8 +106,8 @@ def add_sensor(**args): check_errors(SensorSchema().validate(args)) args["event_resolution"] = timedelta(minutes=args["event_resolution"]) sensor = Sensor(**args) - app.db.session.add(sensor) - app.db.session.commit() + db.session.add(sensor) + db.session.commit() print(f"Successfully created sensor with ID {sensor.id}") print(f"You can access it at its entity address {sensor.entity_address}") @@ -176,8 +176,8 @@ def new_asset(**args): check_errors(AssetSchema().validate(args)) args["event_resolution"] = timedelta(minutes=args["event_resolution"]) asset = Asset(**args) - app.db.session.add(asset) - app.db.session.commit() + db.session.add(asset) + db.session.commit() print(f"Successfully created asset with ID {asset.id}") print(f"You can access it at its entity address {asset.entity_address}") @@ -216,8 +216,8 @@ def add_weather_sensor(**args): check_errors(WeatherSensorSchema().validate(args)) args["event_resolution"] = timedelta(minutes=args["event_resolution"]) sensor = WeatherSensor(**args) - app.db.session.add(sensor) - app.db.session.commit() + db.session.add(sensor) + db.session.commit() print(f"Successfully created weather sensor with ID {sensor.id}") print(f" You can access it at its entity address {sensor.entity_address}") @@ -228,7 +228,7 @@ def add_initial_structure(): """Initialize structural data like asset types, market types and weather sensor types.""" from flexmeasures.data.scripts.data_gen import populate_structure - populate_structure(app.db) + populate_structure(db) @fm_dev_add_data.command("beliefs") @@ -493,7 +493,7 @@ def create_forecasts( from flexmeasures.data.scripts.data_gen import populate_time_series_forecasts populate_time_series_forecasts( - app.db, horizons, from_date, to_date, asset_type, asset_id + db, horizons, from_date, to_date, asset_type, asset_id ) @@ -555,7 +555,7 @@ def check_timezone(timezone): raise click.Abort -def check_errors(errors: list): +def check_errors(errors: Dict[str, List[str]]): if errors: print( f"Please correct the following errors:\n{errors}.\n Use the --help flag to learn more." From dd6c551343633c075e9d7ea5957ef571f06305c2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 27 Jul 2021 15:08:35 +0200 Subject: [PATCH 06/18] Add CLI commands to create generic assets and generic asset types, and add corresponding Marshmallow schemas to validate input parameters --- flexmeasures/data/schemas/generic_assets.py | 70 +++++++++++++++++++ .../data/scripts/cli_tasks/data_add.py | 53 ++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 flexmeasures/data/schemas/generic_assets.py diff --git a/flexmeasures/data/schemas/generic_assets.py b/flexmeasures/data/schemas/generic_assets.py new file mode 100644 index 000000000..50641ca3a --- /dev/null +++ b/flexmeasures/data/schemas/generic_assets.py @@ -0,0 +1,70 @@ +from typing import Optional + +from marshmallow import validates, ValidationError, fields + +from flexmeasures.data import ma +from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType + + +class GenericAssetSchema(ma.SQLAlchemySchema): + """ + GenericAsset schema, with validations. + """ + + id = ma.auto_field() + name = fields.Str() + latitude = ma.auto_field() + longitude = ma.auto_field() + generic_asset_type_id = fields.Integer() + + class Meta: + model = GenericAsset + + @validates("generic_asset_type_id") + def validate_market(self, generic_asset_type_id: int): + generic_asset_type = GenericAssetType.query.get(generic_asset_type_id) + if not generic_asset_type: + raise ValidationError( + f"GenericAssetType with id {generic_asset_type_id} doesn't exist." + ) + + @validates("latitude") + def validate_latitude(self, latitude: Optional[float]): + """Validate optional latitude.""" + if latitude is None: + return + if latitude < -90: + raise ValidationError( + f"Latitude {latitude} exceeds the minimum latitude of -90 degrees." + ) + if latitude > 90: + raise ValidationError( + f"Latitude {latitude} exceeds the maximum latitude of 90 degrees." + ) + + @validates("longitude") + def validate_longitude(self, longitude: Optional[float]): + """Validate optional longitude.""" + if longitude is None: + return + if longitude < -180: + raise ValidationError( + f"Longitude {longitude} exceeds the minimum longitude of -180 degrees." + ) + if longitude > 180: + raise ValidationError( + f"Longitude {longitude} exceeds the maximum longitude of 180 degrees." + ) + + +class GenericAssetTypeSchema(ma.SQLAlchemySchema): + """ + GenericAssetType schema, with validations. + """ + + id = ma.auto_field() + name = fields.Str() + hover_label = ma.auto_field() + + class Meta: + model = GenericAssetType diff --git a/flexmeasures/data/scripts/cli_tasks/data_add.py b/flexmeasures/data/scripts/cli_tasks/data_add.py index 6844afb26..54d5eec57 100644 --- a/flexmeasures/data/scripts/cli_tasks/data_add.py +++ b/flexmeasures/data/scripts/cli_tasks/data_add.py @@ -18,8 +18,13 @@ from flexmeasures.data.services.users import create_user from flexmeasures.data.models.time_series import Sensor, TimedBelief from flexmeasures.data.schemas.sensors import SensorSchema +from flexmeasures.data.schemas.generic_assets import ( + GenericAssetSchema, + GenericAssetTypeSchema, +) from flexmeasures.data.models.assets import Asset from flexmeasures.data.schemas.assets import AssetSchema +from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType from flexmeasures.data.models.markets import Market from flexmeasures.data.models.weather import WeatherSensor from flexmeasures.data.schemas.weather import WeatherSensorSchema @@ -112,6 +117,54 @@ def add_sensor(**args): print(f"You can access it at its entity address {sensor.entity_address}") +@fm_dev_add_data.command("generic-asset-type") +@with_appcontext +@click.option("--name", required=True) +@click.option( + "--hover-label", + type=str, + help="Label visible when hovering over the name in the UI.\n" + "Useful to explain acronyms, for example.", +) +def add_generic_asset_type(**args): + """Add a generic asset type.""" + check_errors(GenericAssetTypeSchema().validate(args)) + generic_asset_type = GenericAssetType(**args) + db.session.add(generic_asset_type) + db.session.commit() + print(f"Successfully created generic asset type with ID {generic_asset_type.id}") + print("You can now assign generic assets to it") + + +@fm_dev_add_data.command("generic-asset") +@with_appcontext +@click.option("--name", required=True) +@click.option( + "--latitude", + type=float, + help="Latitude of the asset's location", +) +@click.option( + "--longitude", + type=float, + help="Longitude of the asset's location", +) +@click.option( + "--generic-asset-type-id", + required=True, + type=int, + help="Generic asset type to assign this sensor to", +) +def add_generic_asset(**args): + """Add a generic asset.""" + check_errors(GenericAssetSchema().validate(args)) + generic_asset = GenericAsset(**args) + db.session.add(generic_asset) + db.session.commit() + print(f"Successfully created generic asset with ID {generic_asset.id}") + print("You can now assign sensors to it") + + @fm_add_data.command("asset") @with_appcontext @click.option("--name", required=True) From 330d4f75fc933d257cda803a952178b90270031d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Mon, 2 Aug 2021 12:43:16 +0200 Subject: [PATCH 07/18] allow to generate db schema pics (with --uml) including the new dev models --- .../data/scripts/visualize_data_model.py | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/flexmeasures/data/scripts/visualize_data_model.py b/flexmeasures/data/scripts/visualize_data_model.py index 3341674fa..b7e9eea1b 100755 --- a/flexmeasures/data/scripts/visualize_data_model.py +++ b/flexmeasures/data/scripts/visualize_data_model.py @@ -30,6 +30,17 @@ FALLBACK_VIEWER_CMD = "gwenview" # Use this program if none of the standard viewers # (e.g. display) can be found. Can be overwritten as env var. +RELEVANT_MODULES = [ + "task_runs", + "data_sources", + "markets", + "assets", + "generic_assets", + "weather", + "user", + "time_series", +] + RELEVANT_TABLES = [ "asset", "asset_type", @@ -45,10 +56,15 @@ "weather_sensor", "weather_sensor_type", ] +RELEVANT_TABLES_DEV = [ + "generic_asset_type", + "generic_asset", + "sensor", + "timed_belief", + "timed_value", +] IGNORED_TABLES = ["alembic_version", "roles_users"] -RELEVANT_MODULES = ["task_runs", "data_sources", "markets", "assets", "weather", "user"] - def check_sqlalchemy_schemadisplay_installation(): """Make sure the library which translates the model into a graph structure @@ -101,7 +117,7 @@ def create_schema_pic(pg_url, pg_user, pg_pwd, store: bool = False): print( f"Connecting to database {pg_url} as user {pg_user} and loading schema metadata ..." ) - db_metadata = MetaData(f"postgres://{pg_user}:{pg_pwd}@{pg_url}") + db_metadata = MetaData(f"postgresql://{pg_user}:{pg_pwd}@{pg_url}") kwargs = dict( metadata=db_metadata, show_datatypes=False, # The image would get nasty big if we'd show the datatypes @@ -120,7 +136,7 @@ def create_schema_pic(pg_url, pg_user, pg_pwd, store: bool = False): @uses_dot -def create_uml_pic(store: bool = False): +def create_uml_pic(store: bool = False, dev: bool = False): print("CREATING UML CODE DIAGRAM ...") print("Finding all the relevant mappers in our model...") mappers = [] @@ -138,11 +154,14 @@ def create_uml_pic(store: bool = False): if inspect.isclass(mclass) and issubclass(mclass, flexmeasures_db.Model) } ) + relevant_tables = RELEVANT_TABLES + if dev: + relevant_tables += RELEVANT_TABLES_DEV if DEBUG: - print(f"Relevant tables: {RELEVANT_TABLES}") + print(f"Relevant tables: {relevant_tables}") print(f"Relevant models: {relevant_models}") matched_models = { - m: c for (m, c) in relevant_models.items() if c.__tablename__ in RELEVANT_TABLES + m: c for (m, c) in relevant_models.items() if c.__tablename__ in relevant_tables } for model_name, model_class in matched_models.items(): if DEBUG: @@ -215,9 +234,8 @@ def show_image(graph, fb_viewer_command: str): parser.add_argument( "--store", action="store_true", - help="Store the images a files, instead of showing them directly (which requires pillow).", + help="Store the images as files, instead of showing them directly (which requires pillow).", ) - parser.add_argument( "--pg_url", help="Postgres URL (needed if --schema is on).", @@ -228,6 +246,11 @@ def show_image(graph, fb_viewer_command: str): help="Postgres user (needed if --schema is on).", default="flexmeasures", ) + parser.add_argument( + "--dev", + action="store_false", + help="If true (and --uml is used), include the parts of the new data model which are in development.", + ) args = parser.parse_args() @@ -245,4 +268,4 @@ def show_image(graph, fb_viewer_command: str): f"We need flexmeasures.data to be in the path, so we can read the data model. Error: '{ie}''." ) sys.exit(0) - create_uml_pic(store=args.store) + create_uml_pic(store=args.store, dev=args.dev) From 0f8b81221eff1de4e35ea977789859bfe9a8bb2c Mon Sep 17 00:00:00 2001 From: nhoening Date: Wed, 11 Aug 2021 12:34:19 +0000 Subject: [PATCH 08/18] Create draft PR for #158 From 535229e4b82e0d340330699779b2551b914b11a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Thu, 12 Aug 2021 14:31:52 +0200 Subject: [PATCH 09/18] add account table and according data migration script --- .../994170c26bc6_add_account_table.py | 223 ++++++++++++++++++ flexmeasures/data/models/generic_assets.py | 8 +- flexmeasures/data/models/user.py | 13 + 3 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 flexmeasures/data/migrations/versions/994170c26bc6_add_account_table.py diff --git a/flexmeasures/data/migrations/versions/994170c26bc6_add_account_table.py b/flexmeasures/data/migrations/versions/994170c26bc6_add_account_table.py new file mode 100644 index 000000000..4b2e35a66 --- /dev/null +++ b/flexmeasures/data/migrations/versions/994170c26bc6_add_account_table.py @@ -0,0 +1,223 @@ +"""add account table + +Revision ID: 994170c26bc6 +Revises: b6d49ed7cceb +Create Date: 2021-08-11 19:21:07.083253 + +""" +import os +import json + +from alembic import context, op +import sqlalchemy as sa +from sqlalchemy import orm +import inflection + +from flexmeasures.data.models.user import Account, User +from flexmeasures.data.models.generic_assets import GenericAsset +from flexmeasures.data.models.time_series import Sensor +from flexmeasures.data.models.assets import Asset + + +# revision identifiers, used by Alembic. +revision = "994170c26bc6" +down_revision = "b6d49ed7cceb" +branch_labels = None +depends_on = None +asset_ownership_backup_script = "generic_asset_fm_user_ownership.sql" + + +def upgrade(): + """ + Add account table. + 1. Users need an account. You can pass this info in (user ID to account name) like this: + flexmeasures db upgrade +1 -x '{"1": "One account", "2": "Bccount", "4": "Bccount"}' + Note that user IDs are strings here, as this is a JSON array. + The +1 makes sure we only upgrade by 1 revision, as these arguments are only meant to be used by this upgrade function. + Users not mentioned here get an account derived from their email address' main domain, capitalized (info@company.com becomes "Company") + 2. The ownership of a generic_asset now goes to account. + Here we fill in the user's new account (see point 1). + (we save a backup of the generic_asset.owner_id info which linked to fm_user) + """ + backup_generic_asset_user_associations() + + upgrade_schema() + upgrade_data() + + op.alter_column("fm_user", "account_id", nullable=False) + op.drop_column("generic_asset", "owner_id") + + +def downgrade(): + downgrade_schema() + downgrade_data() + + +def upgrade_schema(): + op.create_table( + "account", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("account_pkey")), + sa.UniqueConstraint("name", name=op.f("account_name_key")), + ) + op.add_column("fm_user", sa.Column("account_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + op.f("fm_user_account_id_account_fkey"), + "fm_user", + "account", + ["account_id"], + ["id"], + ) + op.add_column("generic_asset", sa.Column("account_id", sa.Integer(), nullable=True)) + op.drop_constraint( + "generic_asset_owner_id_fm_user_fkey", "generic_asset", type_="foreignkey" + ) + op.create_foreign_key( + op.f("generic_asset_account_id_account_fkey"), + "generic_asset", + "account", + ["account_id"], + ["id"], + ondelete="CASCADE", + ) + + +def upgrade_data(): + # add custom accounts + user_account_mappings = context.get_x_argument() + connection = op.get_bind() + session = orm.Session(bind=connection) + for i, user_account_map in enumerate(user_account_mappings): + print(user_account_map) + user_account_dict = json.loads(user_account_map) + for user_id, account_name in user_account_dict.items(): + print( + f"Linking user {user_id} to account {account_name} (as from custom param) ..." + ) + account = session.query(Account).filter_by(name=account_name).one_or_none() + if account is None: + print(f"need to create account {account_name} ...") + account = Account(name=account_name) + session.add(account) + session.flush() + user = session.query(User).filter_by(id=user_id).one_or_none() + if not user: + raise ValueError(f"User with ID {user_id} does not exist!") + user.account_id = account.id + + # Make sure each existing user has an account + for user in session.query(User).all(): + if user.account_id is None: + domain = user.email.split("@")[-1].rsplit(".", maxsplit=1)[0] + main_domain = domain.rsplit(".", maxsplit=1)[-1] + account_name = inflection.titleize(main_domain) + print(f"Linking user {user.id} to account {account_name} ...") + account = session.query(Account).filter_by(name=account_name).one_or_none() + if account is None: + print(f"need to create account {account_name} ...") + account = Account(name=account_name) + session.add(account) + session.flush() + user.account_id = account.id + + # For all generic assets, set the user's account + for generic_asset in session.query(GenericAsset).all(): + user = generic_asset.owner + if user is None: + sensor = ( + session.query(Sensor) + .filter_by(generic_asset_id=generic_asset.id) + .one_or_none() + ) + if sensor is None: + raise ValueError( + f"GenericAsset {generic_asset.id} ({generic_asset.name}) does not have an assorted sensor. Please investigate ..." + ) + asset = session.query(Asset).filter_by(id=sensor.id).one_or_none() + if asset is None: + print( + f"Generic asset {generic_asset.name} does not have an asset associated, probably because it's of type {generic_asset.generic_asset_type.name}." + ) + else: + user = asset.owner + if user is not None: + generic_asset.account_id = user.account.id + session.commit() + + +def downgrade_schema(): + op.add_column( + "generic_asset", + sa.Column("owner_id", sa.INTEGER(), autoincrement=False, nullable=True), + ) + op.drop_constraint( + op.f("generic_asset_account_id_account_fkey"), + "generic_asset", + type_="foreignkey", + ) + op.create_foreign_key( + "generic_asset_owner_id_fm_user_fkey", + "generic_asset", + "fm_user", + ["owner_id"], + ["id"], + ondelete="CASCADE", + ) + op.drop_column("generic_asset", "account_id") + op.drop_constraint( + op.f("fm_user_account_id_account_fkey"), "fm_user", type_="foreignkey" + ) + op.drop_column("fm_user", "account_id") + op.drop_table("account") + + +def downgrade_data(): + if os.path.exists(asset_ownership_backup_script): + print( + f"Re-applying previous asset ownership from {asset_ownership_backup_script} ..." + ) + connection = op.get_bind() + session = orm.Session(bind=connection) + with open(asset_ownership_backup_script, "r") as bckp_file: + for statement in bckp_file.readlines(): + connection.execute(statement) + session.commit() + else: + print(f"Could not find backup script {asset_ownership_backup_script} ...") + print("Previous asset ownership information is probably lost.") + + +def backup_generic_asset_user_associations(): + t_asset_owners = sa.Table( + "generic_asset", + sa.MetaData(), + sa.Column("id", sa.Integer), + sa.Column("owner_id", sa.Integer), + ) + + # Use SQLAlchemy's connection and transaction to go through the data + connection = op.get_bind() + + # Select all existing ids that need migrating, while keeping names intact + asset_ownership_results = connection.execute( + sa.select( + [ + t_asset_owners.c.id, + t_asset_owners.c.owner_id, + ] + ) + ).fetchall() + + with open(asset_ownership_backup_script, "w") as bckp_file: + for oid, aid in asset_ownership_results: + if oid is None: + oid = "null" + bckp_file.write( + f"UPDATE generic_asset SET owner_id = {oid} WHERE id = {aid};\n" + ) + + print("Your generic_asset.owner_id associations are being dropped!") + print( + f"We saved UPDATE statements to put them back in {asset_ownership_backup_script}." + ) diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index d14e5145d..092fedf05 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -35,14 +35,14 @@ class GenericAsset(db.Model): backref=db.backref("generic_assets", lazy=True), ) - owner_id = db.Column( - db.Integer, db.ForeignKey("fm_user.id", ondelete="CASCADE"), nullable=True + account_id = db.Column( + db.Integer, db.ForeignKey("account.id", ondelete="CASCADE"), nullable=True ) # if null, asset is public owner = db.relationship( - "User", + "Account", backref=db.backref( "generic_assets", - foreign_keys=[owner_id], + foreign_keys=[account_id], lazy=True, cascade="all, delete-orphan", passive_deletes=True, diff --git a/flexmeasures/data/models/user.py b/flexmeasures/data/models/user.py index fca257a48..a8993f765 100644 --- a/flexmeasures/data/models/user.py +++ b/flexmeasures/data/models/user.py @@ -25,6 +25,16 @@ def __repr__(self): return "" % (self.name, self.id) +class Account(db.Model): + """ + Account of a tenant on the server. + """ + + __tablename__ = "account" + id = Column(Integer, primary_key=True) + name = Column(String(100), default="", unique=True) + + class User(db.Model, UserMixin): """ We use the flask security UserMixin, which does include functionality, @@ -46,6 +56,9 @@ class User(db.Model, UserMixin): # Faster token checking fs_uniquifier = Column(String(64), unique=True, nullable=False) timezone = Column(String(255), default="Europe/Amsterdam") + account_id = Column(Integer, db.ForeignKey("account.id"), nullable=False) + + account = db.relationship(Account, backref=db.backref("users", lazy=True)) flexmeasures_roles = relationship( "Role", secondary="roles_users", From a4eec8f5ba8559964848eed66c7657cbd90b9f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Fri, 13 Aug 2021 17:06:44 +0200 Subject: [PATCH 10/18] fix the preservation of generic_asset.owner_id information between up & downgrading the database migration --- .../994170c26bc6_add_account_table.py | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/flexmeasures/data/migrations/versions/994170c26bc6_add_account_table.py b/flexmeasures/data/migrations/versions/994170c26bc6_add_account_table.py index 4b2e35a66..8626be838 100644 --- a/flexmeasures/data/migrations/versions/994170c26bc6_add_account_table.py +++ b/flexmeasures/data/migrations/versions/994170c26bc6_add_account_table.py @@ -5,6 +5,7 @@ Create Date: 2021-08-11 19:21:07.083253 """ +from typing import List, Tuple, Optional import os import json @@ -38,6 +39,7 @@ def upgrade(): 2. The ownership of a generic_asset now goes to account. Here we fill in the user's new account (see point 1). (we save a backup of the generic_asset.owner_id info which linked to fm_user) + The old-style asset's ownership remains in place for now! Our code will keep it consistent, until we have completed the move. """ backup_generic_asset_user_associations() @@ -122,8 +124,15 @@ def upgrade_data(): user.account_id = account.id # For all generic assets, set the user's account + # We query the db for old ownership directly, as the generic asset code already points to account + asset_ownership_db = _generic_asset_ownership() for generic_asset in session.query(GenericAsset).all(): - user = generic_asset.owner + # 1. first look into GenericAsset ownership + old_owner_id = _get_old_owner_id_from_db_result( + asset_ownership_db, generic_asset.id + ) + user = session.query(User).get(old_owner_id) + # 2. Otherwise, then try the old-style Asset's ownership (via Sensor) if user is None: sensor = ( session.query(Sensor) @@ -189,6 +198,22 @@ def downgrade_data(): def backup_generic_asset_user_associations(): + asset_ownership_results = _generic_asset_ownership() + with open(asset_ownership_backup_script, "w") as bckp_file: + for aid, oid in asset_ownership_results: + if oid is None: + oid = "null" + bckp_file.write( + f"UPDATE generic_asset SET owner_id = {oid} WHERE id = {aid};\n" + ) + + print("Your generic_asset.owner_id associations are being dropped!") + print( + f"We saved UPDATE statements to put them back in {asset_ownership_backup_script}." + ) + + +def _generic_asset_ownership() -> List[Tuple[int, int]]: t_asset_owners = sa.Table( "generic_asset", sa.MetaData(), @@ -208,16 +233,13 @@ def backup_generic_asset_user_associations(): ] ) ).fetchall() + return asset_ownership_results - with open(asset_ownership_backup_script, "w") as bckp_file: - for oid, aid in asset_ownership_results: - if oid is None: - oid = "null" - bckp_file.write( - f"UPDATE generic_asset SET owner_id = {oid} WHERE id = {aid};\n" - ) - print("Your generic_asset.owner_id associations are being dropped!") - print( - f"We saved UPDATE statements to put them back in {asset_ownership_backup_script}." - ) +def _get_old_owner_id_from_db_result( + generic_asset_ownership, asset_id +) -> Optional[int]: + for aid, oid in generic_asset_ownership: + if aid == asset_id: + return oid + return None From 3f154c3cf114f8c8143f325072cbf1fe3906d212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Fri, 13 Aug 2021 23:14:40 +0200 Subject: [PATCH 11/18] mention account name on user page and listings --- flexmeasures/data/models/user.py | 5 ++++- flexmeasures/data/schemas/users.py | 1 + flexmeasures/ui/crud/users.py | 3 ++- flexmeasures/ui/templates/admin/account.html | 8 ++++++++ flexmeasures/ui/templates/crud/user.html | 8 ++++++++ flexmeasures/ui/templates/crud/users.html | 4 ++++ 6 files changed, 27 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/user.py b/flexmeasures/data/models/user.py index a8993f765..0f2370285 100644 --- a/flexmeasures/data/models/user.py +++ b/flexmeasures/data/models/user.py @@ -34,6 +34,9 @@ class Account(db.Model): id = Column(Integer, primary_key=True) name = Column(String(100), default="", unique=True) + def __repr__(self): + return " Account overview for {{ logged_in_user.username }} {{logged_in_user.email }} + + + Account + + + {{ logged_in_user.account_id }} + + Time Zone diff --git a/flexmeasures/ui/templates/crud/user.html b/flexmeasures/ui/templates/crud/user.html index 6044878d7..8bca402fa 100644 --- a/flexmeasures/ui/templates/crud/user.html +++ b/flexmeasures/ui/templates/crud/user.html @@ -54,6 +54,14 @@

Account overview for {{ user.username }}

{{ user.timezone }} + + + Account + + + {{ user.account.name }} + + Last login was diff --git a/flexmeasures/ui/templates/crud/users.html b/flexmeasures/ui/templates/crud/users.html index bfed0d8de..a7dc0167b 100644 --- a/flexmeasures/ui/templates/crud/users.html +++ b/flexmeasures/ui/templates/crud/users.html @@ -30,6 +30,7 @@

All {% if not include_inactive %}active {% endif %}users

Username Email Roles + Account Timezone Last Login Active @@ -48,6 +49,9 @@

All {% if not include_inactive %}active {% endif %}users

{{ role.name }}{{ "," if not loop.last }} {% endfor %} + + {{ user.account.name }} + {{ user.timezone }} From 00a1d4e654dc128e31e2462f95cd525b20c1241f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Sat, 14 Aug 2021 10:18:26 +0200 Subject: [PATCH 12/18] change name of /account page to /logged-in-user to avoid confusion --- documentation/api/introduction.rst | 2 +- documentation/getting-started.rst | 4 ++-- documentation/views/admin.rst | 4 ++-- flexmeasures/ui/templates/admin/forgot_password.html | 2 +- .../templates/admin/{account.html => logged_in_user.html} | 6 +++--- flexmeasures/ui/templates/admin/login_user.html | 4 ++-- flexmeasures/ui/templates/admin/reset_password.html | 2 +- flexmeasures/ui/templates/crud/user.html | 6 +++--- flexmeasures/ui/templates/defaults.jinja | 2 +- flexmeasures/ui/tests/test_user_crud.py | 2 +- flexmeasures/ui/views/__init__.py | 4 +++- flexmeasures/ui/views/{account.py => logged_in_user.py} | 6 +++--- 12 files changed, 23 insertions(+), 21 deletions(-) rename flexmeasures/ui/templates/admin/{account.html => logged_in_user.html} (95%) rename flexmeasures/ui/views/{account.py => logged_in_user.py} (79%) diff --git a/documentation/api/introduction.rst b/documentation/api/introduction.rst index 59d547a93..63ec770f4 100644 --- a/documentation/api/introduction.rst +++ b/documentation/api/introduction.rst @@ -102,7 +102,7 @@ A fresh "" can be generated on the user's profile after logging in: .. code-block:: html - https://company.flexmeasures.io/account + https://company.flexmeasures.io/logged-in-user or through a POST request to the following endpoint: diff --git a/documentation/getting-started.rst b/documentation/getting-started.rst index 321f35989..cfb106eb1 100644 --- a/documentation/getting-started.rst +++ b/documentation/getting-started.rst @@ -99,7 +99,7 @@ This suffices for a quick start. Add a user ^^^^^^^^^^ -FlexMeasures is a web-based platform, so we need a user account: +FlexMeasures is a web-based platform, so we need to create a user: .. code-block:: @@ -215,4 +215,4 @@ More information (e.g. for installing on Windows) on `the Cbc website `_ server (or rent one) and configure access to it within FlexMeasures' config file (see above). You can find the necessary settings in :ref:`redis-config`. \ No newline at end of file +To let FlexMeasures queue forecasting and scheduling jobs, install a `Redis `_ server (or rent one) and configure access to it within FlexMeasures' config file (see above). You can find the necessary settings in :ref:`redis-config`. diff --git a/documentation/views/admin.rst b/documentation/views/admin.rst index e3f0187ba..2c5f8b183 100644 --- a/documentation/views/admin.rst +++ b/documentation/views/admin.rst @@ -4,7 +4,7 @@ Administration ************** -The administrator can edit assets and user accounts here. +The administrator can edit assets and users here. Assets ------ @@ -36,4 +36,4 @@ Viewing one user: .. image:: https://github.com/SeitaBV/screenshots/raw/main/screenshot_user.png :align: center -.. :scale: 40% \ No newline at end of file +.. :scale: 40% diff --git a/flexmeasures/ui/templates/admin/forgot_password.html b/flexmeasures/ui/templates/admin/forgot_password.html index 4d470c07e..d56891a04 100644 --- a/flexmeasures/ui/templates/admin/forgot_password.html +++ b/flexmeasures/ui/templates/admin/forgot_password.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% set active_page = "account" %} +{% set active_page = "logged-in-user" %} {% block title %} Forgot password {% endblock %} diff --git a/flexmeasures/ui/templates/admin/account.html b/flexmeasures/ui/templates/admin/logged_in_user.html similarity index 95% rename from flexmeasures/ui/templates/admin/account.html rename to flexmeasures/ui/templates/admin/logged_in_user.html index c7d838e9a..c656041d7 100644 --- a/flexmeasures/ui/templates/admin/account.html +++ b/flexmeasures/ui/templates/admin/logged_in_user.html @@ -1,13 +1,13 @@ {% extends "base.html" %} -{% set active_page = "account" %} +{% set active_page = "logged-in-user" %} -{% block title %} Your account {% endblock %} +{% block title %} Your user overview {% endblock %} {% block divs %} -

Account overview for {{ logged_in_user.username }}

+

Overview for logged-in user: {{ logged_in_user.username }}

diff --git a/flexmeasures/ui/templates/admin/login_user.html b/flexmeasures/ui/templates/admin/login_user.html index 557fc9028..b2ab3e2a0 100644 --- a/flexmeasures/ui/templates/admin/login_user.html +++ b/flexmeasures/ui/templates/admin/login_user.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% set active_page = "account" %} +{% set active_page = "logged-in-user" %} {% block title %} Please log in {% endblock %} @@ -30,7 +30,7 @@

{{ _('Login') }}

Interested in a demo?

- Simply log in to our demo account. + Simply log in as our demo user.

  • Email: {{ FLEXMEASURES_PUBLIC_DEMO_CREDENTIALS[0] }}
  • Password: {{ FLEXMEASURES_PUBLIC_DEMO_CREDENTIALS[1] }}
  • diff --git a/flexmeasures/ui/templates/admin/reset_password.html b/flexmeasures/ui/templates/admin/reset_password.html index 62989ac83..6c34fb87a 100644 --- a/flexmeasures/ui/templates/admin/reset_password.html +++ b/flexmeasures/ui/templates/admin/reset_password.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% set active_page = "account" %} +{% set active_page = "logged-in-user" %} {% block title %} Reset password {% endblock %} diff --git a/flexmeasures/ui/templates/crud/user.html b/flexmeasures/ui/templates/crud/user.html index 8bca402fa..a64ccb6ff 100644 --- a/flexmeasures/ui/templates/crud/user.html +++ b/flexmeasures/ui/templates/crud/user.html @@ -1,8 +1,8 @@ {% extends "base.html" %} -{% set active_page = "account" %} +{% set active_page = "logged-in-user" %} -{% block title %} Account overview {% endblock %} +{% block title %} User {{ user.username }} {% endblock %} {% block divs %} @@ -12,7 +12,7 @@ {% if user %} -

    Account overview for {{ user.username }}

    +

    Overview for user {{ user.username }}

    diff --git a/flexmeasures/ui/templates/defaults.jinja b/flexmeasures/ui/templates/defaults.jinja index fd1551c24..da65c845a 100644 --- a/flexmeasures/ui/templates/defaults.jinja +++ b/flexmeasures/ui/templates/defaults.jinja @@ -32,7 +32,7 @@ {% set show_queues = True if current_user.is_authenticated and (current_user.has_role('admin') or FLEXMEASURES_MODE == "demo") else False %} {% do navigation_bar.append(('tasks', 'tasks', 'Tasks', '', 'tasks')) if show_queues %} -{% do navigation_bar.append(('account', 'account', '', user_name, 'user')) if current_user.is_authenticated %} +{% do navigation_bar.append(('logged-in-user', 'logged-in-user', '', user_name, 'user')) if current_user.is_authenticated %} {% do navigation_bar.append(('ui/static/documentation/html/index.html', 'docs', '', 'Documentation (new window)', 'question')) if documentation_exists and current_user.is_authenticated %} diff --git a/flexmeasures/ui/tests/test_user_crud.py b/flexmeasures/ui/tests/test_user_crud.py index 513b9d62a..57008cfb0 100644 --- a/flexmeasures/ui/tests/test_user_crud.py +++ b/flexmeasures/ui/tests/test_user_crud.py @@ -49,7 +49,7 @@ def test_user_page(client, as_admin, requests_mock): user_page = client.get(url_for("UserCrudUI:get", id=2), follow_redirects=True) assert user_page.status_code == 200 assert ( - "Account overview for %s" % mock_user["username"] + "Overview for logged-in user: %s" % mock_user["username"] ).encode() in user_page.data assert (">3").encode() in user_page.data # this is the asset count assert mock_user["email"].encode() in user_page.data diff --git a/flexmeasures/ui/views/__init__.py b/flexmeasures/ui/views/__init__.py index 44b39ca86..d427aca78 100644 --- a/flexmeasures/ui/views/__init__.py +++ b/flexmeasures/ui/views/__init__.py @@ -9,7 +9,9 @@ from flexmeasures.ui.views.analytics import analytics_view # noqa: F401 from flexmeasures.ui.views.state import state_view # noqa: F401 -from flexmeasures.ui.views.account import account_view # noqa: F401 # noqa: F401 +from flexmeasures.ui.views.logged_in_user import ( # noqa: F401 # noqa: F401 + logged_in_user_view, +) from flexmeasures.ui.views.charts import get_power_chart # noqa: F401 diff --git a/flexmeasures/ui/views/account.py b/flexmeasures/ui/views/logged_in_user.py similarity index 79% rename from flexmeasures/ui/views/account.py rename to flexmeasures/ui/views/logged_in_user.py index a132d0ebf..012cbf766 100644 --- a/flexmeasures/ui/views/account.py +++ b/flexmeasures/ui/views/logged_in_user.py @@ -6,11 +6,11 @@ from flexmeasures.ui.utils.view_utils import render_flexmeasures_template -@flexmeasures_ui.route("/account", methods=["GET"]) +@flexmeasures_ui.route("/logged-in-user", methods=["GET"]) @login_required -def account_view(): +def logged_in_user_view(): return render_flexmeasures_template( - "admin/account.html", + "admin/logged_in_user.html", logged_in_user=current_user, roles=",".join([role.name for role in current_user.roles]), num_assets=len(get_assets()), From 8a30b14cd9ae7b6020be90ce6188d4a2da8b69b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Sat, 14 Aug 2021 15:32:16 +0200 Subject: [PATCH 13/18] adapt data.services.users:create_user to deal with the account_id; make sure all test users have an account --- flexmeasures/api/tests/conftest.py | 3 +- flexmeasures/api/v1/tests/conftest.py | 4 ++- flexmeasures/api/v1_1/tests/conftest.py | 4 ++- flexmeasures/api/v2_0/tests/conftest.py | 3 +- flexmeasures/conftest.py | 30 +++++++++++++++---- flexmeasures/data/models/user.py | 1 + .../data/scripts/cli_tasks/data_add.py | 2 +- flexmeasures/data/services/users.py | 26 ++++++++++++---- flexmeasures/data/tests/test_user_services.py | 19 ++++++++++-- flexmeasures/ui/tests/conftest.py | 9 +++++- flexmeasures/ui/tests/test_user_crud.py | 4 +-- 11 files changed, 82 insertions(+), 23 deletions(-) diff --git a/flexmeasures/api/tests/conftest.py b/flexmeasures/api/tests/conftest.py index 32c6b57d7..de4cc6003 100644 --- a/flexmeasures/api/tests/conftest.py +++ b/flexmeasures/api/tests/conftest.py @@ -8,7 +8,7 @@ @pytest.fixture(scope="module", autouse=True) -def setup_api_test_data(db, setup_roles_users): +def setup_api_test_data(db, setup_account, setup_roles_users): """ Adding the task-runner """ @@ -31,6 +31,7 @@ def setup_api_test_data(db, setup_roles_users): username="test user", email="task_runner@seita.nl", password=hash_password("testtest"), + account_id=setup_account.id, ) user_datastore.add_role_to_user(test_task_runner, test_task_runner_role) diff --git a/flexmeasures/api/v1/tests/conftest.py b/flexmeasures/api/v1/tests/conftest.py index 234fe1b5d..6183ecc5a 100644 --- a/flexmeasures/api/v1/tests/conftest.py +++ b/flexmeasures/api/v1/tests/conftest.py @@ -10,7 +10,7 @@ @pytest.fixture(scope="module", autouse=True) -def setup_api_test_data(db, setup_roles_users, add_market_prices): +def setup_api_test_data(db, setup_account, setup_roles_users, add_market_prices): """ Set up data for API v1 tests. """ @@ -25,6 +25,7 @@ def setup_api_test_data(db, setup_roles_users, add_market_prices): username="anonymous user with Prosumer role", email="demo@seita.nl", password=hash_password("testtest"), + account_name=setup_account.name, user_roles=[ "Prosumer", dict(name="anonymous", description="Anonymous test user"), @@ -56,6 +57,7 @@ def setup_api_test_data(db, setup_roles_users, add_market_prices): username="test user without roles", email="test_user@seita.nl", password=hash_password("testtest"), + account_name=setup_account.name, ) # Create 5 test assets for the test_prosumer user diff --git a/flexmeasures/api/v1_1/tests/conftest.py b/flexmeasures/api/v1_1/tests/conftest.py index 55860cd5c..5855cc1e1 100644 --- a/flexmeasures/api/v1_1/tests/conftest.py +++ b/flexmeasures/api/v1_1/tests/conftest.py @@ -12,7 +12,7 @@ @pytest.fixture(scope="module") -def setup_api_test_data(db, setup_roles_users, add_market_prices): +def setup_api_test_data(db, setup_account, setup_roles_users, add_market_prices): """ Set up data for API v1.1 tests. """ @@ -29,6 +29,7 @@ def setup_api_test_data(db, setup_roles_users, add_market_prices): username="test user with improper registration", email="test_improper_user@seita.nl", password=hash_password("testtest"), + account_id=setup_account.id, ) role = user_datastore.find_role("Prosumer") user_datastore.add_role_to_user(user, role) @@ -38,6 +39,7 @@ def setup_api_test_data(db, setup_roles_users, add_market_prices): username="test user without roles", email="test_user@seita.nl", password=hash_password("testtest"), + account_name=setup_account.name, ) # Create 3 test assets for the test_prosumer user diff --git a/flexmeasures/api/v2_0/tests/conftest.py b/flexmeasures/api/v2_0/tests/conftest.py index 783e330f6..8ba3d8045 100644 --- a/flexmeasures/api/v2_0/tests/conftest.py +++ b/flexmeasures/api/v2_0/tests/conftest.py @@ -23,7 +23,7 @@ def setup_api_test_data(db, setup_roles_users, add_market_prices, add_battery_as @pytest.fixture(scope="module") -def setup_inactive_user(db, setup_roles_users): +def setup_inactive_user(db, setup_account, setup_roles_users): """ Set up one inactive user. """ @@ -34,5 +34,6 @@ def setup_inactive_user(db, setup_roles_users): username="inactive test user", email="inactive@seita.nl", password=hash_password("testtest"), + account_id=setup_account.id, active=False, ) diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 7719107c0..21d901c50 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -28,7 +28,7 @@ from flexmeasures.data.models.weather import WeatherSensor, WeatherSensorType from flexmeasures.data.models.markets import Market, MarketType, Price from flexmeasures.data.models.time_series import Sensor, TimedBelief -from flexmeasures.data.models.user import User +from flexmeasures.data.models.user import User, Account """ @@ -112,26 +112,44 @@ def create_test_db(app): @pytest.fixture(scope="module") -def setup_roles_users() -> Dict[str, User]: - return create_roles_users() +def setup_account(db) -> Dict[str, Market]: + return create_test_account(db) @pytest.fixture(scope="function") -def setup_roles_users_fresh_db() -> Dict[str, User]: - return create_roles_users() +def setup_account_fresh_db(fresh_db) -> Dict[str, Market]: + return create_test_account(fresh_db) -def create_roles_users() -> Dict[str, User]: +def create_test_account(db) -> Dict[str, Market]: + test_account = Account(name="Test Account") + db.session.add(test_account) + return test_account + + +@pytest.fixture(scope="module") +def setup_roles_users(db, setup_account) -> Dict[str, User]: + return create_roles_users(db, setup_account) + + +@pytest.fixture(scope="function") +def setup_roles_users_fresh_db(fresh_db, setup_account_fresh_db) -> Dict[str, User]: + return create_roles_users(fresh_db, setup_account_fresh_db) + + +def create_roles_users(db, test_account) -> Dict[str, User]: """Create a minimal set of roles and users""" test_prosumer = create_user( username="Test Prosumer", email="test_prosumer@seita.nl", + account_name=test_account.name, password=hash_password("testtest"), user_roles=dict(name="Prosumer", description="A Prosumer with a few assets."), ) test_supplier = create_user( username="Test Supplier", email="test_supplier@seita.nl", + account_name=test_account.name, password=hash_password("testtest"), user_roles=dict(name="Supplier", description="A Supplier trading on markets."), ) diff --git a/flexmeasures/data/models/user.py b/flexmeasures/data/models/user.py index 0f2370285..a3a8ad1e7 100644 --- a/flexmeasures/data/models/user.py +++ b/flexmeasures/data/models/user.py @@ -28,6 +28,7 @@ def __repr__(self): class Account(db.Model): """ Account of a tenant on the server. + Bundles Users as well as GenericAssets. """ __tablename__ = "account" diff --git a/flexmeasures/data/scripts/cli_tasks/data_add.py b/flexmeasures/data/scripts/cli_tasks/data_add.py index 54d5eec57..e80a2b454 100644 --- a/flexmeasures/data/scripts/cli_tasks/data_add.py +++ b/flexmeasures/data/scripts/cli_tasks/data_add.py @@ -78,7 +78,7 @@ def new_user(username: str, email: str, roles: List[str], timezone: str): password=hash_password(pwd1), timezone=timezone, user_roles=roles, - check_deliverability=False, + check_email_deliverability=False, ) db.session.commit() print(f"Successfully created user {created_user}") diff --git a/flexmeasures/data/services/users.py b/flexmeasures/data/services/users.py index 5a379567f..219441f93 100644 --- a/flexmeasures/data/services/users.py +++ b/flexmeasures/data/services/users.py @@ -15,7 +15,7 @@ from flexmeasures.data.config import db from flexmeasures.data.models.data_sources import DataSource -from flexmeasures.data.models.user import User, Role +from flexmeasures.data.models.user import User, Role, Account class InvalidFlexMeasuresUser(Exception): @@ -58,12 +58,15 @@ def find_user_by_email(user_email: str, keep_in_session: bool = True) -> User: def create_user( # noqa: C901 user_roles: Union[Dict[str, str], List[Dict[str, str]], str, List[str]] = None, - check_deliverability: bool = True, - **kwargs + check_email_deliverability: bool = True, + account_name: Optional[str] = None, + **kwargs, ) -> User: """ - Convenience wrapper to create a new User object and new Role objects (if user roles do not already exist), - and new DataSource object that corresponds to the user. + Convenience wrapper to create a new User object, together with + - new Role objects (if user roles do not already exist) + - an Account object (if it does not exist yet) + - a new DataSource object that corresponds to the user Remember to commit the session after calling this function! """ @@ -76,7 +79,7 @@ def create_user( # noqa: C901 email_info = validate_email(email, check_deliverability=False) # The mx check talks to the SMTP server. During testing, we skip it because it # takes a bit of time and without internet connection it fails. - if check_deliverability and not current_app.testing: + if check_email_deliverability and not current_app.testing: try: validate_email_deliverability( email_info.domain, email_info["domain_i18n"] @@ -105,6 +108,15 @@ def create_user( # noqa: C901 "User with username %s already exists." % username ) + # check if we can link/create an account + if account_name is None: + raise InvalidFlexMeasuresUser("Cannot create user without knowing account_name") + account = db.session.query(Account).filter_by(name=account_name).one_or_none() + if account is None: + print(f"Creating account {account_name} ...") + account = Account(name=account_name) + db.session.add(account) + user_datastore = SQLAlchemySessionUserDatastore(db.session, User, Role) kwargs.update(email=email, username=username) user = user_datastore.create_user(**kwargs) @@ -112,6 +124,8 @@ def create_user( # noqa: C901 if user.password is None: set_random_password(user) + user.account_id = account.id + # add roles to user (creating new roles if necessary) if user_roles: if not isinstance(user_roles, list): diff --git a/flexmeasures/data/tests/test_user_services.py b/flexmeasures/data/tests/test_user_services.py index 6008b7894..573cec78f 100644 --- a/flexmeasures/data/tests/test_user_services.py +++ b/flexmeasures/data/tests/test_user_services.py @@ -13,12 +13,13 @@ from flexmeasures.data.models.data_sources import DataSource -def test_create_user(fresh_db, setup_roles_users_fresh_db, app): +def test_create_user(fresh_db, setup_account_fresh_db, setup_roles_users_fresh_db, app): """Create a user""" num_users = User.query.count() user = create_user( email="new_prosumer@seita.nl", password=hash_password("testtest"), + account_name=setup_account_fresh_db.name, user_roles=["Prosumer"], ) assert User.query.count() == num_users + 1 @@ -29,7 +30,9 @@ def test_create_user(fresh_db, setup_roles_users_fresh_db, app): assert DataSource.query.filter_by(name=user.username).one_or_none() -def test_create_invalid_user(fresh_db, setup_roles_users_fresh_db, app): +def test_create_invalid_user( + fresh_db, setup_account_fresh_db, setup_roles_users_fresh_db, app +): """A few invalid attempts to create a user""" with pytest.raises(InvalidFlexMeasuresUser) as exc_info: create_user(password=hash_password("testtest"), user_roles=["Prosumer"]) @@ -38,6 +41,7 @@ def test_create_invalid_user(fresh_db, setup_roles_users_fresh_db, app): create_user( email="test_prosumer_AT_seita.nl", password=hash_password("testtest"), + account_name=setup_account_fresh_db.name, user_roles=["Prosumer"], ) assert "not a valid" in str(exc_info.value) @@ -46,6 +50,7 @@ def test_create_invalid_user(fresh_db, setup_roles_users_fresh_db, app): create_user( email="test_prosumer@sdkkhflzsxlgjxhglkzxjhfglkxhzlzxcvlzxvb.nl", password=hash_password("testtest"), + account_name=setup_account_fresh_db.name, user_roles=["Prosumer"], ) assert "not seem to be deliverable" in str(exc_info.value) @@ -54,6 +59,7 @@ def test_create_invalid_user(fresh_db, setup_roles_users_fresh_db, app): create_user( email="test_prosumer@seita.nl", password=hash_password("testtest"), + account_name=setup_account_fresh_db.name, user_roles=["Prosumer"], ) assert "already exists" in str(exc_info.value) @@ -62,9 +68,18 @@ def test_create_invalid_user(fresh_db, setup_roles_users_fresh_db, app): email="new_prosumer@seita.nl", username="Test Prosumer", password=hash_password("testtest"), + account_name=setup_account_fresh_db.name, user_roles=["Prosumer"], ) assert "already exists" in str(exc_info.value) + with pytest.raises(InvalidFlexMeasuresUser) as exc_info: + create_user( + email="new_prosumer@seita.nl", + username="New Test Prosumer", + password=hash_password("testtest"), + user_roles=["Prosumer"], + ) + assert "without knowing account_name" in str(exc_info.value) def test_delete_user(fresh_db, setup_roles_users_fresh_db, app): diff --git a/flexmeasures/ui/tests/conftest.py b/flexmeasures/ui/tests/conftest.py index c4b761acc..d04ec234b 100644 --- a/flexmeasures/ui/tests/conftest.py +++ b/flexmeasures/ui/tests/conftest.py @@ -31,7 +31,12 @@ def as_admin(client): @pytest.fixture(scope="module", autouse=True) def setup_ui_test_data( - db, setup_roles_users, setup_markets, setup_sources, setup_asset_types + db, + setup_account, + setup_roles_users, + setup_markets, + setup_sources, + setup_asset_types, ): """ Create another prosumer, without data, and an admin @@ -43,6 +48,7 @@ def setup_ui_test_data( username="Site Admin", email="flexmeasures-admin@seita.nl", password=hash_password("testtest"), + account_name=setup_account.name, user_roles=dict(name="admin", description="A site admin."), ) @@ -50,6 +56,7 @@ def setup_ui_test_data( username="Second Test Prosumer", email="test_prosumer2@seita.nl", password=hash_password("testtest"), + account_name=setup_account.name, user_roles=dict( name="Prosumer", description="A Prosumer with one asset but no data." ), diff --git a/flexmeasures/ui/tests/test_user_crud.py b/flexmeasures/ui/tests/test_user_crud.py index 57008cfb0..fa7e8a8be 100644 --- a/flexmeasures/ui/tests/test_user_crud.py +++ b/flexmeasures/ui/tests/test_user_crud.py @@ -48,9 +48,7 @@ def test_user_page(client, as_admin, requests_mock): ) user_page = client.get(url_for("UserCrudUI:get", id=2), follow_redirects=True) assert user_page.status_code == 200 - assert ( - "Overview for logged-in user: %s" % mock_user["username"] - ).encode() in user_page.data + assert ("Overview for user %s" % mock_user["username"]).encode() in user_page.data assert (">3").encode() in user_page.data # this is the asset count assert mock_user["email"].encode() in user_page.data From a3e58dbbfc2dd5702bdabb99356ad04ef8f6b4c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Sat, 14 Aug 2021 15:36:13 +0200 Subject: [PATCH 14/18] display account name on logged-in-user page, not account ID --- flexmeasures/ui/templates/admin/logged_in_user.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/ui/templates/admin/logged_in_user.html b/flexmeasures/ui/templates/admin/logged_in_user.html index c656041d7..e4fce8949 100644 --- a/flexmeasures/ui/templates/admin/logged_in_user.html +++ b/flexmeasures/ui/templates/admin/logged_in_user.html @@ -46,7 +46,7 @@

    Overview for logged-in user: {{ logged_in_user.username }}

    Account - {{ logged_in_user.account_id }} + {{ logged_in_user.account.name }} From d80338e75f4c8865a140dca1627bde1e5f69d49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Sun, 15 Aug 2021 16:03:31 +0200 Subject: [PATCH 15/18] give automatically generated generoc assets the correct account ID (derived from asset.owner_id) --- flexmeasures/data/models/assets.py | 9 ++++++++- flexmeasures/data/models/generic_assets.py | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/assets.py b/flexmeasures/data/models/assets.py index d8059813a..8697584e9 100644 --- a/flexmeasures/data/models/assets.py +++ b/flexmeasures/data/models/assets.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Query from flexmeasures.data.config import db +from flexmeasures.data.models.user import User from flexmeasures.data.models.time_series import Sensor, TimedValue from flexmeasures.data.models.generic_assets import create_generic_asset from flexmeasures.utils.entity_address_utils import build_entity_address @@ -102,8 +103,14 @@ class Asset(db.Model, tb.SensorDBMixin): def __init__(self, **kwargs): # Create a new Sensor with unique id across assets, markets and weather sensors + # Also keep track of ownership. if "id" not in kwargs: - new_generic_asset = create_generic_asset("asset", **kwargs) + generic_assets_arg = kwargs.copy() + if "owner_id" in kwargs: + owner = User.query.get(kwargs["owner_id"]) + if owner: + generic_assets_arg.update(account_id=owner.account_id) + new_generic_asset = create_generic_asset("asset", **generic_assets_arg) new_sensor = Sensor(name=kwargs["name"], generic_asset=new_generic_asset) db.session.add(new_sensor) db.session.flush() # generates the pkey for new_sensor diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 092fedf05..2d5944926 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -38,6 +38,7 @@ class GenericAsset(db.Model): account_id = db.Column( db.Integer, db.ForeignKey("account.id", ondelete="CASCADE"), nullable=True ) # if null, asset is public + owner = db.relationship( "Account", backref=db.backref( @@ -81,6 +82,9 @@ def create_generic_asset(generic_asset_type: str, **kwargs) -> GenericAsset: new_generic_asset = GenericAsset( name=kwargs["name"], generic_asset_type_id=generic_asset_type.id ) + for arg in ("latitude", "longitude", "account_id"): + if arg in kwargs: + setattr(new_generic_asset, arg, kwargs[arg]) db.session.add(new_generic_asset) db.session.flush() # generates the pkey for new_generic_asset return new_generic_asset From 98e0d30ea1df7e50e8af13fb7dfcabc0bd242a9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Sun, 15 Aug 2021 20:52:10 +0200 Subject: [PATCH 16/18] Support adding & deleting accounts in CLI, also add --account-id to the add user command. Make sure deleting GenericAssets actually csscades to deleting the connected sensors. --- documentation/cli/change_log.rst | 7 +++ documentation/cli/commands.rst | 2 + documentation/getting-started.rst | 18 ++++++-- ..._sensor_generic_asset_id_should_not_be_.py | 46 +++++++++++++++++++ flexmeasures/data/models/time_series.py | 8 +++- .../data/scripts/cli_tasks/data_add.py | 31 +++++++++++-- .../data/scripts/cli_tasks/data_delete.py | 45 +++++++++++++++++- 7 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 flexmeasures/data/migrations/versions/e4c9cf837311_sensor_generic_asset_id_should_not_be_.py diff --git a/documentation/cli/change_log.rst b/documentation/cli/change_log.rst index 28131d801..26a05a562 100644 --- a/documentation/cli/change_log.rst +++ b/documentation/cli/change_log.rst @@ -4,6 +4,13 @@ FlexMeasures CLI Changelog ********************** + +since v0.6.0 | April 2, 2021 +===================== + +* add ``flexmeasures add account``, ``flexmeasures delete account`` and the ``--account-id`` param to ``flexmeasures add user``. + + since v0.4.0 | April 2, 2021 ===================== diff --git a/documentation/cli/commands.rst b/documentation/cli/commands.rst index d56afc785..7c152415e 100644 --- a/documentation/cli/commands.rst +++ b/documentation/cli/commands.rst @@ -25,6 +25,7 @@ of which some are referred to in this documentation. ================================================= ======================================= ``flexmeasures add structure`` Initialize structural data like asset types, market types and weather sensor types. +``flexmeasures add account`` Create a FlexMeasures tenant account. ``flexmeasures add user`` Create a FlexMeasures user. ``flexmeasures add asset`` Create a new asset. ``flexmeasures add weather-sensor`` Add a weather sensor. @@ -39,6 +40,7 @@ of which some are referred to in this documentation. ================================================= ======================================= ``flexmeasures delete structure`` Delete all structural (non time-series) data like assets (types), markets (types) and weather sensors (types) and users. +``flexmeasures delete account`` Delete a tenant account & also their users (with assets and power measurements). ``flexmeasures delete user`` Delete a user & also their assets and power measurements. ``flexmeasures delete measurements`` Delete measurements (with horizon <= 0). ``flexmeasures delete prognoses`` Delete forecasts and schedules (forecasts > 0). diff --git a/documentation/getting-started.rst b/documentation/getting-started.rst index cfb106eb1..01c56d214 100644 --- a/documentation/getting-started.rst +++ b/documentation/getting-started.rst @@ -96,18 +96,26 @@ This suffices for a quick start. -Add a user -^^^^^^^^^^ +Add an account & user +^^^^^^^^^^^^^^^^^^^^^ + +FlexMeasures is a tenant-based platform ― multiple clients can enjoy its services on one server. Let's create a tenant account first: + +.. code-block:: + + flexmeasures add account --name "Some company" + +This command will tell us the ID of this account. Let's assume it was ``2``. -FlexMeasures is a web-based platform, so we need to create a user: +FlexMeasures is also a web-based platform, so we need to create a user to authenticate: .. code-block:: - flexmeasures add user --username --email --roles=admin + flexmeasures add user --username --email --account-id 2 --roles=admin * This will ask you to set a password for the user. -* Giving the first user the ``admin`` role is probably what you want. +* Giving the first user the ``admin`` role is probably what you want. TODO: is `admin` the new super-user? Add structure diff --git a/flexmeasures/data/migrations/versions/e4c9cf837311_sensor_generic_asset_id_should_not_be_.py b/flexmeasures/data/migrations/versions/e4c9cf837311_sensor_generic_asset_id_should_not_be_.py new file mode 100644 index 000000000..08bb98702 --- /dev/null +++ b/flexmeasures/data/migrations/versions/e4c9cf837311_sensor_generic_asset_id_should_not_be_.py @@ -0,0 +1,46 @@ +"""sensor.generic_asset_id should not be orphaned on delete + +Revision ID: e4c9cf837311 +Revises: 994170c26bc6 +Create Date: 2021-08-15 20:42:29.729532 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "e4c9cf837311" +down_revision = "994170c26bc6" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "sensor_generic_asset_id_generic_asset_fkey", "sensor", type_="foreignkey" + ) + op.create_foreign_key( + op.f("sensor_generic_asset_id_generic_asset_fkey"), + "sensor", + "generic_asset", + ["generic_asset_id"], + ["id"], + ondelete="CASCADE", + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("sensor_generic_asset_id_generic_asset_fkey"), "sensor", type_="foreignkey" + ) + op.create_foreign_key( + "sensor_generic_asset_id_generic_asset_fkey", + "sensor", + "generic_asset", + ["generic_asset_id"], + ["id"], + ) + # ### end Alembic commands ### diff --git a/flexmeasures/data/models/time_series.py b/flexmeasures/data/models/time_series.py index 523d1f681..8983e9d4d 100644 --- a/flexmeasures/data/models/time_series.py +++ b/flexmeasures/data/models/time_series.py @@ -26,12 +26,16 @@ class Sensor(db.Model, tb.SensorDBMixin): """A sensor measures events. """ generic_asset_id = db.Column( - db.Integer, db.ForeignKey("generic_asset.id"), nullable=False + db.Integer, + db.ForeignKey("generic_asset.id", ondelete="CASCADE"), + nullable=False, ) generic_asset = db.relationship( "GenericAsset", foreign_keys=[generic_asset_id], - backref=db.backref("sensors", lazy=True), + backref=db.backref( + "sensors", lazy=True, cascade="all, delete-orphan", passive_deletes=True + ), ) def __init__(self, **kwargs): diff --git a/flexmeasures/data/scripts/cli_tasks/data_add.py b/flexmeasures/data/scripts/cli_tasks/data_add.py index e80a2b454..3e413bc08 100644 --- a/flexmeasures/data/scripts/cli_tasks/data_add.py +++ b/flexmeasures/data/scripts/cli_tasks/data_add.py @@ -15,7 +15,7 @@ from flexmeasures.data import db from flexmeasures.data.services.forecasting import create_forecasting_jobs -from flexmeasures.data.services.users import create_user +from flexmeasures.data.services.users import create_user, Account from flexmeasures.data.models.time_series import Sensor, TimedBelief from flexmeasures.data.schemas.sensors import SensorSchema from flexmeasures.data.schemas.generic_assets import ( @@ -45,17 +45,37 @@ def fm_dev_add_data(): """Developer CLI commands not yet meant for users: Add data.""" +@fm_add_data.command("account") +@with_appcontext +@click.option("--name", required=True) +def new_account(name: str): + """ + Create an account for a tenant in the FlexMeasures platform. + """ + account = db.session.query(Account).filter_by(name=name).one_or_none() + if account is not None: + print(f"Account '{name}' already exist.") + raise click.Abort + account = Account(name=name) + db.session.add(account) + db.session.commit() + print(f"Account '{name}' (ID: {account.id}) successfully created.") + + @fm_add_data.command("user") @with_appcontext @click.option("--username", required=True) @click.option("--email", required=True) +@click.option("--account-id", type=int, required=True) @click.option("--roles", help="e.g. anonymous,Prosumer,CPO") @click.option( "--timezone", default="UTC", help="timezone as string, e.g. 'UTC' or 'Europe/Amsterdam'", ) -def new_user(username: str, email: str, roles: List[str], timezone: str): +def new_user( + username: str, email: str, account_id: int, roles: List[str], timezone: str +): """ Create a FlexMeasures user. @@ -65,7 +85,11 @@ def new_user(username: str, email: str, roles: List[str], timezone: str): try: pytz.timezone(timezone) except pytz.UnknownTimeZoneError: - print("Timezone %s is unknown!" % timezone) + print(f"Timezone {timezone} is unknown!") + raise click.Abort + account = db.session.query(Account).get(account_id) + if account is None: + print(f"No account with id {account_id} found!") raise click.Abort pwd1 = getpass.getpass(prompt="Please enter the password:") pwd2 = getpass.getpass(prompt="Please repeat the password:") @@ -76,6 +100,7 @@ def new_user(username: str, email: str, roles: List[str], timezone: str): username=username, email=email, password=hash_password(pwd1), + account_name=account.name, timezone=timezone, user_roles=roles, check_email_deliverability=False, diff --git a/flexmeasures/data/scripts/cli_tasks/data_delete.py b/flexmeasures/data/scripts/cli_tasks/data_delete.py index b7b630acf..c7ab7cd09 100644 --- a/flexmeasures/data/scripts/cli_tasks/data_delete.py +++ b/flexmeasures/data/scripts/cli_tasks/data_delete.py @@ -4,6 +4,8 @@ from flask import current_app as app from flask.cli import with_appcontext +from flexmeasures.data import db +from flexmeasures.data.models.user import Account from flexmeasures.data.models.assets import Power from flexmeasures.data.models.markets import Price from flexmeasures.data.models.weather import Weather @@ -16,13 +18,49 @@ def fm_delete_data(): """FlexMeasures: Delete data.""" +@fm_delete_data.command("account") +@with_appcontext +@click.option("--id", type=int) +@click.option( + "--force/--no-force", default=False, help="Skip warning about consequences." +) +def delete_account(id: int, force: bool): + """ + Delete an account, including their users & data. + """ + if not force: + prompt = "Delete account including generic assets, users and all their data?" + if not click.confirm(prompt): + raise click.Abort() + account: Account = db.session.query(Account).get(id) + if account is None: + print(f"Account with ID '{id}' does not exist.") + raise click.Abort + for user in account.users: + print(f"Deleting user {user} (and assets & data) ...") + delete_user(user) + for asset in account.generic_assets: + print(f"Deleting generic asset {asset} (and sensors & beliefs) ...") + db.session.delete(asset) + db.session.delete(account) + db.session.commit() + + @fm_delete_data.command("user") @with_appcontext @click.option("--email") -def delete_user_and_data(email: str): +@click.option( + "--force/--no-force", default=False, help="Skip warning about consequences." +) +def delete_user_and_data(email: str, force: bool): """ - Delete a user & also their data. + Delete a user & also their assets and data. """ + if not force: + # TODO: later, when assets belong to accounts, remove this. + prompt = "Delete user including all their assets and data?" + if not click.confirm(prompt): + raise click.Abort() the_user = find_user_by_email(email) if the_user is None: print(f"Could not find user with email address '{email}' ...") @@ -71,6 +109,9 @@ def delete_structure(force): """ Delete all structural (non time-series) data like assets (types), markets (types) and weather sensors (types) and users. + + TODO: This could in our future data model (currently in development) be replaced by + `flexmeasures delete generic-asset-type` and `flexmeasures delete sensor`. """ if not force: confirm_deletion(structure=True) From 8d6819df728863bce5154273e21f1270fec2768d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Mon, 16 Aug 2021 12:04:25 +0200 Subject: [PATCH 17/18] API: protect change in asset.owner_id from switching accounts --- flexmeasures/api/v2_0/implementations/assets.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/flexmeasures/api/v2_0/implementations/assets.py b/flexmeasures/api/v2_0/implementations/assets.py index e23e4d2e2..f014f61e6 100644 --- a/flexmeasures/api/v2_0/implementations/assets.py +++ b/flexmeasures/api/v2_0/implementations/assets.py @@ -9,6 +9,7 @@ from marshmallow import fields from flexmeasures.data.services.resources import get_assets +from flexmeasures.data.models.user import User from flexmeasures.data.models.assets import Asset as AssetModel from flexmeasures.data.schemas.assets import AssetSchema from flexmeasures.data.auth_setup import unauthorized_handler @@ -115,6 +116,19 @@ def decorated_endpoint(*args, **kwargs): return wrapper +def ensure_asset_remains_in_account(db_asset: AssetModel, new_owner_id: int): + """ + Temporary protection of information kept in two places + (Asset.owner_id, GenericAsset.account_id) until we use GenericAssets throughout. + """ + new_owner = User.query.get(new_owner_id) + if new_owner and new_owner.account != db_asset.owner.account: + raise abort( + 400, + f"New owner {new_owner_id} not allowed, belongs to different account than current owner.", + ) + + @load_asset() @as_json def fetch_one(asset): @@ -129,6 +143,8 @@ def patch(db_asset, asset_data): """Update an asset given its identifier""" ignored_fields = ["id"] for k, v in [(k, v) for k, v in asset_data.items() if k not in ignored_fields]: + if k == "owner_id": + ensure_asset_remains_in_account(db_asset, v) setattr(db_asset, k, v) db.session.add(db_asset) try: From d50caab503a666178e87fa43a70b10ee7ea3385c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20H=C3=B6ning?= Date: Mon, 16 Aug 2021 12:05:30 +0200 Subject: [PATCH 18/18] a few user/asset API docs improvements found on the way, also adding account_id to /user docs --- flexmeasures/api/v2_0/routes.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/flexmeasures/api/v2_0/routes.py b/flexmeasures/api/v2_0/routes.py index a89270880..2a0dbb033 100644 --- a/flexmeasures/api/v2_0/routes.py +++ b/flexmeasures/api/v2_0/routes.py @@ -47,14 +47,14 @@ ) v2_0_service_listing["services"].append( { - "name": "PATCH /assets/", + "name": "PATCH /asset/", "access": [], "description": "Edit an asset.", }, ) v2_0_service_listing["services"].append( { - "name": "DELETE /assets/", + "name": "DELETE /asset/", "access": [], "description": "Delete an asset and its data.", }, @@ -117,11 +117,11 @@ def get_assets(): "id": 1, "latitude": 10, "longitude": 100, - "market": 1, + "market_id": 1, "max_soc_in_mwh": 5, "min_soc_in_mwh": 0, "name": "Test battery", - "owner": 2, + "owner_id": 2, "soc_datetime": "2015-01-01T00:00:00+00:00", "soc_in_mwh": 2.5, "soc_udi_event_id": 203, @@ -166,8 +166,8 @@ def post_assets(): "name": "Test battery", "asset_type": "battery", "unit": "kW", - "owner": 2, - "market": 1, + "owner_id": 2, + "market_id": 1, "event_resolution": 5, "capacity_in_mw": 4.2, "latitude": 40, @@ -197,8 +197,8 @@ def post_assets(): "max_soc_in_mwh": 5, "min_soc_in_mwh": 0, "name": "Test battery", - "owner": 2, - "market": 1, + "owner_id": 2, + "market_id": 1, "soc_datetime": null, "soc_in_mwh": null, "soc_udi_event_id": null @@ -238,11 +238,11 @@ def get_asset(id: int): "id": 1, "latitude": 10, "longitude": 100, - "market": 1, + "market_id": 1, "max_soc_in_mwh": 5, "min_soc_in_mwh": 0, "name": "Test battery", - "owner": 2, + "owner_id": 2, "soc_datetime": "2015-01-01T00:00:00+00:00", "soc_in_mwh": 2.5, "soc_udi_event_id": 203, @@ -300,11 +300,11 @@ def patch_asset(id: int): "id": 1, "latitude": 11.1, "longitude": 99.9, - "market": 1, + "market_id": 1, "max_soc_in_mwh": 5, "min_soc_in_mwh": 0, "name": "Test battery", - "owner": 2, + "owner_id": 2, "soc_datetime": "2015-01-01T00:00:00+00:00", "soc_in_mwh": 2.5, "soc_udi_event_id": 203, @@ -403,6 +403,7 @@ def get_user(id: int): .. sourcecode:: json { + 'account_id': 1, 'active': True, 'email': 'test_prosumer@seita.nl', 'flexmeasures_roles': [1, 3], @@ -435,7 +436,7 @@ def patch_user(id: int): Only the user themselves or admins are allowed to update its data, while a non-admin can only edit a few of their own fields. - Several fields are not allowed to be updated, e.g. id. They are ignored. + Several fields are not allowed to be updated, e.g. id and account_id. They are ignored. **Example request** @@ -452,6 +453,7 @@ def patch_user(id: int): .. sourcecode:: json { + 'account_id': 1, 'active': True, 'email': 'test_prosumer@seita.nl', 'flexmeasures_roles': [1, 3],