From edfc21b4eb525863763a26902bb292ca42b5edff Mon Sep 17 00:00:00 2001 From: dinesh Date: Tue, 6 Aug 2024 23:26:41 -0700 Subject: [PATCH] GET /staff-users --- compliance-api/docker-compose.yml | 4 +- .../5c3d1411be77_staff_and_position_tables.py | 171 ++++++++++++++++++ compliance-api/pre-hook-update-db.sh | 0 compliance-api/sample.env | 3 +- compliance-api/src/compliance_api/auth.py | 29 +++ compliance-api/src/compliance_api/config.py | 32 ++-- .../src/compliance_api/models/__init__.py | 3 +- .../src/compliance_api/models/base_model.py | 37 ++-- .../src/compliance_api/models/db.py | 36 +++- .../src/compliance_api/models/position.py | 27 +++ .../src/compliance_api/models/staff_user.py | 82 +++++++++ .../src/compliance_api/models/user.py | 59 ------ .../src/compliance_api/resources/__init__.py | 4 +- .../src/compliance_api/resources/apihelper.py | 22 ++- .../src/compliance_api/resources/position.py | 48 +++++ .../resources/{user.py => staff_user.py} | 62 ++++--- .../src/compliance_api/schemas/__init__.py | 3 +- .../src/compliance_api/schemas/common.py | 22 +++ .../src/compliance_api/schemas/staff_user.py | 97 ++++++++++ .../src/compliance_api/schemas/user.py | 39 ---- .../src/compliance_api/services/__init__.py | 4 +- .../compliance_api/services/auth_service.py | 10 + .../src/compliance_api/services/position.py | 11 ++ .../src/compliance_api/services/staff_user.py | 69 +++++++ .../compliance_api/services/user_service.py | 40 ---- .../src/compliance_api/utils/constant.py | 3 + compliance-api/tests/unit/models/__init__.py | 2 +- 27 files changed, 713 insertions(+), 206 deletions(-) create mode 100644 compliance-api/migrations/versions/5c3d1411be77_staff_and_position_tables.py mode change 100755 => 100644 compliance-api/pre-hook-update-db.sh create mode 100644 compliance-api/src/compliance_api/models/position.py create mode 100644 compliance-api/src/compliance_api/models/staff_user.py delete mode 100644 compliance-api/src/compliance_api/models/user.py create mode 100644 compliance-api/src/compliance_api/resources/position.py rename compliance-api/src/compliance_api/resources/{user.py => staff_user.py} (61%) create mode 100644 compliance-api/src/compliance_api/schemas/common.py create mode 100644 compliance-api/src/compliance_api/schemas/staff_user.py delete mode 100644 compliance-api/src/compliance_api/schemas/user.py create mode 100644 compliance-api/src/compliance_api/services/auth_service.py create mode 100644 compliance-api/src/compliance_api/services/position.py create mode 100644 compliance-api/src/compliance_api/services/staff_user.py delete mode 100644 compliance-api/src/compliance_api/services/user_service.py create mode 100644 compliance-api/src/compliance_api/utils/constant.py diff --git a/compliance-api/docker-compose.yml b/compliance-api/docker-compose.yml index 958ed500..5ec4fd57 100644 --- a/compliance-api/docker-compose.yml +++ b/compliance-api/docker-compose.yml @@ -8,10 +8,10 @@ services: environment: - POSTGRES_USER=compliance - POSTGRES_PASSWORD=compliance - - POSTGRES_DB=compliance + - POSTGRES_DB=compliance-db - POSTGRES_HOST_AUTH_METHOD=trust ports: - - 54332:5432/tcp + - 44332:5432/tcp restart: unless-stopped compliance-api-db-test: diff --git a/compliance-api/migrations/versions/5c3d1411be77_staff_and_position_tables.py b/compliance-api/migrations/versions/5c3d1411be77_staff_and_position_tables.py new file mode 100644 index 00000000..a59d4517 --- /dev/null +++ b/compliance-api/migrations/versions/5c3d1411be77_staff_and_position_tables.py @@ -0,0 +1,171 @@ +"""staff and position tables + +Revision ID: 5c3d1411be77 +Revises: 20bcb68bc2ac +Create Date: 2024-08-02 14:27:42.565023 + +""" +from datetime import datetime +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "5c3d1411be77" +down_revision = "20bcb68bc2ac" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + position_table = op.create_table( + "positions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(length=100), nullable=True), + sa.Column("description", sa.String(length=500), nullable=True), + sa.Column("created_date", sa.DateTime(), nullable=False), + sa.Column("updated_date", sa.DateTime(), nullable=True), + sa.Column("created_by", sa.String(length=100), nullable=False), + sa.Column("updated_by", sa.String(length=100), nullable=True), + sa.Column("is_active", sa.Boolean(), server_default="t", nullable=False), + sa.Column("is_deleted", sa.Boolean(), server_default="f", nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("staff_users", schema=None) as batch_op: + batch_op.add_column(sa.Column("position_id", sa.Integer(), nullable=False)) + batch_op.add_column( + sa.Column("deputy_director_id", sa.Integer(), nullable=True) + ) + batch_op.add_column(sa.Column("supervisor_id", sa.Integer(), nullable=True)) + batch_op.add_column( + sa.Column("auth_user_guid", sa.String(length=100), nullable=True) + ) + batch_op.add_column( + sa.Column("is_active", sa.Boolean(), server_default="t", nullable=False) + ) + batch_op.add_column( + sa.Column("is_deleted", sa.Boolean(), server_default="f", nullable=False) + ) + batch_op.alter_column( + "created_by", + existing_type=sa.VARCHAR(length=50), + type_=sa.String(length=100), + nullable=False, + ) + batch_op.alter_column( + "updated_by", + existing_type=sa.VARCHAR(length=50), + type_=sa.String(length=100), + existing_nullable=True, + ) + batch_op.drop_index("ix_staff_users_username") + batch_op.create_index( + batch_op.f("ix_staff_users_auth_user_guid"), ["auth_user_guid"], unique=True + ) + batch_op.create_foreign_key( + "staff_users_deputy_director_id_fkey", + "staff_users", + ["deputy_director_id"], + ["id"], + ) + batch_op.create_foreign_key( + "staff_users_position_id_fkey", "positions", ["position_id"], ["id"] + ) + batch_op.create_foreign_key( + "staff_users_supervisor_id_fkey", "staff_users", ["supervisor_id"], ["id"] + ) + batch_op.drop_column("contact_number") + batch_op.drop_column("username") + batch_op.drop_column("email_address") + batch_op.drop_column("middle_name") + op.bulk_insert( + position_table, + [ + { + "name": "Compliance & Enforcement Officer", + "description": "Compliance and Enforcement Officer Position", + "created_date": datetime.utcnow(), + "created_by": "system", # Or replace with the actual creator + }, + { + "name": "Senior Compliance & Enforcement Officer", + "description": "Senior Compliance and Enforcement Officer Position", + "created_date": datetime.utcnow(), + "created_by": "system", # Or replace with the actual creator + }, + { + "name": "Deputy Director Operations", + "description": "Deputy Director Operations Position", + "created_date": datetime.utcnow(), + "created_by": "system", # Or replace with the actual creator + }, + { + "name": "Director", + "description": "Director Position", + "created_date": datetime.utcnow(), + "created_by": "system", # Or replace with the actual creator + }, + ], + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("staff_users", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "middle_name", sa.VARCHAR(length=50), autoincrement=False, nullable=True + ) + ) + batch_op.add_column( + sa.Column( + "email_address", + sa.VARCHAR(length=100), + autoincrement=False, + nullable=True, + ) + ) + batch_op.add_column( + sa.Column( + "username", sa.VARCHAR(length=100), autoincrement=False, nullable=True + ) + ) + batch_op.add_column( + sa.Column( + "contact_number", + sa.VARCHAR(length=50), + autoincrement=False, + nullable=True, + ) + ) + batch_op.drop_constraint("staff_users_supervisor_id_fkey", type_="foreignkey") + batch_op.drop_constraint("staff_users_position_id_fkey", type_="foreignkey") + batch_op.drop_constraint( + "staff_users_deputy_director_id_fkey", type_="foreignkey" + ) + batch_op.drop_index(batch_op.f("ix_staff_users_auth_user_guid")) + batch_op.create_index("ix_staff_users_username", ["username"], unique=True) + batch_op.alter_column( + "updated_by", + existing_type=sa.String(length=100), + type_=sa.VARCHAR(length=50), + existing_nullable=True, + ) + batch_op.alter_column( + "created_by", + existing_type=sa.String(length=100), + type_=sa.VARCHAR(length=50), + nullable=True, + ) + batch_op.drop_column("is_deleted") + batch_op.drop_column("is_active") + batch_op.drop_column("auth_user_guid") + batch_op.drop_column("supervisor_id") + batch_op.drop_column("deputy_director_id") + batch_op.drop_column("position_id") + + op.drop_table("positions") + # ### end Alembic commands ### diff --git a/compliance-api/pre-hook-update-db.sh b/compliance-api/pre-hook-update-db.sh old mode 100755 new mode 100644 diff --git a/compliance-api/sample.env b/compliance-api/sample.env index 61fdccf8..4b559e4a 100644 --- a/compliance-api/sample.env +++ b/compliance-api/sample.env @@ -43,4 +43,5 @@ S3_ACCESS_KEY_ID=compliance-admin S3_SECRET_ACCESS_KEY= S3_HOST= S3_REGION= -S3_SERVICE= \ No newline at end of file +S3_SERVICE= +AUTH_BASE_URL= \ No newline at end of file diff --git a/compliance-api/src/compliance_api/auth.py b/compliance-api/src/compliance_api/auth.py index 5e8294c7..e506a98e 100644 --- a/compliance-api/src/compliance_api/auth.py +++ b/compliance-api/src/compliance_api/auth.py @@ -13,10 +13,13 @@ # limitations under the License. """Bring in the common JWT Manager.""" from functools import wraps +from http import HTTPStatus from flask import g, request from flask_jwt_oidc import JwtManager +from compliance_api.exceptions import PermissionDeniedError + jwt = ( JwtManager() @@ -40,5 +43,31 @@ def decorated(*args, **kwargs): return decorated + @classmethod + def has_one_of_roles(cls, roles): + """Check that at least one of the realm roles are in the token. + + Args: + roles [str,]: Comma separated list of valid roles + """ + + def decorated(f): + @Auth.require + @wraps(f) + def wrapper(*args, **kwargs): + if jwt.contains_role(roles): + return f(*args, **kwargs) + + raise PermissionDeniedError("Access Denied", HTTPStatus.UNAUTHORIZED) + + return wrapper + + return decorated + + @classmethod + def has_role(cls, role): + """Validate the role.""" + return jwt.validate_roles(role) + auth = Auth() diff --git a/compliance-api/src/compliance_api/config.py b/compliance-api/src/compliance_api/config.py index ad5dabb3..fd86657b 100644 --- a/compliance-api/src/compliance_api/config.py +++ b/compliance-api/src/compliance_api/config.py @@ -88,6 +88,7 @@ class _Config: # pylint: disable=too-few-public-methods # TODO API client wont need user management roles in keycloak. KEYCLOAK_ADMIN_USERNAME = os.getenv("MET_ADMIN_CLIENT_ID") KEYCLOAK_ADMIN_SECRET = os.getenv("MET_ADMIN_CLIENT_SECRET") + AUTH_BASE_URL = os.getenv("AUTH_BASE_URL") class DevConfig(_Config): # pylint: disable=too-few-public-methods @@ -101,8 +102,6 @@ class DevConfig(_Config): # pylint: disable=too-few-public-methods class TestConfig(_Config): # pylint: disable=too-few-public-methods """In support of testing only.used by the py.test suite.""" - DEBUG = True - TESTING = True DEBUG = True TESTING = True @@ -124,7 +123,6 @@ class TestConfig(_Config): # pylint: disable=too-few-public-methods JWT_OIDC_WELL_KNOWN_CONFIG = os.getenv("JWT_OIDC_WELL_KNOWN_CONFIG") JWT_OIDC_TEST_ALGORITHMS = os.getenv("JWT_OIDC_TEST_ALGORITHMS") JWT_OIDC_TEST_JWKS_URI = os.getenv("JWT_OIDC_TEST_JWKS_URI", default=None) - JWT_OIDC_TEST_KEYS = { 'keys': [ { @@ -162,20 +160,20 @@ class TestConfig(_Config): # pylint: disable=too-few-public-methods } JWT_OIDC_TEST_PRIVATE_KEY_PEM = """-----BEGIN RSA PRIVATE KEY----- - MIICXQIBAAKBgQDfn1nKQshOSj8xw44oC2klFWSNLmK3BnHONCJ1bZfq0EQ5gIfg - tlvB+Px8Ya+VS3OnK7Cdi4iU1fxO9ktN6c6TjmmmFevk8wIwqLthmCSF3r+3+h4e - ddj7hucMsXWv05QUrCPoL6YUUz7Cgpz7ra24rpAmK5z7lsV+f3BEvXkrUQIDAQAB - AoGAC0G3QGI6OQ6tvbCNYGCqq043YI/8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhs - kURaDwk4+8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh/ - xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0CQQD13LrBTEDR44ei - lQ/4TlCMPO5bytd1pAxHnrqgMnWovSIPSShAAH1feFugH7ZGu7RoBO7pYNb6N3ia - C1idc7yjAkEA6Nfc6c8meTRkVRAHCF24LB5GLfsjoMB0tOeEO9w9Ous1a4o+D24b - AePMUImAp3woFoNDRfWtlNktOqLel5PjewJBAN9kBoA5o6/Rl9zeqdsIdWFmv4DB - 5lEqlEnC7HlAP+3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhcCQQDb - W0mOp436T6ZaELBfbFNulNLOzLLi5YzNRPLppfG1SRNZjbIrvTIKVL4N/YxLvQbT - NrQw+2OdQACBJiEHsdZzAkBcsTk7frTH4yGx0VfHxXDPjfTj4wmD6gZIlcIr9lZg - 4H8UZcVFN95vEKxJiLRjAmj6g273pu9kK4ymXNEjWWJn - -----END RSA PRIVATE KEY-----""" +MIICXQIBAAKBgQDfn1nKQshOSj8xw44oC2klFWSNLmK3BnHONCJ1bZfq0EQ5gIfg +tlvB+Px8Ya+VS3OnK7Cdi4iU1fxO9ktN6c6TjmmmFevk8wIwqLthmCSF3r+3+h4e +ddj7hucMsXWv05QUrCPoL6YUUz7Cgpz7ra24rpAmK5z7lsV+f3BEvXkrUQIDAQAB +AoGAC0G3QGI6OQ6tvbCNYGCqq043YI/8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhs +kURaDwk4+8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh/ +xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0CQQD13LrBTEDR44ei +lQ/4TlCMPO5bytd1pAxHnrqgMnWovSIPSShAAH1feFugH7ZGu7RoBO7pYNb6N3ia +C1idc7yjAkEA6Nfc6c8meTRkVRAHCF24LB5GLfsjoMB0tOeEO9w9Ous1a4o+D24b +AePMUImAp3woFoNDRfWtlNktOqLel5PjewJBAN9kBoA5o6/Rl9zeqdsIdWFmv4DB +5lEqlEnC7HlAP+3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhcCQQDb +W0mOp436T6ZaELBfbFNulNLOzLLi5YzNRPLppfG1SRNZjbIrvTIKVL4N/YxLvQbT +NrQw+2OdQACBJiEHsdZzAkBcsTk7frTH4yGx0VfHxXDPjfTj4wmD6gZIlcIr9lZg +4H8UZcVFN95vEKxJiLRjAmj6g273pu9kK4ymXNEjWWJn +-----END RSA PRIVATE KEY-----""" class DockerConfig(_Config): # pylint: disable=too-few-public-methods diff --git a/compliance-api/src/compliance_api/models/__init__.py b/compliance-api/src/compliance_api/models/__init__.py index cd1419d6..8ded6bfd 100644 --- a/compliance-api/src/compliance_api/models/__init__.py +++ b/compliance-api/src/compliance_api/models/__init__.py @@ -15,4 +15,5 @@ """This exports all of the models and schemas used by the application.""" from .db import db, ma, migrate -from .user import User +from .position import Position +from .staff_user import PERMISSION_MAP, PermissionEnum, StaffUser diff --git a/compliance-api/src/compliance_api/models/base_model.py b/compliance-api/src/compliance_api/models/base_model.py index 60895caf..b96c804f 100644 --- a/compliance-api/src/compliance_api/models/base_model.py +++ b/compliance-api/src/compliance_api/models/base_model.py @@ -14,32 +14,29 @@ """Super class to handle all operations related to base model.""" from datetime import datetime -from sqlalchemy import Column -from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy import Boolean, Column, DateTime, String from .db import db -TENANT_ID = 'tenant_id' - - class BaseModel(db.Model): """This class manages all of the base model functions.""" __abstract__ = True - created_date = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - updated_date = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=True) - - @declared_attr - def created_by(cls): # pylint:disable=no-self-argument, no-self-use, # noqa: N805 - """Return foreign key for created by.""" - return Column(db.String(50)) + created_date = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_date = Column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=True + ) + created_by = Column(String(100), nullable=False) + updated_by = Column(String(100), nullable=True) + is_active = Column(Boolean, default=True, server_default="t", nullable=False) + is_deleted = Column(Boolean, default=False, server_default="f", nullable=False) - @declared_attr - def updated_by(cls): # pylint:disable=no-self-argument, no-self-use, # noqa: N805 - """Return foreign key for modified by.""" - return Column(db.String(50)) + @classmethod + def get_all(cls): + """Fetch list of users by access type.""" + return cls.query.all() @classmethod def find_by_id(cls, identifier: int): @@ -67,6 +64,14 @@ def save(self): db.session.flush() db.session.commit() + def update(self, payload: dict, commit=True): + """Update and commit.""" + for key, value in payload.items(): + if key != "id": + setattr(self, key, value) + if commit: + self.commit() + def delete(self): """Delete and commit.""" db.session.delete(self) diff --git a/compliance-api/src/compliance_api/models/db.py b/compliance-api/src/compliance_api/models/db.py index 733dae88..bdf1cf3d 100644 --- a/compliance-api/src/compliance_api/models/db.py +++ b/compliance-api/src/compliance_api/models/db.py @@ -1,9 +1,12 @@ - """Initilizations for db, migration and marshmallow.""" +from contextlib import contextmanager + +from flask import current_app, g from flask_marshmallow import Marshmallow from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import event # DB initialize in __init__ file @@ -16,3 +19,34 @@ # Marshmallow for database model schema ma = Marshmallow() + + +@contextmanager +def session_scope(): + """Provide a transactional scope around a series of operations.""" + # Using the default session for the scope + session = db.session + try: + yield session + session.commit() + except Exception as e: # noqa: B901, E722 + print(str(e)) + session.rollback() + raise + + +@event.listens_for(db.session, "before_commit") +def before_commit(session, *args): # pylint: disable=unused-argument + """Listen to the and updates the created_by/updated_by fields.""" + new_objects = session.new + updated_objects = session.dirty + username = g.jwt_oidc_token_info.get("preferred_username", None) + current_app.logger.info("Before commit event. Updating created/updated by") + current_app.logger.info(f"Preferred username is {username}") + + if username is None: + username = g.jwt_oidc_token_info.get("email") + for new_object in new_objects: + setattr(new_object, "created_by", username) + for updated_object in updated_objects: + setattr(updated_object, "updated_by", username) diff --git a/compliance-api/src/compliance_api/models/position.py b/compliance-api/src/compliance_api/models/position.py new file mode 100644 index 00000000..6b1d64f7 --- /dev/null +++ b/compliance-api/src/compliance_api/models/position.py @@ -0,0 +1,27 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Position Model.""" +from sqlalchemy import Column + +from .base_model import BaseModel +from .db import db + + +class Position(BaseModel): + """Definition of Position Entity.""" + + __tablename__ = "positions" + id = Column(db.Integer, primary_key=True, autoincrement=True) + name = Column(db.String(100), nullable=True) + description = Column(db.String(500), nullable=True) diff --git a/compliance-api/src/compliance_api/models/staff_user.py b/compliance-api/src/compliance_api/models/staff_user.py new file mode 100644 index 00000000..68d58106 --- /dev/null +++ b/compliance-api/src/compliance_api/models/staff_user.py @@ -0,0 +1,82 @@ +"""Staff user model class. + +Manages the staff user +""" + +from __future__ import annotations + +import enum +from typing import Optional + +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import column_property, relationship + +from .base_model import BaseModel + + +class PermissionEnum(enum.Enum): + """Enum for Staff User Permissions.""" + + VIEWER = "VIEWER" + USER = "USER" + SUPERUSER = "SUPERUSER" + + +PERMISSION_MAP = { + PermissionEnum.SUPERUSER: "Superuser", + PermissionEnum.VIEWER: "Viewer", + PermissionEnum.USER: "User", +} + + +class StaffUser(BaseModel): + """Definition of the Staff User entity.""" + + __tablename__ = "staff_users" + + id = Column(Integer, primary_key=True, autoincrement=True) + first_name = Column(String(50)) + last_name = Column(String(50)) + full_name = column_property(first_name + " " + last_name) + position_id = Column( + Integer, + ForeignKey("positions.id", name="staff_users_position_id_fkey"), + nullable=False, + ) + deputy_director_id = Column( + Integer, + ForeignKey("staff_users.id", name="staff_users_deputy_director_id_fkey"), + nullable=True, + ) + supervisor_id = Column( + Integer, + ForeignKey("staff_users.id", name="staff_users_supervisor_id_fkey"), + nullable=True, + ) + auth_user_guid = Column(String(100), index=True, unique=True) + position = relationship("Position", foreign_keys=[position_id], lazy="select") + + @classmethod + def create_user(cls, user_data, session=None) -> StaffUser: + """Create user.""" + staff_user = StaffUser(**user_data) + if session: + session.add(staff_user) + session.flush() + else: + staff_user.save() + return staff_user + + @classmethod + def update_user(cls, user_id, user_dict, session=None) -> Optional[StaffUser]: + """Update user.""" + query = StaffUser.query.filter_by(id=user_id) + user: StaffUser = query.first() + if not user: + return None + query.update(user_dict) + if session: + session.flush() + else: + cls.session.commit() + return user diff --git a/compliance-api/src/compliance_api/models/user.py b/compliance-api/src/compliance_api/models/user.py deleted file mode 100644 index e060b2f3..00000000 --- a/compliance-api/src/compliance_api/models/user.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Staff user model class. - -Manages the staff user -""" -from __future__ import annotations - -from typing import Optional - -from sqlalchemy import Column, String -from sqlalchemy.orm import column_property - -from .base_model import BaseModel -from .db import db - - -class User(BaseModel): - """Definition of the User entity.""" - - __tablename__ = 'staff_users' - - id = Column(db.Integer, primary_key=True, autoincrement=True) - first_name = Column(db.String(50)) - middle_name = Column(db.String(50), nullable=True) - last_name = Column(db.String(50)) - full_name = column_property(first_name + ' ' + last_name) - # To store the IDP user name..ie IDIR username - username = Column('username', String(100), index=True, unique=True) - email_address = Column(db.String(100), nullable=True) - contact_number = Column(db.String(50), nullable=True) - - @classmethod - def get_all(cls): - """Fetch list of users by access type.""" - return cls.query.all() - - @classmethod - def create_user(cls, user_data) -> User: - """Create user.""" - user_data = User( - first_name=user_data.get('first_name', None), - middle_name=user_data.get('middle_name', None), - last_name=user_data.get('last_name', None), - email_address=user_data.get('email_address', None), - contact_number=user_data.get('contact_number', None), - ) - user_data.save() - return user_data - - @classmethod - def update_user(cls, user_id, user_dict) -> Optional[User]: - """Update user.""" - query = User.query.filter_by(id=user_id) - user: User = query.first() - if not user: - return None - - query.update(user_dict) - db.session.commit() - return user diff --git a/compliance-api/src/compliance_api/resources/__init__.py b/compliance-api/src/compliance_api/resources/__init__.py index 935bd25c..d32ee476 100644 --- a/compliance-api/src/compliance_api/resources/__init__.py +++ b/compliance-api/src/compliance_api/resources/__init__.py @@ -25,7 +25,8 @@ from .apihelper import Api from .ops import API as OPS_API -from .user import API as USER_API +from .position import API as POSITION_API +from .staff_user import API as USER_API __all__ = ("API_BLUEPRINT", "OPS_BLUEPRINT") @@ -61,3 +62,4 @@ ) API.add_namespace(USER_API) +API.add_namespace(POSITION_API) diff --git a/compliance-api/src/compliance_api/resources/apihelper.py b/compliance-api/src/compliance_api/resources/apihelper.py index 7dc1cc38..3538ea12 100644 --- a/compliance-api/src/compliance_api/resources/apihelper.py +++ b/compliance-api/src/compliance_api/resources/apihelper.py @@ -21,6 +21,7 @@ from flask_restx import Api as BaseApi from flask_restx import apidoc, fields from marshmallow import fields as ma_fields +from marshmallow_enum import EnumField class Api(BaseApi): @@ -62,12 +63,27 @@ def convert_ma_schema_to_restx_model(cls, api, schema, name): model_fields = {} for field_name, field in schema.fields.items(): field_type = type(field) - restx_field = type_mapping.get(field_type) - if restx_field: - model_fields[field_name] = restx_field( + if isinstance(field, EnumField): + model_fields[field_name] = fields.String( required=field.required, description=field.metadata.get("description", ""), + enum=[member.value for member in field.enum], ) + elif isinstance(field, ma_fields.Nested): + nested_model_name = f"{name}_{field_name}" + nested_model = cls.convert_ma_schema_to_restx_model( + api, field.schema, nested_model_name + ) + model_fields[field_name] = fields.Nested( + nested_model, required=field.required + ) + else: + restx_field = type_mapping.get(field_type) + if restx_field: + model_fields[field_name] = restx_field( + required=field.required, + description=field.metadata.get("description", ""), + ) # Add more field types as needed return api.model(name, model_fields) diff --git a/compliance-api/src/compliance_api/resources/position.py b/compliance-api/src/compliance_api/resources/position.py new file mode 100644 index 00000000..d104455a --- /dev/null +++ b/compliance-api/src/compliance_api/resources/position.py @@ -0,0 +1,48 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for managing position resource.""" + +from http import HTTPStatus + +from flask_restx import Namespace, Resource + +from compliance_api.auth import auth +from compliance_api.schemas import KeyValueSchema +from compliance_api.services import PositionService +from compliance_api.utils.util import cors_preflight + +from .apihelper import Api as ApiHelper + + +API = Namespace("positions", description="Endpoints for Position Resource Management") + +key_value_list_model = ApiHelper.convert_ma_schema_to_restx_model( + API, KeyValueSchema(), "List" +) + + +@cors_preflight("GET, OPTIONS, POST") +@API.route("", methods=["POST", "GET", "OPTIONS"]) +class StaffUsers(Resource): + """Resource for managing positions.""" + + @staticmethod + @API.response(code=200, description="Success", model=[key_value_list_model]) + @ApiHelper.swagger_decorators(API, endpoint_description="Fetch all positions") + @auth.require + def get(): + """Fetch all users.""" + users = PositionService.get_all_positions() + user_list_schema = KeyValueSchema(many=True) + return user_list_schema.dump(users), HTTPStatus.OK diff --git a/compliance-api/src/compliance_api/resources/user.py b/compliance-api/src/compliance_api/resources/staff_user.py similarity index 61% rename from compliance-api/src/compliance_api/resources/user.py rename to compliance-api/src/compliance_api/resources/staff_user.py index f7db33b8..b1fbe300 100644 --- a/compliance-api/src/compliance_api/resources/user.py +++ b/compliance-api/src/compliance_api/resources/staff_user.py @@ -11,7 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""API endpoints for managing an user resource.""" +"""API endpoints for managing staff user resource.""" from http import HTTPStatus @@ -19,28 +19,29 @@ from compliance_api.auth import auth from compliance_api.exceptions import ResourceNotFoundError -from compliance_api.schemas import UserRequestSchema, UserSchema -from compliance_api.services import UserService +from compliance_api.schemas import KeyValueSchema, StaffUserCreateSchema, StaffUserSchema +from compliance_api.services import StaffUserService from compliance_api.utils.util import cors_preflight from .apihelper import Api as ApiHelper -API = Namespace("users", description="Endpoints for User Management") -"""Custom exception messages -""" +API = Namespace("staff-users", description="Endpoints for Staff User Management") user_request_model = ApiHelper.convert_ma_schema_to_restx_model( - API, UserRequestSchema(), "User" + API, StaffUserCreateSchema(), "StaffUser" ) user_list_model = ApiHelper.convert_ma_schema_to_restx_model( - API, UserSchema(), "UserListItem" + API, StaffUserSchema(), "StaffUserList" +) +key_value_list_model = ApiHelper.convert_ma_schema_to_restx_model( + API, KeyValueSchema(), "List" ) @cors_preflight("GET, OPTIONS, POST") @API.route("", methods=["POST", "GET", "OPTIONS"]) -class Users(Resource): +class StaffUsers(Resource): """Resource for managing users.""" @staticmethod @@ -49,8 +50,8 @@ class Users(Resource): @auth.require def get(): """Fetch all users.""" - users = UserService.get_all_users() - user_list_schema = UserSchema(many=True) + users = StaffUserService.get_all_users() + user_list_schema = StaffUserSchema(many=True) return user_list_schema.dump(users), HTTPStatus.OK @staticmethod @@ -61,15 +62,15 @@ def get(): @API.response(400, "Bad Request") def post(): """Create a user.""" - user_data = UserRequestSchema().load(API.payload) - created_user = UserService.create_user(user_data) - return UserSchema().dump(created_user), HTTPStatus.CREATED + user_data = StaffUserCreateSchema().load(API.payload) + created_user = StaffUserService.create_user(user_data) + return StaffUserSchema().dump(created_user), HTTPStatus.CREATED @cors_preflight("GET, OPTIONS, PATCH, DELETE") -@API.route("/", methods=["PATCH", "GET", "OPTIONS", "DELETE"]) +@API.route("/", methods=["PATCH", "GET", "OPTIONS", "DELETE"]) @API.doc(params={"user_id": "The user identifier"}) -class User(Resource): +class StaffUser(Resource): """Resource for managing a single user.""" @staticmethod @@ -79,10 +80,10 @@ class User(Resource): @API.response(404, "Not Found") def get(user_id): """Fetch a user by id.""" - user = UserService.get_user_by_id(user_id) + user = StaffUserService.get_user_by_id(user_id) if not user: raise ResourceNotFoundError(f"User with {user_id} not found") - return UserSchema().dump(user), HTTPStatus.OK + return StaffUserSchema().dump(user), HTTPStatus.OK @staticmethod @auth.require @@ -93,11 +94,11 @@ def get(user_id): @API.response(404, "Not Found") def patch(user_id): """Update a user by id.""" - user_data = UserRequestSchema().load(API.payload) - updated_user = UserService.update_user(user_id, user_data) + user_data = StaffUserCreateSchema().load(API.payload) + updated_user = StaffUserService.update_user(user_id, user_data) if not updated_user: raise ResourceNotFoundError(f"User with {user_id} not found") - return UserSchema().dump(updated_user), HTTPStatus.OK + return StaffUserSchema().dump(updated_user), HTTPStatus.OK @staticmethod @auth.require @@ -106,7 +107,22 @@ def patch(user_id): @API.response(404, "Not Found") def delete(user_id): """Delete a user by id.""" - deleted_user = UserService.delete_user(user_id) + deleted_user = StaffUserService.delete_user(user_id) if not deleted_user: raise ResourceNotFoundError(f"User with {user_id} not found") - return UserSchema().dump(deleted_user), HTTPStatus.OK + return StaffUserSchema().dump(deleted_user), HTTPStatus.OK + + +@cors_preflight("GET") +@API.route("/permissions", methods=["GET"]) +class StaffUserPermissions(Resource): + """Resources to manage permission level of staff user.""" + + @staticmethod + @auth.require + @ApiHelper.swagger_decorators(API, endpoint_description="Get the permission levels") + @API.response(code=200, model=key_value_list_model, description="Success") + def get(): + """Fetch the permission levels.""" + permissions = StaffUserService.get_permission_levels() + return KeyValueSchema(many=True).dump(permissions), HTTPStatus.OK diff --git a/compliance-api/src/compliance_api/schemas/__init__.py b/compliance-api/src/compliance_api/schemas/__init__.py index bae9156b..0b9ca6aa 100644 --- a/compliance-api/src/compliance_api/schemas/__init__.py +++ b/compliance-api/src/compliance_api/schemas/__init__.py @@ -12,4 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. """Exposes all of the schemas in the compliance_api.""" -from .user import UserRequestSchema, UserSchema +from .common import KeyValueSchema +from .staff_user import StaffUserCreateSchema, StaffUserSchema diff --git a/compliance-api/src/compliance_api/schemas/common.py b/compliance-api/src/compliance_api/schemas/common.py new file mode 100644 index 00000000..0c860e62 --- /dev/null +++ b/compliance-api/src/compliance_api/schemas/common.py @@ -0,0 +1,22 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Common Schema.""" +from marshmallow import Schema, fields + + +class KeyValueSchema(Schema): + """Schema to represent key/value.""" + + id = fields.Str(metadata={"description": "Unique id in the list"}) + name = fields.Str(metadata={"description": "Name of the list item"}) diff --git a/compliance-api/src/compliance_api/schemas/staff_user.py b/compliance-api/src/compliance_api/schemas/staff_user.py new file mode 100644 index 00000000..276f7d0c --- /dev/null +++ b/compliance-api/src/compliance_api/schemas/staff_user.py @@ -0,0 +1,97 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Staff User Schema.""" +from marshmallow import EXCLUDE, Schema, fields +from marshmallow_enum import EnumField + +from compliance_api.models.staff_user import PERMISSION_MAP, PermissionEnum, StaffUser + +from .common import KeyValueSchema + + +class StaffUserSchema(Schema): + """Staff User schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + model = StaffUser + include_fk = True + + id = fields.Int( + metadata={"description": "The unique identifier of the staff user."} + ) + first_name = fields.Str( + metadata={"description": "The firstname of the staff user."} + ) + last_name = fields.Str(metadata={"description": "The lastname of the staff user."}) + position_id = fields.Int( + metadata={ + "description": "The unique identifier of the position of the staff user." + } + ) + position = fields.Nested( + KeyValueSchema, dump_only=True + ) + deputy_director_id = fields.Int( + metadata={"description": "The unique identifier of the deputy director."} + ) + supervisor_id = fields.Int( + metadata={"description": "The unique identifier of the supervisor."} + ) + auth_user_id = fields.Str( + metadata={"description": "The unique identifier from the identity provider."} + ) + full_name = fields.Str( + metadata={"description": "Fullname of the staff user"} + ) + # permission = fields.Method("get_user_permission", required=True) + + def get_user_permission(self, staff_user: StaffUser): # pylint: disable=no-self-use + """Extract the permission value from the enum.""" + permission_value = PERMISSION_MAP[staff_user.permission] + return permission_value + + +class StaffUserCreateSchema(Schema): + """User Request Schema.""" + + class Meta: # pylint: disable=too-few-public-methods + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + position_id = fields.Int( + metadata={ + "description": "The unique identifier of the position of the staff user." + }, + required=True, + ) + deputy_director_id = fields.Int( + metadata={"description": "The unique identifier of the deputy director."} + ) + supervisor_id = fields.Int( + metadata={"description": "The unique identifier of the supervisor."} + ) + auth_user_id = fields.Str( + metadata={"description": "The unique identifier from the identity provider."}, + required=True, + ) + permission = EnumField( + PermissionEnum, + metadata={"description": "The permission level of the staff user."}, + by_value=True, + required=True, + ) diff --git a/compliance-api/src/compliance_api/schemas/user.py b/compliance-api/src/compliance_api/schemas/user.py deleted file mode 100644 index 576512ba..00000000 --- a/compliance-api/src/compliance_api/schemas/user.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Engagement model class. - -Manages the engagement -""" - -from marshmallow import EXCLUDE, Schema, fields - - -class UserSchema(Schema): - """User schema.""" - - class Meta: # pylint: disable=too-few-public-methods - """Exclude unknown fields in the deserialized output.""" - - unknown = EXCLUDE - - id = fields.Int(data_key="id") - first_name = fields.Str(data_key="first_name") - middle_name = fields.Str(data_key="description") - last_name = fields.Str(data_key="last_name") - email_address = fields.Str(data_key="email_address") - contact_number = fields.Str(data_key="contact_number") - username = fields.Str(data_key="username") - - -class UserRequestSchema(Schema): - """User Request Schema.""" - - class Meta: # pylint: disable=too-few-public-methods - """Exclude unknown fields in the deserialized output.""" - - unknown = EXCLUDE - - first_name = fields.Str(data_key="first_name") - middle_name = fields.Str(data_key="description") - last_name = fields.Str(data_key="last_name") - email_address = fields.Str(data_key="email_address") - contact_number = fields.Str(data_key="contact_number") - username = fields.Str(data_key="username") diff --git a/compliance-api/src/compliance_api/services/__init__.py b/compliance-api/src/compliance_api/services/__init__.py index 614893e8..e405832c 100644 --- a/compliance-api/src/compliance_api/services/__init__.py +++ b/compliance-api/src/compliance_api/services/__init__.py @@ -12,4 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. """Exposes all of the Services used in the compliance_api.""" -from .user_service import UserService +from .auth_service import AuthService +from .position import PositionService +from .staff_user import StaffUserService diff --git a/compliance-api/src/compliance_api/services/auth_service.py b/compliance-api/src/compliance_api/services/auth_service.py new file mode 100644 index 00000000..1afa42b1 --- /dev/null +++ b/compliance-api/src/compliance_api/services/auth_service.py @@ -0,0 +1,10 @@ +"""Service to call epic.authorize endpoints.""" + + +class AuthService: + """Handle service request for epic.authorize.""" + + @staticmethod + def get_epic_user_by_id(user_id: str): + """Return the user representation from epic.authorize.""" + return {"first_name": "Dinesh", "last_name": "Balakrishnan", "user_id": user_id} diff --git a/compliance-api/src/compliance_api/services/position.py b/compliance-api/src/compliance_api/services/position.py new file mode 100644 index 00000000..933ece54 --- /dev/null +++ b/compliance-api/src/compliance_api/services/position.py @@ -0,0 +1,11 @@ +"""Service for position.""" +from compliance_api.models import Position + + +class PositionService: + """Position service.""" + + @classmethod + def get_all_positions(cls): + """Get all positions.""" + return Position.get_all() diff --git a/compliance-api/src/compliance_api/services/staff_user.py b/compliance-api/src/compliance_api/services/staff_user.py new file mode 100644 index 00000000..a9709472 --- /dev/null +++ b/compliance-api/src/compliance_api/services/staff_user.py @@ -0,0 +1,69 @@ +"""Service for user management.""" + +from compliance_api.exceptions import UnprocessableEntityError +from compliance_api.models.staff_user import PERMISSION_MAP, PermissionEnum +from compliance_api.models.staff_user import StaffUser as UserModel + +from .auth_service import AuthService + + +class StaffUserService: + """User management service.""" + + @classmethod + def get_user_by_id(cls, user_id): + """Get user by id.""" + staff_user = UserModel.find_by_id(user_id) + return staff_user + + @classmethod + def get_all_users(cls): + """Get all users.""" + users = UserModel.get_all() + return users + + @classmethod + def create_user(cls, user_data): + """Create user.""" + auth_user_id = user_data.get("auth_user_id", None) + auth_user = AuthService.get_epic_user_by_id(auth_user_id) + if not auth_user: + raise UnprocessableEntityError( + f"No user found from EPIC.Authorize corresponding to the given {auth_user_id}" + ) + user_data["first_name"] = auth_user.get("first_name", None) + user_data["last_name"] = auth_user.get("last_name", None) + created_user = UserModel.create_user(user_data) + return created_user + + @classmethod + def update_user(cls, user_id, user_data): + """Update staff user.""" + auth_user_id = user_data.get("auth_user_id", None) + auth_user = AuthService.get_epic_user_by_id(auth_user_id) + if not auth_user: + raise UnprocessableEntityError( + f"No user found from EPIC.Authorize corresponding to the given {auth_user_id}" + ) + user_data["first_name"] = auth_user.get("first_name", None) + user_data["last_name"] = auth_user.get("last_name", None) + updated_user = UserModel.update_user(user_id, user_data) + return updated_user + + @classmethod + def delete_user(cls, user_id): + """Update user.""" + user = UserModel.find_by_id(user_id) + if not user: + return None + + user.is_deleted = True + user.save() + return user + + @classmethod + def get_permission_levels(cls): + """List all the permission levels.""" + return [ + {"id": perm.name, "name": PERMISSION_MAP[perm]} for perm in PermissionEnum + ] diff --git a/compliance-api/src/compliance_api/services/user_service.py b/compliance-api/src/compliance_api/services/user_service.py deleted file mode 100644 index 0c29d405..00000000 --- a/compliance-api/src/compliance_api/services/user_service.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Service for user management.""" -from compliance_api.models.user import User as UserModel - - -class UserService: - """User management service.""" - - @classmethod - def get_user_by_id(cls, _user_id): - """Get user by id.""" - db_user = UserModel.find_by_id(_user_id) - return db_user - - @classmethod - def get_all_users(cls): - """Get all users.""" - users = UserModel.get_all() - return users - - @classmethod - def create_user(cls, user_data): - """Create user.""" - created_user = UserModel.create_user(user_data) - return created_user - - @classmethod - def update_user(cls, user_id, user_data): - """Update user.""" - updated_user = UserModel.update_user(user_id, user_data) - return updated_user - - @classmethod - def delete_user(cls, user_id): - """Update user.""" - user = UserModel.find_by_id(user_id) - if not user: - return None - - user.delete() - return user diff --git a/compliance-api/src/compliance_api/utils/constant.py b/compliance-api/src/compliance_api/utils/constant.py new file mode 100644 index 00000000..895c6a4b --- /dev/null +++ b/compliance-api/src/compliance_api/utils/constant.py @@ -0,0 +1,3 @@ +"""Constants.""" + +AUTH_APP = "COMPLIANCE" diff --git a/compliance-api/tests/unit/models/__init__.py b/compliance-api/tests/unit/models/__init__.py index a15622ac..fff7f629 100644 --- a/compliance-api/tests/unit/models/__init__.py +++ b/compliance-api/tests/unit/models/__init__.py @@ -14,4 +14,4 @@ """The Test-Suite used to ensure that the Model objects are working correctly.""" -from ....src.compliance_api.models.user import StaffUser +from ....src.compliance_api.models.staff_user import StaffUser