Repository: FlexMeasures/flexmeasures (language: python)

Issue:
Multi-tenancy
We need to provide some support to let servers support multiple tenant accounts. (we typically run one server per customer, who then has multiple clients themselves).

The goals (at this stage) are mostly auth-related:
- separate data cleanly
- allow for better role definitions between customers and tenants

This will be done on top of the new data model we're building out at the moment. So we'll be using `GenericAsset` as the hook between users/accounts and assets/sensors.


Discussion / hints:
Branch [issue-158-Multi-tenancy](https://github.com/SeitaBV/flexmeasures/tree/issue-158-Multi-tenancy) created!

Fix patch:
diff --git a/Makefile b/Makefile
index badc52ac2..c13812ea3 100644
--- a/Makefile
+++ b/Makefile
@@ -74,4 +74,8 @@ upgrade-db:
 	flask db current
 
 show-data-model:
-	./flexmeasures/data/scripts/visualize_data_model.py --uml --store  # also try with --schema for database model
+	# This generates the data model, as currently written in code, as a PNG picture.
+	# Also try with --schema for the database model. 
+	# With --dev, you'll see the currently experimental parts, as well.
+	# Use --help to learn more. 
+	./flexmeasures/data/scripts/visualize_data_model.py --uml --store
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 "<token>" 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/changelog.rst b/documentation/changelog.rst
index b3b9830e5..e5e748594 100644
--- a/documentation/changelog.rst
+++ b/documentation/changelog.rst
@@ -2,16 +2,17 @@
 FlexMeasures Changelog
 **********************
 
-v0.6.0 | July XX, 2021
+v0.6.0 | August XX, 2021
 ===========================
 
 .. warning:: Upgrading to this version requires running ``flexmeasures db upgrade`` (you can create a backup first with ``flexmeasures db-ops dump``).
-             In case you are using experimental developer features and have previously set up sensors, be sure to check out the upgrade instructions in `PR #157 <https://github.com/SeitaBV/flexmeasures/pull/157>`_.
+             In case you are using experimental developer features and have previously set up sensors, be sure to check out the upgrade instructions in `PR #157 <https://github.com/SeitaBV/flexmeasures/pull/157>`_. Furthermore, if you want to create custom user/account relationships while upgrading (otherwise the upgrade script creates accounts based on email domains), check out the upgrade instructions in `PR #159 <https://github.com/SeitaBV/flexmeasures/pull/159>`_. If you want to use both of these custom upgrade features, do the upgrade in two steps. First, as described in PR 157 and upgrading up to revision b6d49ed7cceb, then as described in PR 159 for the rest.
 
 New features
 -----------
 * Analytics view offers grouping of all assets by location [see `PR #148 <http://www.github.com/SeitaBV/flexmeasures/pull/148>`_]
 * Add (experimental) endpoint to post sensor data for any sensor. Also supports our ongoing integration with data internally represented using the `timely beliefs <https://github.com/SeitaBV/timely-beliefs>`_ lib [see `PR #147 <http://www.github.com/SeitaBV/flexmeasures/pull/147>`_]
+* Multi-tenancy: Supporting multiple customers per FlexMeasures server, by introducing the `Account` concept. Accounts have users and assets associated. [see `PR #159 <http://www.github.com/SeitaBV/flexmeasures/pull/159>`_] 
 
 Bugfixes
 -----------
@@ -23,6 +24,8 @@ Infrastructure / Support
 * Add CLI task to monitor if tasks ran successfully and recently enough [see `PR #146 <http://www.github.com/SeitaBV/flexmeasures/pull/146>`_]
 * Document how to use a custom favicon in plugins [see `PR #152 <http://www.github.com/SeitaBV/flexmeasures/pull/152>`_]
 * Continue experimental integration with `timely beliefs <https://github.com/SeitaBV/timely-beliefs>`_ lib: link multiple sensors to a single asset [see `PR #157 <https://github.com/SeitaBV/flexmeasures/pull/157>`_]
+* The experimental parts of the data model can now be visualised, as well, via `make show-data-model --uml --dev` [also in `PR #157 <https://github.com/SeitaBV/flexmeasures/pull/157>`_]
+
 
 
 v0.5.0 | June 7, 2021
diff --git a/documentation/cli/change_log.rst b/documentation/cli/change_log.rst
index 28131d801..1f695d5ba 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
 =====================
 
@@ -15,4 +22,4 @@ since v0.3.0 | April 2, 2021
 
 * Refactor CLI into the main groups ``add``, ``delete``, ``jobs`` and ``db-ops``
 * Add ``flexmeasures add asset``,  ``flexmeasures add user`` and ``flexmeasures add weather-sensor``
-* split the ``populate-db`` command into ``flexmeasures add structure`` and ``flexmeasures add forecasts``
\ No newline at end of file
+* split the ``populate-db`` command into ``flexmeasures add structure`` and ``flexmeasures add forecasts``
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 321f35989..950966cea 100644
--- a/documentation/getting-started.rst
+++ b/documentation/getting-started.rst
@@ -96,14 +96,22 @@ 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 a user account:
+FlexMeasures is also a web-based platform, so we need to create a user to authenticate:
 
 .. code-block::
 
-   flexmeasures add user --username <your-username> --email <your-email-address> --roles=admin
+   flexmeasures add user --username <your-username> --email <your-email-address> --account-id 2 --roles=admin
 
 
 * This will ask you to set a password for the user.
@@ -215,4 +223,4 @@ More information (e.g. for installing on Windows) on `the Cbc website <https://p
 Install and configure Redis
 ^^^^^^^^^^^^^^^^^^^^^^^
 
-To let FlexMeasures queue forecasting and scheduling jobs, install a `Redis <https://redis.io/>`_ 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 <https://redis.io/>`_ 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/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:
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/<id>",
+        "name": "PATCH /asset/<id>",
         "access": [],
         "description": "Edit an asset.",
     },
 )
 v2_0_service_listing["services"].append(
     {
-        "name": "DELETE /assets/<id>",
+        "name": "DELETE /asset/<id>",
         "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],
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..8626be838
--- /dev/null
+++ b/flexmeasures/data/migrations/versions/994170c26bc6_add_account_table.py
@@ -0,0 +1,245 @@
+"""add account table
+
+Revision ID: 994170c26bc6
+Revises: b6d49ed7cceb
+Create Date: 2021-08-11 19:21:07.083253
+
+"""
+from typing import List, Tuple, Optional
+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)
+       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()
+
+    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
+    # 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():
+        # 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)
+                .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():
+    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(),
+        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()
+    return asset_ownership_results
+
+
+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
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/assets.py b/flexmeasures/data/models/assets.py
index 079f7fb23..6756f0172 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,
@@ -109,8 +110,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 40b30dc64..b1d791f81 100644
--- a/flexmeasures/data/models/generic_assets.py
+++ b/flexmeasures/data/models/generic_assets.py
@@ -35,14 +35,15 @@ 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,
@@ -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
diff --git a/flexmeasures/data/models/time_series.py b/flexmeasures/data/models/time_series.py
index febc4490c..db7b8493a 100644
--- a/flexmeasures/data/models/time_series.py
+++ b/flexmeasures/data/models/time_series.py
@@ -27,12 +27,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, name: str, generic_asset: GenericAsset, **kwargs):
diff --git a/flexmeasures/data/models/user.py b/flexmeasures/data/models/user.py
index fca257a48..a3a8ad1e7 100644
--- a/flexmeasures/data/models/user.py
+++ b/flexmeasures/data/models/user.py
@@ -25,6 +25,20 @@ def __repr__(self):
         return "<Role:%s (ID:%d)>" % (self.name, self.id)
 
 
