From 444ff9644742cd2794657d014fcd03ca82aa9f9b Mon Sep 17 00:00:00 2001 From: amandazhu Date: Fri, 17 Jul 2026 10:10:02 +1000 Subject: [PATCH 1/3] feat: add eDNA Explorer platform - Add EDNA_EXPLORER to PlatformEnum + admin PLATFORM_MAPPING - Alembic migration adding the EDNA_EXPLORER enum value - Keep it out of DEFAULT_PLATFORMS so access is manual-approval only - Tests: admin create + no auto-grant on registration Co-Authored-By: Claude Opus 4.8 (1M context) --- db/types.py | 1 + .../f3a9c1e7b2d4_edna_explorer_platform.py | 41 +++++++++++++++++++ routers/admin.py | 1 + tests/test_biocommons_admin.py | 22 ++++++++++ tests/test_biocommons_register.py | 32 +++++++++++++++ 5 files changed, 97 insertions(+) create mode 100644 migrations/versions/f3a9c1e7b2d4_edna_explorer_platform.py diff --git a/db/types.py b/db/types.py index df76c5f1..794397a6 100644 --- a/db/types.py +++ b/db/types.py @@ -29,6 +29,7 @@ class PlatformEnum(str, Enum): GALAXY = "galaxy" BPA_DATA_PORTAL = "bpa_data_portal" SBP = "sbp" + EDNA_EXPLORER = "edna_explorer" class PlatformMembershipData(BaseModel): diff --git a/migrations/versions/f3a9c1e7b2d4_edna_explorer_platform.py b/migrations/versions/f3a9c1e7b2d4_edna_explorer_platform.py new file mode 100644 index 00000000..76221611 --- /dev/null +++ b/migrations/versions/f3a9c1e7b2d4_edna_explorer_platform.py @@ -0,0 +1,41 @@ +"""edna_explorer_platform: add EDNA_EXPLORER value to PlatformEnum + +Adds the eDNA Explorer platform enum value. The `platform` row itself is +created at runtime by `populate_platforms_from_auth0` once the Auth0 roles +`biocommons/platform/edna_explorer` and `biocommons/role/edna_explorer/admin` +exist (same mechanism that maintains galaxy/bpa/sbp), so we only extend the +Postgres enum type here. We deliberately do NOT seed/use the new value in this +migration to avoid Postgres' "unsafe use of new enum value in the same +transaction" restriction (mirrors c4c7a8e9b2d3). + +Revision ID: f3a9c1e7b2d4 +Revises: 4000ecb2796d +Create Date: 2026-07-17 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "f3a9c1e7b2d4" +down_revision: Union[str, Sequence[str], None] = "4000ecb2796d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + dialect_name = bind.dialect.name + + # NOTE: alembic doesn't automatically add new enum values to existing types. + # SQLite stores enums as plain strings, so no DDL is needed there. + if dialect_name != "sqlite": + op.execute("ALTER TYPE \"PlatformEnum\" ADD VALUE IF NOT EXISTS 'EDNA_EXPLORER'") + + +def downgrade() -> None: + # Postgres does not support removing a value from an enum type, so there is + # nothing to reverse. (Matches the other ADD VALUE migrations.) + pass diff --git a/routers/admin.py b/routers/admin.py index aae0bef6..ce6bd689 100644 --- a/routers/admin.py +++ b/routers/admin.py @@ -78,6 +78,7 @@ "galaxy": {"enum": PlatformEnum.GALAXY, "name": "Galaxy Australia"}, "bpa_data_portal": {"enum": PlatformEnum.BPA_DATA_PORTAL, "name": "Bioplatforms Australia Data Portal"}, "sbp": {"enum": PlatformEnum.SBP, "name": "Structural Biology Platform"}, + "edna_explorer": {"enum": PlatformEnum.EDNA_EXPLORER, "name": "eDNA Explorer"}, } GROUP_MAPPING: dict[BundleType, dict] = { diff --git a/tests/test_biocommons_admin.py b/tests/test_biocommons_admin.py index be41e5c4..dbe6a64a 100644 --- a/tests/test_biocommons_admin.py +++ b/tests/test_biocommons_admin.py @@ -120,6 +120,28 @@ def test_create_platform(test_client, as_admin_user, test_db_session, persistent assert [role.name for role in platform_from_db.admin_roles] == [admin_role.name] +def test_create_platform_edna_explorer(test_client, as_admin_user, test_db_session, persistent_factories): + """eDNA Explorer is created like any other platform; access stays manual-approval only (not in DEFAULT_PLATFORMS).""" + Auth0RoleFactory.create_sync(name="biocommons/platform/edna_explorer") + admin_role = Auth0RoleFactory.create_sync(name="biocommons/role/edna_explorer/admin") + resp = test_client.post( + "/biocommons-admin/platforms/create", + json={ + "id": PlatformEnum.EDNA_EXPLORER.value, + "name": "eDNA Explorer", + "admin_roles": [admin_role.name], + }, + ) + assert resp.status_code == HTTPStatus.OK + body = resp.json() + assert body["id"] == PlatformEnum.EDNA_EXPLORER.value + assert body["name"] == "eDNA Explorer" + platform_from_db = test_db_session.get(Platform, PlatformEnum.EDNA_EXPLORER) + assert platform_from_db is not None + test_db_session.refresh(platform_from_db) + assert [role.name for role in platform_from_db.admin_roles] == [admin_role.name] + + def test_create_platform_duplicate_id(test_client, as_admin_user, test_db_session, persistent_factories): Auth0RoleFactory.create_sync(name="biocommons/platform/galaxy") admin_role = Auth0RoleFactory.create_sync(name="biocommons/role/galaxy/admin") diff --git a/tests/test_biocommons_register.py b/tests/test_biocommons_register.py index 30f3b1ab..5981a4da 100644 --- a/tests/test_biocommons_register.py +++ b/tests/test_biocommons_register.py @@ -773,3 +773,35 @@ def test_registration_endpoint_no_bundle(test_db_session, test_client, galaxy_pl assert platform in platform_ids for pm in user.platform_memberships: assert pm.approval_status.value == "approved" + + +def test_edna_explorer_not_in_default_platforms(): + """eDNA Explorer must be manual-approval only, so it must never be auto-granted at registration.""" + assert PlatformEnum.EDNA_EXPLORER not in DEFAULT_PLATFORMS + + +def test_registration_does_not_grant_edna_explorer(test_db_session, test_client, galaxy_platform, bpa_platform, sbp_platform, mock_auth0_client, mock_recaptcha_verify,): + """A newly-registered user must not receive an eDNA Explorer membership (manual approval only).""" + registration = BiocommonsRegistrationRequest( + first_name="No", + last_name="Edna", + email="no.edna@example.com", + username="no_edna", + password="StrongPass1!", + bundles=None, + recaptcha_token="mock-token", + ) + auth0_data = Auth0UserDataFactory.build( + email="no.edna@example.com", + username="no_edna", + name="Test User", + ) + mock_auth0_client.create_user.return_value = Auth0UserDataFactory.build( + user_id=auth0_data.user_id, email=auth0_data.email, username=auth0_data.username + ) + + test_client.post("/biocommons/register", json=registration.model_dump(mode="json")) + + user = test_db_session.get(BiocommonsUser, auth0_data.user_id) + platform_ids = {pm.platform_id for pm in user.platform_memberships} + assert PlatformEnum.EDNA_EXPLORER not in platform_ids From a51f2df0fea60ba820fd7aca2ecb0b2cbfaefe1d Mon Sep 17 00:00:00 2001 From: amandazhu Date: Fri, 17 Jul 2026 10:24:36 +1000 Subject: [PATCH 2/3] fix: migration --- .../versions/39caad32922e_edna_explorer.py | 31 ++++++++++++++ .../f3a9c1e7b2d4_edna_explorer_platform.py | 41 ------------------- 2 files changed, 31 insertions(+), 41 deletions(-) create mode 100644 migrations/versions/39caad32922e_edna_explorer.py delete mode 100644 migrations/versions/f3a9c1e7b2d4_edna_explorer_platform.py diff --git a/migrations/versions/39caad32922e_edna_explorer.py b/migrations/versions/39caad32922e_edna_explorer.py new file mode 100644 index 00000000..1c736155 --- /dev/null +++ b/migrations/versions/39caad32922e_edna_explorer.py @@ -0,0 +1,31 @@ +"""edna-explorer + +Revision ID: 39caad32922e +Revises: 4000ecb2796d +Create Date: 2026-07-17 10:24:19.065484 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '39caad32922e' +down_revision: Union[str, None] = '4000ecb2796d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/migrations/versions/f3a9c1e7b2d4_edna_explorer_platform.py b/migrations/versions/f3a9c1e7b2d4_edna_explorer_platform.py deleted file mode 100644 index 76221611..00000000 --- a/migrations/versions/f3a9c1e7b2d4_edna_explorer_platform.py +++ /dev/null @@ -1,41 +0,0 @@ -"""edna_explorer_platform: add EDNA_EXPLORER value to PlatformEnum - -Adds the eDNA Explorer platform enum value. The `platform` row itself is -created at runtime by `populate_platforms_from_auth0` once the Auth0 roles -`biocommons/platform/edna_explorer` and `biocommons/role/edna_explorer/admin` -exist (same mechanism that maintains galaxy/bpa/sbp), so we only extend the -Postgres enum type here. We deliberately do NOT seed/use the new value in this -migration to avoid Postgres' "unsafe use of new enum value in the same -transaction" restriction (mirrors c4c7a8e9b2d3). - -Revision ID: f3a9c1e7b2d4 -Revises: 4000ecb2796d -Create Date: 2026-07-17 00:00:00.000000 - -""" -from typing import Sequence, Union - -from alembic import op - - -# revision identifiers, used by Alembic. -revision: str = "f3a9c1e7b2d4" -down_revision: Union[str, Sequence[str], None] = "4000ecb2796d" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - bind = op.get_bind() - dialect_name = bind.dialect.name - - # NOTE: alembic doesn't automatically add new enum values to existing types. - # SQLite stores enums as plain strings, so no DDL is needed there. - if dialect_name != "sqlite": - op.execute("ALTER TYPE \"PlatformEnum\" ADD VALUE IF NOT EXISTS 'EDNA_EXPLORER'") - - -def downgrade() -> None: - # Postgres does not support removing a value from an enum type, so there is - # nothing to reverse. (Matches the other ADD VALUE migrations.) - pass From b78e9702aaa7fb9191df82a60cf9b6b26a7ae9e8 Mon Sep 17 00:00:00 2001 From: amandazhu Date: Fri, 17 Jul 2026 10:34:11 +1000 Subject: [PATCH 3/3] test: include eDNA Explorer in admin filter options --- tests/admin_api/test_admin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/admin_api/test_admin.py b/tests/admin_api/test_admin.py index 041f0dac..7b001ca2 100644 --- a/tests/admin_api/test_admin.py +++ b/tests/admin_api/test_admin.py @@ -503,7 +503,7 @@ def test_get_filter_options(test_client, as_admin_user, test_db_session, persist options = resp.json() assert isinstance(options, list) - assert len(options) == 5 + assert len(options) == 6 for option in options: assert "id" in option @@ -512,13 +512,14 @@ def test_get_filter_options(test_client, as_admin_user, test_db_session, persist assert isinstance(option["name"], str) option_ids = {opt["id"] for opt in options} - expected_ids = {"galaxy", "bpa_data_portal", "sbp", "tsi", "sbp_workflow_execution"} + expected_ids = {"galaxy", "bpa_data_portal", "sbp", "edna_explorer", "tsi", "sbp_workflow_execution"} assert option_ids == expected_ids option_dict = {opt["id"]: opt["name"] for opt in options} assert option_dict["galaxy"] == "Galaxy Australia" assert option_dict["bpa_data_portal"] == "Bioplatforms Australia Data Portal" assert option_dict["sbp"] == "Structural Biology Platform" + assert option_dict["edna_explorer"] == "eDNA Explorer" assert option_dict["sbp_workflow_execution"] == "Structural Biology Platform Bundle" assert option_dict["tsi"] == "Threatened Species Initiative"