diff --git a/README.md b/README.md index 78e1eeb89..d8e54ecf0 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ Additional documentation for development is available in each folder's README. T If you would like to develop with a full local loculus instance for development you need to: -1. Deploy a local kubernetes instance: [kubernetes](/kubernetes/README.md) -2. Deploy the backend: [backend](/backend/README.md) -3. Deploy the frontend/website: [website](/website/README.md) +1. Deploy a local kubernetes instance: [kubernetes](./kubernetes/README.md) +2. Deploy the backend: [backend](./backend/README.md) +3. Deploy the frontend/website: [website](./website/README.md) Note that if you are developing the backend or frontend/website in isolation a full local loculus instance is not required. See the individual READMEs for more information. @@ -79,6 +79,7 @@ For testing we added multiple users to the realm. The users are: - Each user can be a member of multiple submitting groups. - Users can create new submitting groups, becoming the initial member automatically. - Group members have the authority to add or remove other members. +- Group members have the authority to edit all group metadata (except for group id) - If the last user leaves a submitting group, the group becomes 'dangling'—it exists but is no longer accessible, and a new group with the same name cannot be created. - Admin users can manually delete a submitting group directly on the DB but must transfer ownership of sequence entries to another submitting group before doing so to fulfill the foreign key constraint. diff --git a/backend/README.md b/backend/README.md index bcbef607c..d9038aab8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -31,6 +31,8 @@ All commands mentioned in this section are run from the `backend` directory unle ./start_dev.sh ``` +The service listens, by default, to **port 8079**: . + 3. Clean up the database when done: ```sh @@ -67,8 +69,6 @@ You need to set: We use Flyway, so that the service can provision an empty/existing DB without any manual steps in between. On startup scripts in `src/main/resources/db/migration` are executed in order, i.e. `V1__*.sql` before `V2__*.sql` if they didn't run before, so that the DB is always up-to-date. (For more info on the naming convention, see [this](https://www.red-gate.com/blog/database-devops/flyway-naming-patterns-matter) blog post.) -The service listens, by default, to **port 8079**: . - Note: When using a postgresSQL development platform (e.g. pgAdmin) the hostname is 127.0.0.1 and not localhost - this is defined in the `deploy.py` file. Note that we also use flyway in the ena-submission pod to create an additional schema in the database, ena-submission. This schema is not added here. @@ -85,7 +85,10 @@ When running the backend behind a proxy, the proxy needs to set X-Forwarded head ### Run tests and lints -The tests use Testcontainers to start a PostgreSQL database. This requires Docker or a Docker-API compatible container runtime to be installed, and the user executing the test needs the necessary permissions to use it. See [the documentation of the Testcontainers](https://java.testcontainers.org/supported_docker_environment/) for details. +The tests use [Testcontainers](https://testcontainers.com/) to start a PostgreSQL database. +This requires Docker or a Docker-API compatible container runtime to be installed and running, +and the user executing the test needs the necessary permissions to use it. +See [the documentation of the Testcontainers](https://java.testcontainers.org/supported_docker_environment/) for details. ```bash ./gradlew test @@ -97,7 +100,7 @@ The tests use Testcontainers to start a PostgreSQL database. This requires Docke ./gradlew ktlintCheck ``` -## Format +### Format ```bash ./gradlew ktlintFormat diff --git a/backend/build.gradle b/backend/build.gradle index 816304c0a..ace9f8bef 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -72,7 +72,25 @@ dependencies { } } +// Check if the docker engine is running and reachable +task checkDocker { + doLast { + def process = "docker info".execute() + def output = new StringWriter() + def error = new StringWriter() + process.consumeProcessOutput(output, error) + process.waitFor() + + if (process.exitValue() != 0) { + throw new GradleException("Docker is not running: ${error.toString()}") + } + println "Docker is running." + } +} + tasks.named('test') { + // Docker is required to start the testing database with https://java.testcontainers.org/ + dependsOn checkDocker useJUnitPlatform() testLogging { events TestLogEvent.FAILED diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt index 3f221704d..0bd149518 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt @@ -29,11 +29,24 @@ class GroupManagementController(private val groupManagementDatabaseService: Grou @PostMapping("/groups", produces = [MediaType.APPLICATION_JSON_VALUE]) fun createNewGroup( @HiddenParam authenticatedUser: AuthenticatedUser, - @Parameter(description = "Information about the newly created group") + @Parameter(description = "Information about the newly created group.") @RequestBody group: NewGroup, ): Group = groupManagementDatabaseService.createNewGroup(group, authenticatedUser) + @Operation(description = "Edit a group. Only users part of the group can edit it. The updated group is returned.") + @ResponseStatus(HttpStatus.OK) + @PutMapping("/groups/{groupId}", produces = [MediaType.APPLICATION_JSON_VALUE]) + fun editGroup( + @HiddenParam authenticatedUser: AuthenticatedUser, + @Parameter( + description = "The id of the group to edit.", + ) @PathVariable groupId: Int, + @Parameter(description = "Updated group properties.") + @RequestBody + group: NewGroup, + ): Group = groupManagementDatabaseService.updateGroup(groupId, group, authenticatedUser) + @Operation(description = "Get details of a group.") @ResponseStatus(HttpStatus.OK) @GetMapping("/groups/{groupId}", produces = [MediaType.APPLICATION_JSON_VALUE]) diff --git a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt index 037a242e7..a40177e6c 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt @@ -32,35 +32,14 @@ class GroupManagementDatabaseService( val users = UserGroupEntity.find { UserGroupsTable.groupIdColumn eq groupId } return GroupDetails( - group = Group( - groupId = groupEntity.id.value, - groupName = groupEntity.groupName, - institution = groupEntity.institution, - address = Address( - line1 = groupEntity.addressLine1, - line2 = groupEntity.addressLine2, - postalCode = groupEntity.addressPostalCode, - city = groupEntity.addressCity, - state = groupEntity.addressState, - country = groupEntity.addressCountry, - ), - contactEmail = groupEntity.contactEmail, - ), + group = groupEntity.toGroup(), users = users.map { User(it.userName) }, ) } fun createNewGroup(group: NewGroup, authenticatedUser: AuthenticatedUser): Group { val groupEntity = GroupEntity.new { - groupName = group.groupName - institution = group.institution - addressLine1 = group.address.line1 - addressLine2 = group.address.line2 - addressPostalCode = group.address.postalCode - addressState = group.address.state - addressCity = group.address.city - addressCountry = group.address.country - contactEmail = group.contactEmail + this.updateWith(group) } val groupId = groupEntity.id.value @@ -72,20 +51,21 @@ class GroupManagementDatabaseService( auditLogger.log(authenticatedUser.username, "Created group: ${group.groupName}") - return Group( - groupId = groupEntity.id.value, - groupName = groupEntity.groupName, - institution = groupEntity.institution, - address = Address( - line1 = groupEntity.addressLine1, - line2 = groupEntity.addressLine2, - postalCode = groupEntity.addressPostalCode, - city = groupEntity.addressCity, - state = groupEntity.addressState, - country = groupEntity.addressCountry, - ), - contactEmail = groupEntity.contactEmail, - ) + return groupEntity.toGroup() + } + + fun updateGroup(groupId: Int, group: NewGroup, authenticatedUser: AuthenticatedUser): Group { + groupManagementPreconditionValidator.validateThatUserExists(authenticatedUser.username) + + groupManagementPreconditionValidator.validateUserIsAllowedToModifyGroup(groupId, authenticatedUser) + + val groupEntity = GroupEntity.findById(groupId) ?: throw NotFoundException("Group $groupId does not exist.") + + groupEntity.updateWith(group) + + auditLogger.log(authenticatedUser.username, "Updated group: ${group.groupName}") + + return groupEntity.toGroup() } fun getGroupsOfUser(authenticatedUser: AuthenticatedUser): List { @@ -172,4 +152,31 @@ class GroupManagementDatabaseService( contactEmail = it.contactEmail, ) } + + private fun GroupEntity.updateWith(group: NewGroup) { + groupName = group.groupName + institution = group.institution + addressLine1 = group.address.line1 + addressLine2 = group.address.line2 + addressPostalCode = group.address.postalCode + addressState = group.address.state + addressCity = group.address.city + addressCountry = group.address.country + contactEmail = group.contactEmail + } + + private fun GroupEntity.toGroup(): Group = Group( + groupId = this.id.value, + groupName = this.groupName, + institution = this.institution, + address = Address( + line1 = this.addressLine1, + line2 = this.addressLine2, + postalCode = this.addressPostalCode, + city = this.addressCity, + state = this.addressState, + country = this.addressCountry, + ), + contactEmail = this.contactEmail, + ) } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/EndpointTestExtension.kt b/backend/src/test/kotlin/org/loculus/backend/controller/EndpointTestExtension.kt index 7bd31625b..cb5fa0b7f 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/EndpointTestExtension.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/EndpointTestExtension.kt @@ -54,6 +54,7 @@ private const val SPRING_DATASOURCE_PASSWORD = "spring.datasource.password" const val ACCESSION_SEQUENCE_NAME = "accession_sequence" const val DEFAULT_GROUP_NAME = "testGroup" +const val DEFAULT_GROUP_NAME_CHANGED = "testGroup name updated" val DEFAULT_GROUP = NewGroup( groupName = DEFAULT_GROUP_NAME, institution = "testInstitution", @@ -67,6 +68,19 @@ val DEFAULT_GROUP = NewGroup( ), contactEmail = "testEmail", ) +val DEFAULT_GROUP_CHANGED = NewGroup( + groupName = DEFAULT_GROUP_NAME_CHANGED, + institution = "Updated institution", + address = Address( + line1 = "Updated address line 1", + line2 = "Updated address line 2", + postalCode = "Updated post code", + city = "Updated city", + state = "Updated state", + country = "Updated country", + ), + contactEmail = "Updated email", +) const val DEFAULT_USER_NAME = "testuser" const val SUPER_USER_NAME = "test_superuser" diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/RequestAuthorization.kt b/backend/src/test/kotlin/org/loculus/backend/controller/RequestAuthorization.kt index b9fc1497f..0d6d28429 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/RequestAuthorization.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/RequestAuthorization.kt @@ -13,6 +13,7 @@ import java.util.Date val keyPair: KeyPair = Jwts.SIG.RS256.keyPair().build() val jwtForDefaultUser = generateJwtFor(DEFAULT_USER_NAME) +val jwtForAlternativeUser = generateJwtFor(ALTERNATIVE_DEFAULT_USER_NAME) val jwtForProcessingPipeline = generateJwtFor("preprocessing_pipeline", listOf(PREPROCESSING_PIPELINE)) val jwtForExternalMetadataUpdatePipeline = generateJwtFor("external_metadata_updater", listOf(EXTERNAL_METADATA_UPDATER)) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt index 1794d877c..d8d9f58a3 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt @@ -13,6 +13,9 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delet import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status const val NEW_GROUP_NAME = "newGroup" val NEW_GROUP = NewGroup( @@ -49,6 +52,14 @@ class GroupManagementControllerClient(private val mockMvc: MockMvc, private val .withAuth(jwt), ) + fun updateGroup(groupId: Int, group: NewGroup = NEW_GROUP, jwt: String? = jwtForDefaultUser): ResultActions = + mockMvc.perform( + put("/groups/$groupId") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(group)) + .withAuth(jwt), + ) + fun getGroupsOfUser(jwt: String? = jwtForDefaultUser): ResultActions = mockMvc.perform( get("/user/groups").withAuth(jwt), ) @@ -74,3 +85,15 @@ fun ResultActions.andGetGroupId(): Int = andReturn() .response .contentAsString .let { JsonPath.read(it, "\$.groupId") }!! + +fun ResultActions.verifyGroupInfo(groupPath: String, expectedGroup: NewGroup) = andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$groupPath.groupName").value(expectedGroup.groupName)) + .andExpect(jsonPath("$groupPath.institution").value(expectedGroup.institution)) + .andExpect(jsonPath("$groupPath.address.line1").value(expectedGroup.address.line1)) + .andExpect(jsonPath("$groupPath.address.line2").value(expectedGroup.address.line2)) + .andExpect(jsonPath("$groupPath.address.city").value(expectedGroup.address.city)) + .andExpect(jsonPath("$groupPath.address.state").value(expectedGroup.address.state)) + .andExpect(jsonPath("$groupPath.address.postalCode").value(expectedGroup.address.postalCode)) + .andExpect(jsonPath("$groupPath.address.country").value(expectedGroup.address.country)) + .andExpect(jsonPath("$groupPath.contactEmail").value(expectedGroup.contactEmail)) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt index 48119cd23..bd5cc9319 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt @@ -15,11 +15,13 @@ import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_GROUP import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_GROUP_NAME import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_USER_NAME import org.loculus.backend.controller.DEFAULT_GROUP +import org.loculus.backend.controller.DEFAULT_GROUP_CHANGED import org.loculus.backend.controller.DEFAULT_GROUP_NAME import org.loculus.backend.controller.DEFAULT_USER_NAME import org.loculus.backend.controller.EndpointTest import org.loculus.backend.controller.expectUnauthorizedResponse import org.loculus.backend.controller.generateJwtFor +import org.loculus.backend.controller.jwtForAlternativeUser import org.loculus.backend.controller.jwtForDefaultUser import org.loculus.backend.controller.jwtForSuperUser import org.loculus.backend.service.KeycloakAdapter @@ -104,6 +106,56 @@ class GroupManagementControllerTest(@Autowired private val client: GroupManageme .andExpect(jsonPath("\$.[0].contactEmail").value(NEW_GROUP.contactEmail)) } + @Test + fun `GIVEN I'm a member of a group WHEN I edit the group THEN the group information is updated`() { + val groupId = client.createNewGroup(group = DEFAULT_GROUP, jwt = jwtForDefaultUser) + .andExpect(status().isOk) + .andGetGroupId() + + client.updateGroup( + groupId = groupId, + group = DEFAULT_GROUP_CHANGED, + jwt = jwtForDefaultUser, + ).verifyGroupInfo("\$", DEFAULT_GROUP_CHANGED) + + client.getDetailsOfGroup(groupId = groupId, jwt = jwtForDefaultUser) + .verifyGroupInfo("\$.group", DEFAULT_GROUP_CHANGED) + } + + @Test + fun `GIVEN I'm a superuser WHEN I edit the group THEN the group information is updated`() { + val groupId = client.createNewGroup(group = DEFAULT_GROUP, jwt = jwtForDefaultUser) + .andExpect(status().isOk) + .andGetGroupId() + + client.updateGroup( + groupId = groupId, + group = DEFAULT_GROUP_CHANGED, + jwt = jwtForSuperUser, + ) + .verifyGroupInfo("\$", DEFAULT_GROUP_CHANGED) + + client.getDetailsOfGroup(groupId = groupId, jwt = jwtForSuperUser) + .verifyGroupInfo("\$.group", DEFAULT_GROUP_CHANGED) + } + + @Test + fun `GIVEN I'm not a member of a group WHEN I edit the group THEN I am not authorized`() { + val groupId = client.createNewGroup(group = DEFAULT_GROUP, jwt = jwtForDefaultUser) + .andExpect(status().isOk) + .andGetGroupId() + + val updateGroupResult = client.updateGroup( + groupId = groupId, + group = DEFAULT_GROUP_CHANGED, + jwt = jwtForAlternativeUser, + ) + updateGroupResult.andExpect(status().isForbidden) + + client.getDetailsOfGroup(groupId = groupId, jwt = jwtForDefaultUser) + .verifyGroupInfo("\$.group", DEFAULT_GROUP) + } + @Test fun `WHEN superuser queries groups of user THEN returns all groups`() { client.createNewGroup(group = DEFAULT_GROUP, jwt = jwtForDefaultUser) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt index 7e32926e6..42d1312c7 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt @@ -7,6 +7,8 @@ import com.fasterxml.jackson.databind.node.NullNode import com.fasterxml.jackson.databind.node.TextNode import com.fasterxml.jackson.module.kotlin.readValue import com.github.luben.zstd.ZstdInputStream +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone @@ -18,17 +20,26 @@ import org.hamcrest.Matchers.hasSize import org.hamcrest.Matchers.matchesPattern import org.hamcrest.Matchers.not import org.hamcrest.Matchers.notNullValue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.keycloak.representations.idm.UserRepresentation import org.loculus.backend.api.GeneticSequence import org.loculus.backend.api.ProcessedData import org.loculus.backend.api.Status import org.loculus.backend.api.VersionStatus +import org.loculus.backend.controller.DEFAULT_GROUP +import org.loculus.backend.controller.DEFAULT_GROUP_CHANGED import org.loculus.backend.controller.DEFAULT_GROUP_NAME +import org.loculus.backend.controller.DEFAULT_GROUP_NAME_CHANGED import org.loculus.backend.controller.DEFAULT_USER_NAME import org.loculus.backend.controller.EndpointTest import org.loculus.backend.controller.expectNdjsonAndGetContent +import org.loculus.backend.controller.groupmanagement.GroupManagementControllerClient +import org.loculus.backend.controller.groupmanagement.andGetGroupId import org.loculus.backend.controller.jacksonObjectMapper +import org.loculus.backend.controller.jwtForDefaultUser import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.NUMBER_OF_SEQUENCES +import org.loculus.backend.service.KeycloakAdapter import org.loculus.backend.utils.Accession import org.loculus.backend.utils.Version import org.springframework.beans.factory.annotation.Autowired @@ -50,11 +61,20 @@ private val ADDED_FIELDS_WITH_UNKNOWN_VALUES_FOR_RELEASE = listOf( @EndpointTest class GetReleasedDataEndpointTest( - @Autowired val convenienceClient: SubmissionConvenienceClient, - @Autowired val submissionControllerClient: SubmissionControllerClient, + @Autowired private val convenienceClient: SubmissionConvenienceClient, + @Autowired private val submissionControllerClient: SubmissionControllerClient, + @Autowired private val groupClient: GroupManagementControllerClient, ) { val currentYear = Clock.System.now().toLocalDateTime(TimeZone.UTC).year + @MockkBean + lateinit var keycloakAdapter: KeycloakAdapter + + @BeforeEach + fun setup() { + every { keycloakAdapter.getUsersWithName(any()) } returns listOf(UserRepresentation()) + } + @Test fun `GIVEN no sequence entries in database THEN returns empty response & etag in header`() { val response = submissionControllerClient.getReleasedData() @@ -68,7 +88,17 @@ class GetReleasedDataEndpointTest( @Test fun `GIVEN released data exists THEN returns it with additional metadata fields`() { - convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease() + val groupId = groupClient.createNewGroup(group = DEFAULT_GROUP, jwt = jwtForDefaultUser) + .andExpect(status().isOk) + .andGetGroupId() + + convenienceClient.prepareDefaultSequenceEntriesToApprovedForRelease(groupId = groupId) + + groupClient.updateGroup( + groupId = groupId, + group = DEFAULT_GROUP_CHANGED, + jwt = jwtForDefaultUser, + ) val response = submissionControllerClient.getReleasedData() @@ -89,7 +119,7 @@ class GetReleasedDataEndpointTest( "accessionVersion" to TextNode("$id.$version"), "isRevocation" to BooleanNode.FALSE, "submitter" to TextNode(DEFAULT_USER_NAME), - "groupName" to TextNode(DEFAULT_GROUP_NAME), + "groupName" to TextNode(DEFAULT_GROUP_NAME_CHANGED), "versionStatus" to TextNode("LATEST_VERSION"), "dataUseTerms" to TextNode("OPEN"), "releasedDate" to TextNode(Clock.System.now().toLocalDateTime(TimeZone.UTC).date.toString()), @@ -109,7 +139,7 @@ class GetReleasedDataEndpointTest( "submittedAtTimestamp" -> expectIsTimestampWithCurrentYear(value) "releasedAtTimestamp" -> expectIsTimestampWithCurrentYear(value) "submissionId" -> assertThat(value.textValue(), matchesPattern("^custom\\d$")) - "groupId" -> assertThat(value.intValue(), greaterThan(0)) + "groupId" -> assertThat(value.intValue(), `is`(groupId)) else -> assertThat(value, `is`(expectedMetadata[key])) } } diff --git a/docs/src/content/docs/for-users/create-manage-groups.md b/docs/src/content/docs/for-users/create-manage-groups.md index a371bd3fd..245ef545e 100644 --- a/docs/src/content/docs/for-users/create-manage-groups.md +++ b/docs/src/content/docs/for-users/create-manage-groups.md @@ -28,3 +28,16 @@ To remove a user: 2. Within your groups, select the name of the group that you'd like to remove a user from 3. Using the 'user' interface at the bottom of the page, click the red button next to the user you wish to remove 4. The user is now removed + +## Edit group information + +Anyone in the group can edit group information such as the group name, contact email and adress. + +1. Log into your account and navigate to your account page ('My account' in the top-right of the website) +2. Within your groups, select the name of the group that you would like to edit +3. In the group detail view, click the 'Edit group' button at the top +4. Update any information in the edit group form that you would like to change and then click the 'Update group' button + +:::caution +If you change the name of a group the center name in corresponding ENA submissions will also change. +::: diff --git a/ena-submission/README.md b/ena-submission/README.md index 191add661..2b18c707e 100644 --- a/ena-submission/README.md +++ b/ena-submission/README.md @@ -37,6 +37,10 @@ In a loop: - else: set status to HAS_ERRORS and fill in errors - Get sequences in `project_table` in state HAS_ERRORS for over 15min and sequences in status SUBMITTING for over 15min: send slack notification +##### Known limitations + +Group info can be updated in loculus after the project has been created in ENA. This is not currently handled by the pipeline. Issue: + #### create_sample Maps loculus metadata to ena metadata using template: https://www.ebi.ac.uk/ena/browser/view/ERC000033 diff --git a/ena-submission/scripts/call_loculus.py b/ena-submission/scripts/call_loculus.py index df0dda936..aaeb7061a 100644 --- a/ena-submission/scripts/call_loculus.py +++ b/ena-submission/scripts/call_loculus.py @@ -27,7 +27,6 @@ class Config: keycloak_client_id: str username: str password: str - group_name: str ena_specific_metadata: list[str] diff --git a/ena-submission/scripts/create_project.py b/ena-submission/scripts/create_project.py index 3a6bec28d..99ac88b01 100644 --- a/ena-submission/scripts/create_project.py +++ b/ena-submission/scripts/create_project.py @@ -217,6 +217,7 @@ def submission_table_update(db_config: SimpleConnectionPool): raise RuntimeError(error_msg) +# TODO Allow propagating updated group info https://github.com/loculus-project/loculus/issues/2939 def project_table_create( db_config: SimpleConnectionPool, config: Config, retry_number: int = 3, test: bool = False ): diff --git a/generate_local_test_config.sh b/generate_local_test_config.sh index 027988681..b36b7a7e9 100755 --- a/generate_local_test_config.sh +++ b/generate_local_test_config.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e diff --git a/get_testuser_token.sh b/get_testuser_token.sh index 08ed323ac..b9565c8b6 100755 --- a/get_testuser_token.sh +++ b/get_testuser_token.sh @@ -1,6 +1,12 @@ -#!/usr/bin/bash +#!/usr/bin/env bash set -eu +# Check if jq is installed +if ! command -v jq &> /dev/null; then + echo "Error: jq is not installed. Please install it from https://jqlang.github.io/jq/" + exit 1 +fi + KEYCLOAK_TOKEN_URL="http://localhost:8083/realms/loculus/protocol/openid-connect/token" KEYCLOAK_CLIENT_ID="backend-client" diff --git a/ingest/config/defaults.yaml b/ingest/config/defaults.yaml index 306326e46..71354e67f 100644 --- a/ingest/config/defaults.yaml +++ b/ingest/config/defaults.yaml @@ -70,7 +70,7 @@ column_mapping: Virus Name: ncbiVirusName Virus Pangolin Classification: ncbiVirusPangolin Virus Taxonomic ID: ncbiVirusTaxId -group_name: insdc_ingest_group +group_name: insdc_ingest_group # Used only to set the group name, never read username: insdc_ingest_user password: insdc_ingest_user keycloak_client_id: backend-client diff --git a/ingest/scripts/call_loculus.py b/ingest/scripts/call_loculus.py index 694f41a9f..69c64a62e 100644 --- a/ingest/scripts/call_loculus.py +++ b/ingest/scripts/call_loculus.py @@ -123,7 +123,7 @@ def make_request( # noqa: PLR0913, PLR0917 return response -def create_group(config: Config) -> str: +def create_group_and_return_group_id(config: Config) -> str: create_group_url = f"{backend_url(config)}/groups" group_name = config.group_name @@ -151,7 +151,7 @@ def create_group(config: Config) -> str: return group_id -def get_or_create_group(config: Config, allow_creation: bool = False) -> str: +def get_or_create_group_and_return_group_id(config: Config, allow_creation: bool = False) -> str: """Returns group id""" get_user_groups_url = f"{backend_url(config)}/user/groups" @@ -168,7 +168,7 @@ def get_or_create_group(config: Config, allow_creation: bool = False) -> str: raise ValueError(msg) logger.info("User is not in any group. Creating a new group") - return create_group(config) + return create_group_and_return_group_id(config) def submit_or_revise( @@ -332,8 +332,10 @@ def get_submitted(config: Config): if len(entries) == expected_record_count: f"Got {len(entries)} records as expected" break - logger.error(f"Got incomplete original metadata stream: expected {len(entries)}" - f"records but got {expected_record_count}. Retrying after 60 seconds.") + logger.error( + f"Got incomplete original metadata stream: expected {len(entries)}" + f"records but got {expected_record_count}. Retrying after 60 seconds." + ) sleep(60) # Initialize the dictionary to store results @@ -467,7 +469,9 @@ def record_factory(*args, **kwargs): if mode in {"submit", "revise"}: logging.info(f"Starting {mode}") try: - group_id = get_or_create_group(config, allow_creation=mode == "submit") + group_id = get_or_create_group_and_return_group_id( + config, allow_creation=mode == "submit" + ) except ValueError as e: logger.error(f"Aborting {mode} due to error: {e}") return @@ -487,7 +491,9 @@ def record_factory(*args, **kwargs): if mode == "regroup-and-revoke": try: - group_id = get_or_create_group(config, allow_creation=mode == "submit") + group_id = get_or_create_group_and_return_group_id( + config, allow_creation=mode == "submit" + ) except ValueError as e: logger.error(f"Aborting {mode} due to error: {e}") return diff --git a/kubernetes/README.md b/kubernetes/README.md index 3163f8aec..d8f1eec73 100644 --- a/kubernetes/README.md +++ b/kubernetes/README.md @@ -31,10 +31,12 @@ helm install loculus kubernetes/loculus -f my-values.yaml ### Prerequisites Install [k3d](https://k3d.io/v5.6.0/) and [helm](https://helm.sh/). - +We also recommend installing [k9s](https://k9scli.io/) to inspect cluster resources. We deploy to kubernetes via the `../deploy.py` script. It requires you to have `pyyaml` and `requests` installed. +NOTE: On MacOS, make sure that you have configured enough RAM in Docker, we recommend 8GB. + ### Setup for local development #### TLDR diff --git a/preprocessing/specification.md b/preprocessing/specification.md index 6a7abe923..3518ec26b 100644 --- a/preprocessing/specification.md +++ b/preprocessing/specification.md @@ -38,7 +38,7 @@ To retrieve unpreprocessed data, the preprocessing pipeline sends a POST request In the unprocessed NDJSON, each line contains a sequence entry represented as a JSON object and looks as follows: -``` +```json {"accession": 1, "version": 1, "data": {"metadata": {...}, "unalignedNucleotideSequences": {...}}, "submitter": insdc_ingest_user, ...} {"accession": 2, "version": 1, "data": {"metadata": {...}, "unalignedNucleotideSequences": {...}}, "submitter": john_smith, ...} ``` diff --git a/website/src/components/Group/GroupForm.tsx b/website/src/components/Group/GroupForm.tsx new file mode 100644 index 000000000..585d8537e --- /dev/null +++ b/website/src/components/Group/GroupForm.tsx @@ -0,0 +1,102 @@ +import { useState, type FC, type FormEvent } from 'react'; + +import { + AddressLineOneInput, + AddressLineTwoInput, + CityInput, + CountryInput, + CountryInputNoOptionChosen, + EmailContactInput, + GroupNameInput, + InstitutionNameInput, + PostalCodeInput, + StateInput, + groupFromFormData, +} from './Inputs'; +import useClientFlag from '../../hooks/isClient'; +import type { NewGroup } from '../../types/backend'; +import { ErrorFeedback } from '../ErrorFeedback.tsx'; + +interface GroupFormProps { + title: string; + buttonText: string; + defaultGroupData?: NewGroup; + onSubmit: (group: NewGroup) => Promise; +} + +export type GroupSubmitSuccess = { + succeeded: true; + nextPageHref: string; +}; +export type GroupSubmitError = { + succeeded: false; + errorMessage: string; +}; +export type GroupSubmitResult = GroupSubmitSuccess | GroupSubmitError; + +export const GroupForm: FC = ({ title, buttonText, defaultGroupData, onSubmit }) => { + const [errorMessage, setErrorMessage] = useState(undefined); + + const internalOnSubmit = async (e: FormEvent) => { + e.preventDefault(); + + const formData = new FormData(e.currentTarget); + + const group = groupFromFormData(formData); + + if (group.address.country === CountryInputNoOptionChosen) { + setErrorMessage('Please choose a country'); + return false; + } + + const result = await onSubmit(group); + + if (result.succeeded) { + window.location.href = result.nextPageHref; + } else { + setErrorMessage(result.errorMessage); + } + }; + + const isClient = useClientFlag(); + + return ( +
+

{title}

+ + {errorMessage !== undefined && ( + setErrorMessage(undefined)} /> + )} + +
+
+

+ The information you enter on this form will be publicly available on your group page. +

+ +
+ + + + + + + + + +
+ +
+ +
+
+
+
+ ); +}; diff --git a/website/src/components/Group/Inputs.tsx b/website/src/components/Group/Inputs.tsx new file mode 100644 index 000000000..82533c206 --- /dev/null +++ b/website/src/components/Group/Inputs.tsx @@ -0,0 +1,228 @@ +import { type ComponentProps, type FC, type PropsWithChildren } from 'react'; + +import { listOfCountries } from './listOfCountries.ts'; +import type { NewGroup } from '../../types/backend.ts'; + +export const CountryInputNoOptionChosen = 'Choose a country...'; + +const fieldMapping = { + groupName: 'groupName', + institution: 'institution', + contactEmail: 'contactEmail', + country: 'country', + line1: 'line1', + line2: 'line2', + city: 'city', + state: 'state', + postalCode: 'postalCode', +} as const; + +const groupCreationCssClass = + 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6'; + +type LabelledInputContainerProps = PropsWithChildren<{ + label: string; + htmlFor: string; + className: string; + required?: boolean; +}>; + +const LabelledInputContainer: FC = ({ children, label, htmlFor, className, required }) => ( +
+ +
{children}
+
+); + +type TextInputProps = { + className: string; + label: string; + name: string; + required: boolean; + type: ComponentProps<'input'>['type']; + defaultValue?: string; +}; + +const TextInput: FC = ({ className, label, name, required, type, defaultValue }) => ( + + + +); + +type GroupNameInputProps = { + defaultValue?: string; +}; + +export const GroupNameInput: FC = ({ defaultValue }) => ( + +); + +type InstitutionNameInputProps = { + defaultValue?: string; +}; + +export const InstitutionNameInput: FC = ({ defaultValue }) => ( + +); + +type EmailContactInputProps = { + defaultValue?: string; +}; + +export const EmailContactInput: FC = ({ defaultValue }) => ( + +); + +type CountryInputProps = { + defaultValue?: string; +}; + +export const CountryInput: FC = ({ defaultValue }) => ( + + + +); + +type AddressLineOneInputProps = { + defaultValue?: string; +}; + +export const AddressLineOneInput: FC = ({ defaultValue }) => ( + +); + +type AddressLineTwoInputProps = { + defaultValue?: string; +}; + +export const AddressLineTwoInput: FC = ({ defaultValue }) => ( + +); + +type CityInputProps = { + defaultValue?: string; +}; + +export const CityInput: FC = ({ defaultValue }) => ( + +); + +type StateInputProps = { + defaultValue?: string; +}; + +export const StateInput: FC = ({ defaultValue }) => ( + +); + +type PostalCodeInputProps = { + defaultValue?: string; +}; + +export const PostalCodeInput: FC = ({ defaultValue }) => ( + +); + +export const groupFromFormData = (formData: FormData): NewGroup => { + const getField = (key: keyof typeof fieldMapping) => formData.get(fieldMapping[key]) as string; + + return { + groupName: getField('groupName'), + institution: getField('institution'), + contactEmail: getField('contactEmail'), + address: { + line1: getField('line1'), + line2: getField('line2'), + city: getField('city'), + postalCode: getField('postalCode'), + state: getField('state'), + country: getField('country'), + }, + }; +}; diff --git a/website/src/components/User/listOfCountries.ts b/website/src/components/Group/listOfCountries.ts similarity index 100% rename from website/src/components/User/listOfCountries.ts rename to website/src/components/Group/listOfCountries.ts diff --git a/website/src/components/User/GroupCreationForm.tsx b/website/src/components/User/GroupCreationForm.tsx index b03f12392..baba09032 100644 --- a/website/src/components/User/GroupCreationForm.tsx +++ b/website/src/components/User/GroupCreationForm.tsx @@ -1,11 +1,10 @@ -import { type ComponentProps, type FC, type FormEvent, type PropsWithChildren, useState } from 'react'; +import { type FC } from 'react'; -import { listOfCountries } from './listOfCountries.ts'; -import useClientFlag from '../../hooks/isClient.ts'; import { useGroupCreation } from '../../hooks/useGroupOperations.ts'; import { routes } from '../../routes/routes.ts'; +import type { NewGroup } from '../../types/backend.ts'; import { type ClientConfig } from '../../types/runtimeConfig.ts'; -import { ErrorFeedback } from '../ErrorFeedback.tsx'; +import { GroupForm, type GroupSubmitError, type GroupSubmitSuccess } from '../Group/GroupForm.tsx'; import { withQueryProvider } from '../common/withQueryProvider.tsx'; interface GroupManagerProps { @@ -13,255 +12,29 @@ interface GroupManagerProps { accessToken: string; } -const chooseCountry = 'Choose a country...'; - const InnerGroupCreationForm: FC = ({ clientConfig, accessToken }) => { - const [errorMessage, setErrorMessage] = useState(undefined); - const { createGroup } = useGroupCreation({ clientConfig, accessToken, }); - const handleCreateGroup = async (e: FormEvent) => { - e.preventDefault(); - - const formData = new FormData(e.currentTarget); - - const groupName = formData.get(fieldMapping.groupName.id) as string; - const institution = formData.get(fieldMapping.institution.id) as string; - const contactEmail = formData.get(fieldMapping.contactEmail.id) as string; - const country = formData.get(fieldMapping.country.id) as string; - const line1 = formData.get(fieldMapping.line1.id) as string; - const line2 = formData.get(fieldMapping.line2.id) as string; - const city = formData.get(fieldMapping.city.id) as string; - const state = formData.get(fieldMapping.state.id) as string; - const postalCode = formData.get(fieldMapping.postalCode.id) as string; - - if (country === chooseCountry) { - setErrorMessage('Please choose a country'); - return false; - } - - const result = await createGroup({ - groupName, - institution, - contactEmail, - address: { line1, line2, city, postalCode, state, country }, - }); + const handleCreateGroup = async (group: NewGroup) => { + const result = await createGroup(group); if (result.succeeded) { - window.location.href = routes.groupOverviewPage(result.group.groupId); + return { + succeeded: true, + nextPageHref: routes.groupOverviewPage(result.group.groupId), + } as GroupSubmitSuccess; } else { - setErrorMessage(result.errorMessage); + return { + succeeded: false, + errorMessage: result.errorMessage, + } as GroupSubmitError; } }; - const isClient = useClientFlag(); - - return ( -
-

Create a new submitting group

- - {errorMessage !== undefined && ( - setErrorMessage(undefined)} /> - )} - -
-
-

- The information you enter on this form will be publicly available on your group page. -

- -
- - - - - - - - - -
- -
- -
-
-
-
- ); + return ; }; export const GroupCreationForm = withQueryProvider(InnerGroupCreationForm); - -const fieldMapping = { - groupName: { - id: 'group-name', - required: true, - }, - institution: { - id: 'institution-name', - required: true, - }, - contactEmail: { - id: 'email', - required: true, - }, - country: { - id: 'country', - required: true, - }, - line1: { - id: 'address-line-1', - required: true, - }, - line2: { - id: 'address-line-2', - required: false, - }, - city: { - id: 'city', - required: true, - }, - state: { - id: 'state', - required: false, - }, - postalCode: { - id: 'postal-code', - required: true, - }, -} as const; - -const groupCreationCssClass = - 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6'; - -type LabelledInputContainerProps = PropsWithChildren<{ - label: string; - htmlFor: string; - className: string; - required?: boolean; -}>; - -const LabelledInputContainer: FC = ({ children, label, htmlFor, className, required }) => ( -
- -
{children}
-
-); - -type TextInputProps = { - className: string; - label: string; - name: string; - fieldMappingKey: keyof typeof fieldMapping; - type: ComponentProps<'input'>['type']; -}; - -const TextInput: FC = ({ className, label, name, fieldMappingKey, type }) => ( - - - -); - -const GroupNameInput = () => ( - -); - -const InstitutionNameInput = () => ( - -); - -const EmailContactInput = () => ( - -); - -const CountryInput = () => ( - - - -); - -const AddressLineOneInput = () => ( - -); - -const AddressLineTwoInput = () => ( - -); - -const CityInput = () => ( - -); - -const StateInput = () => ( - -); - -const PostalCodeInput = () => ( - -); diff --git a/website/src/components/User/GroupEditForm.tsx b/website/src/components/User/GroupEditForm.tsx new file mode 100644 index 000000000..fbf51c0a1 --- /dev/null +++ b/website/src/components/User/GroupEditForm.tsx @@ -0,0 +1,50 @@ +import { type FC } from 'react'; + +import { useGroupEdit } from '../../hooks/useGroupOperations.ts'; +import { routes } from '../../routes/routes.ts'; +import type { GroupDetails, NewGroup } from '../../types/backend.ts'; +import { type ClientConfig } from '../../types/runtimeConfig.ts'; +import { GroupForm, type GroupSubmitError, type GroupSubmitSuccess } from '../Group/GroupForm.tsx'; +import { withQueryProvider } from '../common/withQueryProvider.tsx'; + +interface GroupEditFormProps { + prefetchedGroupDetails: GroupDetails; + clientConfig: ClientConfig; + accessToken: string; +} + +const InnerGroupEditForm: FC = ({ prefetchedGroupDetails, clientConfig, accessToken }) => { + const { groupId, ...groupInfo } = prefetchedGroupDetails.group; + + const { editGroup } = useGroupEdit({ + clientConfig, + accessToken, + }); + + const handleEditGroup = async (group: NewGroup) => { + const result = await editGroup(groupId, group); + + if (result.succeeded) { + return { + succeeded: true, + nextPageHref: routes.groupOverviewPage(result.group.groupId), + } as GroupSubmitSuccess; + } else { + return { + succeeded: false, + errorMessage: result.errorMessage, + } as GroupSubmitError; + } + }; + + return ( + + ); +}; + +export const GroupEditForm = withQueryProvider(InnerGroupEditForm); diff --git a/website/src/components/User/GroupPage.tsx b/website/src/components/User/GroupPage.tsx index 108d21e55..8a3fbf900 100644 --- a/website/src/components/User/GroupPage.tsx +++ b/website/src/components/User/GroupPage.tsx @@ -28,6 +28,7 @@ const InnerGroupPage: FC = ({ userGroups, }) => { const groupName = prefetchedGroupDetails.group.groupName; + const groupId = prefetchedGroupDetails.group.groupId; const [newUserName, setNewUserName] = useState(''); const [errorMessage, setErrorMessage] = useState(undefined); @@ -94,22 +95,30 @@ const InnerGroupPage: FC = ({ {userIsGroupMember && ( - + <> + + Edit group + + + )} ) : ( diff --git a/website/src/hooks/useGroupOperations.ts b/website/src/hooks/useGroupOperations.ts index ecf2dd715..0a8676656 100644 --- a/website/src/hooks/useGroupOperations.ts +++ b/website/src/hooks/useGroupOperations.ts @@ -76,6 +76,19 @@ export const useGroupCreation = ({ }; }; +export const useGroupEdit = ({ clientConfig, accessToken }: { clientConfig: ClientConfig; accessToken: string }) => { + const { zodios } = useGroupManagementClient(clientConfig); + + const editGroup = useCallback( + async (groupId: number, group: NewGroup) => callEditGroup(accessToken, zodios)(groupId, group), + [accessToken, zodios], + ); + + return { + editGroup, + }; +}; + export const useGroupManagementClient = (clientConfig: ClientConfig) => { const zodios = useMemo(() => new Zodios(clientConfig.backendUrl, groupManagementApi), [clientConfig]); const zodiosHooks = useMemo(() => new ZodiosHooks('loculus', zodios), [zodios]); @@ -115,6 +128,39 @@ function callCreateGroup(accessToken: string, zodios: ZodiosInstance) { + return async (groupId: number, group: NewGroup) => { + try { + const groupResult = await zodios.editGroup(group, { + headers: createAuthorizationHeader(accessToken), + params: { + groupId, + }, + }); + return { + succeeded: true, + group: groupResult, + } as EditGroupSuccess; + } catch (error) { + const message = `Failed to create group: ${stringifyMaybeAxiosError(error)}`; + return { + succeeded: false, + errorMessage: message, + } as EditGroupError; + } + }; +} + function callRemoveFromGroup( accessToken: string, openErrorFeedback: (message: string | undefined) => void, diff --git a/website/src/pages/group/[groupId]/edit.astro b/website/src/pages/group/[groupId]/edit.astro new file mode 100644 index 000000000..d2448f65e --- /dev/null +++ b/website/src/pages/group/[groupId]/edit.astro @@ -0,0 +1,43 @@ +--- +import { GroupEditForm } from '../../../components/User/GroupEditForm'; +import ErrorBox from '../../../components/common/ErrorBox.tsx'; +import NeedToLogin from '../../../components/common/NeedToLogin.astro'; +import { getRuntimeConfig } from '../../../config'; +import BaseLayout from '../../../layouts/BaseLayout.astro'; +import { GroupManagementClient } from '../../../services/groupManagementClient'; +import { getAccessToken } from '../../../utils/getAccessToken'; + +const session = Astro.locals.session!; +const accessToken = getAccessToken(session)!; +const groupId = parseInt(Astro.params.groupId!, 10); +const clientConfig = getRuntimeConfig().public; + +if (isNaN(groupId)) { + return new Response(undefined, { + status: 404, + }); +} + +const groupManagementClient = GroupManagementClient.create(); +const groupDetailsResult = await groupManagementClient.getGroupDetails(accessToken, groupId); +--- + + + { + !accessToken ? ( + + ) : ( + groupDetailsResult.match( + (groupDetails) => ( + + ), + () => Failed to fetch group details, sorry for the inconvenience!, + ) + ) + } + diff --git a/website/src/routes/routes.ts b/website/src/routes/routes.ts index 4c0a229d3..69838c43e 100644 --- a/website/src/routes/routes.ts +++ b/website/src/routes/routes.ts @@ -47,6 +47,7 @@ export const routes = { return organism === undefined ? userPagePath : withOrganism(organism, userPagePath); }, groupOverviewPage: (groupId: number) => `/group/${groupId}`, + editGroupPage: (groupId: number) => `/group/${groupId}/edit`, userSequenceReviewPage: (organism: string, groupId: number) => SubmissionRouteUtils.toUrl({ name: 'review', organism, groupId }), versionPage: (accession: string) => `/seq/${accession}/versions`, diff --git a/website/src/services/groupManagementApi.ts b/website/src/services/groupManagementApi.ts index 674a2081c..a94399aff 100644 --- a/website/src/services/groupManagementApi.ts +++ b/website/src/services/groupManagementApi.ts @@ -18,6 +18,21 @@ const createGroupEndpoint = makeEndpoint({ response: group, errors: [notAuthorizedError, conflictError], }); +const editGroupEndpoint = makeEndpoint({ + method: 'put', + path: '/groups/:groupId', + alias: 'editGroup', + parameters: [ + authorizationHeader, + { + name: 'data', + type: 'Body', + schema: newGroup, + }, + ], + response: group, + errors: [notAuthorizedError], +}); const addUserToGroupEndpoint = makeEndpoint({ method: 'put', path: '/groups/:groupId/users/:userToAdd', @@ -60,6 +75,7 @@ const getAllGroupsEndpoint = makeEndpoint({ }); export const groupManagementApi = makeApi([ createGroupEndpoint, + editGroupEndpoint, addUserToGroupEndpoint, removeUserFromGroupEndpoint, getGroupDetailsEndpoint, diff --git a/website/tests/pages/user/group/index.spec.ts b/website/tests/pages/user/group/index.spec.ts index d6829d6b2..0984d693e 100644 --- a/website/tests/pages/user/group/index.spec.ts +++ b/website/tests/pages/user/group/index.spec.ts @@ -24,5 +24,7 @@ test.describe('The group page', () => { await groupPage.removeUserFromGroup(testUser); await groupPage.verifyUserIsNotPresent(testUser); + + // TODO: Edit a group and verify the changes are shown }); });