+class Account(db.Model):
+    """
+    Account of a tenant on the server.
+    Bundles Users as well as GenericAssets.
+    """
+
+    __tablename__ = "account"
+    id = Column(Integer, primary_key=True)
+    name = Column(String(100), default="", unique=True)
+
+    def __repr__(self):
+        return "<Account %s (ID:%d)" % (self.name, self.id)
+
+
 class User(db.Model, UserMixin):
     """
     We use the flask security UserMixin, which does include functionality,
@@ -46,6 +60,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",
diff --git a/flexmeasures/data/schemas/generic_assets.py b/flexmeasures/data/schemas/generic_assets.py
index d246a9a9d..290d02b03 100644
--- a/flexmeasures/data/schemas/generic_assets.py
+++ b/flexmeasures/data/schemas/generic_assets.py
@@ -3,6 +3,7 @@
 from marshmallow import validates, ValidationError, fields
 
 from flexmeasures.data import ma
+from flexmeasures.data.models.user import Account
 from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
 
 
@@ -13,6 +14,7 @@ class GenericAssetSchema(ma.SQLAlchemySchema):
 
     id = ma.auto_field()
     name = fields.Str()
+    account_id = ma.auto_field()
     latitude = ma.auto_field()
     longitude = ma.auto_field()
     generic_asset_type_id = fields.Integer()
@@ -28,6 +30,12 @@ def validate_generic_asset_type(self, generic_asset_type_id: int):
                 f"GenericAssetType with id {generic_asset_type_id} doesn't exist."
             )
 
+    @validates("account_id")
+    def validate_account(self, account_id: int):
+        account = Account.query.get(account_id)
+        if not account:
+            raise ValidationError(f"Account with Id {account_id} doesn't exist.")
+
     @validates("latitude")
     def validate_latitude(self, latitude: Optional[float]):
         """Validate optional latitude."""
diff --git a/flexmeasures/data/schemas/users.py b/flexmeasures/data/schemas/users.py
index 18a135166..dbec66c28 100644
--- a/flexmeasures/data/schemas/users.py
+++ b/flexmeasures/data/schemas/users.py
@@ -22,6 +22,7 @@ def validate_timezone(self, timezone):
     id = ma.auto_field()
     email = ma.auto_field(required=True, validate=validate.Email)
     username = ma.auto_field(required=True)
+    account_id = ma.auto_field()
     active = ma.auto_field()
     timezone = ma.auto_field()
     flexmeasures_roles = ma.auto_field()
diff --git a/flexmeasures/data/scripts/cli_tasks/data_add.py b/flexmeasures/data/scripts/cli_tasks/data_add.py
index 267b9387d..fe3fbe91e 100755
--- 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,9 +100,10 @@ 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_deliverability=False,
+        check_email_deliverability=False,
     )
     db.session.commit()
     print(f"Successfully created user {created_user}")
@@ -149,6 +174,7 @@ def add_generic_asset_type(**args):
     type=float,
     help="Longitude of the asset's location",
 )
+@click.option("--account-id", type=int, required=True)
 @click.option(
     "--generic-asset-type-id",
     required=True,
diff --git a/flexmeasures/data/scripts/cli_tasks/data_delete.py b/flexmeasures/data/scripts/cli_tasks/data_delete.py
index b7b630acf..d15e6644a 100644
--- a/flexmeasures/data/scripts/cli_tasks/data_delete.py
+++ b/flexmeasures/data/scripts/cli_tasks/data_delete.py
@@ -4,7 +4,10 @@
 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, User
 from flexmeasures.data.models.assets import Power
+from flexmeasures.data.models.generic_assets import GenericAsset
 from flexmeasures.data.models.markets import Price
 from flexmeasures.data.models.weather import Weather
 from flexmeasures.data.scripts.data_gen import get_affected_classes
@@ -16,13 +19,59 @@ 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.
+    """
+    account: Account = db.session.query(Account).get(id)
+    if account is None:
+        print(f"Account with ID '{id}' does not exist.")
+        raise click.Abort
+    if not force:
+        prompt = "Delete account including generic assets, users and all their data?\n"
+        users = User.query.filter(User.account_id == id).all()
+        if users:
+            prompt += "Affected users: " + ",".join([u.username for u in users]) + "\n"
+        generic_assets = GenericAsset.query.filter(GenericAsset.account_id == id).all()
+        if generic_assets:
+            prompt += (
+                "Affected generic assets: "
+                + ",".join([ga.name for ga in generic_assets])
+                + "\n"
+            )
+        if not click.confirm(prompt):
+            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 +120,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)
diff --git a/flexmeasures/data/scripts/visualize_data_model.py b/flexmeasures/data/scripts/visualize_data_model.py
index b7e9eea1b..8c6cde154 100755
--- a/flexmeasures/data/scripts/visualize_data_model.py
+++ b/flexmeasures/data/scripts/visualize_data_model.py
@@ -111,22 +111,27 @@ def wrapper(*args, **kwargs):
 
 
 @uses_dot
