Skip to content

Commit

Permalink
[JN-1534] allow renaming studies (#1329)
Browse files Browse the repository at this point in the history
  • Loading branch information
connorlbark authored Dec 12, 2024
1 parent 7b9aedc commit 1ae9f53
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,16 @@ public ResponseEntity<Object> getByPortalWithEnvs(String portalShortcode, String
EnvironmentName.valueOfCaseInsensitive(envName));
return ResponseEntity.ok(studies);
}

@Override
public ResponseEntity<Object> update(String portalShortcode, String studyShortcode, Object body) {
AdminUser operator = requestService.requireAdminUser(request);
Study study = objectMapper.convertValue(body, Study.class);

study =
studyExtService.update(
PortalStudyAuthContext.of(operator, portalShortcode, studyShortcode), study);

return ResponseEntity.ok(study);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
import com.auth0.jwt.JWT;
import com.auth0.jwt.interfaces.DecodedJWT;
import jakarta.servlet.http.HttpServletRequest;
import java.util.*;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Service;

/** Utility service for common auth-related methods */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import bio.terra.pearl.core.model.study.StudyEnvironment;
import bio.terra.pearl.core.model.study.StudyEnvironmentConfig;
import bio.terra.pearl.core.service.CascadeProperty;
import bio.terra.pearl.core.service.exception.PermissionDeniedException;
import bio.terra.pearl.core.service.exception.internal.InternalServerException;
import bio.terra.pearl.core.service.study.PortalStudyService;
import bio.terra.pearl.core.service.study.StudyService;
Expand Down Expand Up @@ -137,4 +138,18 @@ private StudyEnvironment makeEmptyEnvironment(EnvironmentName envName, boolean i
.build();
return studyEnv;
}

@EnforcePortalStudyPermission(permission = "study_settings_edit")
public Study update(PortalStudyAuthContext authContext, Study studyUpdate) {
Study study = studyService.find(authContext.getPortalStudy().getStudyId()).orElseThrow();

if (!study.getShortcode().equals(studyUpdate.getShortcode())
&& !authContext.getOperator().isSuperuser()) {
throw new PermissionDeniedException("Study shortcode cannot be changed");
}

study.setName(studyUpdate.getName());
study.setShortcode(studyUpdate.getShortcode());
return studyService.update(study);
}
}
16 changes: 16 additions & 0 deletions api-admin/src/main/resources/api/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,22 @@ paths:
description: no content
'500':
$ref: '#/components/responses/ServerError'
put:
summary: updates a study
tags: [ study ]
operationId: update
parameters:
- *portalShortcodeParam
- *studyShortcodeParam
requestBody:
required: true
content: *jsonContent
responses:
'200':
description: updated study object
content: *jsonContent
'500':
$ref: '#/components/responses/ServerError'
/api/portals/v1/{portalShortcode}/studies/{studyShortcode}/env/{envName}/kitTypes:
get:
summary: Gets the kit types for a study
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,23 @@
import bio.terra.pearl.api.admin.service.auth.SuperuserOnly;
import bio.terra.pearl.api.admin.service.auth.context.PortalAuthContext;
import bio.terra.pearl.api.admin.service.auth.context.PortalStudyAuthContext;
import bio.terra.pearl.core.factory.StudyFactory;
import bio.terra.pearl.core.factory.admin.AdminUserBundle;
import bio.terra.pearl.core.factory.admin.AdminUserFactory;
import bio.terra.pearl.core.factory.admin.PortalAdminUserFactory;
import bio.terra.pearl.core.factory.portal.PortalEnvironmentFactory;
import bio.terra.pearl.core.factory.portal.PortalFactory;
import bio.terra.pearl.core.model.EnvironmentName;
import bio.terra.pearl.core.model.admin.AdminUser;
import bio.terra.pearl.core.model.portal.Portal;
import bio.terra.pearl.core.model.study.Study;
import bio.terra.pearl.core.model.study.StudyEnvironment;
import bio.terra.pearl.core.service.exception.PermissionDeniedException;
import bio.terra.pearl.core.service.notification.TriggerService;
import bio.terra.pearl.core.service.study.PortalStudyService;
import bio.terra.pearl.core.service.study.StudyEnvironmentService;
import bio.terra.pearl.core.service.study.StudyService;
import bio.terra.pearl.populate.service.BaseSeedPopulator;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.RandomStringUtils;
Expand All @@ -38,24 +43,29 @@ public class StudyExtServiceTests extends BaseSpringBootTest {
@Autowired private PortalFactory portalFactory;
@Autowired private PortalEnvironmentFactory portalEnvironmentFactory;
@Autowired private AdminUserFactory adminUserFactory;
@Autowired private PortalAdminUserFactory portalAdminUserFactory;
@Autowired private StudyEnvironmentService studyEnvironmentService;
@Autowired private StudyService studyService;
@Autowired private PortalStudyService portalStudyService;
@Autowired private TriggerService triggerService;
@Autowired private StudyFactory studyFactory;
@Autowired private BaseSeedPopulator baseSeedPopulator;

@Test
public void allMethodsAuthed(TestInfo info) {
AuthTestUtils.assertAllMethodsAnnotated(
studyExtService,
Map.of(
"create",
AuthAnnotationSpec.withPortalPerm(
AuthUtilService.BASE_PERMISSION, List.of(SuperuserOnly.class)),
AuthAnnotationSpec.withPortalPerm(
AuthUtilService.BASE_PERMISSION, List.of(SuperuserOnly.class)),
"delete",
AuthAnnotationSpec.withPortalStudyPerm(
AuthUtilService.BASE_PERMISSION, List.of(SuperuserOnly.class)),
AuthAnnotationSpec.withPortalStudyPerm(
AuthUtilService.BASE_PERMISSION, List.of(SuperuserOnly.class)),
"getStudiesWithEnvs",
AuthAnnotationSpec.withPortalPerm(AuthUtilService.BASE_PERMISSION)));
AuthAnnotationSpec.withPortalPerm(AuthUtilService.BASE_PERMISSION),
"update",
AuthAnnotationSpec.withPortalStudyPerm("study_settings_edit")));
}

