diff --git a/.github/workflows/met-api-ci.yml b/.github/workflows/met-api-ci.yml index 7532f53be..80861281e 100644 --- a/.github/workflows/met-api-ci.yml +++ b/.github/workflows/met-api-ci.yml @@ -78,7 +78,7 @@ jobs: KEYCLOAK_TEST_ADMIN_SECRET: "2222222222" KEYCLOAK_TEST_AUTH_AUDIENCE: "met-web" KEYCLOAK_TEST_AUTH_CLIENT_SECRET: "1111111111" - KEYCLOAK_TEST_BASE_URL: "http://localhost:8081" + KEYCLOAK_TEST_BASE_URL: "http://localhost:8081/auth" KEYCLOAK_TEST_REALMNAME: "demo" USE_TEST_KEYCLOAK_DOCKER: "YES" SQLALCHEMY_DATABASE_URI: "postgresql://postgres:postgres@localhost:5432/postgres" diff --git a/met-api/migrations/versions/45f89f245e3d_engagement_metadata.py b/met-api/migrations/versions/45f89f245e3d_engagement_metadata.py index bd216de6f..a9e71b9f0 100644 --- a/met-api/migrations/versions/45f89f245e3d_engagement_metadata.py +++ b/met-api/migrations/versions/45f89f245e3d_engagement_metadata.py @@ -22,6 +22,7 @@ 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 index 25d0a79de..db404b097 100644 --- a/met-api/migrations/versions/ec0128056a33_rework_engagement_metadata.py +++ b/met-api/migrations/versions/ec0128056a33_rework_engagement_metadata.py @@ -41,23 +41,38 @@ def upgrade(): 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 + # remove old data from engagement_metadata table + op.execute('DELETE FROM engagement_metadata') + + # Drop the existing primary key constraint op.drop_constraint('engagement_metadata_pkey', 'engagement_metadata', type_='primary') + + # Create a new index on engagement_metadata_taxa op.create_index(op.f('ix_engagement_metadata_taxa_position'), 'engagement_metadata_taxa', ['position'], unique=False) + + # Add new columns to engagement_metadata op.add_column('engagement_metadata', sa.Column('id', sa.Integer(), nullable=False)) - # add new primary key constraint on engagement_metadata.id + 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)) + + # Create a new primary key constraint on the 'id' column op.create_primary_key('pk_engagement_metadata', 'engagement_metadata', ['id']) - # add autoincrement to engagement_metadata.id by creating a sequence + + # Create a new sequence and set it as the default for the 'id' column 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 + + # Remove the not-null constraint from '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)) + # Create a foreign key constraint op.create_foreign_key('fk_engagement_meta_taxon', 'engagement_metadata', 'engagement_metadata_taxa', ['taxon_id'], ['id'], ondelete='CASCADE') + + # Remove the 'project_tracking_id' and 'project_metadata' column op.drop_column('engagement_metadata', 'project_tracking_id') + op.drop_column('engagement_metadata', 'project_metadata') + # 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 @@ -133,16 +148,17 @@ def upgrade(): def downgrade(): + op.add_column('engagement_metadata', sa.Column('project_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) 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', 'value') + op.drop_column('engagement_metadata', 'taxon_id') 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.create_primary_key('engagement_metadata_pkey', '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 e083a0adf..6b659668f 100644 --- a/met-api/sample.env +++ b/met-api/sample.env @@ -121,7 +121,7 @@ DATABASE_TEST_HOST= DATABASE_TEST_PORT= # A keycloak server is started automatically by Pytest; there is no need to start your own instance. -KEYCLOAK_TEST_BASE_URL="http://localhost:8081" +KEYCLOAK_TEST_BASE_URL="http://localhost:8081/auth" # Docker 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 05ba58f1a..e11300fad 100644 --- a/met-api/src/met_api/__init__.py +++ b/met-api/src/met_api/__init__.py @@ -138,8 +138,8 @@ def get_roles(token_info) -> list: 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.') + 'Please check your JWT_ROLE_CALLBACK ' + 'configuration.') return [] return token_info diff --git a/met-api/src/met_api/auth.py b/met-api/src/met_api/auth.py index 605156a7e..724b8f670 100644 --- a/met-api/src/met_api/auth.py +++ b/met-api/src/met_api/auth.py @@ -18,7 +18,7 @@ from flask_jwt_oidc import JwtManager from flask_jwt_oidc.exceptions import AuthError -auth_methods = { # for swagger documentation +auth_methods = { # for swagger documentation 'apikey': { 'type': 'apiKey', 'in': 'header', @@ -26,6 +26,7 @@ } } + class Auth(JwtManager): # pylint: disable=too-few-public-methods """Extends the JwtManager to include additional functionalities.""" @@ -61,6 +62,7 @@ def decorated(*args, **kwargs): return decorated + jwt = auth = ( Auth() ) diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index 1cda9f69f..17ce01c95 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -49,7 +49,7 @@ from .report_setting import ReportSetting from .widget_video import WidgetVideo from .cac_form import CACForm -from .engagement_metadata import MetadataTaxon, EngagementMetadata +from .engagement_metadata import EngagementMetadata, MetadataTaxon 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 6703cd24c..f9563c0c7 100644 --- a/met-api/src/met_api/models/db.py +++ b/met-api/src/met_api/models/db.py @@ -18,19 +18,24 @@ # Marshmallow for database model schema ma = Marshmallow() -class AbortTransaction(Exception): + +class AbortTransaction(Exception): # noqa """ - An exception to be raised when a transaction should be aborted. Handled - gracefully in the transactional decorator. Only raise this 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. """ + 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. + Decorate an operation to quickly make it 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 @@ -45,7 +50,7 @@ def transactional(database=db, autocommit=True, end_session=False """ def decorator(f: Callable[..., T]) -> Callable[..., T]: @wraps(f) - def decorated_function(*args, **kwargs)-> Optional[T]: + def decorated_function(*args, **kwargs) -> Optional[T]: try: result = f(*args, **kwargs) if autocommit: @@ -54,11 +59,11 @@ def decorated_function(*args, **kwargs)-> Optional[T]: 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.info('Transaction aborted.') + database.session.rollback() # we meant to roll back; don't raise :) + except Exception as e: # noqa: B902 logging.exception( - "An error occurred during a transaction; rolling back.") + 'An error occurred during a transaction; rolling back.') database.session.rollback() raise e finally: diff --git a/met-api/src/met_api/models/engagement_metadata.py b/met-api/src/met_api/models/engagement_metadata.py index 20cbc2bc0..7b6eb7ed5 100644 --- a/met-api/src/met_api/models/engagement_metadata.py +++ b/met-api/src/met_api/models/engagement_metadata.py @@ -1,5 +1,6 @@ """ 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. @@ -13,43 +14,46 @@ class EngagementMetadata(BaseModel): - """ - A unit of metadata for an Engagement. Can be used to store arbitrary data. - """ + """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) + 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) + 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): + """Extracts the tenant details for taxon.""" return self.taxon.tenant if self.taxon else None - # Prevent primary key and foreign keys from being updated after creation @validates('id') def validate_id(self, _, new_id): + """Prevent primary key and foreign keys from being updated after creation.""" 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): + """Prevent update of tenant id.""" if self.tenant_id and self.tenant_id != new_tenant_id: raise ValueError('Cannot change tenant_id') return new_tenant_id @validates('engagement_id') def validate_engagement_id(self, _, new_engagement_id): + """Prevent update of 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: + """Return a string representation of the EngagementMetadata.""" if not self: return '' if (not self.engagement and not self.taxon): @@ -61,10 +65,10 @@ def __repr__(self) -> str: return (f'') + class MetadataTaxonDataType(str, enum.Enum): - """ - The data types that can be stored in a metadata property. - """ + """The data types that can be stored in a metadata property.""" + TEXT = 'string' LONG_TEXT = 'long-text' NUMBER = 'number' @@ -88,18 +92,20 @@ 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. + 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) + 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) @@ -110,6 +116,7 @@ class MetadataTaxon(BaseModel): position = db.Column(db.Integer, nullable=False, index=True) def __init__(self, **kwargs) -> None: + """Initialize a new instance of the MetadataTaxon class.""" super().__init__(**kwargs) if not self.data_type: self.data_type = 'text' @@ -118,30 +125,32 @@ def __init__(self, **kwargs) -> None: 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): + """Prevent primary key and foreign keys from being updated after creation.""" 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): + """Prevent update of 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: + """Return a string representation of the EngagementMetadata.""" 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. + Move this taxon to a specific position within the same tenant. + + Update positions of other taxa accordingly. """ tenant_id = self.tenant_id current_position = self.position @@ -165,9 +174,7 @@ def move_to_position(self, new_position: int) -> None: @transactional() def delete(self) -> None: - """ - Remove the taxon, updating the positions of subsequent taxa within the same tenant. - """ + """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, @@ -182,8 +189,9 @@ def delete(self) -> None: @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. + 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() diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index 7852b009f..1f31982bd 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -81,7 +81,7 @@ API.add_namespace(VALUE_COMPONENTS_API) API.add_namespace(SHAPEFILE_API) API.add_namespace(TENANT_API) -API.add_namespace(METADATA_TAXON_API,path='/tenants//metadata') +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') diff --git a/met-api/src/met_api/resources/engagement_metadata.py b/met-api/src/met_api/resources/engagement_metadata.py index d1cf70229..566a0a798 100644 --- a/met-api/src/met_api/resources/engagement_metadata.py +++ b/met-api/src/met_api/resources/engagement_metadata.py @@ -13,6 +13,7 @@ # limitations under the License. """ API endpoints for managing the metadata for an engagement resource. + This API is located at /api/engagements//metadata """ @@ -41,7 +42,7 @@ 'value': fields.String(required=True, description='The value of the metadata entry'), }) -metadata_create_model = API.model('EngagementMetadataCreate', model_dict :={ +metadata_create_model = API.model('EngagementMetadataCreate', model_dict := { 'taxon_id': fields.Integer(required=True, description='The id of the taxon'), **model_dict }) @@ -55,8 +56,9 @@ engagement_service = EngagementService() metadata_service = EngagementMetadataService() + @cors_preflight('GET,POST') -@API.route('') # /api/engagements/{engagement.id}/metadata +@API.route('') # /api/engagements/{engagement.id}/metadata @API.doc(params={'engagement_id': 'The numeric id of the engagement'}) class EngagementMetadata(Resource): """Resource for managing engagements' metadata.""" @@ -74,7 +76,7 @@ def get(engagement_id): @cross_origin(origins=allowedorigins()) @API.doc(security='apikey') @API.expect(metadata_create_model) - @API.marshal_with(metadata_return_model, code=HTTPStatus.CREATED) # type: ignore + @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.""" @@ -91,10 +93,11 @@ def post(engagement_id: int): except (ValueError, ValidationError) as err: return str(err), HTTPStatus.BAD_REQUEST + @cors_preflight('GET,PUT,DELETE') -@API.route('/') # /api/engagements/{engagement.id}/metadata/{metadata.id} +@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'}) + 'metadata_id': 'The numeric id of the metadata entry'}) @API.doc(security='apikey') class EngagementMetadataById(Resource): """Resource for managing invividual engagement metadata entries.""" @@ -111,7 +114,7 @@ def get(engagement_id, 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}") + f' != {engagement_id}') return metadata, HTTPStatus.OK except KeyError as err: return str(err), HTTPStatus.NOT_FOUND @@ -134,7 +137,7 @@ def patch(engagement_id, 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}") + f' != {engagement_id}') metadata = metadata_service.update(metadata_id, value) return metadata, HTTPStatus.OK except KeyError as err: @@ -142,7 +145,6 @@ def patch(engagement_id, metadata_id): except ValidationError as err: return err.messages, HTTPStatus.BAD_REQUEST - @staticmethod @cross_origin(origins=allowedorigins()) @auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES) @@ -150,12 +152,12 @@ 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) + 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}") + f' != {engagement_id}') metadata_service.delete(metadata_id) return {}, HTTPStatus.NO_CONTENT except KeyError as err: diff --git a/met-api/src/met_api/resources/metadata_taxon.py b/met-api/src/met_api/resources/metadata_taxon.py index 7229cfda6..ec36203d2 100644 --- a/met-api/src/met_api/resources/metadata_taxon.py +++ b/met-api/src/met_api/resources/metadata_taxon.py @@ -12,15 +12,16 @@ # 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 +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 import abort, g, request from flask_cors import cross_origin from flask_restx import Namespace, Resource, fields from marshmallow import ValidationError @@ -32,11 +33,11 @@ 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] +VIEW_TAXA_ROLES = [Role.VIEW_ENGAGEMENT.value, Role.CREATE_ENGAGEMENT.value] +MODIFY_TAXA_ROLES = [Role.EDIT_ENGAGEMENT.value] TAXON_NOT_FOUND_MSG = 'Metadata taxon was not found' -API = Namespace('metadata_taxa', description="Endpoints for managing the taxa " +API = Namespace('metadata_taxa', description='Endpoints for managing the taxa ' "that organize a tenant's metadata. Admin-level users only.", authorizations=auth_methods) @@ -70,10 +71,12 @@ 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 + 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): @@ -83,20 +86,22 @@ def decorated_function(*args, **func_kwargs): 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') + 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}') + 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.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) @@ -110,7 +115,7 @@ def get(tenant: Tenant): @staticmethod @cross_origin(origins=allowedorigins()) @API.expect(taxon_modify_model) - @API.marshal_with(taxon_return_model, code=HTTPStatus.CREATED) # type: ignore + @API.marshal_with(taxon_return_model, code=HTTPStatus.CREATED) # type: ignore @ensure_tenant_access() @require_role(MODIFY_TAXA_ROLES) def post(tenant: Tenant): @@ -146,8 +151,9 @@ def patch(tenant: Tenant): 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.route('/taxon/') # /tenants//metadata/taxon/ @API.doc(security='apikey', params=params, responses=responses) class MetadataTaxon(Resource): """Resource for managing a single metadata taxon.""" @@ -178,7 +184,6 @@ def patch(tenant: Tenant, taxon_id: int): 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() diff --git a/met-api/src/met_api/schemas/engagement_metadata.py b/met-api/src/met_api/schemas/engagement_metadata.py index 1ba12144f..fce17e7ed 100644 --- a/met-api/src/met_api/schemas/engagement_metadata.py +++ b/met-api/src/met_api/schemas/engagement_metadata.py @@ -1,13 +1,20 @@ -from met_api.models.engagement_metadata import (EngagementMetadata, - MetadataTaxon, MetadataTaxonDataType) +"""Engagement Metadata schema class. + +Manages the Engagement Metadata +""" + from marshmallow import ValidationError, fields, pre_load, validate from marshmallow_sqlalchemy import SQLAlchemyAutoSchema from marshmallow_sqlalchemy.fields import Nested +from met_api.models.engagement_metadata import EngagementMetadata, MetadataTaxon, MetadataTaxonDataType class EngagementMetadataSchema(SQLAlchemyAutoSchema): """Schema for engagement metadata.""" + class Meta: + """Initialize values.""" + model = EngagementMetadata load_instance = True include_fk = True # Include foreign keys in the schema @@ -17,6 +24,7 @@ class Meta: @pre_load def check_immutable_fields(self, data, **kwargs): + """Validate fields.""" if self.instance: if 'id' in data and data['id'] != self.instance.id: raise ValidationError('id field cannot be changed.') @@ -32,7 +40,10 @@ def check_immutable_fields(self, data, **kwargs): class MetadataTaxonSchema(SQLAlchemyAutoSchema): """Schema for metadata taxa.""" + class Meta: + """Initialize values.""" + model = MetadataTaxon load_instance = True include_fk = True @@ -44,9 +55,10 @@ class Meta: 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): + """Check fields.""" if self.instance: if 'id' in data and data['id'] != self.instance.id: raise ValidationError('id field cannot be changed.') @@ -59,5 +71,3 @@ def check_immutable_fields(self, data, **kwargs): # Nested field entries = Nested(EngagementMetadataSchema, many=True, exclude=['taxon']) - - \ 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 fb39da7ca..e3d1862be 100644 --- a/met-api/src/met_api/services/authorization.py +++ b/met-api/src/met_api/services/authorization.py @@ -16,6 +16,7 @@ UNAUTHORIZED_MSG = 'You are not authorized to perform this action!' + # pylint: disable=unused-argument @user_context def check_auth(**kwargs): 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 4f2f3420e..9dae4e013 100644 --- a/met-api/src/met_api/services/engagement_metadata_service.py +++ b/met-api/src/met_api/services/engagement_metadata_service.py @@ -15,6 +15,7 @@ class EngagementMetadataService: def get(metadata_id) -> dict: """ Get engagement metadata by id. + Args: id: The ID of the engagement metadata. Returns: @@ -32,6 +33,7 @@ def get(metadata_id) -> dict: def get_by_engagement(engagement_id) -> List[dict]: """ Get metadata by engagement id. + Args: engagement_id: The ID of the engagement. Returns: @@ -48,6 +50,7 @@ def get_by_engagement(engagement_id) -> List[dict]: 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: @@ -63,9 +66,10 @@ def check_association(engagement_id, metadata_id) -> bool: @staticmethod @transactional(database=db) - def create(engagement_id: int, taxon_id:int, value:str) -> dict: + 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. @@ -90,13 +94,14 @@ def create(engagement_id: int, taxon_id:int, value:str) -> dict: engagement_metadata = EngagementMetadataSchema().load( metadata, session=db.session ) - db.session.add(engagement_metadata) # type: ignore + db.session.add(engagement_metadata) # type: ignore engagement_metadata.save() return dict(EngagementMetadataSchema().dump(engagement_metadata)) 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. @@ -106,9 +111,8 @@ def create_for_engagement(self, engagement_id: int, metadata: dict, **kwargs) -> metadata = metadata or {} metadata = self.create(metadata, engagement_id, **kwargs) - @staticmethod - def create_defaults(engagement_id: int, tenant_id:int) -> List[dict]: + 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() @@ -122,12 +126,12 @@ def create_defaults(engagement_id: int, tenant_id:int) -> List[dict]: taxon.default_value)) return metadata - @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. @@ -146,6 +150,7 @@ def update(metadata_id: int, value: str) -> dict: def delete(metadata_id: int) -> None: """ Delete engagement metadata. + Args: id: The ID of the engagement metadata. """ @@ -153,5 +158,4 @@ def delete(metadata_id: int) -> None: 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 + db.session.delete(metadata) # type: ignore diff --git a/met-api/src/met_api/services/metadata_taxon_service.py b/met-api/src/met_api/services/metadata_taxon_service.py index 00d8979f7..043c164cd 100644 --- a/met-api/src/met_api/services/metadata_taxon_service.py +++ b/met-api/src/met_api/services/metadata_taxon_service.py @@ -8,6 +8,7 @@ from met_api.models.tenant import Tenant from met_api.schemas.engagement_metadata import MetadataTaxonSchema + class MetadataTaxonService: """Metadata taxon management service.""" @@ -19,7 +20,6 @@ def get_by_id(taxon_id: int) -> Optional[dict]: return None return dict(MetadataTaxonSchema().dump(taxon)) - @staticmethod def get_by_tenant(tenant_id: int) -> List[dict]: """Get all taxa for a tenant.""" @@ -28,7 +28,6 @@ def get_by_tenant(tenant_id: int) -> List[dict]: 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.""" @@ -38,7 +37,6 @@ def create(tenant_id: int, taxon_data: dict) -> dict: taxon.save() return dict(MetadataTaxonSchema().dump(taxon)) - @staticmethod def update(taxon_id: int, taxon_data: dict) -> Union[dict, list]: """Update a taxon.""" @@ -54,6 +52,8 @@ def update(taxon_id: int, taxon_data: dict) -> Union[dict, list]: @transactional() def reorder_tenant(tenant_id: int, taxon_ids: List[int]) -> List[dict]: """ + Reorder Tenant. + Reorder all taxa within a specific tenant based on a provided list of taxon IDs, setting their positions accordingly. """ @@ -73,13 +73,13 @@ def reorder_tenant(tenant_id: int, taxon_ids: List[int]) -> List[dict]: # 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 + 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. """ diff --git a/met-api/src/met_api/utils/user_context.py b/met-api/src/met_api/utils/user_context.py index 3c071a3aa..facafc426 100644 --- a/met-api/src/met_api/utils/user_context.py +++ b/met-api/src/met_api/utils/user_context.py @@ -81,7 +81,7 @@ 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)) diff --git a/met-api/tests/unit/api/test_engagement_metadata.py b/met-api/tests/unit/api/test_engagement_metadata.py index 31430a2cd..08f4f97a8 100644 --- a/met-api/tests/unit/api/test_engagement_metadata.py +++ b/met-api/tests/unit/api/test_engagement_metadata.py @@ -12,9 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -""" -Tests for the Engagement Metadata endpoints. -""" +"""Tests for the Engagement Metadata endpoints.""" import json from http import HTTPStatus @@ -22,18 +20,17 @@ 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_scenarios import TestEngagementMetadataInfo, TestJwtClaims, TestTenantInfo 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) + factory_auth_header, factory_engagement_metadata_model, factory_metadata_requirements, factory_metadata_taxon_model, + factory_tenant_model) + + +fake = Faker() 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.""" @@ -41,14 +38,14 @@ def test_get_engagement_metadata(client, jwt, session): 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, + '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) + headers=headers, content_type=ContentType.JSON.value) assert response.status_code == HTTPStatus.OK metadata_list = response.json assert len(metadata_list) == 1 @@ -60,28 +57,29 @@ 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, + '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) + 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()} + 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}") + 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): @@ -91,13 +89,14 @@ def test_add_engagement_metadata_invalid_tenant(client, jwt, session): 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()} + 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}") + 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.""" @@ -106,40 +105,42 @@ def test_add_engagement_metadata_invalid_user(client, jwt, session): 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) + 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, + '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.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, + '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 + 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}') diff --git a/met-api/tests/unit/api/test_metadata_taxa.py b/met-api/tests/unit/api/test_metadata_taxa.py index a16d5419b..b45ea0413 100644 --- a/met-api/tests/unit/api/test_metadata_taxa.py +++ b/met-api/tests/unit/api/test_metadata_taxa.py @@ -12,9 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -""" -Tests for Metadata Taxon related endpoints. -""" +"""Tests for Metadata Taxon related endpoints.""" import json from http import HTTPStatus @@ -22,52 +20,53 @@ 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) +from tests.utilities.factory_scenarios import TestEngagementMetadataTaxonInfo +from tests.utilities.factory_utils import factory_metadata_taxon_model, factory_taxon_requirements 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) + 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) + 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) + 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) @@ -78,12 +77,13 @@ def test_update_metadata_taxon(client, jwt, session): response = client.patch(f'/api/tenants/{tenant.short_name}/metadata/taxon/{taxon.id}', headers=headers, data=json.dumps(data), - content_type=ContentType.JSON.value) + 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) @@ -93,10 +93,10 @@ def test_reorder_tenant_metadata_taxa(client, jwt, session): 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':[ + data=json.dumps({'taxon_ids': [ taxon3.id, taxon1.id, taxon2.id ]}), - content_type=ContentType.JSON.value) + 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 @@ -106,13 +106,14 @@ def test_reorder_tenant_metadata_taxa(client, jwt, session): 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) + 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 + assert metatada_taxon_service.get_by_id(taxon.id) is None diff --git a/met-api/tests/unit/models/test_engagement_metadata.py b/met-api/tests/unit/models/test_engagement_metadata.py index 9d4a15771..6dd2a1a44 100644 --- a/met-api/tests/unit/models/test_engagement_metadata.py +++ b/met-api/tests/unit/models/test_engagement_metadata.py @@ -18,66 +18,68 @@ from faker import Faker -from met_api.models.engagement_metadata import EngagementMetadata -from met_api.models.engagement_metadata import MetadataTaxon +from met_api.models.engagement_metadata import EngagementMetadata, 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() + 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) + '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 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.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" + 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) + '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" + 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" + 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) + '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" + 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") + '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") + '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") + '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 index 3ee3260d2..adcd99d8a 100644 --- a/met-api/tests/unit/models/test_metadata_taxa.py +++ b/met-api/tests/unit/models/test_metadata_taxa.py @@ -20,11 +20,11 @@ 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 +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() @@ -33,7 +33,7 @@ def test_create_metadata_taxon(session): 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() @@ -41,22 +41,24 @@ def test_delete_taxon(session): 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" + 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" + 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" + 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.""" + """Assert the order of taxa using a helper function.""" actual_order = [taxon.position for taxon in taxa] - assert actual_order == expected_order, f"Taxon order is incorrect ({actual_order} != {expected_order})" + 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.""" @@ -68,7 +70,7 @@ def test_move_taxon_to_position(session): 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" + 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) @@ -79,4 +81,4 @@ def test_move_taxon_to_position(session): 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 + check_taxon_order(session, test_taxa, [1, 2, 3]) diff --git a/met-api/tests/unit/services/test_engagement_metadata.py b/met-api/tests/unit/services/test_engagement_metadata.py index 81a4648f8..e2919dd59 100644 --- a/met-api/tests/unit/services/test_engagement_metadata.py +++ b/met-api/tests/unit/services/test_engagement_metadata.py @@ -11,27 +11,24 @@ # 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. -""" +"""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 +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" +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() + taxon, engagement, _, _ = factory_metadata_requirements() assert engagement.id is not None assert taxon.id is not None eng_meta: dict = engagement_metadata_service.create( @@ -39,11 +36,12 @@ def test_get_engagement_metadata(session): 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) + 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() + taxon, engagement, _, _ = factory_metadata_requirements() assert engagement.id is not None assert taxon.id is not None eng_meta: dict = engagement_metadata_service.create( @@ -54,11 +52,12 @@ def test_get_engagement_metadata_by_id(session): 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" + 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'], @@ -69,41 +68,44 @@ def test_create_engagement_metadata(session): 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" + 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 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" + 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() + 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 + '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 + 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) + 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() + taxon, engagement, _, _ = factory_metadata_requirements() eng_meta = factory_engagement_metadata_model({ 'engagement_id': engagement.id, 'taxon_id': taxon.id, @@ -112,4 +114,4 @@ def test_delete_engagement_metadata(session): 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 + assert not any(em['id'] == eng_meta.id for em in existing_metadata) diff --git a/met-api/tests/unit/services/test_metadata_taxa.py b/met-api/tests/unit/services/test_metadata_taxa.py index 8e7f27c3f..54bdae840 100644 --- a/met-api/tests/unit/services/test_metadata_taxa.py +++ b/met-api/tests/unit/services/test_metadata_taxa.py @@ -23,6 +23,7 @@ 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() @@ -34,14 +35,15 @@ def test_create_taxon(session): 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, + taxon1 = taxon_service.create(tenant.id, TestEngagementMetadataTaxonInfo.taxon1) assert taxon1.get('id') is not None - taxon2 = taxon_service.create(tenant.id, + taxon2 = taxon_service.create(tenant.id, TestEngagementMetadataTaxonInfo.taxon3) assert taxon2.get('id') is not None taxon2_existing = taxon_service.get_by_id(taxon2['id']) @@ -53,6 +55,7 @@ def test_insert_taxon(session): 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() @@ -64,6 +67,7 @@ def test_get_by_tenant(session): 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() @@ -74,12 +78,13 @@ def test_get_by_id(session): 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) + TestEngagementMetadataTaxonInfo.taxon1) assert taxon.get('id') is not None taxon_existing = taxon_service.get_by_id(taxon['id']) assert taxon_existing is not None @@ -88,6 +93,7 @@ def test_update_taxon(session): 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() @@ -98,6 +104,7 @@ def test_delete_taxon(session): 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() @@ -113,6 +120,7 @@ def test_reorder_tenant(session): 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() @@ -130,5 +138,5 @@ def test_auto_order_tenant(session): 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 + # Every number appears once + assert tenant_taxa[i]['position'] == i + 1 diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index c1c96234f..8e3acf1b3 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -249,8 +249,10 @@ class TestEngagementInfo(dict, Enum): \"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"' } + class TestEngagementMetadataInfo(dict, Enum): """Test data for engagement metadata.""" + metadata0 = { 'engagement_id': None, 'taxon_id': None, @@ -324,6 +326,7 @@ class TestEngagementMetadataTaxonInfo(dict, Enum): 'position': 4 } + class TestFeedbackInfo(dict, Enum): """Test scenarios of feedback.""" @@ -421,9 +424,7 @@ class TestJwtClaims(dict, Enum): 'export_proponent_comment_sheet', 'export_internal_comment_sheet', 'export_cac_form_to_sheet', - 'view_members', - 'create_tenant', - 'view_tenant', + 'view_members' ] } team_member_role = { diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index 15c4ebc86..7d2b8809c 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -27,8 +27,8 @@ from met_api.models.comment import Comment as CommentModel from met_api.models.email_verification import EmailVerification as EmailVerificationModel from met_api.models.engagement import Engagement as EngagementModel +from met_api.models.engagement_metadata import EngagementMetadata, MetadataTaxon 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 @@ -51,15 +51,17 @@ from met_api.utils.constants import TENANT_ID_HEADER from met_api.utils.enums import MembershipStatus from tests.utilities.factory_scenarios import ( - TestCommentInfo, TestEngagementInfo, TestEngagementSlugInfo, TestFeedbackInfo, TestParticipantInfo, - TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, TestTimelineInfo, TestUserInfo, - TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, TestWidgetMap, TestWidgetVideo, TestJwtClaims, - TestEngagementMetadataTaxonInfo, TestEngagementMetadataInfo, TestPollAnswerInfo, TestPollResponseInfo, - TestWidgetPollInfo) + TestCommentInfo, TestEngagementInfo, TestEngagementMetadataInfo, TestEngagementMetadataTaxonInfo, + TestEngagementSlugInfo, TestFeedbackInfo, TestJwtClaims, TestParticipantInfo, TestPollAnswerInfo, + TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, TestTimelineInfo, + TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo, TestWidgetMap, TestWidgetPollInfo, + TestWidgetVideo) + -CONFIG = get_named_config('testing') fake = Faker() +CONFIG = get_named_config('testing') + JWT_HEADER = { 'alg': CONFIG.JWT_OIDC_TEST_ALGORITHMS, 'typ': 'JWT', @@ -177,7 +179,8 @@ def factory_engagement_metadata_model( metadata.save() return metadata -def factory_metadata_requirements(auth: Optional[Auth]=None): + +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() @@ -191,7 +194,8 @@ def factory_metadata_requirements(auth: Optional[Auth]=None): return taxon, engagement, tenant, headers return taxon, engagement, tenant, None -def factory_taxon_requirements(auth: Optional[Auth]=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() @@ -202,8 +206,9 @@ def factory_taxon_requirements(auth: Optional[Auth]=None): return tenant, headers return tenant, None + def factory_metadata_taxon_model(tenant_id: int = 1, - taxon_info: dict = TestEngagementMetadataTaxonInfo.taxon1): + taxon_info: dict = TestEngagementMetadataTaxonInfo.taxon1): """Produce a test-ready metadata taxon model.""" taxon = MetadataTaxon( tenant_id=tenant_id, @@ -370,6 +375,7 @@ def 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.""" slug = EngagementSlugModel(