diff --git a/.vscode/settings.json b/.vscode/settings.json index 2146929d0..18547bc23 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,14 @@ "makefile.extensionOutputFolder": "./.vscode", "python.defaultInterpreterPath": "${workspaceFolder}/met-api/venv/bin/python", "python.envFile": "${workspaceFolder}/met-api/.env", - "python.analysis.extraPaths": ["./met-api/src", "./met-cron/src", "./analytics-api/src"] + "python.analysis.extraPaths": [ + "./analytics-api/src", + "./met-cron/src", + "./met-api/src", + ], + "pylint.args": [ + "--load-plugins", + "pylint_flask_sqlalchemy", + "pylint_flask"], + "python.analysis.autoImportCompletions": true } diff --git a/CHANGELOG.MD b/CHANGELOG.MD index d1e9d2bc1..8e49ae507 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -3,6 +3,11 @@ - Created UI for Poll Widget. - Updated Poll widget API and unit tests. +## January 25, 2024 +- **Task** Resolve issue preventing met-web from deploying on the Dev OpenShift environment. [🎟️DESENG-469](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-469) + - Remove Epic Engage-related links and update Keycloak link. + - Remove additional authentication method. + ## January 24, 2024 - **Task** Update default project type to GDX for all deployments by default. [🎟️DESENG-472](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-472) - Set the default project type to GDX on all continuous deployment (CD) files. @@ -17,6 +22,18 @@ - Added missing unit tests for met api - Added unit tests for error handling for met api +## January 19, 2024 + +- **Feature**: Add metadata management to the API [🎟️DESENG-442](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-442) + - Add new models, services, and endpoints for metadata and metadata taxonomy + - Add test coverage for new endpoints + - Begin documenting new endpoints using Flask-RESTX + - Add new endpoints to the API blueprint + - Enabled pylint for flask and flask_sqlalchemy in vscode + - Created a new "transactional" decorator to wrap model methods in a transaction that safely rolls back on error + - Stub out frontend components for metadata management in preparation for the next step +- **Task**: Continue switching staff users to db-based (rather than keycloak based) authentication + ## January 15, 2024 - **Task** Audit for missing unit tests [🎟️DESENG-436](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-436) @@ -49,7 +66,7 @@ ## December 28, 2023 -> **Feature**: Added the timeline widget. [🎟️DESENG-439](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-439) +- **Feature**: Added the timeline widget. [🎟️DESENG-439](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-439) ## December 11, 2023 diff --git a/met-api/migrations/versions/45f89f245e3d_engagement_metadata.py b/met-api/migrations/versions/45f89f245e3d_engagement_metadata.py index 97d86c33f..bd216de6f 100644 --- a/met-api/migrations/versions/45f89f245e3d_engagement_metadata.py +++ b/met-api/migrations/versions/45f89f245e3d_engagement_metadata.py @@ -22,7 +22,6 @@ def upgrade(): sa.Column('created_date', sa.DateTime(), nullable=False), sa.Column('updated_date', sa.DateTime(), nullable=True), sa.Column('engagement_id', sa.Integer(), nullable=False), - # sa.Column('project_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column('created_by', sa.String(length=50), nullable=True), sa.Column('updated_by', sa.String(length=50), nullable=True), sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), diff --git a/met-api/migrations/versions/ec0128056a33_rework_engagement_metadata.py b/met-api/migrations/versions/ec0128056a33_rework_engagement_metadata.py new file mode 100644 index 000000000..25d0a79de --- /dev/null +++ b/met-api/migrations/versions/ec0128056a33_rework_engagement_metadata.py @@ -0,0 +1,148 @@ +"""Rework engagement metadata + +Revision ID: ec0128056a33 +Revises: 08f69642b7ae +Create Date: 2023-12-18 18:37:08.781433 + +""" +from enum import auto +from alembic import op +from regex import F +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from flask import current_app + +from met_api.models.tenant import Tenant as TenantModel + + +# revision identifiers, used by Alembic. +revision = 'ec0128056a33' +down_revision = '08f69642b7ae' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('engagement_metadata_taxa', + sa.Column('created_date', sa.DateTime(), nullable=False), + sa.Column('updated_date', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False, unique=True, autoincrement=True), + sa.Column('tenant_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=64), nullable=True), + sa.Column('description', sa.String(length=256), nullable=True), + sa.Column('freeform', sa.Boolean(), nullable=False), + sa.Column('data_type', sa.String(length=64), nullable=True), + sa.Column('default_value', sa.Text(), nullable=True), + sa.Column('one_per_engagement', sa.Boolean(), nullable=True), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['tenant_id'], ['tenant.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='pk_engagement_metadata_taxa'), + sa.UniqueConstraint('id', name='uq_engagement_metadata_taxa_id') + ) + # remove old primary key constraint from engagement_metadata.engagement_id + op.drop_constraint('engagement_metadata_pkey', 'engagement_metadata', type_='primary') + op.create_index(op.f('ix_engagement_metadata_taxa_position'), 'engagement_metadata_taxa', ['position'], unique=False) + op.add_column('engagement_metadata', sa.Column('id', sa.Integer(), nullable=False)) + # add new primary key constraint on engagement_metadata.id + op.create_primary_key('pk_engagement_metadata', 'engagement_metadata', ['id']) + # add autoincrement to engagement_metadata.id by creating a sequence + op.execute('CREATE SEQUENCE engagement_metadata_id_seq START 1') + op.execute('ALTER TABLE engagement_metadata ALTER COLUMN id SET DEFAULT nextval(\'engagement_metadata_id_seq\')') + op.execute('ALTER SEQUENCE engagement_metadata_id_seq OWNED BY engagement_metadata.id') + # remove not-null constraint from engagement_metadata.engagement_id + op.alter_column('engagement_metadata', 'engagement_id', existing_type=sa.INTEGER(), nullable=True) + + op.add_column('engagement_metadata', sa.Column('taxon_id', sa.Integer(), nullable=False)) + op.add_column('engagement_metadata', sa.Column('value', sa.Text(), nullable=False)) + op.create_foreign_key('fk_engagement_meta_taxon', 'engagement_metadata', 'engagement_metadata_taxa', ['taxon_id'], ['id'], ondelete='CASCADE') + op.drop_column('engagement_metadata', 'project_tracking_id') + # add default taxa for default tenant + default_short_name = current_app.config.get('DEFAULT_TENANT_SHORT_NAME') + tenant_id = TenantModel.find_by_short_name(default_short_name).id + taxa = [ + { + 'name': 'keywords', + 'description': 'Keywords for categorizing the engagement', + 'freeform': True, + 'one_per_engagement': False, + 'data_type': 'text', + }, + { + 'name': 'description', + 'description': 'Description of the engagement', + 'freeform': True, + 'data_type': 'long_text', + }, + { + 'name': 'jira_ticket_url', + 'description': 'URL of the Jira ticket for this engagement', + 'freeform': True, + 'data_type': 'text', + }, + { + 'name': 'pmo_project_number', + 'description': 'PMO project number', + 'freeform': True, + 'data_type': 'text', + }, + { + 'name': 'engagement_category', + 'description': 'Category of the engagement', + 'data_type': 'text', + 'one_per_engagement': False, + }, + { + 'name': 'engagement_method', + 'description': 'Method of engagement', + 'data_type': 'text', + 'default_value': "Survey", + 'one_per_engagement': False, + }, + { + 'name': 'language', + 'description': 'Language of the engagement', + 'data_type': 'text', + 'default_value': "English", + 'one_per_engagement': False, + }, + { + 'name': 'ministry', + 'description': 'Ministry of the engagement', + 'data_type': 'text', + } + ] + for index, taxon in enumerate(taxa): + op.execute( + sa.text('INSERT INTO engagement_metadata_taxa (tenant_id, name, description, freeform, data_type, default_value, one_per_engagement, position, created_date, updated_date) ' + 'VALUES (:tenant_id, :name, :description, :freeform, :data_type, :default_value, :one_per_engagement, :position, now(), now())') + .params( + tenant_id=tenant_id, + name=taxon['name'], + description=taxon['description'], + freeform=taxon.get('freeform', False), + data_type=taxon['data_type'], + default_value=taxon.get('default_value'), + one_per_engagement=taxon.get('one_per_engagement', True), + position=index + 1, + ) + ) + + # ### end Alembic commands ### + + +def downgrade(): + op.add_column('engagement_metadata', sa.Column('project_tracking_id', sa.VARCHAR(length=100), autoincrement=False, nullable=True)) + op.alter_column('engagement_metadata', 'engagement_id', existing_type=sa.INTEGER(), nullable=False) + op.drop_constraint('fk_engagement_meta_taxon', 'engagement_metadata', type_='foreignkey') + op.drop_column('engagement_metadata', 'value') + op.drop_column('engagement_metadata', 'taxon_id') + # remove primary key constraint from engagement_metadata.id + op.drop_constraint('pk_engagement_metadata', 'engagement_metadata', type_='primary') + op.drop_column('engagement_metadata', 'id') + op.drop_index(op.f('ix_engagement_metadata_taxa_position'), table_name='engagement_metadata_taxa') + # add primary key constraint to engagement_metadata.engagement_id + op.create_primary_key('pk_engagement_metadata', 'engagement_metadata', ['engagement_id']) + op.drop_table('engagement_metadata_taxa') + # ### end Alembic commands ### diff --git a/met-api/sample.env b/met-api/sample.env index 61b7167ca..e083a0adf 100644 --- a/met-api/sample.env +++ b/met-api/sample.env @@ -24,11 +24,7 @@ ENGAGEMENT_END_TIME="5 PM" # Default name for the tenant. Used to initially populate the database. DEFAULT_TENANT_SHORT_NAME="GDX" DEFAULT_TENANT_NAME="Government Digital Experience Division" -DEFAULT_TENANT_DESCRIPTION="The Government Digital Experience (GDX) Division - is responsible for setting standards in delivering government information and - services digitally. Their work includes creating web content guides, ensuring - accessibility and inclusion, and overseeing forms management and visual design - for a better digital user experience." +DEFAULT_TENANT_DESCRIPTION="The GDX Division helps inform digital standards for web content, accessibility, forms, and design." # Keycloak configuration. # Populate from 'GDX Modern Engagement Tools-installation-*.json' @@ -37,7 +33,7 @@ KEYCLOAK_BASE_URL="" # auth-server-url KEYCLOAK_REALMNAME="" # realm MET_ADMIN_CLIENT_ID="" # resource MET_ADMIN_CLIENT_SECRET="" # credentials.secret -KEYCLOAK_CONNECT_TIMEOUT= 60 # seconds +KEYCLOAK_CONNECT_TIMEOUT="60" # JWT OIDC configuration for authentication # Populate from 'GDX MET web (public)-installation-*.json' @@ -111,10 +107,10 @@ CDOGS_SERVICE_CLIENT= CDOGS_SERVICE_CLIENT_SECRET= CDOGS_TOKEN_URL= -JWT_OIDC_TEST_AUDIENCE= -JWT_OIDC_TEST_CLIENT_SECRET= -JWT_OIDC_TEST_ISSUER= -JWT_OIDC_TEST_ALGORITHMS= +JWT_OIDC_TEST_AUDIENCE=met-web +JWT_OIDC_TEST_CLIENT_SECRET="1111111111" +JWT_OIDC_TEST_ISSUER=localhost.localdomain +JWT_OIDC_TEST_ALGORITHMS=RS256 # Test database settings # If unset, uses the same settings as the main database diff --git a/met-api/src/met_api/__init__.py b/met-api/src/met_api/__init__.py index 2858d08dd..05ba58f1a 100644 --- a/met-api/src/met_api/__init__.py +++ b/met-api/src/met_api/__init__.py @@ -127,7 +127,7 @@ def build_cache(app): def setup_jwt_manager(app_context, jwt_manager): """Use flask app to configure the JWTManager to work for a particular Realm.""" - def get_roles(token_info): + def get_roles(token_info) -> list: """ Consumes a token_info dictionary and returns a list of roles. @@ -135,7 +135,12 @@ def get_roles(token_info): """ role_access_path = app_context.config['JWT_CONFIG']['ROLE_CLAIM'] for key in role_access_path.split('.'): - token_info = token_info.get(key, {}) + token_info = token_info.get(key, None) + if token_info is None: + app_context.logger.warning('Unable to find role in token_info. ' + 'Please check your JWT_ROLE_CALLBACK ' + 'configuration.') + return [] return token_info app_context.config['JWT_ROLE_CALLBACK'] = get_roles diff --git a/met-api/src/met_api/auth.py b/met-api/src/met_api/auth.py index 4660aecd4..605156a7e 100644 --- a/met-api/src/met_api/auth.py +++ b/met-api/src/met_api/auth.py @@ -18,19 +18,22 @@ from flask_jwt_oidc import JwtManager from flask_jwt_oidc.exceptions import AuthError -jwt = ( - JwtManager() -) # pylint: disable=invalid-name; lower case name as used by convention in most Flask apps +auth_methods = { # for swagger documentation + 'apikey': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'Authorization' + } +} - -class Auth: # pylint: disable=too-few-public-methods - """Extending JwtManager to include additional functionalities.""" +class Auth(JwtManager): # pylint: disable=too-few-public-methods + """Extends the JwtManager to include additional functionalities.""" @classmethod def require(cls, f): """Validate the Bearer Token.""" - @jwt.requires_auth + @auth.requires_auth @wraps(f) def decorated(*args, **kwargs): g.authorization_header = request.headers.get('Authorization', None) @@ -58,7 +61,6 @@ def decorated(*args, **kwargs): return decorated - -auth = ( +jwt = auth = ( Auth() ) diff --git a/met-api/src/met_api/config.py b/met-api/src/met_api/config.py index 84c3da562..2b30e4e06 100644 --- a/met-api/src/met_api/config.py +++ b/met-api/src/met_api/config.py @@ -55,12 +55,12 @@ def get_named_config(environment: Union[str, None]) -> 'Config': } try: print(f'Loading configuration: {environment}...') - return config_mapping.get(environment, ProdConfig)() + return config_mapping.get(environment or 'production', ProdConfig)() except KeyError as e: raise KeyError(f'Configuration "{environment}" not found.') from e -def env_truthy(env_var, default: bool = False): +def env_truthy(env_var, default: Union[bool, str] = False): """ Return True if the environment variable is set to a truthy value. @@ -198,7 +198,7 @@ def SQLALCHEMY_DATABASE_URI(self) -> str: 'JWKS_URI': os.getenv('JWT_OIDC_JWKS_URI', f'{_issuer}/protocol/openid-connect/certs'), 'ALGORITHMS': os.getenv('JWT_OIDC_ALGORITHMS', 'RS256'), 'AUDIENCE': os.getenv('JWT_OIDC_AUDIENCE', 'account'), - 'CACHING_ENABLED': str(env_truthy('JWT_OIDC_CACHING_ENABLED', 'True')), + 'CACHING_ENABLED': str(env_truthy('JWT_OIDC_CACHING_ENABLED', True)), 'JWKS_CACHE_TIMEOUT': int(os.getenv('JWT_OIDC_JWKS_CACHE_TIMEOUT', '300')), 'ROLE_CLAIM': os.getenv('JWT_OIDC_ROLE_CLAIM', 'client_roles'), } diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index 951c99073..1cda9f69f 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -49,6 +49,7 @@ from .report_setting import ReportSetting from .widget_video import WidgetVideo from .cac_form import CACForm +from .engagement_metadata import MetadataTaxon, EngagementMetadata from .widget_timeline import WidgetTimeline from .timeline_event import TimelineEvent from .widget_poll import Poll diff --git a/met-api/src/met_api/models/db.py b/met-api/src/met_api/models/db.py index 62b60756f..6703cd24c 100644 --- a/met-api/src/met_api/models/db.py +++ b/met-api/src/met_api/models/db.py @@ -1,7 +1,9 @@ -"""Initilizations for db, migration and marshmallow.""" +"""Initializations for db, migration and marshmallow.""" -from contextlib import contextmanager +import logging +from typing import Callable, Optional, TypeVar +from functools import wraps from flask_marshmallow import Marshmallow from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy @@ -16,16 +18,52 @@ # Marshmallow for database model schema ma = Marshmallow() +class AbortTransaction(Exception): + """ + An exception to be raised when a transaction should be aborted. Handled + gracefully in the transactional decorator. Only raise this exception + when already inside a transactional block. + """ -@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 +T = TypeVar('T') + +def transactional(database=db, autocommit=True, end_session=False + ) -> Callable[[Callable[..., T]], Callable[..., T]]: + """ + A decorator to quickly make an operation transactional. + If there is an exception during execution, the entire session will be + safely rolled back to a point in time just before the decorated function + was called. If not, the session will be saved, unless autocommit is set + to False. This helps replace most session management boilerplate. + + Args: + db: The database instance to be used for the transaction. + autocommit: A boolean indicating whether to commit the session. Default is True. + + Returns: + The result of the wrapped function `f`. + """ + def decorator(f: Callable[..., T]) -> Callable[..., T]: + @wraps(f) + def decorated_function(*args, **kwargs)-> Optional[T]: + try: + result = f(*args, **kwargs) + if autocommit: + database.session.commit() + else: + database.session.flush() + return result + except AbortTransaction: + logging.info("Transaction aborted.") + database.session.rollback() # we meant to roll back; don't raise :) + except Exception as e: # all other exceptions + logging.exception( + "An error occurred during a transaction; rolling back.") + database.session.rollback() + raise e + finally: + if end_session: + database.session.close() + return None + return decorated_function # type: ignore + return decorator diff --git a/met-api/src/met_api/models/engagement.py b/met-api/src/met_api/models/engagement.py index b122a2684..766a02597 100644 --- a/met-api/src/met_api/models/engagement.py +++ b/met-api/src/met_api/models/engagement.py @@ -15,7 +15,6 @@ from met_api.constants.engagement_status import EngagementDisplayStatus, Status from met_api.constants.user import SYSTEM_USER -# from met_api.models.engagement_metadata import EngagementMetadataModel from met_api.models.membership import Membership as MembershipModel from met_api.models.staff_user import StaffUser from met_api.models.pagination_options import PaginationOptions @@ -39,6 +38,7 @@ class Engagement(BaseModel): start_date = db.Column(db.DateTime) end_date = db.Column(db.DateTime) status_id = db.Column(db.Integer, ForeignKey('engagement_status.id', ondelete='CASCADE')) + status = db.relationship('EngagementStatus', backref='engagement') published_date = db.Column(db.DateTime, nullable=True) scheduled_date = db.Column(db.DateTime, nullable=True) content = db.Column(db.Text, unique=False, nullable=False) @@ -47,6 +47,7 @@ class Engagement(BaseModel): surveys = db.relationship('Survey', backref='engagement', cascade='all, delete') status_block = db.relationship('EngagementStatusBlock', backref='engagement') tenant_id = db.Column(db.Integer, db.ForeignKey('tenant.id'), nullable=True) + tenant = db.relationship('Tenant', backref='engagements') is_internal = db.Column(db.Boolean, nullable=False) consent_message = db.Column(JSON, unique=False, nullable=True) diff --git a/met-api/src/met_api/models/engagement_metadata.py b/met-api/src/met_api/models/engagement_metadata.py index eb1b95cc7..20cbc2bc0 100644 --- a/met-api/src/met_api/models/engagement_metadata.py +++ b/met-api/src/met_api/models/engagement_metadata.py @@ -1,41 +1,191 @@ -"""Engagement model class. - -Manages the engagement """ - +The EngagementMetadata model represents a unit of metadata for an Engagement. +Metadata is stored as a key-value pair, where the key is a MetadataTaxon and +the value is a string. MetadataTaxa are used to group metadata by type, and +determine how it is displayed in the UI. +""" from __future__ import annotations +import enum +from sqlalchemy.orm import relationship, validates -from typing import Optional +from .base_model import BaseModel +from .db import db, transactional -# from sqlalchemy.dialects import postgresql -from sqlalchemy.sql.schema import ForeignKey -from .base_model import BaseModel -from .db import db +class EngagementMetadata(BaseModel): + """ + A unit of metadata for an Engagement. Can be used to store arbitrary data. + """ + __tablename__ = 'engagement_metadata' + id = db.Column(db.Integer, primary_key=True, nullable=False, autoincrement=True) + engagement_id = db.Column(db.Integer, + db.ForeignKey('engagement.id', ondelete='CASCADE'), nullable=True, index=True) + engagement = db.relationship('Engagement', backref='metadata') + taxon_id = db.Column(db.Integer, + db.ForeignKey('engagement_metadata_taxa.id', ondelete='CASCADE'), nullable=False, index=True) + taxon = db.relationship('MetadataTaxon', backref='entries') + value = db.Column(db.Text, index=True, nullable=False) + @property + def tenant(self): + return self.taxon.tenant if self.taxon else None -class EngagementMetadataModel(BaseModel): - """Definition of the Engagement metadata entity.""" + # Prevent primary key and foreign keys from being updated after creation + @validates('id') + def validate_id(self, _, new_id): + if self.id and self.id != new_id: + raise ValueError('Cannot change own ID') + return new_id - __tablename__ = 'engagement_metadata' - engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), primary_key=True) - # TODO: Uncomment when upcoming changes to app metadata take place - # project_metadata = db.Column(postgresql.JSONB(astext_type=db.Text()), unique=False, nullable=True) + @validates('tenant_id') + def validate_tenant_id(self, _, new_tenant_id): + if self.tenant_id and self.tenant_id != new_tenant_id: + raise ValueError('Cannot change tenant_id') + return new_tenant_id - @classmethod - def find_by_engagement_id(cls, engagement_id): - """Return engagement slug by engagement id.""" - return cls.query.filter_by(engagement_id=engagement_id).first() + @validates('engagement_id') + def validate_engagement_id(self, _, new_engagement_id): + if self.engagement_id and self.engagement_id != new_engagement_id: + raise ValueError('Cannot change engagement_id') + return new_engagement_id + + def __repr__(self) -> str: + if not self: + return '' + if (not self.engagement and not self.taxon): + return f'' + if not self.taxon: + return f'' + if not self.engagement: + return f'') + +class MetadataTaxonDataType(str, enum.Enum): + """ + The data types that can be stored in a metadata property. + """ + TEXT = 'string' + LONG_TEXT = 'long-text' + NUMBER = 'number' + DATE = 'date' + DATETIME = 'datetime' + BOOLEAN = 'boolean' + SELECT = 'select' + IMAGE = 'image' + VIDEO = 'video' + AUDIO = 'audio' + FILE = 'other_file' + URL = 'url' + EMAIL = 'email' + PHONE = 'phone' + ADDRESS = 'address' + OTHER = 'other' @classmethod - def update(cls, engagement_metadata_data: dict) -> Optional[EngagementMetadataModel]: - """Update engagement.""" - engagement_id = engagement_metadata_data.get('engagement_id', None) - query = EngagementMetadataModel.query.filter_by(engagement_id=engagement_id) - engagement_metadata: EngagementMetadataModel = query.first() - if not engagement_metadata: - return None - - # TODO: Restore partial functionality to this method when the new Engagement Metadata is added. + # pylint: disable=no-member + def has_value(cls, value: str) -> bool: + """Return True if the value is a valid data type.""" + return value in cls._value2member_map_ + +class MetadataTaxon(BaseModel): + """ + A taxon to group metadata by. Taxa determine the type of data + that can be stored in a property, and how it is displayed. + """ + + __tablename__ = 'engagement_metadata_taxa' + + id = db.Column(db.Integer, primary_key=True, unique=True, autoincrement=True) + tenant_id = db.Column(db.Integer, + db.ForeignKey('tenant.id', ondelete='CASCADE'), + nullable=False, index=True) + tenant = relationship('Tenant', backref='metadata_taxa') + name = db.Column(db.String(64), nullable=True) + description = db.Column(db.String(256), nullable=True) + freeform = db.Column(db.Boolean, nullable=False, default=False) + data_type = db.Column(db.String(64), nullable=True, default='text') + default_value = db.Column(db.Text, nullable=True) + one_per_engagement = db.Column(db.Boolean) + position = db.Column(db.Integer, nullable=False, index=True) + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + if not self.data_type: + self.data_type = 'text' + if not self.position: + # find other taxa in this tenant and set position to the next highest + max_position = MetadataTaxon.query.filter_by(tenant_id=self.tenant_id).count() + self.position = max_position + 1 + + # Prevent primary key and foreign keys from being updated after creation + @validates('id') + def validate_id(self, _, new_id): + if self.id and self.id != new_id: + raise ValueError('Cannot change own ID') + return new_id + + @validates('tenant_id') + def validate_tenant_id(self, _, new_tenant_id): + if self.tenant_id and self.tenant_id != new_tenant_id: + raise ValueError('Cannot change tenant_id') + return new_tenant_id + + def __repr__(self) -> str: + if not self: + return '' + return f'' + + + @transactional() + def move_to_position(self, new_position: int) -> None: + """ + Move this taxon to a specific position within the same tenant, + updating positions of other taxa accordingly. + """ + tenant_id = self.tenant_id + current_position = self.position + # the affected range of positions + start, end = sorted([current_position, new_position]) + # Create a query that selects the affected taxa in the tenant + affected_taxa = MetadataTaxon.query.filter( + MetadataTaxon.tenant_id == tenant_id, + MetadataTaxon.position.between(start, end) + ).all() + # Determine the direction of the position update + position_delta = -1 if new_position > current_position else 1 + # Update positions for each affected taxon + for taxon in affected_taxa: + if taxon.id != self.id: + taxon.position += position_delta + + # Finally, update the position of the current taxon + self.position = new_position db.session.commit() - return engagement_metadata + + @transactional() + def delete(self) -> None: + """ + Remove the taxon, updating the positions of subsequent taxa within the same tenant. + """ + if self is not None: + subsequent_taxa = MetadataTaxon.query.filter( + MetadataTaxon.tenant_id == self.tenant_id, + MetadataTaxon.position > self.position).all() + + for taxon in subsequent_taxa: + taxon.position -= 1 + + super().delete() + + @classmethod + @transactional() + def reorder_taxa(cls, tenant_id: int, taxon_order: list[int]) -> None: + """ + Reorder all taxa within a specific tenant based on a provided list + of taxon IDs, setting their positions accordingly. + """ + for index, taxon_id in enumerate(taxon_order): + taxon = cls.query.filter_by(tenant_id=tenant_id, taxon_id=taxon_id).first() + if taxon: + taxon.position = index diff --git a/met-api/src/met_api/models/new_engagement_metadata.py b/met-api/src/met_api/models/new_engagement_metadata.py deleted file mode 100644 index edc012728..000000000 --- a/met-api/src/met_api/models/new_engagement_metadata.py +++ /dev/null @@ -1,51 +0,0 @@ -"""The Engagement Metadata models.""" - -# from sqlalchemy.dialects import postgresql -from sqlalchemy.sql.schema import ForeignKey - -from .base_model import BaseModel -from .db import db - - -class MetadataModel(BaseModel): - """Metadata for an Engagement. Can be used to store any arbitrary data.""" - - __tablename__ = 'metadata_relationship' - tenant_id = db.Column( - db.Integer, - ForeignKey('tenant.id', ondelete='CASCADE'), - primary_key=True - ) - engagement_id = db.Column( - db.Integer, - ForeignKey('engagement.id', ondelete='CASCADE'), - primary_key=True - ) - category_id = db.Column( - db.Integer, - ForeignKey('metadata_category.category_id', ondelete='CASCADE'), - primary_key=True - ) - value = db.Column(db.String(512), unique=False, nullable=True) - - category = db.relationship('MetadataTaxonomy', backref='metadata') - engagements = db.relationship('Engagement', backref='metadata') - - -class MetadataTaxonomyModel(BaseModel): - """Defines a category of metadata fields.""" - - __tablename__ = 'metadata_category' - category_id = db.Column(db.Integer, primary_key=True) - tenant_id = db.Column( - db.Integer, - ForeignKey('tenant.id', ondelete='CASCADE'), - primary_key=True - ) - freeform = db.Column(db.Boolean, unique=False, nullable=True) - category_type = db.Column(db.String(100), unique=False, nullable=True) - one_per_engagement = db.Column(db.Boolean, unique=False, nullable=True) - name = db.Column(db.String(100), unique=False, nullable=True) - data_type = db.Column(db.String(100), unique=False, nullable=True) - description = db.Column(db.String(100), unique=False, nullable=True) - metadata = db.relationship(MetadataModel, backref='metadata_category') diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index c40463a03..7852b009f 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -30,6 +30,7 @@ from .email_verification import API as EMAIL_VERIFICATION_API from .engagement import API as ENGAGEMENT_API from .engagement_metadata import API as ENGAGEMENT_METADATA_API +from .metadata_taxon import API as METADATA_TAXON_API from .engagement_members import API as ENGAGEMENT_MEMBERS_API from .feedback import API as FEEDBACK_API from .submission import API as SUBMISSION_API @@ -78,9 +79,10 @@ API.add_namespace(WIDGET_API) API.add_namespace(CONTACT_API) API.add_namespace(VALUE_COMPONENTS_API) -API.add_namespace(ENGAGEMENT_METADATA_API) API.add_namespace(SHAPEFILE_API) API.add_namespace(TENANT_API) +API.add_namespace(METADATA_TAXON_API,path='/tenants//metadata') +API.add_namespace(ENGAGEMENT_METADATA_API, path='/engagements//metadata') API.add_namespace(ENGAGEMENT_MEMBERS_API, path='/engagements//members') API.add_namespace(WIDGET_DOCUMENTS_API, path='/widgets//documents') API.add_namespace(WIDGET_EVENTS_API, path='/widgets//events') diff --git a/met-api/src/met_api/resources/engagement_metadata.py b/met-api/src/met_api/resources/engagement_metadata.py index c73a44d80..d1cf70229 100644 --- a/met-api/src/met_api/resources/engagement_metadata.py +++ b/met-api/src/met_api/resources/engagement_metadata.py @@ -11,87 +11,152 @@ # 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 engagement resource.""" +""" +API endpoints for managing the metadata for an engagement resource. +This API is located at /api/engagements//metadata +""" from http import HTTPStatus from flask import request from flask_cors import cross_origin -from flask_restx import Namespace, Resource +from flask_restx import Namespace, Resource, fields from marshmallow import ValidationError - -from met_api.auth import auth -from met_api.auth import jwt as _jwt -from met_api.schemas.engagement_metadata import EngagementMetadataSchema +from met_api.auth import auth, auth_methods +from met_api.services import authorization +from met_api.services.engagement_service import EngagementService from met_api.services.engagement_metadata_service import EngagementMetadataService -from met_api.utils.token_info import TokenInfo +from met_api.utils.roles import Role from met_api.utils.util import allowedorigins, cors_preflight +EDIT_ENGAGEMENT_ROLES = [Role.EDIT_ENGAGEMENT.value] +VIEW_ENGAGEMENT_ROLES = [Role.VIEW_ENGAGEMENT.value, Role.EDIT_ENGAGEMENT.value] -API = Namespace('engagementsmetadata', description='Endpoints for Engagement Metadata Management') -"""Custom exception messages -""" +API = Namespace('engagement_metadata', + path='/engagements//metadata', + description='Endpoints for Engagement Metadata Management', + authorizations=auth_methods) + +metadata_update_model = API.model('EngagementMetadataUpdate', model_dict := { + 'value': fields.String(required=True, description='The value of the metadata entry'), +}) +metadata_create_model = API.model('EngagementMetadataCreate', model_dict :={ + 'taxon_id': fields.Integer(required=True, description='The id of the taxon'), + **model_dict +}) -@cors_preflight('GET,OPTIONS') -@API.route('/') +metadata_return_model = API.model('EngagementMetadataReturn', { + 'id': fields.Integer(required=True, description='The id of the metadata entry'), + 'engagement_id': fields.Integer(required=True, description='The id of the engagement'), + **model_dict +}) + +engagement_service = EngagementService() +metadata_service = EngagementMetadataService() + +@cors_preflight('GET,POST') +@API.route('') # /api/engagements/{engagement.id}/metadata +@API.doc(params={'engagement_id': 'The numeric id of the engagement'}) class EngagementMetadata(Resource): - """Resource for managing a single engagement.""" + """Resource for managing engagements' metadata.""" @staticmethod @cross_origin(origins=allowedorigins()) - @auth.optional + @API.doc(security='apikey') + @API.marshal_list_with(metadata_return_model) + @auth.has_one_of_roles(VIEW_ENGAGEMENT_ROLES) def get(engagement_id): - """Fetch a single engagement metadata matching the provided id.""" - try: - metadata_record = EngagementMetadataService().get_metadata(engagement_id) - return metadata_record, HTTPStatus.OK - except KeyError: - return 'Engagement metadata was not found', HTTPStatus.INTERNAL_SERVER_ERROR - except ValueError as err: - return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + """Fetch engagement metadata entries by engagement id.""" + return metadata_service.get_by_engagement(engagement_id) + @staticmethod + @cross_origin(origins=allowedorigins()) + @API.doc(security='apikey') + @API.expect(metadata_create_model) + @API.marshal_with(metadata_return_model, code=HTTPStatus.CREATED) # type: ignore + @auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES) + def post(engagement_id: int): + """Create a new metadata entry for an engagement.""" + authorization.check_auth(one_of_roles=EDIT_ENGAGEMENT_ROLES, + engagement_id=engagement_id) + request_json = request.get_json(force=True) + try: + engagement_metadata = metadata_service.create( + engagement_id, request_json['taxon_id'], request_json['value'] + ) + return engagement_metadata, HTTPStatus.CREATED + except KeyError as err: + return str(err), HTTPStatus.NOT_FOUND + except (ValueError, ValidationError) as err: + return str(err), HTTPStatus.BAD_REQUEST -@cors_preflight('POST, PATCH, OPTIONS') -@API.route('/') -class EngagementsMetadata(Resource): - """Resource for managing engagements metadata.""" +@cors_preflight('GET,PUT,DELETE') +@API.route('/') # /api/engagements/{engagement.id}/metadata/{metadata.id} +@API.doc(params={'engagement_id': 'The numeric id of the engagement', + 'metadata_id': 'The numeric id of the metadata entry'}) +@API.doc(security='apikey') +class EngagementMetadataById(Resource): + """Resource for managing invividual engagement metadata entries.""" @staticmethod @cross_origin(origins=allowedorigins()) - @_jwt.requires_auth - def post(): - """Create a new engagement metadata.""" + @auth.has_one_of_roles(VIEW_ENGAGEMENT_ROLES) + def get(engagement_id, metadata_id): + """Fetch an engagement metadata entry by id.""" + authorization.check_auth(one_of_roles=VIEW_ENGAGEMENT_ROLES, + engagement_id=engagement_id) try: - requestjson = request.get_json() - metadata_schema = EngagementMetadataSchema() - metadata_model = EngagementMetadataService().create_metadata(requestjson) - return metadata_schema.dump(metadata_model), HTTPStatus.OK + metadata = metadata_service.get(metadata_id) + if str(metadata['engagement_id']) != str(engagement_id): + raise ValidationError('Metadata does not belong to this ' + f"engagement:{metadata['engagement_id']}" + f" != {engagement_id}") + return metadata, HTTPStatus.OK except KeyError as err: - return str(err), HTTPStatus.INTERNAL_SERVER_ERROR - except ValueError as err: - return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + return str(err), HTTPStatus.NOT_FOUND except ValidationError as err: - return str(err.messages), HTTPStatus.INTERNAL_SERVER_ERROR + return err.messages, HTTPStatus.BAD_REQUEST @staticmethod @cross_origin(origins=allowedorigins()) - @_jwt.requires_auth - def patch(): - """Update saved engagement metadata partially.""" + @auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES) + def patch(engagement_id, metadata_id): + """Update the values of an existing metadata entry for an engagement.""" + authorization.check_auth(one_of_roles=EDIT_ENGAGEMENT_ROLES, + engagement_id=engagement_id) + request_json = request.get_json(force=True) try: - requestjson = request.get_json() - user_id = TokenInfo.get_id() - requestjson['updated_by'] = user_id + value = request_json.get('value') + if not value: + raise ValidationError('Value is required') + metadata = metadata_service.get(metadata_id) + if str(metadata['engagement_id']) != str(engagement_id): + raise ValidationError('Metadata does not belong to this ' + f"engagement:{metadata['engagement_id']}" + f" != {engagement_id}") + metadata = metadata_service.update(metadata_id, value) + return metadata, HTTPStatus.OK + except KeyError as err: + return str(err), HTTPStatus.NOT_FOUND + except ValidationError as err: + return err.messages, HTTPStatus.BAD_REQUEST - metadata_schema = EngagementMetadataSchema() - metadata_schema.load(requestjson, partial=True) - metadata = EngagementMetadataService().update_metadata(requestjson) - return metadata_schema.dump(metadata), HTTPStatus.OK + @staticmethod + @cross_origin(origins=allowedorigins()) + @auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES) + def delete(engagement_id, metadata_id): + """Delete an existing metadata entry for an engagement.""" + try: + authorization.check_auth(one_of_roles=EDIT_ENGAGEMENT_ROLES, + engagement_id=engagement_id) + metadata = metadata_service.get(metadata_id) + if str(metadata['engagement_id']) != str(engagement_id): + raise ValidationError('Metadata does not belong to this ' + f"engagement:{metadata['engagement_id']}" + f" != {engagement_id}") + metadata_service.delete(metadata_id) + return {}, HTTPStatus.NO_CONTENT except KeyError as err: - return str(err), HTTPStatus.INTERNAL_SERVER_ERROR - except ValueError as err: - return str(err), HTTPStatus.INTERNAL_SERVER_ERROR - except ValidationError as err: - return str(err.messages), HTTPStatus.INTERNAL_SERVER_ERROR + return str(err), HTTPStatus.NOT_FOUND diff --git a/met-api/src/met_api/resources/metadata_taxon.py b/met-api/src/met_api/resources/metadata_taxon.py new file mode 100644 index 000000000..7229cfda6 --- /dev/null +++ b/met-api/src/met_api/resources/metadata_taxon.py @@ -0,0 +1,196 @@ +# Copyright © 2021 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 a tenant's metadata taxa. This determines what taxa +are available for a tenant's normal users to select when creating a new +engagement. This API is located at /api/tenants//metadata/taxa +""" + +from functools import wraps +from http import HTTPStatus +from typing import Callable +from flask import request, g, abort +from flask_cors import cross_origin +from flask_restx import Namespace, Resource, fields +from marshmallow import ValidationError +from met_api.auth import auth_methods +from met_api.models.tenant import Tenant +from met_api.services.metadata_taxon_service import MetadataTaxonService +from met_api.utils.roles import Role +from met_api.utils.tenant_validator import require_role +from met_api.utils.util import allowedorigins, cors_preflight + + +VIEW_TAXA_ROLES = [Role.VIEW_TENANT.value, Role.CREATE_TENANT.value] +MODIFY_TAXA_ROLES = [Role.CREATE_TENANT.value] +TAXON_NOT_FOUND_MSG = 'Metadata taxon was not found' + +API = Namespace('metadata_taxa', description="Endpoints for managing the taxa " + "that organize a tenant's metadata. Admin-level users only.", + authorizations=auth_methods) + +taxon_service = MetadataTaxonService() + +taxon_modify_model = API.model('MetadataTaxon', taxon_model_dict := { + 'name': fields.String(required=False, description='The name of the taxon'), + 'description': fields.String(required=False, description='The taxon description'), + 'freeform': fields.Boolean(required=False, description='Whether the taxon is freeform'), + 'default_value': fields.String(required=False, description='The default value for the taxon'), + 'data_type': fields.String(required=False, description='The data type for the taxon'), + 'one_per_engagement': fields.Boolean(required=False, description='Whether the taxon is limited' + ' to one entry per engagement'), +}) + +taxon_return_model = API.model('MetadataTaxonReturn', { + 'id': fields.Integer(required=True, description='The id of the taxon'), + 'tenant_id': fields.Integer(required=True, description='The tenant id'), + 'position': fields.Integer(required=False, + description="The taxon's position within the tenant"), + **taxon_model_dict +}) + +params = {'tenant_id': 'The short name of the tenant'} + +responses = { + HTTPStatus.UNAUTHORIZED.value: 'No known user logged in', + HTTPStatus.BAD_REQUEST.value: 'Invalid request', + HTTPStatus.FORBIDDEN.value: 'Not authorized to access taxa for tenant', + HTTPStatus.NOT_FOUND.value: 'Tenant not found', + HTTPStatus.INTERNAL_SERVER_ERROR.value: 'Internal server error' +} + +def ensure_tenant_access(): + """ + Ensure that the user is authorized to access the tenant specified in the + request. This decorator should be used on any endpoint that requires + access to a tenant's data. Makes the tenant accessible via kwargs. + """ + def wrapper(f: Callable): + @wraps(f) + def decorated_function(*args, **func_kwargs): + tenant_short_name = func_kwargs.pop('tenant_name') + tenant = Tenant.find_by_short_name(tenant_short_name) + if not tenant: + abort(HTTPStatus.NOT_FOUND, + f'Tenant with short name {tenant_short_name} not found') + if tenant.short_name.upper() != g.tenant_name: + abort(HTTPStatus.FORBIDDEN, + f'You are not authorized to access tenant {tenant_short_name}') + func_kwargs['tenant'] = tenant + return f(*args, **func_kwargs) + return decorated_function + return wrapper + +@cors_preflight('GET,POST,PATCH,OPTIONS') +@API.route('/taxa') # /api/tenants/{tenant.short_name}/metadata/taxa +@API.doc(params=params, security='apikey', responses=responses) +class MetadataTaxa(Resource): + """Resource for managing engagement metadata taxa.""" + @staticmethod + @cross_origin(origins=allowedorigins()) + @API.marshal_list_with(taxon_return_model) + @ensure_tenant_access() + @require_role(VIEW_TAXA_ROLES) + def get(tenant: Tenant): + """Fetch a list of metadata taxa by tenant id.""" + tenant_taxa = taxon_service.get_by_tenant(tenant.id) + return tenant_taxa, HTTPStatus.OK + + @staticmethod + @cross_origin(origins=allowedorigins()) + @API.expect(taxon_modify_model) + @API.marshal_with(taxon_return_model, code=HTTPStatus.CREATED) # type: ignore + @ensure_tenant_access() + @require_role(MODIFY_TAXA_ROLES) + def post(tenant: Tenant): + """Create a new metadata taxon for a tenant and return it.""" + request_json = request.get_json(force=True) + try: + metadata_taxon = taxon_service.create(tenant.id, request_json) + return metadata_taxon, HTTPStatus.CREATED + except ValidationError as err: + return err.messages, HTTPStatus.BAD_REQUEST + except ValueError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @cross_origin(origins=allowedorigins()) + @API.expect({'taxon_ids': fields.List(fields.Integer(required=True))}) + @API.marshal_list_with(taxon_return_model) + @ensure_tenant_access() + @require_role(MODIFY_TAXA_ROLES) + def patch(tenant: Tenant): + """Reorder the tenant's metadata taxa.""" + request_json = request.get_json(force=True) + try: + taxon_ids = request_json['taxon_ids'] + taxon_service.reorder_tenant(tenant.id, taxon_ids) + return taxon_service.get_by_tenant(tenant.id), HTTPStatus.OK + except ValidationError as err: + return err.messages, HTTPStatus.BAD_REQUEST + except ValueError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + +params['taxon_id'] = 'The numeric id of the taxon' +responses[HTTPStatus.NOT_FOUND.value] = 'Metadata taxon or tenant not found' + +@cors_preflight('GET,PATCH,DELETE,OPTIONS') +@API.route('/taxon/') # /tenants//metadata/taxon/ +@API.doc(security='apikey', params=params, responses=responses) +class MetadataTaxon(Resource): + """Resource for managing a single metadata taxon.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @ensure_tenant_access() + @require_role(VIEW_TAXA_ROLES) + @API.marshal_with(taxon_return_model) + def get(tenant: Tenant, taxon_id: int): + """Fetch a single metadata taxon matching the provided id.""" + metadata_taxon = taxon_service.get_by_id(taxon_id) + if not metadata_taxon or metadata_taxon['tenant_id'] != tenant.id: + return TAXON_NOT_FOUND_MSG, HTTPStatus.NOT_FOUND + return metadata_taxon, HTTPStatus.OK + + @staticmethod + @cross_origin(origins=allowedorigins()) + @API.expect(taxon_modify_model) + @API.marshal_with(taxon_return_model) + @ensure_tenant_access() + @require_role(MODIFY_TAXA_ROLES) + def patch(tenant: Tenant, taxon_id: int): + """Update a metadata taxon.""" + metadata_taxon = taxon_service.get_by_id(taxon_id) + if not metadata_taxon or metadata_taxon['tenant_id'] != tenant.id: + return TAXON_NOT_FOUND_MSG, HTTPStatus.NOT_FOUND + patch = {**request.get_json(), 'tenant_id': tenant.id} + return taxon_service.update(taxon_id, patch), HTTPStatus.OK + + + @staticmethod + @cross_origin(origins=allowedorigins()) + @ensure_tenant_access() + @require_role(MODIFY_TAXA_ROLES) + @API.doc(responses={**responses, HTTPStatus.NO_CONTENT.value: 'Taxon deleted'}) + def delete(tenant: Tenant, taxon_id: int): + """Delete a metadata taxon.""" + try: + metadata_taxon = taxon_service.get_by_id(taxon_id) + if not metadata_taxon or metadata_taxon['tenant_id'] != tenant.id: + return TAXON_NOT_FOUND_MSG, HTTPStatus.NOT_FOUND + taxon_service.delete(taxon_id) + return {}, HTTPStatus.NO_CONTENT + except ValueError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/met-api/src/met_api/schemas/engagement_metadata.py b/met-api/src/met_api/schemas/engagement_metadata.py index 84e4ccd54..1ba12144f 100644 --- a/met-api/src/met_api/schemas/engagement_metadata.py +++ b/met-api/src/met_api/schemas/engagement_metadata.py @@ -1,19 +1,63 @@ -"""Engagement model class. +from met_api.models.engagement_metadata import (EngagementMetadata, + MetadataTaxon, MetadataTaxonDataType) +from marshmallow import ValidationError, fields, pre_load, validate +from marshmallow_sqlalchemy import SQLAlchemyAutoSchema +from marshmallow_sqlalchemy.fields import Nested -Manages the engagement -""" -from marshmallow import EXCLUDE, Schema, fields +class EngagementMetadataSchema(SQLAlchemyAutoSchema): + """Schema for engagement metadata.""" + class Meta: + model = EngagementMetadata + load_instance = True + include_fk = True # Include foreign keys in the schema + value = fields.String(validate=validate.Length(max=512)) + taxon_id = fields.Integer(required=True) -class EngagementMetadataSchema(Schema): - """Schema for engagement metadata.""" + @pre_load + def check_immutable_fields(self, data, **kwargs): + if self.instance: + if 'id' in data and data['id'] != self.instance.id: + raise ValidationError('id field cannot be changed.') + if 'tenant_id' in data and data['tenant_id'] != self.instance.tenant_id: + raise ValidationError('tenant_id field cannot be changed.') + if 'engagement_id' in data and data['engagement_id'] != self.instance.engagement_id: + raise ValidationError('engagement_id field cannot be changed.') + return data + + # Nested fields + taxon = Nested('MetadataTaxonSchema', many=False) + + +class MetadataTaxonSchema(SQLAlchemyAutoSchema): + """Schema for metadata taxa.""" + class Meta: + model = MetadataTaxon + load_instance = True + include_fk = True - class Meta: # pylint: disable=too-few-public-methods - """Exclude unknown fields in the deserialized output.""" + name = fields.String(required=True, validate=validate.Length(max=64)) + description = fields.String(validate=validate.Length(max=512), allow_none=True) + freeform = fields.Boolean() + default_value = fields.String(validate=validate.Length(max=512), allow_none=True) + data_type = fields.String(validate=validate.OneOf([e.value for e in MetadataTaxonDataType])) + one_per_engagement = fields.Boolean() + position = fields.Integer(required=False) + + @pre_load + def check_immutable_fields(self, data, **kwargs): + if self.instance: + if 'id' in data and data['id'] != self.instance.id: + raise ValidationError('id field cannot be changed.') + if 'tenant_id' in data and data['tenant_id'] != self.instance.tenant_id: + raise ValidationError('tenant_id field cannot be changed.') + if 'position' in data and data['position'] != self.instance.position: + raise ValidationError('Position field cannot be updated directly;' + ' use a reorder operation instead.') + return data - unknown = EXCLUDE + # Nested field + entries = Nested(EngagementMetadataSchema, many=True, exclude=['taxon']) - engagement_id = fields.Int(data_key='engagement_id') - # TODO: Uncomment depending on future metadata work - # project_metadata = fields.Dict(data_key='project_metadata') + \ No newline at end of file diff --git a/met-api/src/met_api/services/authorization.py b/met-api/src/met_api/services/authorization.py index b30cdb0de..fb39da7ca 100644 --- a/met-api/src/met_api/services/authorization.py +++ b/met-api/src/met_api/services/authorization.py @@ -14,6 +14,7 @@ from met_api.utils.enums import MembershipStatus from met_api.utils.user_context import UserContext, user_context +UNAUTHORIZED_MSG = 'You are not authorized to perform this action!' # pylint: disable=unused-argument @user_context @@ -22,6 +23,8 @@ def check_auth(**kwargs): skip_tenant_check = current_app.config.get('IS_SINGLE_TENANT_ENVIRONMENT') user_from_context: UserContext = kwargs['user_context'] user_from_db = StaffUserModel.get_user_by_external_id(user_from_context.sub) + if not user_from_db: + abort(HTTPStatus.FORBIDDEN, 'User not found') token_roles = set(user_from_context.roles) permitted_roles = set(kwargs.get('one_of_roles', [])) has_valid_roles = token_roles & permitted_roles @@ -39,7 +42,7 @@ def check_auth(**kwargs): if has_valid_team_access: return - abort(403) + abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG) def _validate_tenant(eng_id, tenant_id): @@ -48,11 +51,11 @@ def _validate_tenant(eng_id, tenant_id): return engagement_tenant_id = EngagementModel.find_tenant_id_by_id(eng_id) if engagement_tenant_id and str(tenant_id) != str(engagement_tenant_id): - current_app.logger.debug(f'Aborting . Tenant Id on Engagement and user context Mismatch\n' + current_app.logger.debug('Aborting . Tenant Id on Engagement and user context Mismatch\n' f'engagement_tenant_id:{engagement_tenant_id}\n' f'tenant_id: {tenant_id}') - abort(HTTPStatus.FORBIDDEN) + abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG) def _has_team_membership(kwargs, user_from_context, team_permitted_roles) -> bool: @@ -78,6 +81,6 @@ def _has_team_membership(kwargs, user_from_context, team_permitted_roles) -> boo current_app.logger.debug(f'Aborting . Tenant Id on membership and user context Mismatch' f'membership.tenant_id:{membership.tenant_id} ' f'user_from_context.tenant_id: {user_from_context.tenant_id}') - abort(HTTPStatus.FORBIDDEN) + abort(HTTPStatus.FORBIDDEN, UNAUTHORIZED_MSG) return membership.type.name in team_permitted_roles diff --git a/met-api/src/met_api/services/comment_service.py b/met-api/src/met_api/services/comment_service.py index 805e47d3a..8de9f4eb7 100644 --- a/met-api/src/met_api/services/comment_service.py +++ b/met-api/src/met_api/services/comment_service.py @@ -6,7 +6,6 @@ from met_api.constants.export_comments import RejectionReason from met_api.models import Survey as SurveyModel from met_api.models.comment import Comment -# from met_api.models.engagement_metadata import EngagementMetadataModel from met_api.models.membership import Membership as MembershipModel from met_api.models.pagination_options import PaginationOptions from met_api.models.submission import Submission as SubmissionModel diff --git a/met-api/src/met_api/services/email_verification_service.py b/met-api/src/met_api/services/email_verification_service.py index d361e73bb..3872c8f7f 100644 --- a/met-api/src/met_api/services/email_verification_service.py +++ b/met-api/src/met_api/services/email_verification_service.py @@ -13,7 +13,6 @@ from met_api.models import Survey as SurveyModel from met_api.models import Tenant as TenantModel from met_api.models.email_verification import EmailVerification -# from met_api.models.engagement_metadata import EngagementMetadataModel from met_api.schemas.email_verification import EmailVerificationSchema from met_api.services.participant_service import ParticipantService from met_api.utils import notification diff --git a/met-api/src/met_api/services/engagement_metadata_service.py b/met-api/src/met_api/services/engagement_metadata_service.py index 84233ef91..4f2f3420e 100644 --- a/met-api/src/met_api/services/engagement_metadata_service.py +++ b/met-api/src/met_api/services/engagement_metadata_service.py @@ -1,84 +1,157 @@ """Service for engagement management.""" -from datetime import datetime -from met_api.constants.engagement_status import Status -from met_api.constants.membership_type import MembershipType +from typing import List, Optional +from met_api.models import db +from met_api.models.db import transactional from met_api.models.engagement import Engagement as EngagementModel -from met_api.models.engagement_metadata import EngagementMetadataModel +from met_api.models.engagement_metadata import EngagementMetadata, MetadataTaxon from met_api.schemas.engagement_metadata import EngagementMetadataSchema -from met_api.services import authorization -from met_api.services.project_service import ProjectService -from met_api.utils.roles import Role class EngagementMetadataService: """Engagement metadata management service.""" @staticmethod - def get_metadata(engagement_id) -> EngagementMetadataSchema: - """Get Engagement metadata by the id.""" - engagement_model: EngagementModel = EngagementModel.find_by_id(engagement_id) - if engagement_model.status_id in (Status.Draft.value, Status.Scheduled.value): - one_of_roles = ( - MembershipType.TEAM_MEMBER.name, - Role.VIEW_ALL_ENGAGEMENTS.value - ) - authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement_id) - - metadata_model: EngagementMetadataModel = EngagementMetadataModel.find_by_id(engagement_id) - metadata = EngagementMetadataSchema().dump(metadata_model) - return metadata + def get(metadata_id) -> dict: + """ + Get engagement metadata by id. + Args: + id: The ID of the engagement metadata. + Returns: + A serialized point of engagement metadata. + Raises: + HTTP 404 error if the engagement metadata is not found. + Authorization error if the user does not have the required role. + """ + engagement_metadata = EngagementMetadata.query.get(metadata_id) + if not engagement_metadata: + raise KeyError(f'Engagement metadata with id {metadata_id} does not exist.') + return dict(EngagementMetadataSchema().dump(engagement_metadata)) @staticmethod - def create_metadata(request_json: dict): - """Create engagement metadata.""" - if engagement_id := request_json.get('engagement_id', None): - one_of_roles = ( - MembershipType.TEAM_MEMBER.name, - Role.CREATE_ENGAGEMENT.value - ) - authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement_id) + def get_by_engagement(engagement_id) -> List[dict]: + """ + Get metadata by engagement id. + Args: + engagement_id: The ID of the engagement. + Returns: + A list of serialized engagement metadata points. + Raises: + HTTP 404 error if the engagement is not found. + """ + engagement_model = EngagementModel.query.get(engagement_id) + if not engagement_model: + raise KeyError(f'Engagement with id {engagement_id} does not exist.') + return EngagementMetadataSchema(many=True).dump(engagement_model.metadata) - metadata_model = EngagementMetadataService._create_metadata_model(request_json) - metadata_model.commit() - updated_metadata: EngagementMetadataModel = metadata_model.find_by_id(metadata_model.engagement_id) - # publish changes to EPIC - ProjectService.update_project_info(updated_metadata.engagement_id) - return updated_metadata + @staticmethod + def check_association(engagement_id, metadata_id) -> bool: + """ + Check if some metadata is actually associated with an engagement. + Used to prevent users from accessing metadata that does not belong to + an engagement they have access to. + Args: + engagement_id: The ID of the engagement. + metadata_id: The ID of the metadata. + Returns: + True if the metadata is associated with the engagement, False otherwise. + """ + engagement_metadata = EngagementMetadata.query.get(metadata_id) + if not engagement_metadata: + raise KeyError(f'Engagement metadata with id {metadata_id} does not exist.') + return engagement_metadata.engagement_id == engagement_id @staticmethod - def _create_metadata_model(metadata: dict) -> EngagementMetadataModel: - """Save engagement metadata.""" - new_metadata_model = EngagementMetadataModel( - engagement_id=metadata.get('engagement_id', None), - # TODO: Uncomment depending on future metadata work - # project_metadata=metadata.get('project_metadata', None), - created_date=datetime.utcnow(), - updated_date=None, + @transactional(database=db) + def create(engagement_id: int, taxon_id:int, value:str) -> dict: + """ + Create engagement metadata. + Args: + engagement_id: The ID of the engagement. + taxon_id: The ID of the metadata taxon. + value: The value of the metadata. + Returns: + The created metadata. + """ + # Ensure that the engagement exists, or else raise the appropriate error + engagement = EngagementModel.query.get(engagement_id) + if not engagement: + raise KeyError(f'Engagement with id {engagement_id} does not exist.') + taxon = MetadataTaxon.query.get(taxon_id) + if not taxon: + raise ValueError(f'Taxon with id {taxon_id} does not exist.') + if engagement.tenant.id != taxon.tenant.id: + raise ValueError(f'Taxon {taxon} does not belong to tenant {engagement.tenant}') + metadata = { + 'engagement_id': engagement_id, + 'taxon_id': taxon_id, + 'value': value, + } + engagement_metadata = EngagementMetadataSchema().load( + metadata, session=db.session ) - new_metadata_model.save() - return new_metadata_model + db.session.add(engagement_metadata) # type: ignore + engagement_metadata.save() + return dict(EngagementMetadataSchema().dump(engagement_metadata)) - @staticmethod - def update_metadata(data: dict): - """Update engagement metadata partially.""" - engagement_id = data.get('engagement_id', None) - if not engagement_id: - raise KeyError('Engagement id is required.') + def create_for_engagement(self, engagement_id: int, metadata: dict, **kwargs) -> Optional[dict]: + """ + Create engagement metadata. + Args: + engagement_id: The ID of the engagement. + metadata: The point of engagement metadata to create. + Returns: + The created metadata. + """ + metadata = metadata or {} + metadata = self.create(metadata, engagement_id, **kwargs) - authorization.check_auth( - one_of_roles=(MembershipType.TEAM_MEMBER.name, Role.EDIT_ENGAGEMENT.value), - engagement_id=engagement_id - ) - saved_metadata = EngagementMetadataModel.find_by_id(engagement_id) + @staticmethod + def create_defaults(engagement_id: int, tenant_id:int) -> List[dict]: + """Create default metadata for an engagement.""" + # Get metadata taxa for the tenant + taxa = MetadataTaxon.query.filter_by(tenant_id=tenant_id).all() + # Create a list of metadata to create + metadata = [] + for taxon in taxa: + if taxon.default_value: + metadata.append(EngagementMetadataService.create( + engagement_id, + taxon.id, + taxon.default_value)) + return metadata - if saved_metadata: - updated_metadata = EngagementMetadataModel.update(data) - else: - updated_metadata = EngagementMetadataService._create_metadata_model(data) - # publish changes to EPIC - ProjectService.update_project_info(updated_metadata.engagement_id) + @staticmethod + @transactional() + def update(metadata_id: int, value: str) -> dict: + """ + Update engagement metadata. + Args: + id: The ID of the engagement metadata. + metadata: The fields to update. + Returns: + The updated metadata. + """ + metadata = EngagementMetadata.query.get(metadata_id) + if not metadata: + raise KeyError(f'Engagement metadata with id {metadata_id}' + ' does not exist.') + metadata.value = value + return dict(EngagementMetadataSchema().dump(metadata, many=False)) - return updated_metadata + @staticmethod + @transactional() + def delete(metadata_id: int) -> None: + """ + Delete engagement metadata. + Args: + id: The ID of the engagement metadata. + """ + metadata = EngagementMetadata.query.get(metadata_id) + if not metadata: + raise KeyError(f'Engagement metadata with id {metadata_id}' + ' does not exist.') + db.session.delete(metadata) # type: ignore + \ No newline at end of file diff --git a/met-api/src/met_api/services/keycloak.py b/met-api/src/met_api/services/keycloak.py index 743267806..0e4bf3fe4 100644 --- a/met-api/src/met_api/services/keycloak.py +++ b/met-api/src/met_api/services/keycloak.py @@ -39,7 +39,7 @@ def get_user_groups(user_id): } # Get the user and return - query_user_url = f'{base_url}/auth/admin/realms/{realm}/users/{user_id}/groups' + query_user_url = f'{base_url}/admin/realms/{realm}/users/{user_id}/groups' response = requests.get(query_user_url, headers=headers, timeout=timeout) response.raise_for_status() return response.json() @@ -64,7 +64,7 @@ def get_users_groups(user_ids: List): user_group_mapping = {} # Get the user and return for user_id in user_ids: - query_user_url = f'{base_url}/auth/admin/realms/{realm}/users/{user_id}/groups' + query_user_url = f'{base_url}/admin/realms/{realm}/users/{user_id}/groups' response = requests.get(query_user_url, headers=headers, timeout=timeout) if response.status_code == 200: @@ -82,7 +82,7 @@ def _get_group_id(admin_token: str, group_name: str): base_url = keycloak['BASE_URL'] realm = keycloak['REALMNAME'] timeout = keycloak['CONNECT_TIMEOUT'] - get_group_url = f'{base_url}/auth/admin/realms/{realm}/groups?search={group_name}' + get_group_url = f'{base_url}/admin/realms/{realm}/groups?search={group_name}' headers = { 'Content-Type': ContentType.JSON.value, 'Authorization': f'Bearer {admin_token}' @@ -139,7 +139,7 @@ def _remove_user_from_group(user_id: str, group_name: str): 'Content-Type': ContentType.JSON.value, 'Authorization': f'Bearer {admin_token}' } - remove_group_url = f'{base_url}/auth/admin/realms/{realm}/users/{user_id}/groups/{group_id}' + remove_group_url = f'{base_url}/admin/realms/{realm}/users/{user_id}/groups/{group_id}' response = requests.delete(remove_group_url, headers=headers, timeout=timeout) response.raise_for_status() @@ -161,7 +161,7 @@ def add_user_to_group(user_id: str, group_name: str): 'Content-Type': ContentType.JSON.value, 'Authorization': f'Bearer {admin_token}' } - add_to_group_url = f'{base_url}/auth/admin/realms/{realm}/users/{user_id}/groups/{group_id}' + add_to_group_url = f'{base_url}/admin/realms/{realm}/users/{user_id}/groups/{group_id}' response = requests.put(add_to_group_url, headers=headers, timeout=timeout) response.raise_for_status() @@ -169,16 +169,16 @@ def add_user_to_group(user_id: str, group_name: str): @staticmethod def add_attribute_to_user(user_id: str, attribute_value: str, attribute_id: str = 'tenant_id'): """Add attribute to a keyclaok user.Default is set as tenant Id.""" - config = current_app.config - base_url = config.get('KEYCLOAK_BASE_URL') - realm = config.get('KEYCLOAK_REALMNAME') + config = current_app.config['KEYCLOAK_CONFIG'] + base_url = config.get('BASE_URL') + realm = config.get('REALMNAME') admin_token = KeycloakService._get_admin_token() tenant_attributes = { attribute_id: attribute_value } - user_url = f'{base_url}/auth/admin/realms/{realm}/users/{user_id}' + user_url = f'{base_url}/admin/realms/{realm}/users/{user_id}' headers = {'Authorization': f'Bearer {admin_token}'} response = requests.get(user_url, headers=headers) user_data = response.json() @@ -203,7 +203,7 @@ def remove_user_from_group(user_id: str, group_name: str): 'Content-Type': ContentType.JSON.value, 'Authorization': f'Bearer {admin_token}' } - remove_from_group_url = f'{base_url}/auth/admin/realms/{realm}/users/{user_id}/groups/{group_id}' + remove_from_group_url = f'{base_url}/admin/realms/{realm}/users/{user_id}/groups/{group_id}' response = requests.delete(remove_from_group_url, headers=headers, timeout=timeout) response.raise_for_status() @@ -223,7 +223,7 @@ def add_user(user: dict): 'Authorization': f'Bearer {admin_token}' } - add_user_url = f'{base_url}/auth/admin/realms/{realm}/users' + add_user_url = f'{base_url}/admin/realms/{realm}/users' response = requests.post(add_user_url, data=json.dumps(user), headers=headers, timeout=timeout) response.raise_for_status() @@ -245,7 +245,7 @@ def get_user_by_username(username, admin_token=None): 'Authorization': f'Bearer {admin_token}' } # Get the user and return - query_user_url = f'{base_url}/auth/admin/realms/{realm}/users?username={username}' + query_user_url = f'{base_url}/admin/realms/{realm}/users?username={username}' response = requests.get(query_user_url, headers=headers, timeout=timeout) return response.json()[0] @@ -267,6 +267,6 @@ def toggle_user_enabled_status(user_id, enabled): } # Update the user's enabled status - update_user_url = f'{base_url}/auth/admin/realms/{realm}/users/{user_id}' + update_user_url = f'{base_url}/admin/realms/{realm}/users/{user_id}' response = requests.put(update_user_url, json=user_data, headers=headers, timeout=timeout) response.raise_for_status() diff --git a/met-api/src/met_api/services/metadata_taxon_service.py b/met-api/src/met_api/services/metadata_taxon_service.py new file mode 100644 index 000000000..00d8979f7 --- /dev/null +++ b/met-api/src/met_api/services/metadata_taxon_service.py @@ -0,0 +1,100 @@ +"""Service for engagement management.""" + +from typing import List, Optional, Union + +from met_api.models import db +from met_api.models.db import transactional +from met_api.models.engagement_metadata import MetadataTaxon +from met_api.models.tenant import Tenant +from met_api.schemas.engagement_metadata import MetadataTaxonSchema + +class MetadataTaxonService: + """Metadata taxon management service.""" + + @staticmethod + def get_by_id(taxon_id: int) -> Optional[dict]: + """Get a taxon by id.""" + taxon = MetadataTaxon.query.get(taxon_id) + if not taxon: + return None + return dict(MetadataTaxonSchema().dump(taxon)) + + + @staticmethod + def get_by_tenant(tenant_id: int) -> List[dict]: + """Get all taxa for a tenant.""" + tenant = Tenant.query.get(tenant_id) + results = tenant.metadata_taxa if tenant else [] + sorted_results = sorted(results, key=lambda taxon: taxon.position) + return MetadataTaxonSchema(many=True).dump(sorted_results) + + + @staticmethod + def create(tenant_id: int, taxon_data: dict) -> dict: + """Create a new taxon.""" + taxon_data['tenant_id'] = tenant_id + taxon: MetadataTaxon = MetadataTaxonSchema().load(taxon_data, session=db.session) + taxon.position = MetadataTaxon.query.filter_by(tenant_id=tenant_id).count() + 1 + taxon.save() + return dict(MetadataTaxonSchema().dump(taxon)) + + + @staticmethod + def update(taxon_id: int, taxon_data: dict) -> Union[dict, list]: + """Update a taxon.""" + taxon = MetadataTaxon.query.get(taxon_id) + if not taxon: + raise KeyError(f'Taxon with id {taxon_id} does not exist.') + schema = MetadataTaxonSchema() + taxon = schema.load(taxon_data, session=db.session, instance=taxon) + taxon.save() + return schema.dump(taxon) + + @staticmethod + @transactional() + def reorder_tenant(tenant_id: int, taxon_ids: List[int]) -> List[dict]: + """ + Reorder all taxa within a specific tenant based on a provided list of taxon IDs, + setting their positions accordingly. + """ + # get all taxa for the tenant + tenant = Tenant.query.get(tenant_id) + if not tenant: + raise KeyError(f'Tenant with id {tenant_id} does not exist.') + taxa: List[MetadataTaxon] = list(tenant.metadata_taxa) + # create a dictionary of taxon IDs to taxon objects + taxon_dict = {taxon.id: taxon for taxon in taxa} + # iterate through the provided taxon IDs and update their positions + for index, taxon_id in enumerate(taxon_ids): + taxon = taxon_dict[taxon_id] + taxon.position = index + 1 + # return MetadataTaxonService.get_by_tenant(tenant_id) + # It's possible that method was called with a list of taxon IDs that is not + # contiguous, so we need to ensure that all taxa have a valid position + return MetadataTaxonService.auto_order_tenant(tenant_id) + + + @staticmethod + @transactional() + def auto_order_tenant(tenant_id: int) -> List[dict]: + """ + Automatically order all taxa within a specific tenant based on their + current positions. This has the benefit of ensuring that the position + indices are contiguous and that there are no gaps or duplicates. The + new ordering will be as close to the original as possible. + """ + tenant = Tenant.query.get(tenant_id) + schema = MetadataTaxonSchema() + taxon_ordered = sorted(tenant.metadata_taxa, key=lambda taxon: taxon.position) + for index, taxon in enumerate(taxon_ordered): + taxon.position = index + 1 + return schema.dump(taxon_ordered, many=True) + + @staticmethod + @transactional() + def delete(taxon_id: int) -> None: + """Delete a taxon.""" + taxon: MetadataTaxon = MetadataTaxon.query.get(taxon_id) + if not taxon: + raise KeyError(f'Taxon with id {taxon_id} does not exist.') + taxon.delete() diff --git a/met-api/src/met_api/services/object_storage_service.py b/met-api/src/met_api/services/object_storage_service.py index be5500bf1..c6b93e5ef 100644 --- a/met-api/src/met_api/services/object_storage_service.py +++ b/met-api/src/met_api/services/object_storage_service.py @@ -4,10 +4,9 @@ from typing import List import requests -from flask import current_app from aws_requests_auth.aws_auth import AWSRequestsAuth -from markupsafe import string +from met_api.config import Config from met_api.schemas.document import Document @@ -17,7 +16,7 @@ class ObjectStorageService: def __init__(self): """Initialize the service.""" # initialize s3 config from environment variables - s3_client = current_app.config['S3_CONFIG'] + s3_client = Config().S3_CONFIG self.s3_auth = AWSRequestsAuth( aws_access_key=s3_client['ACCESS_KEY_ID'], aws_secret_access_key=s3_client['SECRET_ACCESS_KEY'], @@ -27,7 +26,7 @@ def __init__(self): ) self.s3_bucket = s3_client['BUCKET'] - def get_url(self, filename: string): + def get_url(self, filename: str): """Get the object url.""" if(not self.s3_auth.aws_host or not self.s3_bucket or diff --git a/met-api/src/met_api/services/project_service.py b/met-api/src/met_api/services/project_service.py index a9aca2644..2e1ce178b 100644 --- a/met-api/src/met_api/services/project_service.py +++ b/met-api/src/met_api/services/project_service.py @@ -5,7 +5,7 @@ from flask import current_app from met_api.models.engagement import Engagement as EngagementModel -from met_api.models.engagement_metadata import EngagementMetadataModel +from met_api.models.engagement_metadata import EngagementMetadata as EngagementMetadataModel from met_api.services.email_verification_service import EmailVerificationService from met_api.services.rest_service import RestService from met_api.utils import notification diff --git a/met-api/src/met_api/services/submission_service.py b/met-api/src/met_api/services/submission_service.py index a3ccdc666..abfeb3410 100644 --- a/met-api/src/met_api/services/submission_service.py +++ b/met-api/src/met_api/services/submission_service.py @@ -17,7 +17,7 @@ from met_api.models import Tenant as TenantModel from met_api.models.comment import Comment from met_api.models.comment_status import CommentStatus -from met_api.models.db import session_scope +from met_api.models.db import db, transactional from met_api.models.engagement_slug import EngagementSlug as EngagementSlugModel from met_api.models.pagination_options import PaginationOptions from met_api.models.participant import Participant as ParticipantModel @@ -73,6 +73,7 @@ def get_by_token(cls, token): return PublicSubmissionSchema().dump(submission) @classmethod + @transactional() def create(cls, token, submission: SubmissionSchema): """Create submission.""" cls._validate_fields(submission) @@ -83,26 +84,24 @@ def create(cls, token, submission: SubmissionSchema): if SubmissionService.is_unpublished(engagement_id): return {} - # Creates a scoped session that will be committed when diposed or rolledback if a exception occurs - with session_scope() as session: - email_verification = EmailVerificationService().verify( - token, survey_id, None, session) - participant_id = email_verification.get('participant_id') - submission['participant_id'] = participant_id - submission['created_by'] = participant_id - submission['engagement_id'] = engagement_id - - submission_result = SubmissionModel.create(submission, session) - submission['id'] = submission_result.id - comments = CommentService.extract_comments_from_survey( - submission, survey) - CommentService().create_comments(comments, session) - - engagement_settings: EngagementSettingsModel =\ - EngagementSettingsModel.find_by_id(engagement_id) - if engagement_settings: - if engagement_settings.send_report: - SubmissionService._send_submission_response_email(participant_id, engagement_id) + email_verification = EmailVerificationService().verify( + token, survey_id, None, db.session) + participant_id = email_verification.get('participant_id') + submission['participant_id'] = participant_id + submission['created_by'] = participant_id + submission['engagement_id'] = engagement_id + + submission_result = SubmissionModel.create(submission, db.session) + submission['id'] = submission_result.id + comments = CommentService.extract_comments_from_survey( + submission, survey) + CommentService().create_comments(comments, db.session) + + engagement_settings: EngagementSettingsModel =\ + EngagementSettingsModel.find_by_id(engagement_id) + if engagement_settings: + if engagement_settings.send_report: + SubmissionService._send_submission_response_email(participant_id, engagement_id) return submission_result @classmethod @@ -123,14 +122,12 @@ def update_comments(cls, token, data: PublicSubmissionSchema): return {} submission.comment_status_id = Status.Pending - - with session_scope() as session: - EmailVerificationService().verify( - token, submission.survey_id, submission.id, session) - comments_result = [Comment.update( - submission.id, comment, session) for comment in data.get('comments', [])] - SubmissionModel.update(SubmissionSchema().dump(submission), session) - return comments_result + EmailVerificationService().verify( + token, submission.survey_id, submission.id, db.session) + comments_result = [Comment.update(submission.id, comment, db.session) + for comment in data.get('comments', [])] + SubmissionModel.update(SubmissionSchema().dump(submission), db.session) + return comments_result @staticmethod def _validate_fields(submission): @@ -158,22 +155,19 @@ def review_comment(cls, submission_id, staff_review_details: dict, external_user staff_review_details['reviewed_by'] = reviewed_by staff_review_details['user_id'] = user.get('id') - - with session_scope() as session: - should_send_email = SubmissionService._should_send_email( - submission_id, staff_review_details, submission.engagement_id) - submission = SubmissionModel.update_comment_status( - submission_id, staff_review_details, session) - if staff_notes := staff_review_details.get('staff_note', []): - cls.add_or_update_staff_note( - submission.survey_id, submission_id, staff_notes) - - if should_send_email: - rejection_review_note = StaffNote.get_staff_note_by_type( - submission_id, StaffNoteType.Review.name) - SubmissionService._trigger_email( - rejection_review_note[0].note, session, staff_review_details, submission) - session.commit() + should_send_email = SubmissionService._should_send_email( + submission_id, staff_review_details, submission.engagement_id) + submission = SubmissionModel.update_comment_status( + submission_id, staff_review_details, db.session) + if staff_notes := staff_review_details.get('staff_note', []): + cls.add_or_update_staff_note( + submission.survey_id, submission_id, staff_notes) + + if should_send_email: + rejection_review_note = StaffNote.get_staff_note_by_type( + submission_id, StaffNoteType.Review.name) + SubmissionService._trigger_email( + rejection_review_note[0].note, db.session, staff_review_details, submission) return SubmissionSchema().dump(submission) @staticmethod diff --git a/met-api/src/met_api/services/survey_service.py b/met-api/src/met_api/services/survey_service.py index 61cc05acc..7a6ac9b16 100644 --- a/met-api/src/met_api/services/survey_service.py +++ b/met-api/src/met_api/services/survey_service.py @@ -5,7 +5,7 @@ from met_api.constants.membership_type import MembershipType from met_api.models import Engagement as EngagementModel from met_api.models import Survey as SurveyModel -from met_api.models.db import session_scope +from met_api.models.db import db, transactional from met_api.models.pagination_options import PaginationOptions from met_api.models.report_setting import ReportSetting from met_api.models.survey_search_options import SurveySearchOptions @@ -154,14 +154,12 @@ def clone(cls, data, survey_id): return cloned_survey @classmethod + @transactional() def create_report_setting(cls, survey_id, cloned_survey_id): """Create report setting.""" report_settings = ReportSetting.find_by_survey_id(survey_id) - - with session_scope() as session: - new_report_setting = ReportSetting.add_all_report_settings(cloned_survey_id, - report_settings, session) - + new_report_setting = ReportSetting.add_all_report_settings( + cloned_survey_id, report_settings, db.session) return new_report_setting @classmethod diff --git a/met-api/src/met_api/utils/user_context.py b/met-api/src/met_api/utils/user_context.py index 9ca283659..3c071a3aa 100644 --- a/met-api/src/met_api/utils/user_context.py +++ b/met-api/src/met_api/utils/user_context.py @@ -14,7 +14,7 @@ """User Context to hold request scoped variables.""" import functools -from typing import Dict +from typing import Dict, List from flask import current_app, g, request @@ -81,6 +81,10 @@ def sub(self) -> str: def has_role(self, role_name: str) -> bool: """Return True if the user has the role.""" return role_name in self._roles + + def has_roles(self, role_names: List[str]) -> bool: + """Return True if the user has some of the roles.""" + return bool(set(self._roles) & set(role_names)) def is_staff_admin(self) -> bool: """Return True if the user is staff user.""" diff --git a/met-api/src/met_api/utils/util.py b/met-api/src/met_api/utils/util.py index fdfb642c4..b6367d6fc 100644 --- a/met-api/src/met_api/utils/util.py +++ b/met-api/src/met_api/utils/util.py @@ -29,12 +29,12 @@ def cors_preflight(methods): def wrapper(f): def options(self, *args, **kwargs): # pylint: disable=unused-argument - return {'Allow': 'GET, DELETE, PUT, POST'}, 200, \ - { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': methods, - 'Access-Control-Allow-Headers': 'Authorization, Content-Type, registries-trace-id, ' - 'invitation_token'} + return {'Allow': 'GET, DELETE, PUT, POST'}, 200, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': methods, + 'Access-Control-Allow-Headers': 'Authorization, Content-Type, ' + 'registries-trace-id, invitation_token' + } setattr(f, 'options', options) return f diff --git a/met-api/tests/unit/api/test_engagement_metadata.py b/met-api/tests/unit/api/test_engagement_metadata.py new file mode 100644 index 000000000..31430a2cd --- /dev/null +++ b/met-api/tests/unit/api/test_engagement_metadata.py @@ -0,0 +1,145 @@ +# Copyright © 2019 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. + +""" +Tests for the Engagement Metadata endpoints. +""" + +import json +from http import HTTPStatus +from faker import Faker +from met_api.utils.enums import ContentType +from met_api.services.engagement_metadata_service import EngagementMetadataService +from met_api.services.metadata_taxon_service import MetadataTaxonService +from tests.utilities.factory_utils import ( + factory_auth_header, factory_engagement_metadata_model, + factory_metadata_taxon_model, + factory_tenant_model, factory_metadata_requirements) +from tests.utilities.factory_scenarios import ( + TestJwtClaims, TestEngagementMetadataInfo, + TestTenantInfo) + +engagement_metadata_service = EngagementMetadataService() +metatada_taxon_service = MetadataTaxonService() + +fake = Faker() + +def test_get_engagement_metadata(client, jwt, session): + """Test that metadata can be retrieved by engagement id.""" + taxon, engagement, _, headers = factory_metadata_requirements(jwt) + assert engagement and engagement.id is not None + assert taxon and taxon.id is not None + metadata = factory_engagement_metadata_model({ + 'engagement_id':engagement.id, + 'taxon_id':taxon.id, + 'value': fake.sentence(), + }) + existing_metadata = engagement_metadata_service.get_by_engagement(engagement.id) + assert existing_metadata is not None + response = client.get(f'/api/engagements/{engagement.id}/metadata', + headers=headers, content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.OK + metadata_list = response.json + assert len(metadata_list) == 1 + assert metadata_list[0]['id'] == metadata.id + assert metadata_list[0]['engagement_id'] == metadata.engagement_id + + +def test_add_engagement_metadata(client, jwt, session): + """Test that metadata can be added to an engagement.""" + taxon, engagement, _, headers = factory_metadata_requirements(jwt) + data = { + 'taxon_id':taxon.id, + 'value': fake.sentence(), + } + response = client.post(f'/api/engagements/{engagement.id}/metadata', + headers=headers, + data=json.dumps(data), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.CREATED + assert response.json.get('id') is not None + assert response.json.get('value') == data['value'] + assert response.json.get('engagement_id') == engagement.id + +def test_add_engagement_metadata_invalid_engagement(client, jwt, session): + """Test that metadata cannot be added to an invalid engagement.""" + taxon, engagement, _, headers = factory_metadata_requirements(jwt) + data = {'taxon_id':taxon.id, 'value': fake.sentence()} + response = client.post(f'/api/engagements/{engagement.id+1}/metadata', + headers=headers, + data=json.dumps(data), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.NOT_FOUND, (f"Wrong response code; " + f"HTTP {response.status_code} -> {response.text}") + + +def test_add_engagement_metadata_invalid_tenant(client, jwt, session): + """Test that metadata cannot be added to an engagement in another tenant.""" + _, engagement, tenant, headers = factory_metadata_requirements(jwt) + # create a second tenant to test with + tenant2 = factory_tenant_model(TestTenantInfo.tenant2) + assert tenant2.id != tenant.id + taxon = factory_metadata_taxon_model(tenant2.id) + data = {'taxon_id':taxon.id, 'value': fake.sentence()} + response = client.post(f'/api/engagements/{engagement.id}/metadata', + headers=headers, + data=json.dumps(data), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.BAD_REQUEST, (f"Wrong response code; " + f"HTTP {response.status_code} -> {response.text}") + +def test_add_engagement_metadata_invalid_user(client, jwt, session): + """Test that metadata cannot be added by an unauthorized user.""" + taxon, engagement, _, _ = factory_metadata_requirements(jwt) + headers = factory_auth_header(jwt, claims=TestJwtClaims.no_role) + metadata_info = TestEngagementMetadataInfo.metadata1 + metadata_info['taxon_id'] = taxon.id + response = client.post(f'/api/engagements/{engagement.id}/metadata', + headers=headers, + data=json.dumps(metadata_info), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.UNAUTHORIZED + +def test_update_engagement_metadata(client, jwt, session): + """Test that metadata values can be updated.""" + taxon, engagement, _, headers = factory_metadata_requirements(jwt) + metadata = factory_engagement_metadata_model({ + 'taxon_id':taxon.id, + 'engagement_id':engagement.id, + 'value': 'old value' + }) + response = client.patch(f'/api/engagements/{engagement.id}/metadata/{metadata.id}', + headers=headers, + data=json.dumps({'value': 'new value'}), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.OK, (f"Wrong response code; " + f"HTTP {response.status_code} -> {response.text}") + assert response.json is not None + assert response.json.get('id') == metadata.id + assert response.json.get('engagement_id') == engagement.id + assert response.json.get('value') == 'new value' + +def test_delete_engagement_metadata(client, jwt, session): + """Test that metadata can be deleted.""" + taxon, engagement, _, headers = factory_metadata_requirements(jwt) + metadata = factory_engagement_metadata_model({ + 'engagement_id':engagement.id, + 'taxon_id':taxon.id, + 'value': fake.sentence(), + }) + response = client.delete(f'/api/engagements/{engagement.id}/metadata/{metadata.id}', + headers=headers, + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.NO_CONTENT, (f"Wrong response code; " + f"HTTP {response.status_code} -> {response.text}") \ No newline at end of file diff --git a/met-api/tests/unit/api/test_metadata_taxa.py b/met-api/tests/unit/api/test_metadata_taxa.py new file mode 100644 index 000000000..a16d5419b --- /dev/null +++ b/met-api/tests/unit/api/test_metadata_taxa.py @@ -0,0 +1,118 @@ +# Copyright © 2019 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. + +""" +Tests for Metadata Taxon related endpoints. +""" + +import json +from http import HTTPStatus +from met_api.utils.enums import ContentType +from met_api.models.engagement_metadata import MetadataTaxon +from met_api.services.engagement_metadata_service import EngagementMetadataService +from met_api.services.metadata_taxon_service import MetadataTaxonService +from tests.utilities.factory_utils import ( + factory_metadata_taxon_model, + factory_taxon_requirements) +from tests.utilities.factory_scenarios import ( + TestEngagementMetadataTaxonInfo) + +engagement_metadata_service = EngagementMetadataService() +metatada_taxon_service = MetadataTaxonService() + +def test_get_tenant_metadata_taxa(client, jwt, session): + """Test that metadata taxon can be retrieved by tenant id.""" + tenant, headers = factory_taxon_requirements(jwt) + metadata_taxon = factory_metadata_taxon_model(tenant.id) + assert metatada_taxon_service.get_by_tenant(tenant.id) is not None + response = client.get(f'/api/tenants/{tenant.short_name}/metadata/taxa', + headers=headers, content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.OK + metadata_taxon_list = response.json + assert len(metadata_taxon_list) == 1, metadata_taxon_list + assert metadata_taxon_list[0]['id'] == metadata_taxon.id + assert metadata_taxon_list[0]['tenant_id'] == metadata_taxon.tenant_id + +def test_get_taxon_by_id(client, jwt, session): + """Test that metadata taxon can be retrieved by id.""" + tenant, headers = factory_taxon_requirements(jwt) + metadata_taxon = factory_metadata_taxon_model(tenant.id) + assert metatada_taxon_service.get_by_id(metadata_taxon.id) is not None + response = client.get(f'/api/tenants/{tenant.short_name}/metadata/taxon/{metadata_taxon.id}', + headers=headers, content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.OK + metadata_taxon = response.json + assert metadata_taxon['id'] is not None + assert metadata_taxon['tenant_id'] == tenant.id + +def test_add_metadata_taxon(client, jwt, session): + """Test that metadata taxon can be added to a tenant.""" + tenant, headers = factory_taxon_requirements(jwt) + response = client.post(f'/api/tenants/{tenant.short_name}/metadata/taxa', + headers=headers, + data=json.dumps(TestEngagementMetadataTaxonInfo.taxon1), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.CREATED + assert response.json.get('id') is not None + assert response.json.get('name') == TestEngagementMetadataTaxonInfo.taxon1['name'] + assert MetadataTaxonService.get_by_id(response.json.get('id')) is not None + +def test_update_metadata_taxon(client, jwt, session): + """Test that metadata taxon can be updated.""" + tenant, headers = factory_taxon_requirements(jwt) + taxon = factory_metadata_taxon_model(tenant.id) + data = TestEngagementMetadataTaxonInfo.taxon2 + del data['tenant_id'] + del data['position'] + response = client.patch(f'/api/tenants/{tenant.short_name}/metadata/taxon/{taxon.id}', + headers=headers, + data=json.dumps(data), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.OK + assert response.json.get('id') is not None, response.json + assert response.json.get('name') == TestEngagementMetadataTaxonInfo.taxon2['name'] + assert MetadataTaxonService.get_by_id(response.json.get('id')) is not None + +def test_reorder_tenant_metadata_taxa(client, jwt, session): + """Test that metadata taxa can be reordered.""" + tenant, headers = factory_taxon_requirements(jwt) + taxon1 = factory_metadata_taxon_model(tenant.id, TestEngagementMetadataTaxonInfo.taxon1) + taxon2 = factory_metadata_taxon_model(tenant.id, TestEngagementMetadataTaxonInfo.taxon2) + taxon3 = factory_metadata_taxon_model(tenant.id, TestEngagementMetadataTaxonInfo.taxon3) + assert all([taxon1 is not None, taxon2 is not None, taxon3 is not None]) + response = client.patch(f'/api/tenants/{tenant.short_name}/metadata/taxa', + headers=headers, + data=json.dumps({'taxon_ids':[ + taxon3.id, taxon1.id, taxon2.id + ]}), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.OK, response.json + assert len(response.json) == 3 + assert response.json[0].get('id') == taxon3.id + assert response.json[1].get('id') == taxon1.id + assert response.json[2].get('id') == taxon2.id + assert MetadataTaxon.query.get(taxon3.id).position == 1 + assert MetadataTaxon.query.get(taxon1.id).position == 2 + assert MetadataTaxon.query.get(taxon2.id).position == 3 + +def test_delete_taxon(client, jwt, session): + """Test that a metadata taxon can be deleted.""" + tenant, headers = factory_taxon_requirements(jwt) + taxon = factory_metadata_taxon_model(tenant.id) + assert metatada_taxon_service.get_by_id(taxon.id) is not None + response = client.delete(f'/api/tenants/{tenant.short_name}/metadata/taxon/{taxon.id}', + headers=headers, + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.NO_CONTENT + assert metatada_taxon_service.get_by_id(taxon.id) is None \ No newline at end of file diff --git a/met-api/tests/unit/api/test_survey.py b/met-api/tests/unit/api/test_survey.py index 1f47fa918..3829027df 100644 --- a/met-api/tests/unit/api/test_survey.py +++ b/met-api/tests/unit/api/test_survey.py @@ -105,6 +105,7 @@ def test_create_survey_with_tenant(client, jwt, session, # emulate Tenant 2 staff admin by setting tenant id staff_info = dict(TestUserInfo.user_staff_3) + staff_info['tenant_id'] = tenant_2.id user = factory_staff_user_model(user_info=staff_info) claims = copy.deepcopy(TestJwtClaims.staff_admin_role.value) claims['sub'] = str(user.external_id) diff --git a/met-api/tests/unit/models/test_engagement_metadata.py b/met-api/tests/unit/models/test_engagement_metadata.py new file mode 100644 index 000000000..9d4a15771 --- /dev/null +++ b/met-api/tests/unit/models/test_engagement_metadata.py @@ -0,0 +1,83 @@ +# Copyright © 2019 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. +"""Tests for the Org model. + +Test suite to ensure that the Engagement model routines are working as expected. +""" + +from faker import Faker + +from met_api.models.engagement_metadata import EngagementMetadata +from met_api.models.engagement_metadata import MetadataTaxon +from tests.utilities.factory_utils import factory_engagement_metadata_model, factory_metadata_requirements + +fake = Faker() + + +def test_create_basic_engagement_metadata(session): + """Assert that new engagement metadata can be created.""" + taxon, engagement,tenant, _ = factory_metadata_requirements() + engagement_metadata = factory_engagement_metadata_model({ + "tenant_id": tenant.id, + "engagement_id": engagement.id, + "taxon_id": taxon.id, + "value": fake.text(max_nb_chars=256) + }) + assert engagement_metadata.id is not None, ( + "Engagement Metadata ID is missing") + engagement_metadata_existing = EngagementMetadata.find_by_id(engagement_metadata.id) + assert engagement_metadata.value == engagement_metadata_existing.value, ( + "Engagement Metadata value is missing or incorrect") + +def test_engagement_metadata_relationships(session): + """Assert that engagement metadata relationships are working.""" + taxon, engagement,tenant, _ = factory_metadata_requirements() + assert tenant.id is not None, "Tenant ID is missing" + assert engagement.id is not None, "Engagement ID is missing" + assert taxon.id is not None, "Taxon ID is missing" + engagement_metadata: MetadataTaxon = factory_engagement_metadata_model( + { + "tenant_id": tenant.id, + "engagement_id": engagement.id, + "taxon_id": taxon.id, + "value": fake.text(max_nb_chars=256) + } + ) + assert taxon == engagement_metadata.taxon, "Taxon missing relation with engagement metadata" + assert engagement == engagement_metadata.engagement, "Engagement missing relation with metadata" + assert tenant == engagement_metadata.tenant, "Tenant missing relation with engagement metadata" + assert tenant == taxon.tenant, "Tenant missing relation with taxon" + assert taxon in tenant.metadata_taxa, "Taxon missing relation with tenant" + assert engagement_metadata in taxon.entries, "Engagement metadata missing relation with taxon" + +def test_create_engagement_metadata(session): + """Assert that metadata can be added to an engagement.""" + taxon, engagement, tenant, _ = factory_metadata_requirements() + assert tenant.id is not None, "Tenant ID is missing" + assert engagement.id is not None, "Engagement ID is missing" + assert taxon.id is not None, "Taxon ID is missing" + engagement_metadata = factory_engagement_metadata_model({ + "tenant_id": tenant.id, + "engagement_id": engagement.id, + "taxon_id": taxon.id, + "value": fake.text(max_nb_chars=256) + }) + assert engagement_metadata.id is not None, "Engagement Metadata ID is missing" + engagement_metadata_existing = EngagementMetadata.find_by_id(engagement_metadata.id) + assert engagement_metadata.value == engagement_metadata_existing.value, ( + "Engagement Metadata value is missing or incorrect") + assert engagement_metadata.taxon_id == engagement_metadata_existing.taxon_id, ( + "Engagement Metadata taxon ID is missing or incorrect") + assert engagement_metadata.engagement_id == engagement_metadata_existing.engagement_id, ( + "Engagement Metadata engagement ID is missing or incorrect") diff --git a/met-api/tests/unit/models/test_metadata_taxa.py b/met-api/tests/unit/models/test_metadata_taxa.py new file mode 100644 index 000000000..3ee3260d2 --- /dev/null +++ b/met-api/tests/unit/models/test_metadata_taxa.py @@ -0,0 +1,82 @@ +# Copyright © 2019 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. +"""Tests for the Org model. + +Test suite to ensure that the Engagement model routines are working as expected. +""" + +from faker import Faker + +from met_api.models.engagement_metadata import MetadataTaxon +from tests.utilities.factory_scenarios import TestEngagementMetadataTaxonInfo +from tests.utilities.factory_utils import factory_metadata_taxon_model, \ + factory_tenant_model + +fake = Faker() + +def test_create_metadata_taxon(session): + """Assert that a new metadata taxon can be created and retrieved.""" + taxon = factory_metadata_taxon_model() + taxon.save() + assert taxon.id is not None + taxon_existing = MetadataTaxon.find_by_id(taxon.id) + assert taxon.name == taxon_existing.name + + +def test_delete_taxon(session): + """Assert that a taxon can be deleted.""" + tenant = factory_tenant_model() + test_info = TestEngagementMetadataTaxonInfo + taxon1 = factory_metadata_taxon_model(tenant.id, test_info.taxon1) + taxon2 = factory_metadata_taxon_model(tenant.id, test_info.taxon2) + taxon3 = factory_metadata_taxon_model(tenant.id, test_info.taxon3) + assert taxon1.id is not None, "Taxon 1 ID is missing" + assert taxon2.id is not None, "Taxon 2 ID is missing" + assert taxon3.id is not None, "Taxon 3 ID is missing" + # Check initial order + check_taxon_order(session, [taxon1, taxon2, taxon3], [1, 2, 3]) + taxon2.delete() + check_taxon_order(session, [taxon1, taxon3], [1, 2]) + taxon1.delete() + assert taxon3.position == 1, "Taxon 3 should be in the only position" + taxon3.delete() + assert MetadataTaxon.query.get(taxon1.id) is None, "The taxon should not exist" + +def check_taxon_order(session, taxa, expected_order): + """Helper function to assert the order of taxa.""" + actual_order = [taxon.position for taxon in taxa] + assert actual_order == expected_order, f"Taxon order is incorrect ({actual_order} != {expected_order})" + +def test_move_taxon_to_position(session): + """Assert that a taxon can be moved to a new position.""" + tenant = factory_tenant_model() + test_info = TestEngagementMetadataTaxonInfo + taxon1 = factory_metadata_taxon_model(tenant.id, test_info.taxon1) + taxon2 = factory_metadata_taxon_model(tenant.id, test_info.taxon2) + taxon3 = factory_metadata_taxon_model(tenant.id, test_info.taxon3) + test_taxa = [taxon1, taxon2, taxon3] + session.add_all(test_taxa) + session.commit() + assert all([taxon.id is not None for taxon in test_taxa]), "Taxon ID is missing" + # Check initial order + check_taxon_order(session, test_taxa, [1, 2, 3]) + taxon1.move_to_position(3) + # Check that the array of positions reflects the order we expect + check_taxon_order(session, test_taxa, [3, 1, 2]) + taxon2.move_to_position(3) + check_taxon_order(session, test_taxa, [2, 3, 1]) + taxon1.move_to_position(1) + check_taxon_order(session, test_taxa, [1, 3, 2]) + taxon2.move_to_position(2) + check_taxon_order(session, test_taxa, [1, 2, 3]) \ No newline at end of file diff --git a/met-api/tests/unit/services/test_engagement_metadata.py b/met-api/tests/unit/services/test_engagement_metadata.py new file mode 100644 index 000000000..81a4648f8 --- /dev/null +++ b/met-api/tests/unit/services/test_engagement_metadata.py @@ -0,0 +1,115 @@ +# Copyright © 2019 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. +""" +Test suite for the Engagement Metadata service. +""" + +from faker import Faker +from met_api.models import tenant + +from met_api.services.engagement_metadata_service import EngagementMetadataService +from tests.utilities.factory_scenarios import TestEngagementMetadataInfo +from tests.utilities.factory_utils import factory_engagement_metadata_model, \ + factory_metadata_requirements + +fake = Faker() +engagement_metadata_service = EngagementMetadataService() + +TAXON_ID_INCORRECT_MSG = "Taxon ID is incorrect" +ENGAGEMENT_ID_INCORRECT_MSG = "Engagement ID is incorrect or missing" + +def test_get_engagement_metadata(session): + """Assert that engagement metadata can be retrieved by engagement id.""" + taxon, engagement, _ , _ = factory_metadata_requirements() + assert engagement.id is not None + assert taxon.id is not None + eng_meta: dict = engagement_metadata_service.create( + taxon_id=taxon.id, engagement_id=engagement.id, + value=TestEngagementMetadataInfo.metadata1['value'] + ) + existing_metadata = engagement_metadata_service.get_by_engagement(engagement.id) + assert any (meta['id'] == eng_meta['id'] for meta in existing_metadata) + +def test_get_engagement_metadata_by_id(session): + """Assert that engagement metadata can be retrieved by id.""" + taxon, engagement, _ , _ = factory_metadata_requirements() + assert engagement.id is not None + assert taxon.id is not None + eng_meta: dict = engagement_metadata_service.create( + taxon_id=taxon.id, engagement_id=engagement.id, + value=TestEngagementMetadataInfo.metadata1['value'] + ) + existing_metadata = engagement_metadata_service.get(eng_meta['id']) + assert existing_metadata.get('id') == eng_meta['id'], ENGAGEMENT_ID_INCORRECT_MSG + assert existing_metadata.get('taxon_id') == taxon.id, TAXON_ID_INCORRECT_MSG + +def test_create_engagement_metadata(session): + """Assert that engagement metadata can be created.""" + taxon, engagement, _ , _ = factory_metadata_requirements() + assert taxon.id is not None, "Taxon ID is missing" + assert engagement.id is not None, "Engagement ID is missing" + eng_meta: dict = engagement_metadata_service.create( + engagement_id=engagement.id, taxon_id=taxon.id, + value=TestEngagementMetadataInfo.metadata1['value'], + ) + assert eng_meta.get('id') is not None, ENGAGEMENT_ID_INCORRECT_MSG + assert eng_meta.get('taxon_id') == taxon.id, TAXON_ID_INCORRECT_MSG + existing_metadata = engagement_metadata_service.get(eng_meta['id']) + assert existing_metadata.get('id') == eng_meta['id'], ENGAGEMENT_ID_INCORRECT_MSG + assert existing_metadata.get('taxon_id') == taxon.id, TAXON_ID_INCORRECT_MSG + +def test_default_engagement_metadata(session): + """Assert that engagement metadata can be created with default value.""" + taxon, engagement, tenant, _ = factory_metadata_requirements() + assert taxon.id is not None, "Taxon ID is missing" + assert engagement.id is not None, "Engagement ID is missing" + taxon.default_value = 'default value' + eng_meta: list = engagement_metadata_service.create_defaults( + engagement_id=engagement.id, tenant_id=tenant.id + ) + assert len(eng_meta) == 1, "Default engagement metadata not created" + assert eng_meta[0].get('id') is not None, ENGAGEMENT_ID_INCORRECT_MSG + assert eng_meta[0].get('value') == 'default value', "Default value is incorrect" + +def test_update_engagement_metadata(session): + """Assert that engagement metadata can be updated.""" + NEW_VALUE = 'new value' + OLD_VALUE = 'old value' + taxon, engagement, _ , _ = factory_metadata_requirements() + assert engagement.id is not None + assert taxon.id is not None + eng_meta = factory_engagement_metadata_model({ + 'engagement_id': engagement.id, + 'taxon_id': taxon.id, + 'value': OLD_VALUE + }) + existing_metadata = engagement_metadata_service.get_by_engagement(engagement.id) + assert existing_metadata + metadata_updated = engagement_metadata_service.update(eng_meta.id, NEW_VALUE) + assert metadata_updated['value'] == NEW_VALUE + existing_metadata2 = engagement_metadata_service.get_by_engagement(engagement.id) + assert any(meta['value'] == NEW_VALUE for meta in existing_metadata2) + +def test_delete_engagement_metadata(session): + """Assert that engagement metadata can be deleted.""" + taxon, engagement, _ , _ = factory_metadata_requirements() + eng_meta = factory_engagement_metadata_model({ + 'engagement_id': engagement.id, + 'taxon_id': taxon.id, + }) + existing_metadata = engagement_metadata_service.get_by_engagement(engagement.id) + assert any(em['id'] == eng_meta.id for em in existing_metadata) + engagement_metadata_service.delete(eng_meta.id) + existing_metadata = engagement_metadata_service.get_by_engagement(engagement.id) + assert not any(em['id'] == eng_meta.id for em in existing_metadata) \ No newline at end of file diff --git a/met-api/tests/unit/services/test_metadata_taxa.py b/met-api/tests/unit/services/test_metadata_taxa.py new file mode 100644 index 000000000..8e7f27c3f --- /dev/null +++ b/met-api/tests/unit/services/test_metadata_taxa.py @@ -0,0 +1,134 @@ +# Copyright © 2019 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. + +"""Tests for the metadata taxon service.""" + +from faker import Faker +from met_api.services.metadata_taxon_service import MetadataTaxonService +from met_api.services.engagement_metadata_service import EngagementMetadataService +from tests.utilities.factory_scenarios import TestEngagementMetadataTaxonInfo +from tests.utilities.factory_utils import factory_metadata_taxon_model, factory_taxon_requirements + +fake = Faker() +engagement_metadata_service = EngagementMetadataService() + +def test_create_taxon(session): + """Assert that taxa can be created and retrieved by id.""" + tenant, _ = factory_taxon_requirements() + taxon_service = MetadataTaxonService() + taxon = taxon_service.create(tenant.id, + TestEngagementMetadataTaxonInfo.taxon1) + assert taxon.get('id') is not None + taxon_existing = taxon_service.get_by_id(taxon['id']) + assert taxon_existing is not None + assert taxon['name'] == taxon_existing['name'] + +def test_insert_taxon(session): + """Assert that created taxa are positioned correctly by inserting 2 taxa.""" + tenant, _ = factory_taxon_requirements() + taxon_service = MetadataTaxonService() + taxon1 = taxon_service.create(tenant.id, + TestEngagementMetadataTaxonInfo.taxon1) + assert taxon1.get('id') is not None + taxon2 = taxon_service.create(tenant.id, + TestEngagementMetadataTaxonInfo.taxon3) + assert taxon2.get('id') is not None + taxon2_existing = taxon_service.get_by_id(taxon2['id']) + assert taxon2_existing is not None + assert taxon2['name'] == taxon2_existing['name'] + taxon1_existing = taxon_service.get_by_id(taxon1['id']) + assert taxon1_existing is not None + assert taxon1['name'] == taxon1_existing['name'] + assert taxon1['position'] == 1 + assert taxon2['position'] == 2 + +def test_get_by_tenant(session): + """Assert that all taxa for a tenant can be retrieved.""" + tenant, _ = factory_taxon_requirements() + taxon_service = MetadataTaxonService() + # Create multiple taxa for the tenant + taxon1 = taxon_service.create(tenant.id, TestEngagementMetadataTaxonInfo.taxon1) + taxon2 = taxon_service.create(tenant.id, TestEngagementMetadataTaxonInfo.taxon2) + # Retrieve taxa for tenant and assert + tenant_taxa = taxon_service.get_by_tenant(tenant.id) + assert taxon1 in tenant_taxa and taxon2 in tenant_taxa + +def test_get_by_id(session): + """Assert that taxa can be retrieved by id.""" + tenant, _ = factory_taxon_requirements() + taxon_service = MetadataTaxonService() + taxon = factory_metadata_taxon_model(tenant.id) + assert taxon.id is not None + taxon_existing = taxon_service.get_by_id(taxon.id) + assert taxon_existing is not None + assert taxon.name == taxon_existing['name'] + +def test_update_taxon(session): + """Assert that taxa can be updated.""" + taxon_service = MetadataTaxonService() + tenant, _ = factory_taxon_requirements() + taxon = taxon_service.create(tenant.id, + TestEngagementMetadataTaxonInfo.taxon1) + assert taxon.get('id') is not None + taxon_existing = taxon_service.get_by_id(taxon['id']) + assert taxon_existing is not None + assert taxon['name'] == taxon_existing['name'] + taxon['name'] = 'Updated Taxon' + taxon_updated = taxon_service.update(taxon['id'], taxon) + assert taxon_updated['name'] == 'Updated Taxon' + +def test_delete_taxon(session): + """Assert that taxa can be deleted.""" + taxon_service = MetadataTaxonService() + tenant, _ = factory_taxon_requirements() + taxon = factory_metadata_taxon_model(tenant.id) + assert taxon.id is not None + taxon_service.delete(taxon.id) + taxon_existing = taxon_service.get_by_id(taxon.id) + assert taxon_existing is None + +def test_reorder_tenant(session): + """Assert that taxa can be reordered within a tenant.""" + tenant, _ = factory_taxon_requirements() + taxon_service = MetadataTaxonService() + # Create multiple taxa + taxon1 = taxon_service.create(tenant.id, TestEngagementMetadataTaxonInfo.taxon2) + taxon2 = taxon_service.create(tenant.id, TestEngagementMetadataTaxonInfo.taxon1) + assert taxon1['position'] == 1 and taxon2['position'] == 2 + # Reorder taxa + new_order = [taxon2['id'], taxon1['id']] + updated_taxa = taxon_service.reorder_tenant(tenant.id, new_order) + # Assert new order + assert updated_taxa[0]['id'] == taxon2['id'] and updated_taxa[0]['position'] == 1 + assert updated_taxa[1]['id'] == taxon1['id'] and updated_taxa[1]['position'] == 2 + +def test_auto_order_tenant(session): + """Assert that taxa positions are automatically ordered correctly.""" + tenant, _ = factory_taxon_requirements() + taxon_service = MetadataTaxonService() + # Create multiple taxa + for i in range(10): + gap_position = fake.random_int(min=2, max=9) + taxon = factory_metadata_taxon_model(tenant.id) + position = fake.random_int(min=1, max=9) + # hacky way to guarantee at least 1 duplicate position, and a random gap + # the idea is to generate invalid data for the auto_order_tenant function to fix + if position == gap_position: + position = 10 + taxon.position = position + tenant_taxa = taxon_service.auto_order_tenant(tenant.id) + # Assert new order + for i in range(10): + #Every number appears once + assert tenant_taxa[i]['position'] == i + 1 \ No newline at end of file diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index ca0d45c7c..c1c96234f 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -249,6 +249,80 @@ class TestEngagementInfo(dict, Enum): \"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"' } +class TestEngagementMetadataInfo(dict, Enum): + """Test data for engagement metadata.""" + metadata0 = { + 'engagement_id': None, + 'taxon_id': None, + 'value': fake.name() + } + metadata1 = { + 'engagement_id': 1, + 'taxon_id': 1, + 'value': fake.name() + } + metadata2 = { + 'engagement_id': 1, + 'taxon_id': 2, + 'value': fake.name() + } + metadata3 = { + 'engagement_id': 1, + 'taxon_id': 2, + 'value': fake.name() + } + metadata4 = { + 'engagement_id': 2, + 'taxon_id': 1, + 'value': fake.name() + } + + +class TestEngagementMetadataTaxonInfo(dict, Enum): + """Test data for engagement metadata taxa.""" + + taxon1 = { + 'name': fake.name(), + 'description': fake.text(max_nb_chars=256), + 'data_type': 'string', + 'freeform': True, + 'one_per_engagement': False, + 'default_value': None, + 'position': 1 + } + + taxon2 = { + 'name': fake.name(), + 'description': fake.text(max_nb_chars=256), + 'tenant_id': 1, + 'data_type': 'url', + 'freeform': True, + 'one_per_engagement': False, + 'default_value': None, + 'position': 2 + } + + taxon3 = { + 'name': fake.name(), + 'description': fake.text(max_nb_chars=256), + 'tenant_id': 1, + 'data_type': 'string', + 'freeform': False, + 'one_per_engagement': True, + 'default_value': None, + 'position': 3 + } + + taxon4 = { + 'name': fake.name(), + 'description': fake.text(max_nb_chars=256), + 'tenant_id': 1, + 'data_type': 'string', + 'freeform': False, + 'one_per_engagement': False, + 'default_value': fake.name(), + 'position': 4 + } class TestFeedbackInfo(dict, Enum): """Test scenarios of feedback.""" @@ -347,7 +421,9 @@ class TestJwtClaims(dict, Enum): 'export_proponent_comment_sheet', 'export_internal_comment_sheet', 'export_cac_form_to_sheet', - 'view_members' + 'view_members', + 'create_tenant', + 'view_tenant', ] } team_member_role = { diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index 7fa29a843..15c4ebc86 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -15,8 +15,10 @@ Test Utility for creating model factory. """ +from typing import Optional from faker import Faker from flask import current_app, g +from met_api.auth import Auth from met_api.config import get_named_config from met_api.constants.engagement_status import Status @@ -26,6 +28,7 @@ from met_api.models.email_verification import EmailVerification as EmailVerificationModel from met_api.models.engagement import Engagement as EngagementModel from met_api.models.engagement_settings import EngagementSettingsModel +from met_api.models.engagement_metadata import EngagementMetadata, MetadataTaxon as MetadataTaxon from met_api.models.engagement_slug import EngagementSlug as EngagementSlugModel from met_api.models.feedback import Feedback as FeedbackModel from met_api.models.membership import Membership as MembershipModel @@ -49,10 +52,10 @@ from met_api.utils.enums import MembershipStatus from tests.utilities.factory_scenarios import ( TestCommentInfo, TestEngagementInfo, TestEngagementSlugInfo, TestFeedbackInfo, TestParticipantInfo, - TestPollAnswerInfo, TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, - TestTimelineInfo, TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, TestWidgetMap, - TestWidgetPollInfo, TestWidgetVideo) - + TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, TestTimelineInfo, TestUserInfo, + TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, TestWidgetMap, TestWidgetVideo, TestJwtClaims, + TestEngagementMetadataTaxonInfo, TestEngagementMetadataInfo, TestPollAnswerInfo, TestPollResponseInfo, + TestWidgetPollInfo) CONFIG = get_named_config('testing') fake = Faker() @@ -144,6 +147,8 @@ def factory_engagement_model(eng_info: dict = TestEngagementInfo.engagement1, na end_date=eng_info.get('end_date'), is_internal=eng_info.get('is_internal') ) + if tenant_id := eng_info.get('tenant_id'): + engagement.tenant_id = tenant_id engagement.save() return engagement @@ -161,17 +166,69 @@ def factory_tenant_model(tenant_info: dict = TestTenantInfo.tenant1): return tenant +def factory_engagement_metadata_model( + metadata_info: dict = TestEngagementMetadataInfo.metadata0): + """Produce a test-ready engagement metadata model.""" + metadata = EngagementMetadata( + engagement_id=metadata_info.get('engagement_id'), + taxon_id=metadata_info.get('taxon_id'), + value=metadata_info.get('value', fake.text()), + ) + metadata.save() + return metadata + +def factory_metadata_requirements(auth: Optional[Auth]=None): + """Create a tenant, an associated staff user, and engagement, for tests.""" + tenant = factory_tenant_model() + tenant.short_name = fake.lexify(text='????').upper() + (engagement_info := TestEngagementInfo.engagement1.copy())['tenant_id'] = tenant.id + engagement = factory_engagement_model(engagement_info) + (staff_info := TestUserInfo.user_staff_1.copy())['tenant_id'] = tenant.id + factory_staff_user_model(TestJwtClaims.staff_admin_role['sub'], staff_info) + taxon = factory_metadata_taxon_model(tenant.id) + if auth: + headers = factory_auth_header(auth, claims=TestJwtClaims.staff_admin_role, tenant_id=tenant.short_name) + return taxon, engagement, tenant, headers + return taxon, engagement, tenant, None + +def factory_taxon_requirements(auth: Optional[Auth]=None): + """Create a tenant and staff user, and headers for auth.""" + tenant = factory_tenant_model() + tenant.short_name = fake.lexify(text='????').upper() + (staff_info := TestUserInfo.user_staff_1.copy())['tenant_id'] = tenant.id + factory_staff_user_model(TestJwtClaims.staff_admin_role.get('sub'), staff_info) + if auth: + headers = factory_auth_header(auth, claims=TestJwtClaims.staff_admin_role, tenant_id=tenant.short_name) + return tenant, headers + return tenant, None + +def factory_metadata_taxon_model(tenant_id: int = 1, + taxon_info: dict = TestEngagementMetadataTaxonInfo.taxon1): + """Produce a test-ready metadata taxon model.""" + taxon = MetadataTaxon( + tenant_id=tenant_id, + name=taxon_info.get('name'), + description=taxon_info.get('description'), + freeform=taxon_info.get('freeform'), + data_type=taxon_info.get('data_type'), + default_value=taxon_info.get('default_value'), + one_per_engagement=taxon_info.get('one_per_engagement'), + position=taxon_info.get('position'), + ) + taxon.save() + return taxon + + def factory_staff_user_model(external_id=None, user_info: dict = TestUserInfo.user_staff_1): """Produce a staff user model.""" # Generate a external id if not passed - external_id = fake.random_number( - digits=5) if external_id is None else external_id + external_id = external_id or fake.uuid4() user = StaffUserModel( + external_id=str(external_id), first_name=user_info['first_name'], last_name=user_info['last_name'], middle_name=user_info['middle_name'], email_address=user_info['email_address'], - external_id=str(external_id), status_id=user_info['status_id'], tenant_id=user_info['tenant_id'], ) @@ -216,11 +273,12 @@ def factory_feedback_model(feedback_info: dict = TestFeedbackInfo.feedback1, sta return feedback -def factory_auth_header(jwt, claims): +def factory_auth_header(jwt, claims, tenant_id=None): """Produce JWT tokens for use in tests.""" return { 'Authorization': 'Bearer ' + jwt.create_jwt(claims=claims, header=JWT_HEADER), - TENANT_ID_HEADER: current_app.config.get('DEFAULT_TENANT_SHORT_NAME'), + TENANT_ID_HEADER: (tenant_id or + current_app.config.get('DEFAULT_TENANT_SHORT_NAME')), } @@ -309,6 +367,8 @@ def token_info(): monkeypatch.setattr( 'met_api.utils.user_context._get_token_info', token_info) + # Add a database user that matches the token + # factory_staff_user_model(external_id=claims.get('sub')) def factory_engagement_slug_model(eng_slug_info: dict = TestEngagementSlugInfo.slug1): """Produce a engagement model.""" diff --git a/met-web/nginx/nginx.dev.conf b/met-web/nginx/nginx.dev.conf index 5fb4ae837..312199cf5 100644 --- a/met-web/nginx/nginx.dev.conf +++ b/met-web/nginx/nginx.dev.conf @@ -46,9 +46,9 @@ http { worker-src 'self' blob:; img-src 'self' data: blob: https://citz-gdx.objectstore.gov.bc.ca; style-src 'self' 'unsafe-inline'; - connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://epic-engage-analytics-api-dev.apps.gold.devops.gov.bc.ca https://epic-engage-web-dev.apps.gold.devops.gov.bc.ca https://met-analytics-api-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com https://vimeo.com; - frame-src 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://epic-engage-web-dev.apps.gold.devops.gov.bc.ca https://epic-engage-analytics-api-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca https://met-analytics-dev.apps.gold.devops.gov.bc.ca https://www.youtube.com https://player.vimeo.com; - frame-ancestors 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca"; + connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://met-analytics-api-dev.apps.gold.devops.gov.bc.ca https://dev.loginproxy.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com https://vimeo.com; + frame-src 'self' https://dev.loginproxy.gov.bc.ca https://met-analytics-dev.apps.gold.devops.gov.bc.ca https://www.youtube.com https://player.vimeo.com; + frame-ancestors 'self' https://dev.loginproxy.gov.bc.ca"; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; add_header X-Content-Type-Options "nosniff"; add_header X-XSS-Protection 1; @@ -61,14 +61,7 @@ http { error_log /dev/stdout info; access_log /dev/stdout; - # Add a readiness check endpoint - location /readiness { - return 200 "OK"; - } - location / { - auth_basic "Restricted Area"; - auth_basic_user_file /etc/nginx/.htpasswd; root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; diff --git a/met-web/nginx/nginx.prod.conf b/met-web/nginx/nginx.prod.conf index 76893cdea..9b889ccd4 100644 --- a/met-web/nginx/nginx.prod.conf +++ b/met-web/nginx/nginx.prod.conf @@ -46,9 +46,9 @@ http { worker-src 'self' blob:; img-src 'self' data: blob: https://citz-gdx.objectstore.gov.bc.ca; style-src 'self' 'unsafe-inline'; - connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://epic-engage-analytics-api-prod.apps.gold.devops.gov.bc.ca https://epic-engage-web-prod.apps.gold.devops.gov.bc.ca https://met-analytics-api-prod.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-prod.apps.gold.devops.gov.bc.ca https://met-oidc.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com https://vimeo.com; - frame-src 'self' https://met-oidc.apps.gold.devops.gov.bc.ca https://epic-engage-analytics-api-prod.apps.gold.devops.gov.bc.ca https://epic-engage-web-prod.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-prod.apps.gold.devops.gov.bc.ca https://met-analytics-prod.apps.gold.devops.gov.bc.ca https://www.youtube.com https://player.vimeo.com; - frame-ancestors 'self' https://met-oidc.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-prod.apps.gold.devops.gov.bc.ca"; + connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://met-analytics-api-prod.apps.gold.devops.gov.bc.ca https://loginproxy.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com https://vimeo.com; + frame-src 'self' https://loginproxy.gov.bc.ca https://met-analytics-prod.apps.gold.devops.gov.bc.ca https://www.youtube.com https://player.vimeo.com; + frame-ancestors 'self' https://loginproxy.gov.bc.ca"; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; add_header X-Content-Type-Options "nosniff"; add_header X-XSS-Protection 1; diff --git a/met-web/nginx/nginx.test.conf b/met-web/nginx/nginx.test.conf index 55a1f66f2..2e9e21b40 100644 --- a/met-web/nginx/nginx.test.conf +++ b/met-web/nginx/nginx.test.conf @@ -46,9 +46,9 @@ http { worker-src 'self' blob:; img-src 'self' data: blob: https://citz-gdx.objectstore.gov.bc.ca; style-src 'self' 'unsafe-inline'; - connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://epic-engage-web-test.apps.gold.devops.gov.bc.ca https://epic-engage-analytics-api-test.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-test.apps.gold.devops.gov.bc.ca https://met-analytics-api-test.apps.gold.devops.gov.bc.ca https://met-oidc-test.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com https://vimeo.com; - frame-src 'self' https://met-oidc-test.apps.gold.devops.gov.bc.ca https://epic-engage-web-test.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-test.apps.gold.devops.gov.bc.ca https://epic-engage-analytics-api-test.apps.gold.devops.gov.bc.ca https://met-analytics-test.apps.gold.devops.gov.bc.ca https://www.youtube.com https://player.vimeo.com; - frame-ancestors 'self' https://met-oidc-test.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-test.apps.gold.devops.gov.bc.ca"; + connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://met-analytics-api-test.apps.gold.devops.gov.bc.ca https://test.loginproxy.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com https://vimeo.com; + frame-src 'self' https://test.loginproxy.gov.bc.ca https://met-analytics-test.apps.gold.devops.gov.bc.ca https://www.youtube.com https://player.vimeo.com; + frame-ancestors 'self' https://test.loginproxy.gov.bc.ca"; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; add_header X-Content-Type-Options "nosniff"; add_header X-XSS-Protection 1; @@ -61,14 +61,7 @@ http { error_log /dev/stdout info; access_log /dev/stdout; - # Add a readiness check endpoint - location /readiness { - return 200 "OK"; - } - location / { - auth_basic "Restricted Area"; - auth_basic_user_file /etc/nginx/.htpasswd; root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; diff --git a/met-web/src/App.tsx b/met-web/src/App.tsx index 93464e014..f74512a21 100644 --- a/met-web/src/App.tsx +++ b/met-web/src/App.tsx @@ -205,7 +205,6 @@ const App = () => { backgroundColor: 'white', zIndex: ZIndex.footer, position: 'relative', - paddingTop: '5em', }} >