diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 4074c3e9f4..99f4770f2f 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -40,6 +40,7 @@ New features ------------- * Added API and UI support for copying assets and their subtrees [see `PR #2017 `_ and `PR #2120 `_] * Improve UX after deleting a child asset through the UI [see `PR #2119 `_] +* Allow admins and consultants to create accounts via a new ``POST /api/v3_0/accounts`` endpoint and a corresponding UI form [see `PR #2176 `_] * Improve source filtering in the sensor data GET endpoint by exposing the documented query parameters in Swagger and allowing filtering by the account linked to data sources [see `PR #2083 `_ and `PR #2151 `_] * Support sensor references for efficiency fields in storage flex-models [see `PR #2142 `_] * Introduce the ``consumption`` and ``production`` flex-model fields for the ``StorageScheduler`` to save schedules to [see `PR #2190 `_ and `PR #2213 `_] diff --git a/documentation/concepts/security_auth.rst b/documentation/concepts/security_auth.rst index c7f80e95be..0364ffed72 100644 --- a/documentation/concepts/security_auth.rst +++ b/documentation/concepts/security_auth.rst @@ -60,12 +60,12 @@ User and Account Roles We already discussed certain conditions under which a user has access to data ― being a certain user or belonging to a specific account. Furthermore, authorization conditions can also be implemented via *roles*: -* ``Account roles`` are often used for authorization. We support several roles which are mentioned in the USEF framework but more roles are possible (e.g. defined by custom-made services, see below). For example, a user might be authorized to write sensor data if they belong to an account with the "MDC" account role ("MDC" being short for meter data company). +* ``Account roles`` are often used for authorization. They are extensible: hosts and custom services can define their own roles. In the core FlexMeasures codebase, the ``Consultancy`` account role currently has built-in authorization behavior: together with the user role ``consultant``, it allows consultancy accounts to create client accounts and access consultancy-related data. * ``User roles`` give a user personal authorizations. For instance, we have a few `admin`\ s who can perform all actions, and `admin-reader`\ s who can read everything. Other roles have only an effect within the user's account, e.g. there could be an "HR" role which allows to edit user data like surnames within the account. We look into supported user roles in more detail below. -Roles cannot be edited via the UI at the moment. They are decided when a user or account is created in the CLI (for adding roles later, we use the database for now). Editing roles in UI and CLI is future work. +Roles are not a closed built-in list. Some are hardcoded in the core authorization model, while others are installation-specific. Both Account and User's roles can be managed through the account UI and API. .. note:: Custom energy flexibility services which are developed on top of FlexMeasures can also add their own kind of authorization, at least for the endpoints they define - using roles. @@ -100,9 +100,9 @@ These roles are natively supported and give users more rights: Consultancy ^^^^^^^^^^^ -A special case of authorization is consultancy - a consultancy account can read data from other accounts (usually their clients ― this is handy for servicing them). -For this, accounts have an attribute called ``consultancy_account_id``. Users in the consultancy account with the role `consultant` can read data in their client accounts. -We plan to introduce some editing/creation capabilities in the future. +A special case of authorization is consultancy: a consultancy account can read data from other accounts (usually their clients, which is handy for servicing them). +For this, accounts have an attribute called ``consultancy_account_id``. Users in the consultancy account with the user role ``consultant`` can read data in their client accounts. -Setting an account as the consultancy account is something only admins can do. -It is possible via the ``/accounts`` PATCH endpoint, but also in the UI. You can also specify a consultancy account when creating a client account, which for now happens only in the CLI: ``flexmeasures add account --name "Account2" --consultancy 1`` makes account 1 the consultancy account for account 2. +In addition, consultants can create/edit client accounts through the API and UI, when their own account has the Consultancy account role. When they create a client account, it is automatically linked to the consultancy account as client account. + +Setting or changing ``consultancy_account_id`` arbitrarily remains an admin capability. Admins can do this via the ``/accounts`` PATCH endpoint and in the UI. diff --git a/flexmeasures/api/v3_0/__init__.py b/flexmeasures/api/v3_0/__init__.py index 062a59a98f..689fd90ede 100644 --- a/flexmeasures/api/v3_0/__init__.py +++ b/flexmeasures/api/v3_0/__init__.py @@ -41,7 +41,11 @@ from flexmeasures.data.schemas.annotations import AnnotationSchema from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema from flexmeasures.data.schemas.sensors import QuantitySchema, TimeSeriesSchema -from flexmeasures.data.schemas.account import AccountSchema +from flexmeasures.data.schemas.account import ( + AccountSchema, + AccountCreateSchema, + AccountPatchSchema, +) from flexmeasures.api.v3_0.accounts import AccountAPIQuerySchema from flexmeasures.api.v3_0.users import UserAPIQuerySchema, AuthRequestSchema from flexmeasures.utils.doc_utils import rst_to_openapi @@ -160,6 +164,8 @@ def create_openapi_specs(app: Flask): ("CopyAssetSchema", CopyAssetSchema), ("DefaultAssetViewJSONSchema", DefaultAssetViewJSONSchema), ("AccountSchema", AccountSchema(partial=True)), + ("AccountCreateSchema", AccountCreateSchema()), + ("AccountPatchSchema", AccountPatchSchema()), ("AccountAPIQuerySchema", AccountAPIQuerySchema), ("AuthRequestSchema", AuthRequestSchema), ] diff --git a/flexmeasures/api/v3_0/accounts.py b/flexmeasures/api/v3_0/accounts.py index 2bed3cf33f..1483e10aa5 100644 --- a/flexmeasures/api/v3_0/accounts.py +++ b/flexmeasures/api/v3_0/accounts.py @@ -8,15 +8,23 @@ from sqlalchemy import or_, select, func from flask_sqlalchemy.pagination import SelectPagination -from flexmeasures.auth.policy import user_has_admin_access +from flexmeasures.auth.policy import ( + user_has_admin_access, + CONSULTANT_ROLE, + FlexMeasuresPlatform, +) from flexmeasures.auth.decorators import permission_required_for_context from flexmeasures.data.models.annotations import Annotation, get_or_create_annotation from flexmeasures.data.models.audit_log import AuditLog -from flexmeasures.data.models.user import Account, User +from flexmeasures.data.models.user import Account, User, AccountRole from flexmeasures.data.models.generic_assets import GenericAsset from flexmeasures.data.services.accounts import get_accounts, get_audit_log_records from flexmeasures.api.common.schemas.users import AccountIdField -from flexmeasures.data.schemas.account import AccountSchema +from flexmeasures.data.schemas.account import ( + AccountSchema, + AccountCreateSchema, + AccountPatchSchema, +) from flexmeasures.data.schemas.annotations import AnnotationSchema from flexmeasures.utils.time_utils import server_now from flexmeasures.api.common.schemas.users import AccountAPIQuerySchema @@ -24,14 +32,14 @@ """ API endpoints to manage accounts. -Both POST (to create) and DELETE are not accessible via the API, but as CLI functions. -Editing (PATCH) is also not yet implemented, but might be next, e.g. for the name or roles. +DELETE is not accessible via the API, but as a CLI function. """ # Instantiate schemas outside of endpoint logic to minimize response time account_schema = AccountSchema() accounts_schema = AccountSchema(many=True) -partial_account_schema = AccountSchema(partial=True) +partial_account_schema = AccountPatchSchema() +account_create_schema = AccountCreateSchema() annotation_schema = AnnotationSchema() @@ -178,6 +186,61 @@ def index( return response, 200 + @route("", methods=["POST"]) + @use_args(account_create_schema, arg_name="account_data") + @permission_required_for_context( + "create-children", + ctx_loader=FlexMeasuresPlatform.init, + ) + @as_json + def post(self, account_data: dict): + """ + .. :quickref: Accounts; Create an account. + --- + post: + summary: Create a new account. + description: | + Create a new account with a required name. + + - Admin users can create accounts. + - Consultant users can create accounts only if their account has the + `CONSULTANCY_ACCOUNT_ROLE` account role. + - For consultant users, the newly created account is automatically + linked to their own account as consultancy account. + + security: + - ApiKeyAuth: [] + requestBody: + description: Account fields for creation. + required: true + content: + application/json: + schema: AccountCreateSchema + example: + name: New Customer Account + consultancy_account_id: 2 + responses: + 201: + description: PROCESSED + 401: + description: UNAUTHORIZED + 403: + description: INVALID_SENDER + 422: + description: UNPROCESSABLE_ENTITY + tags: + - Accounts + """ + + if current_user.has_role(CONSULTANT_ROLE): + account_data["consultancy_account_id"] = current_user.account.id + + account = Account(**account_data) + db.session.add(account) + db.session.commit() + + return account_schema.dump(account), 201 + @route("/", methods=["GET"]) @use_kwargs({"account": AccountIdField(data_key="id")}, location="path") @permission_required_for_context("read", ctx_arg_name="account") @@ -259,9 +322,10 @@ def patch(self, account_data: dict, id: int, account: Account): required: true content: application/json: - schema: AccountSchema + schema: AccountPatchSchema example: name: Test Account Updated + account_roles: [1, 3] primary_color: '#1a3443' secondary_color: '#f1a122' logo_url: 'https://example.com/logo.png' @@ -326,7 +390,31 @@ def patch(self, account_data: dict, id: int, account: Account): "logo_url", "consultancy_account_id", "attributes", + "account_roles", ] + + if "account_roles" in account_data: + raw_roles = account_data["account_roles"] + if not isinstance(raw_roles, list) or any( + not isinstance(role_id, int) for role_id in raw_roles + ): + return {"errors": ["account_roles must be a list of integer IDs."]}, 422 + + resolved_roles = [ + db.session.get(AccountRole, role_id) for role_id in raw_roles + ] + invalid_role_ids = [ + role_id + for role_id, db_role in zip(raw_roles, resolved_roles) + if db_role is None + ] + if invalid_role_ids: + return { + "errors": [f"Invalid account role ID(s): {invalid_role_ids}."] + }, 422 + + account_data["account_roles"] = resolved_roles + modified_fields = { field: getattr(account, field) for field in fields_to_check diff --git a/flexmeasures/api/v3_0/tests/test_accounts_api.py b/flexmeasures/api/v3_0/tests/test_accounts_api.py index 5906f2c2b4..7daaeff2b5 100644 --- a/flexmeasures/api/v3_0/tests/test_accounts_api.py +++ b/flexmeasures/api/v3_0/tests/test_accounts_api.py @@ -4,7 +4,10 @@ from flask import url_for import pytest +from sqlalchemy import select +from flexmeasures.data.models.user import Account, AccountRole +from flexmeasures.auth.policy import CONSULTANCY_ACCOUNT_ROLE from flexmeasures.data.services.users import find_user_by_email @@ -283,3 +286,96 @@ def test_patch_account_attributes_with_consultancy( response.json["consultancy_account_id"] == consultancy_client_account.consultancy_account_id ) + + +@pytest.mark.parametrize( + "requesting_user, status_code", + [ + (None, 401), + ("test_prosumer_user@seita.nl", 403), + ("test_admin_user@seita.nl", 201), + ("test_consultant@seita.nl", 201), + ("test_consultancy_user_without_consultant_access@seita.nl", 403), + ], + indirect=["requesting_user"], +) +def test_post_account(client, setup_api_test_data, requesting_user, status_code, db): + payload = { + "name": f"Created Account {requesting_user.id if requesting_user else 'anon'}", + } + + response = client.post(url_for("AccountAPI:post"), json=payload) + assert response.status_code == status_code + + if status_code == 201: + created = db.session.execute( + select(Account).filter_by(name=payload["name"]) + ).scalar_one_or_none() + assert created is not None + + if requesting_user.has_role("consultant"): + assert created.consultancy_account_id == requesting_user.account.id + else: + assert created.consultancy_account_id is None + + +@pytest.mark.parametrize( + "requesting_user", + ["test_consultant@seita.nl"], + indirect=["requesting_user"], +) +def test_post_account_consultant_without_required_account_role_forbidden( + client, setup_api_test_data, requesting_user, db +): + + role = db.session.execute( + select(AccountRole).filter_by(name=CONSULTANCY_ACCOUNT_ROLE) + ).scalar_one_or_none() + assert role is not None + + requesting_user.account.account_roles = [ + r + for r in requesting_user.account.account_roles + if r.name != CONSULTANCY_ACCOUNT_ROLE + ] + db.session.commit() + + payload = { + "name": "Consultant Forbidden Account", + } + response = client.post(url_for("AccountAPI:post"), json=payload) + assert response.status_code == 403 + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +def test_patch_account_roles(client, setup_api_test_data, requesting_user, db): + target_account = find_user_by_email("test_prosumer_user_2@seita.nl").account + prosumer_role = db.session.execute( + select(AccountRole).filter_by(name="Prosumer") + ).scalar_one() + supplier_role = db.session.execute( + select(AccountRole).filter_by(name="Supplier") + ).scalar_one() + + response = client.patch( + url_for("AccountAPI:patch", id=target_account.id), + json={"account_roles": [prosumer_role.id, supplier_role.id]}, + ) + + assert response.status_code == 200 + role_names = {role["name"] for role in response.json["account_roles"]} + assert role_names == {"Prosumer", "Supplier"} + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +def test_patch_account_roles_invalid_role_id( + client, setup_api_test_data, requesting_user +): + target_account = find_user_by_email("test_prosumer_user_2@seita.nl").account + + response = client.patch( + url_for("AccountAPI:patch", id=target_account.id), + json={"account_roles": [999999]}, + ) + + assert response.status_code == 422 diff --git a/flexmeasures/auth/policy.py b/flexmeasures/auth/policy.py index 1fa9f21417..6c2e7cbe60 100644 --- a/flexmeasures/auth/policy.py +++ b/flexmeasures/auth/policy.py @@ -11,11 +11,15 @@ PERMISSIONS = ["create-children", "read", "update", "delete"] +# User Roles ADMIN_ROLE = "admin" ADMIN_READER_ROLE = "admin-reader" ACCOUNT_ADMIN_ROLE = "account-admin" CONSULTANT_ROLE = "consultant" +# Account Roels +CONSULTANCY_ACCOUNT_ROLE = "Consultancy" + # constants to allow access to certain groups EVERY_LOGGED_IN_USER = "every-logged-in-user" PRINCIPALS_TYPE = str | tuple[str] | list[str | tuple[str] | None] | None @@ -82,6 +86,25 @@ def __acl__(self) -> dict[str, PRINCIPALS_TYPE]: return {} +class FlexMeasuresPlatform(AuthModelMixin): + """Virtual platform resource to authorize top-level creations.""" + + @classmethod + def init(cls, context: dict | None = None) -> "FlexMeasuresPlatform": + return cls() + + def __acl__(self): + return { + "create-children": [ # this applies to accounts + f"role:{ADMIN_ROLE}", + ( # FM makes sure they are clients + f"role:{CONSULTANT_ROLE}", + f"account-role:{CONSULTANCY_ACCOUNT_ROLE}", + ), + ] + } + + def check_access(context: AuthModelMixin, permission: str): """ Check if current user can access this auth context if this permission diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 06767abeee..aa948455f4 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -25,7 +25,11 @@ ) from flexmeasures.app import create as create_app -from flexmeasures.auth.policy import ADMIN_ROLE, ADMIN_READER_ROLE +from flexmeasures.auth.policy import ( + ADMIN_ROLE, + ADMIN_READER_ROLE, + CONSULTANCY_ACCOUNT_ROLE, +) from flexmeasures.data.services.users import create_user from flexmeasures.data.models.generic_assets import GenericAssetType, GenericAsset from flexmeasures.data.models.data_sources import DataSource @@ -157,10 +161,15 @@ def create_test_accounts(db) -> dict[str, Account]: consultancy_account_role = AccountRole( name="Consultancy", description="A consultancy account" ) + consultancy_account_role = AccountRole( + name=CONSULTANCY_ACCOUNT_ROLE, + description="Consultancy account that can create own client accounts", + ) # Create Consultancy and ConsultancyClient account. # The ConsultancyClient account needs the account id of the Consultancy account so the order is important. consultancy_account = Account( - name="Test Consultancy Account", account_roles=[consultancy_account_role] + name="Test Consultancy Account", + account_roles=[consultancy_account_role, consultancy_account_role], ) db.session.add(consultancy_account) consultancy_client_account_role = AccountRole( diff --git a/flexmeasures/data/schemas/account.py b/flexmeasures/data/schemas/account.py index d38a1093bd..46ab7d0a7c 100644 --- a/flexmeasures/data/schemas/account.py +++ b/flexmeasures/data/schemas/account.py @@ -1,7 +1,7 @@ from typing import Any from flexmeasures.data import ma -from marshmallow import fields, validates +from marshmallow import Schema, fields, validates from flexmeasures.data import db from flexmeasures.data.models.user import Account, AccountRole @@ -62,6 +62,58 @@ def validate_logo_url(self, value, **kwargs): raise FMValidationError(str(e)) +class AccountCreateSchema(Schema): + """Schema for creating an account via API.""" + + name = fields.String(required=True) + consultancy_account_id = fields.Integer(required=False, allow_none=True) + + @validates("name") + def validate_name(self, value, **kwargs): + if not value.strip(): + raise FMValidationError("Account name cannot be empty.") + + # check if account with this name already exists + existing_account = db.session.execute( + db.select(Account).filter_by(name=value) + ).scalar_one_or_none() + if existing_account: + raise FMValidationError(f"An account with name '{value}' already exists.") + + +class AccountPatchSchema(Schema): + """Schema for updating an account via API.""" + + name = fields.String(required=False) + primary_color = fields.String(required=False, allow_none=True) + secondary_color = fields.String(required=False, allow_none=True) + logo_url = fields.String(required=False, allow_none=True) + consultancy_account_id = fields.Integer(required=False, allow_none=True) + attributes = JSON(required=False) + account_roles = fields.List(fields.Integer(), required=False) + + @validates("primary_color") + def validate_primary_color(self, value, **kwargs): + try: + validate_color_hex(value) + except ValueError as e: + raise FMValidationError(str(e)) + + @validates("secondary_color") + def validate_secondary_color(self, value, **kwargs): + try: + validate_color_hex(value) + except ValueError as e: + raise FMValidationError(str(e)) + + @validates("logo_url") + def validate_logo_url(self, value, **kwargs): + try: + validate_url(value) + except ValueError as e: + raise FMValidationError(str(e)) + + class AccountIdField(MarshmallowClickMixin, fields.Int): """Field that deserializes to an Account and serializes back to an integer.""" diff --git a/flexmeasures/ui/static/css/flexmeasures.css b/flexmeasures/ui/static/css/flexmeasures.css index a128b37dcb..1bf8149a1d 100644 --- a/flexmeasures/ui/static/css/flexmeasures.css +++ b/flexmeasures/ui/static/css/flexmeasures.css @@ -881,10 +881,6 @@ table.dataTable.no-footer { /* extra buffer for bootstrap rows */ .top-buffer { margin-top:15px; } -.dataTables_wrapper { - margin-bottom: 20px; -} - .dataTables_wrapper .paginate_button { outline: none; } @@ -1977,6 +1973,7 @@ body.touched [title]:hover:after { .table-responsive{ padding: 20px; + scrollbar-width: none; } .border-on-click { diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 3f122566b7..0b20e8721e 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -1827,10 +1827,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Account1" + "$ref": "#/components/schemas/AccountPatchSchema" }, "example": { "name": "Test Account Updated", + "account_roles": [ + 1, + 3 + ], "primary_color": "#1a3443", "secondary_color": "#f1a122", "logo_url": "https://example.com/logo.png", @@ -2007,6 +2011,47 @@ "tags": [ "Accounts" ] + }, + "post": { + "summary": "Create a new account.", + "description": "Create a new account with a required name.\n\n- Admin users can create accounts.\n- Consultant users can create accounts only if their account has the\n `CONSULTANCY_ACCOUNT_ROLE` account role.\n- For consultant users, the newly created account is automatically\n linked to their own account as consultancy account.\n", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "description": "Account fields for creation.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountCreateSchema" + }, + "example": { + "name": "New Customer Account", + "consultancy_account_id": 2 + } + } + } + }, + "responses": { + "201": { + "description": "PROCESSED" + }, + "401": { + "description": "UNAUTHORIZED" + }, + "403": { + "description": "INVALID_SENDER" + }, + "422": { + "description": "UNPROCESSABLE_ENTITY" + } + }, + "tags": [ + "Accounts" + ] } }, "/api/v3_0/accounts/{id}/annotations": { @@ -5246,6 +5291,64 @@ }, "additionalProperties": false }, + "AccountCreateSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "consultancy_account_id": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "AccountPatchSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "primary_color": { + "type": [ + "string", + "null" + ] + }, + "secondary_color": { + "type": [ + "string", + "null" + ] + }, + "logo_url": { + "type": [ + "string", + "null" + ] + }, + "consultancy_account_id": { + "type": [ + "integer", + "null" + ] + }, + "attributes": {}, + "account_roles": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "additionalProperties": false + }, "AccountAPIQuerySchema": { "type": "object", "properties": { @@ -5947,62 +6050,6 @@ }, "additionalProperties": false }, - "Account1": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "name": { - "type": [ - "string", - "null" - ], - "maxLength": 100 - }, - "primary_color": { - "type": [ - "string", - "null" - ], - "maxLength": 7 - }, - "secondary_color": { - "type": [ - "string", - "null" - ], - "maxLength": 7 - }, - "logo_url": { - "type": [ - "string", - "null" - ], - "maxLength": 255 - }, - "attributes": { - "default": "{}" - }, - "account_roles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AccountRole" - } - }, - "consultancy_account_id": { - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "name" - ], - "additionalProperties": false - }, "User": { "type": "object", "properties": { diff --git a/flexmeasures/ui/templates/accounts/account.html b/flexmeasures/ui/templates/accounts/account.html index d8dbc81c4e..ad3e7dcd4c 100644 --- a/flexmeasures/ui/templates/accounts/account.html +++ b/flexmeasures/ui/templates/accounts/account.html @@ -171,7 +171,17 @@