-def create_schema_pic(pg_url, pg_user, pg_pwd, store: bool = False):
+def create_schema_pic(pg_url, pg_user, pg_pwd, store: bool = False, dev: bool = False):
     """Create a picture of the SCHEMA of relevant tables."""
     print("CREATING SCHEMA PICTURE ...")
     print(
         f"Connecting to database {pg_url} as user {pg_user} and loading schema metadata ..."
     )
     db_metadata = MetaData(f"postgresql://{pg_user}:{pg_pwd}@{pg_url}")
+    relevant_tables = RELEVANT_TABLES
+    if dev:
+        relevant_tables += RELEVANT_TABLES_DEV
     kwargs = dict(
         metadata=db_metadata,
         show_datatypes=False,  # The image would get nasty big if we'd show the datatypes
         show_indexes=False,  # ditto for indexes
         rankdir="LR",  # From left to right (instead of top to bottom)
         concentrate=False,  # Don't try to join the relation lines together
-        restrict_tables=RELEVANT_TABLES,
+        restrict_tables=relevant_tables,
     )
     print("Creating the pydot graph object...")
+    if DEBUG:
+        print(f"Relevant tables: {relevant_tables}")
     graph = create_schema_graph(**kwargs)
     if store:
         print("Storing as image (db_schema.png) ...")