@Test
Expand Down Expand Up @@ -117,4 +127,46 @@ void testCreateWithTemplate(TestInfo info) {

Assertions.assertEquals(6, triggerService.findByStudyEnvironmentId(sandboxEnv.getId()).size());
}

@Test
@Transactional
public void testOnlySuperuserCanUpdateShortcode(TestInfo info) {
baseSeedPopulator.populateRolesAndPermissions();
Portal portal = portalFactory.buildPersistedWithEnvironments(getTestName(info));
AdminUserBundle operatorBundle =
portalAdminUserFactory.buildPersistedWithRoles(
getTestName(info), portal, List.of("study_admin"));
AdminUser operator = operatorBundle.user();
Study study = studyFactory.buildPersisted(portal.getId(), getTestName(info));

Assertions.assertThrows(
PermissionDeniedException.class,
() ->
studyExtService.update(
PortalStudyAuthContext.of(operator, portal.getShortcode(), study.getShortcode()),
Study.builder().shortcode("newShortcode").name("newName").build()));

// Confirm that the study was not updated
Study updatedStudy = studyService.findByShortcode(study.getShortcode()).get();
assertThat(updatedStudy.getShortcode(), equalTo(study.getShortcode()));

studyExtService.update(
PortalStudyAuthContext.of(operator, portal.getShortcode(), study.getShortcode()),
Study.builder().shortcode(study.getShortcode()).name("newName").build());

// Confirm that the study was updated
updatedStudy = studyService.findByShortcode(study.getShortcode()).get();

assertThat(updatedStudy.getName(), equalTo("newName"));

AdminUser superuser = adminUserFactory.buildPersisted(getTestName(info), true);

studyExtService.update(
PortalStudyAuthContext.of(superuser, portal.getShortcode(), study.getShortcode()),
Study.builder().shortcode("newShortcode").name("newName").build());

// Confirm that the study was updated
updatedStudy = studyService.findByShortcode("newShortcode").get();
assertThat(updatedStudy.getId(), equalTo(study.getId()));
}
}
14 changes: 12 additions & 2 deletions ui-admin/src/api/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ import {
StudyEnvParams,
Survey,
SurveyResponse,
Trigger,
SystemSettings
SystemSettings,
Trigger
} from '@juniper/ui-core'
import queryString from 'query-string'
import {
Expand Down Expand Up @@ -588,6 +588,16 @@ export default {
return await this.processResponse(response)
},

async updateStudy(portalShortcode: string, studyShortcode: string, study: Study): Promise<Study> {
const url = `${basePortalUrl(portalShortcode)}/studies/${studyShortcode}`
const response = await fetch(url, {
method: 'PUT',
headers: this.getInitHeaders(),
body: JSON.stringify(study)
})
return await this.processJsonResponse(response)
},

async getPortalMedia(portalShortcode: string): Promise<SiteMediaMetadata[]> {
const response = await fetch(`${API_ROOT}/portals/v1/${portalShortcode}/siteMedia`, this.getGetInit())
return await this.processJsonResponse(response)
Expand Down
31 changes: 28 additions & 3 deletions ui-admin/src/portal/dashboard/widgets/StudyWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import { Button } from 'components/forms/Button'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faGear, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'
import {
faGear,
faPencil,
faPlus,
faTrash
} from '@fortawesome/free-solid-svg-icons'
import CreateNewStudyModal from 'study/CreateNewStudyModal'
import { Link } from 'react-router-dom'
import { studyParticipantsPath } from 'portal/PortalRouter'
import { getMediaUrl } from 'api/api'
import { Portal, Study } from '@juniper/ui-core'
import {
Portal,
Study
} from '@juniper/ui-core'
import React, { useState } from 'react'
import { useUser } from 'user/UserProvider'
import { DropdownButton } from 'study/participants/survey/SurveyResponseView'
import DeleteStudyModal from 'study/adminTasks/DeleteStudyModal'
import { useNavContext } from 'navbar/NavContextProvider'
import { InfoCard, InfoCardBody, InfoCardHeader } from 'components/InfoCard'
import {
InfoCard,
InfoCardBody,
InfoCardHeader
} from 'components/InfoCard'
import { UpdateStudyModal } from 'study/adminTasks/UpdateStudyModal'

export const StudyWidget = ({ portal }: { portal: Portal }) => {
const [showNewStudyModal, setShowNewStudyModal] = useState(false)
Expand Down Expand Up @@ -64,6 +77,7 @@ const StudyControls = ({ portal, study, primaryStudy }: {
}) => {
const { reload } = useNavContext()
const [showDeleteStudyModal, setShowDeleteStudyModal] = useState(false)
const [showUpdateStudyModal, setShowUpdateStudyModal] = useState(false)

return (
<li key={`${portal.shortcode}-${study.shortcode}`}
Expand Down Expand Up @@ -92,6 +106,10 @@ const StudyControls = ({ portal, study, primaryStudy }: {
<FontAwesomeIcon icon={faGear} className="fa-lg"/>
</Button>
<div className="dropdown-menu" aria-labelledby={`editStudyMenu-${study.shortcode}`}>
<DropdownButton
onClick={() => setShowUpdateStudyModal(true)}
label="Update study"
icon={faPencil}/>
<DropdownButton
onClick={() => setShowDeleteStudyModal(true)}
className="text-danger"
Expand All @@ -105,6 +123,13 @@ const StudyControls = ({ portal, study, primaryStudy }: {
reload={reload}
onDismiss={() => setShowDeleteStudyModal(false)}/>
}
{showUpdateStudyModal &&
<UpdateStudyModal
study={study}
portal={portal}
reload={reload}
onClose={() => setShowUpdateStudyModal(false)}/>
}
</div>
</div>
</div>
Expand Down
63 changes: 63 additions & 0 deletions ui-admin/src/study/adminTasks/UpdateStudyModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react'
import {
Portal,
Study
} from '@juniper/ui-core'
import Modal from 'react-bootstrap/Modal'
import Api from 'api/api'
import { RequireUserPermission } from 'util/RequireUserPermission'

export const UpdateStudyModal = ({
study,
portal,
reload,
onClose
}: {
study: Study,
portal: Portal,
reload: () => void,
onClose: () => void
}) => {
const [name, setName] = React.useState(study.name)
const [shortcode, setShortcode] = React.useState(study.shortcode)

const updateStudy = async () => {
// update the study
await Api.updateStudy(portal.shortcode, study.shortcode, { name, shortcode, studyEnvironments: [] })
reload()
onClose()
}

return <Modal show={true} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Update Study</Modal.Title>
<div className="ms-4">
{study.name}
</div>
</Modal.Header>
<Modal.Body>
<div className="mb-3">
<label className="form-label">Study Name</label>
<input
className="form-control"
value={name}
onChange={e => setName(e.target.value)}
/>
<RequireUserPermission superuser>
<label className="form-label mt-2">Shortcode</label>
<input
className="form-control"
value={shortcode}
onChange={e => setShortcode(e.target.value)}/>
</RequireUserPermission>
</div>
</Modal.Body>
<Modal.Footer>
<button
className="btn btn-primary"
onClick={updateStudy}
>Update Study</button>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
</Modal.Footer>
</Modal>
}

0 comments on commit 1ae9f53

Please sign in to comment.