Edit {{ account.name }}

-

Account

+
+

Account

+ {% if can_add_client_account %} + + Add client account + + {% endif %} +
Account: {{ account.name }}
diff --git a/flexmeasures/ui/templates/accounts/account_create.html b/flexmeasures/ui/templates/accounts/account_create.html new file mode 100644 index 0000000000..2f80605feb --- /dev/null +++ b/flexmeasures/ui/templates/accounts/account_create.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% set active_page = "accounts" %} +{% block title %} Create account {% endblock %} + +{% block divs %} +
+
+
+
+

Create account

+
+
+ + +
+ + {% if user_is_admin %} +
+ + +
+ {% endif %} + + +
+ + +
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/flexmeasures/ui/templates/accounts/accounts.html b/flexmeasures/ui/templates/accounts/accounts.html index 98e2fa2d9b..82ed75fd76 100644 --- a/flexmeasures/ui/templates/accounts/accounts.html +++ b/flexmeasures/ui/templates/accounts/accounts.html @@ -8,8 +8,14 @@
-

All Accounts -

+
+

All Accounts

+ {% if user_can_create_account %} + + Create account + + {% endif %} +
diff --git a/flexmeasures/ui/tests/conftest.py b/flexmeasures/ui/tests/conftest.py index 256c23a7e1..0e0d2960e2 100644 --- a/flexmeasures/ui/tests/conftest.py +++ b/flexmeasures/ui/tests/conftest.py @@ -42,6 +42,24 @@ def as_dummy_account_admin(client): logout(client) +@pytest.fixture(scope="function") +def as_consultant(client): + """Login the consultant user and log them out afterwards.""" + login(client, "test_consultant@seita.nl", "testtest") + yield + logout(client) + + +@pytest.fixture(scope="function") +def as_consultancy_user_without_consultant_access(client): + """Login consultancy user without consultant role and log them out afterwards.""" + login( + client, "test_consultancy_user_without_consultant_access@seita.nl", "testtest" + ) + yield + logout(client) + + @pytest.fixture def public_asset(db, setup_generic_asset_types): """A public asset with no owner account, readable by any logged-in user.""" diff --git a/flexmeasures/ui/tests/test_account_crud.py b/flexmeasures/ui/tests/test_account_crud.py index 73dfe4379a..ac34ffabed 100644 --- a/flexmeasures/ui/tests/test_account_crud.py +++ b/flexmeasures/ui/tests/test_account_crud.py @@ -2,9 +2,13 @@ from flask import url_for from flask_login import current_user +from sqlalchemy import select -from flexmeasures.ui.tests.utils import login, logout +from flexmeasures.data.models.user import AccountRole +from flexmeasures.data.services.users import find_user_by_email +from flexmeasures.auth.policy import CONSULTANCY_ACCOUNT_ROLE +from flexmeasures.ui.tests.utils import login, logout account_api_path = "http://localhost//api/v3_0/accounts" @@ -41,6 +45,124 @@ def test_account_page_breadcrumb(db, client, as_prosumer_user1): assert current_user.account.name.encode() in account_page.data +def _set_consultant_account_role(db, enable: bool): + consultant_account = find_user_by_email("test_consultant@seita.nl").account + role = db.session.execute( + select(AccountRole).filter_by(name=CONSULTANCY_ACCOUNT_ROLE) + ).scalar_one_or_none() + if role is None: + role = AccountRole( + name=CONSULTANCY_ACCOUNT_ROLE, + description="Consultancy account that can create own client accounts", + ) + db.session.add(role) + db.session.flush() + + has_role = consultant_account.has_role(CONSULTANCY_ACCOUNT_ROLE) + if enable and not has_role: + consultant_account.account_roles.append(role) + if not enable and has_role: + consultant_account.account_roles = [ + r + for r in consultant_account.account_roles + if r.name != CONSULTANCY_ACCOUNT_ROLE + ] + db.session.commit() + + +@pytest.mark.parametrize( + "login_fixture, consultant_account_role_enabled, expect_create_button", + [ + ("as_admin", True, True), + ("as_consultant", True, True), + ("as_consultant", False, False), + ("as_prosumer_user1", True, False), + ], +) +def test_accounts_index_create_account_button_visibility( + db, + client, + request, + login_fixture, + consultant_account_role_enabled, + expect_create_button, +): + _set_consultant_account_role(db, consultant_account_role_enabled) + request.getfixturevalue(login_fixture) + + account_page = client.get(url_for("AccountCrudUI:index"), follow_redirects=True) + assert account_page.status_code == 200 + if expect_create_button: + assert b"Create account" in account_page.data + else: + assert b"Create account" not in account_page.data + + +@pytest.mark.parametrize( + "login_fixture, consultant_account_role_enabled, expected_status_code", + [ + ("as_admin", True, 200), + ("as_consultant", True, 200), + ("as_consultant", False, 403), + ("as_prosumer_user1", True, 403), + ], +) +def test_create_account_page_access_control( + db, + client, + request, + login_fixture, + consultant_account_role_enabled, + expected_status_code, +): + _set_consultant_account_role(db, consultant_account_role_enabled) + request.getfixturevalue(login_fixture) + + response = client.get(url_for("AccountCrudUI:new"), follow_redirects=True) + assert response.status_code == expected_status_code + + +def test_account_page_add_client_account_button_for_consultancy_account( + db, client, as_consultant +): + _set_consultant_account_role(db, True) + consultancy_account_id = find_user_by_email("test_consultant@seita.nl").account.id + + account_page = client.get( + url_for("AccountCrudUI:get", account_id=consultancy_account_id), + follow_redirects=True, + ) + assert account_page.status_code == 200 + assert b"Add client account" in account_page.data + + +def test_account_page_no_add_client_account_button_for_non_consultancy_account( + db, client, as_consultant +): + _set_consultant_account_role(db, False) + non_consultancy_account_id = find_user_by_email( + "test_consultant@seita.nl" + ).account.id + + account_page = client.get( + url_for("AccountCrudUI:get", account_id=non_consultancy_account_id), + follow_redirects=True, + ) + assert account_page.status_code == 200 + assert b"Add client account" not in account_page.data + + +def test_account_page_add_client_account_button_for_site_admin(db, client, as_admin): + account_id = find_user_by_email("test_prosumer_user@seita.nl").account.id + + account_page = client.get( + url_for("AccountCrudUI:get", account_id=account_id), + follow_redirects=True, + ) + assert account_page.status_code == 200 + assert b"Add client account" in account_page.data + + def test_account_page_forbidden_for_different_account_user( db, client, setup_accounts, as_dummy_user3 ): diff --git a/flexmeasures/ui/views/accounts.py b/flexmeasures/ui/views/accounts.py index 544011fd23..2de248d2cc 100644 --- a/flexmeasures/ui/views/accounts.py +++ b/flexmeasures/ui/views/accounts.py @@ -1,17 +1,23 @@ from __future__ import annotations +from flask import request from sqlalchemy import select -from werkzeug.exceptions import Forbidden, NotFound, Unauthorized -from flask_classful import FlaskView +from werkzeug.exceptions import Forbidden, Unauthorized, NotFound +from flask_classful import FlaskView, route from flask_security import login_required from flask_security.core import current_user -from flexmeasures.auth.policy import user_has_admin_access, check_access +from flexmeasures.auth.policy import ( + user_has_admin_access, + check_access, + FlexMeasuresPlatform, + CONSULTANCY_ACCOUNT_ROLE, +) from flexmeasures.ui.utils.view_utils import render_flexmeasures_template, ICON_MAPPING from flexmeasures.ui.utils.breadcrumb_utils import get_breadcrumb_info from flexmeasures.data.models.audit_log import AuditLog -from flexmeasures.data.models.user import Account +from flexmeasures.data.models.user import Account, AccountRole from flexmeasures.data.services.accounts import get_accounts, get_audit_log_records from flexmeasures.data import db from flexmeasures.ui.views import ( @@ -28,8 +34,32 @@ class AccountCrudUI(FlaskView): def index(self): """/accounts""" + user_can_create_account = True + try: + check_access(FlexMeasuresPlatform.init(), "create-children") + except (Forbidden, Unauthorized): + user_can_create_account = False + return render_flexmeasures_template( "accounts/accounts.html", + user_can_create_account=user_can_create_account, + ) + + @route("/new", methods=["GET"]) + @login_required + def new(self): + """/accounts/new""" + check_access(FlexMeasuresPlatform.init(), "create-children") + user_is_admin = user_has_admin_access(current_user, "read") + potential_consultant_accounts = get_accounts() if user_is_admin else [] + selected_consultancy_account_id = request.args.get( + "consultancy_account_id", default=None, type=int + ) + return render_flexmeasures_template( + "accounts/account_create.html", + user_is_admin=user_is_admin, + accounts=potential_consultant_accounts, + selected_consultancy_account_id=selected_consultancy_account_id, ) @login_required @@ -68,10 +98,24 @@ def get(self, account_id: str): except (Forbidden, Unauthorized): user_can_create_children = False + user_is_admin = user_has_admin_access(current_user, "read") + can_add_client_account = user_is_admin or account.has_role( + CONSULTANCY_ACCOUNT_ROLE + ) + + account_role_options = { + role.name: role.id for role in db.session.scalars(select(AccountRole)).all() + } + selected_account_roles = [role.name for role in account.account_roles] + return render_flexmeasures_template( "accounts/account.html", account=account, accounts=potential_consultant_accounts, + user_is_admin=user_is_admin, + can_add_client_account=can_add_client_account, + account_role_options=account_role_options, + selected_account_roles=selected_account_roles, user_can_update_account=user_can_update_account, user_can_create_children=user_can_create_children, can_view_account_auditlog=user_can_view_account_auditlog,