@@ -231,6 +236,11 @@ def show_image(graph, fb_viewer_command: str):
         action="store_true",
         help="Visualize the relationships available in code (UML style).",
     )
+    parser.add_argument(
+        "--dev",
+        action="store_true",
+        help="If given, include the parts of the new data model which are in development.",
+    )
     parser.add_argument(
         "--store",
         action="store_true",
@@ -246,11 +256,6 @@ 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()
 
@@ -259,7 +264,9 @@ def show_image(graph, fb_viewer_command: str):
 
     if args.schema:
         pg_pwd = getpass(f"Please input the postgres password for user {args.pg_user}:")
-        create_schema_pic(args.pg_url, args.pg_user, pg_pwd, store=args.store)
+        create_schema_pic(
+            args.pg_url, args.pg_user, pg_pwd, store=args.store, dev=args.dev
+        )
     if args.uml:
         try:
             from flexmeasures.data.config import db as flexmeasures_db
diff --git a/flexmeasures/data/services/users.py b/flexmeasures/data/services/users.py
index 5a379567f..32ead6a85 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,18 @@ 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 the name of the account which this user is associated with."
+        )
+    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)
+        db.session.flush()
+
     user_datastore = SQLAlchemySessionUserDatastore(db.session, User, Role)
     kwargs.update(email=email, username=username)
     user = user_datastore.create_user(**kwargs)
@@ -112,6 +127,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/ui/crud/users.py b/flexmeasures/ui/crud/users.py
index fa64c7d7a..e7fd70aeb 100644
--- a/flexmeasures/ui/crud/users.py
+++ b/flexmeasures/ui/crud/users.py
@@ -9,7 +9,7 @@
 from flask_security import roles_required
 
 from flexmeasures.data.config import db
-from flexmeasures.data.models.user import User, Role
+from flexmeasures.data.models.user import User, Role, Account
 from flexmeasures.data.services.users import (
     get_user,
 )
