Skip to content

Commit

Permalink
feat: Edit group info (#2854)
Browse files Browse the repository at this point in the history
Co-authored-by: Cornelius Roemer <cornelius.roemer@gmail.com>
Co-authored-by: Loculus bot <bot@loculus.org>
  • Loading branch information
3 people authored Oct 17, 2024
1 parent a04412f commit 3ad6e42
Show file tree
Hide file tree
Showing 37 changed files with 971 additions and 349 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
11 changes: 7 additions & 4 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**: <http://localhost:8079/swagger-ui/index.html>.

3. Clean up the database when done:

```sh
Expand Down Expand Up @@ -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**: <http://localhost:8079/swagger-ui/index.html>.
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.
Expand All @@ -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
Expand All @@ -97,7 +100,7 @@ The tests use Testcontainers to start a PostgreSQL database. This requires Docke
./gradlew ktlintCheck
```
## Format
### Format
```bash
./gradlew ktlintFormat
Expand Down
18 changes: 18 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import org.loculus.backend.api.Organism
import org.loculus.backend.api.ProcessedData
import org.loculus.backend.api.VersionStatus
import org.loculus.backend.config.BackendConfig
import org.loculus.backend.service.groupmanagement.GROUPS_TABLE_NAME
import org.loculus.backend.service.submission.CURRENT_PROCESSING_PIPELINE_TABLE_NAME
import org.loculus.backend.service.submission.EXTERNAL_METADATA_TABLE_NAME
import org.loculus.backend.service.submission.METADATA_UPLOAD_AUX_TABLE_NAME
import org.loculus.backend.service.submission.RawProcessedData
import org.loculus.backend.service.submission.SEQUENCE_ENTRIES_PREPROCESSED_DATA_TABLE_NAME
import org.loculus.backend.service.submission.SEQUENCE_ENTRIES_TABLE_NAME
import org.loculus.backend.service.submission.SEQUENCE_UPLOAD_AUX_TABLE_NAME
import org.loculus.backend.service.submission.SubmissionDatabaseService
import org.loculus.backend.service.submission.UpdateTrackerTable
import org.loculus.backend.utils.Accession
Expand All @@ -27,12 +34,13 @@ private val log = KotlinLogging.logger { }

val RELEASED_DATA_RELATED_TABLES: List<String> =
listOf(
"sequence_entries",
"sequence_entries_preprocessed_data",
"external_metadata",
"current_processing_pipeline",
"metadata_upload_aux_table",
"sequence_upload_aux_table",
CURRENT_PROCESSING_PIPELINE_TABLE_NAME,
EXTERNAL_METADATA_TABLE_NAME,
GROUPS_TABLE_NAME,
METADATA_UPLOAD_AUX_TABLE_NAME,
SEQUENCE_ENTRIES_TABLE_NAME,
SEQUENCE_ENTRIES_PREPROCESSED_DATA_TABLE_NAME,
SEQUENCE_UPLOAD_AUX_TABLE_NAME,
)

@Service
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Group> {
Expand Down Expand Up @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package org.loculus.backend.service.submission
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.datetime

const val CURRENT_PROCESSING_PIPELINE_TABLE = "current_processing_pipeline"
const val CURRENT_PROCESSING_PIPELINE_TABLE_NAME = "current_processing_pipeline"

object CurrentProcessingPipelineTable : Table(CURRENT_PROCESSING_PIPELINE_TABLE) {
object CurrentProcessingPipelineTable : Table(CURRENT_PROCESSING_PIPELINE_TABLE_NAME) {
val versionColumn = long("version")
val startedUsingAtColumn = datetime("started_using_at")
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
import org.loculus.backend.service.jacksonSerializableJsonb

const val METADATA_UPLOAD_TABLE_NAME = "metadata_upload_aux_table"
const val METADATA_UPLOAD_AUX_TABLE_NAME = "metadata_upload_aux_table"

object MetadataUploadAuxTable : Table(METADATA_UPLOAD_TABLE_NAME) {
object MetadataUploadAuxTable : Table(METADATA_UPLOAD_AUX_TABLE_NAME) {
val accessionColumn = varchar("accession", 255).nullable()
val versionColumn = long("version").nullable()
val uploadIdColumn = varchar("upload_id", 255)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package org.loculus.backend.service.submission
import org.jetbrains.exposed.sql.Table
import org.loculus.backend.service.jacksonSerializableJsonb

const val SEQUENCE_UPLOAD_TABLE_NAME = "sequence_upload_aux_table"
const val SEQUENCE_UPLOAD_AUX_TABLE_NAME = "sequence_upload_aux_table"

object SequenceUploadAuxTable : Table(SEQUENCE_UPLOAD_TABLE_NAME) {
object SequenceUploadAuxTable : Table(SEQUENCE_UPLOAD_AUX_TABLE_NAME) {
val sequenceUploadIdColumn = varchar("upload_id", 255)
val sequenceSubmissionIdColumn = varchar("submission_id", 255)
val segmentNameColumn = varchar("segment_name", 255)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ import org.loculus.backend.controller.submission.SubmissionConvenienceClient
import org.loculus.backend.service.datauseterms.DATA_USE_TERMS_TABLE_NAME
import org.loculus.backend.service.groupmanagement.GROUPS_TABLE_NAME
import org.loculus.backend.service.groupmanagement.USER_GROUPS_TABLE_NAME
import org.loculus.backend.service.submission.CURRENT_PROCESSING_PIPELINE_TABLE
import org.loculus.backend.service.submission.METADATA_UPLOAD_TABLE_NAME
import org.loculus.backend.service.submission.CURRENT_PROCESSING_PIPELINE_TABLE_NAME
import org.loculus.backend.service.submission.METADATA_UPLOAD_AUX_TABLE_NAME
import org.loculus.backend.service.submission.SEQUENCE_ENTRIES_PREPROCESSED_DATA_TABLE_NAME
import org.loculus.backend.service.submission.SEQUENCE_ENTRIES_TABLE_NAME
import org.loculus.backend.service.submission.SEQUENCE_UPLOAD_TABLE_NAME
import org.loculus.backend.service.submission.SEQUENCE_UPLOAD_AUX_TABLE_NAME
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down Expand Up @@ -162,12 +176,12 @@ class EndpointTestExtension :

private fun clearDatabaseStatement(): String = """
truncate table $GROUPS_TABLE_NAME cascade;
update $CURRENT_PROCESSING_PIPELINE_TABLE set version = 1, started_using_at = now();
update $CURRENT_PROCESSING_PIPELINE_TABLE_NAME set version = 1, started_using_at = now();
truncate table $SEQUENCE_ENTRIES_TABLE_NAME;
truncate table $SEQUENCE_ENTRIES_PREPROCESSED_DATA_TABLE_NAME;
alter sequence $ACCESSION_SEQUENCE_NAME restart with 1;
truncate table $USER_GROUPS_TABLE_NAME;
truncate $METADATA_UPLOAD_TABLE_NAME;
truncate $SEQUENCE_UPLOAD_TABLE_NAME;
truncate $METADATA_UPLOAD_AUX_TABLE_NAME;
truncate $SEQUENCE_UPLOAD_AUX_TABLE_NAME;
truncate table $DATA_USE_TERMS_TABLE_NAME cascade;
"""
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
)
Expand All @@ -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))
Loading

0 comments on commit 3ad6e42

Please sign in to comment.