@@ -64,6 +64,7 @@ def process_internal_api_response(
             user_data["id"] = user_id
         if make_obj:
             user = User(**user_data)
+            user.account = Account.query.get(user_data.get("account_id", -1))
             if user in db.session:
                 db.session.expunge(user)
             return user
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 86%
rename from flexmeasures/ui/templates/admin/account.html
rename to flexmeasures/ui/templates/admin/logged_in_user.html
index fa25ecd4c..e4fce8949 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 %}
 
 
-    <h2> Account overview for {{ logged_in_user.username }} </h2>
+    <h2> Overview for logged-in user: {{ logged_in_user.username }} </h2>
 
     <div class="container-fluid">
         <div class="row">
@@ -41,6 +41,14 @@ <h2> Account overview for {{ logged_in_user.username }} </h2>
                                 {{logged_in_user.email }}
                             </td>
                         </tr>
+                        <tr>
+                            <td>
+                                Account
+                            </td>
+                            <td>
+                                {{ logged_in_user.account.name }}
+                            </td>
+                        </tr>
                         <tr>
                             <td>
                                 Time Zone
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 @@ <h1>{{ _('Login') }}</h1>
             <div class="alert alert-info col-sm-8">
                 <h2>Interested in a demo?</h2>
                 <p>
-                    Simply log in to our demo account.
+                    Simply log in as our demo user.
                 <ul>
                     <li>Email: {{ FLEXMEASURES_PUBLIC_DEMO_CREDENTIALS[0] }}</li>
                     <li>Password: {{ FLEXMEASURES_PUBLIC_DEMO_CREDENTIALS[1] }}</li>
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 6044878d7..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 %}
 
-    <h2> Account overview for {{ user.username }} </h2>
+    <h2> Overview for user {{ user.username }} </h2>
 
     <div class="container-fluid">
         <div class="row">
@@ -54,6 +54,14 @@ <h2> Account overview for {{ user.username }} </h2>
                                 {{ user.timezone }}
                             </td>
                         </tr>
+                        <tr>
+                            <td>
+                                Account
+                            </td>
+                            <td>
+                                {{ user.account.name }}
+                            </td>
+                        </tr>
                         <tr>
                             <td>
                                 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 @@ <h2>All {% if not include_inactive %}active {% endif %}users</h2>
                             <th>Username</th>
                             <th>Email</th>
                             <th>Roles</th>
+                            <th>Account</th>
                             <th>Timezone</th>
                             <th>Last Login</th>
                             <th>Active</th>
@@ -48,6 +49,9 @@ <h2>All {% if not include_inactive %}active {% endif %}users</h2>
                                       {{ role.name }}{{ "," if not loop.last }}
                                     {% endfor %}
                                 </td>
+                                <td>
+                                    {{ user.account.name }}
+                                </td>
                                 <td>
                                     {{ user.timezone }}
                                 </td>
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/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()),


Test patch:
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 adb09523f..f0c019cf1 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.
     """
@@ -24,6 +24,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"),
@@ -54,6 +55,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 6fa73f7a2..adb7850f5 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.
     """
@@ -28,6 +28,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)
@@ -37,6 +38,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 4125f02d1..650b17edc 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, Account]:
+    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, Account]:
+    return create_test_account(fresh_db)
 
 
-def create_roles_users() -> Dict[str, User]:
+def create_test_account(db) -> Dict[str, Account]:
+    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/tests/test_user_services.py b/flexmeasures/data/tests/test_user_services.py
index 6008b7894..cfa4807fb 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 the name of the account" 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 f29d34b5a..68a955335 100644
--- a/flexmeasures/ui/tests/conftest.py
+++ b/flexmeasures/ui/tests/conftest.py
@@ -30,7 +30,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
@@ -42,6 +47,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."),
     )
 
@@ -49,6 +55,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 513b9d62a..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 (
-        "Account overview for %s" % mock_user["username"]
-    ).encode() in user_page.data
+    assert ("Overview for user %s" % mock_user["username"]).encode() in user_page.data
     assert (">3</a>").encode() in user_page.data  # this is the asset count
     assert mock_user["email"].encode() in user_page.data