From 1ae00cc4ab36dc99778b2eec34b7e2a74916a8a0 Mon Sep 17 00:00:00 2001 From: anhefti Date: Thu, 16 May 2024 10:50:36 +0200 Subject: [PATCH] SEBSP-119 and SEBSP-111 --- .../seb/sebserver/gbl/model/user/UserMod.java | 4 +- .../servicelayer/dao/impl/UserDAOImpl.java | 4 +- .../plugin/MoodlePluginFullIntegration.java | 7 +- .../ScreenProctoringAPIBinding.java | 196 ++++++++---------- .../ScreenProctoringServiceImpl.java | 9 +- 5 files changed, 94 insertions(+), 126 deletions(-) diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/user/UserMod.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/user/UserMod.java index 8d41bc1c..0709bed9 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/user/UserMod.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/user/UserMod.java @@ -147,8 +147,8 @@ public final class UserMod implements UserAccount { this.language = postAttrMapper.getLocale(USER.ATTR_LANGUAGE); this.timeZone = postAttrMapper.getDateTimeZone(USER.ATTR_TIMEZONE); this.roles = postAttrMapper.getStringSet(USER_ROLE.REFERENCE_NAME); - this.isLocalAccount = BooleanUtils.isNotFalse(postAttrMapper.getBoolean(USER.ATTR_LOCAL_ACCOUNT)); - this.hasDirectLogin = BooleanUtils.isNotFalse(postAttrMapper.getBoolean(USER.ATTR_DIRECT_LOGIN)); + this.isLocalAccount = BooleanUtils.isNotFalse(postAttrMapper.getBooleanObject(USER.ATTR_LOCAL_ACCOUNT)); + this.hasDirectLogin = BooleanUtils.isNotFalse(postAttrMapper.getBooleanObject(USER.ATTR_DIRECT_LOGIN)); } @Override diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/UserDAOImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/UserDAOImpl.java index 9e73bf31..12acaeec 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/UserDAOImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/UserDAOImpl.java @@ -239,10 +239,12 @@ public class UserDAOImpl implements UserDAO { checkUniqueUsername(userMod); checkUniqueMailAddress(userMod); + final String uuid = userMod.uuid != null ? userMod.uuid : UUID.randomUUID().toString(); + final UserRecord recordToSave = new UserRecord( null, userMod.institutionId, - UUID.randomUUID().toString(), + uuid, DateTime.now(DateTimeZone.UTC), userMod.name, userMod.surname, diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/plugin/MoodlePluginFullIntegration.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/plugin/MoodlePluginFullIntegration.java index d00cf424..8478d896 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/plugin/MoodlePluginFullIntegration.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/plugin/MoodlePluginFullIntegration.java @@ -28,8 +28,8 @@ public class MoodlePluginFullIntegration implements FullLmsIntegrationAPI { private static final Logger log = LoggerFactory.getLogger(MoodlePluginFullIntegration.class); - private static final String FUNCTION_NAME_SEBSERVER_CONNECTION = "sebserver_connection"; - private static final String FUNCTION_NAME_SEBSERVER_CONNECTION_DELETE = "sebserver_connection_delete"; + private static final String FUNCTION_NAME_SEBSERVER_CONNECTION = "quizaccess_sebserver_connection"; + private static final String FUNCTION_NAME_SEBSERVER_CONNECTION_DELETE = "quizaccess_sebserver_connection_delete"; private final JSONMapper jsonMapper; private final MoodleRestTemplateFactory restTemplateFactory; @@ -82,9 +82,6 @@ public class MoodlePluginFullIntegration implements FullLmsIntegrationAPI { if (StringUtils.isBlank( data.access_token)) { throw new APIMessage.FieldValidationException("lmsFullIntegration:access_token", "access_token is mandatory"); } -// if (data.exam_templates.isEmpty()) { -// throw new APIMessage.FieldValidationException("lmsFullIntegration:exam_templates", "exam_templates is mandatory"); -// } // apply final LmsSetup lmsSetup = this.restTemplateFactory.getApiTemplateDataSupplier().getLmsSetup(); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringAPIBinding.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringAPIBinding.java index e4144dd3..e1efc298 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringAPIBinding.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringAPIBinding.java @@ -84,30 +84,13 @@ class ScreenProctoringAPIBinding { String USERSYNC_SEBSERVER_ENDPOINT = USER_ACCOUNT_ENDPOINT + "usersync/sebserver"; String ENTITY_PRIVILEGES_ENDPOINT = USER_ACCOUNT_ENDPOINT + "entityprivilege"; String EXAM_ENDPOINT = "/admin-api/v1/exam"; + String EXAM_DELETE_REQUEST_ENDPOINT = "/request"; String SEB_ACCESS_ENDPOINT = "/admin-api/v1/clientaccess"; String GROUP_ENDPOINT = "/admin-api/v1/group"; String SESSION_ENDPOINT = "/admin-api/v1/session"; String ACTIVE_PATH_SEGMENT = "/active"; String INACTIVE_PATH_SEGMENT = "/inactive"; - interface PRIVILEGE_FLAGS { - String READ = "r"; - String MODIFY = "m"; - String WRITE = "w"; - } - - /** The screen proctoring service user-account API attribute names */ - interface USER { - String ATTR_UUID = "uuid"; - String ATTR_NAME = "name"; - String ATTR_SURNAME = "surname"; - String ATTR_USERNAME = "username"; - String ATTR_PASSWORD = "newPassword"; - String ATTR_CONFIRM_PASSWORD = "confirmNewPassword"; - String ATTR_LANGUAGE = "language"; - String ATTR_TIMEZONE = "timeZone"; - String ATTR_ROLES = "roles"; - } interface ENTITY_PRIVILEGE { String ATTR_ENTITY_TYPE = "entityType"; @@ -129,12 +112,14 @@ class ScreenProctoringAPIBinding { /** The screen proctoring service group API attribute names */ interface EXAM { String ATTR_UUID = "uuid"; + String ATTR_SEB_SERVER_ID = "sebserverId"; String ATTR_NAME = "name"; String ATTR_DESCRIPTION = "description"; String ATTR_URL = "url"; String ATTR_TYPE = "type"; String ATTR_START_TIME = "startTime"; String ATTR_END_TIME = "endTime"; + String ATTR_USER_IDS = "userUUIDs"; } /** The screen proctoring service seb-group API attribute names */ @@ -170,6 +155,8 @@ class ScreenProctoringAPIBinding { final Long startTime; @JsonProperty(EXAM.ATTR_END_TIME) final Long endTime; + @JsonProperty(EXAM.ATTR_USER_IDS) + final Collection userIds; public ExamUpdate( final String name, @@ -177,7 +164,8 @@ class ScreenProctoringAPIBinding { final String url, final String type, final Long startTime, - final Long endTime) { + final Long endTime, + final Collection userIds) { this.name = name; this.description = description; @@ -185,6 +173,7 @@ class ScreenProctoringAPIBinding { this.type = type; this.startTime = startTime; this.endTime = endTime; + this.userIds = userIds; } } } @@ -308,7 +297,11 @@ class ScreenProctoringAPIBinding { final SPSData spsData = this.getSPSData(exam.id); // re-activate all needed entities on SPS side - activation(exam, SPS_API.SEB_ACCESS_ENDPOINT, spsData.spsSEBAccessUUID, true, apiTemplate); + if (exam.status == Exam.ExamStatus.RUNNING) { + activation(exam, SPS_API.SEB_ACCESS_ENDPOINT, spsData.spsSEBAccessUUID, true, apiTemplate); + activation(exam, SPS_API.EXAM_ENDPOINT, spsData.spsExamUUID, true, apiTemplate); + } + synchronizeUserAccounts(exam); // mark successfully activated on SPS side this.additionalAttributesDAO.saveAdditionalAttribute( @@ -321,15 +314,13 @@ class ScreenProctoringAPIBinding { } final SPSData spsData = new SPSData(); - log.info( "SPS Exam for SEB Server Exam: {} don't exists yet, create necessary structures on SPS", exam.externalId); - exam.supporter.forEach(userUUID -> synchronizeUserAccount(userUUID, apiTemplate)); + synchronizeUserAccounts(exam); createSEBAccess(exam, apiTemplate, spsData); createExam(exam, apiTemplate, spsData); - exam.supporter.forEach(userUUID -> createExamReadPrivilege(userUUID, spsData.spsExamUUID, apiTemplate)); final Collection initializeGroups = initializeGroups(exam, apiTemplate, spsData); // store encrypted spsData @@ -381,6 +372,9 @@ class ScreenProctoringAPIBinding { final SPSData spsData = this.getSPSData(exam.id); exam.supporter.forEach(userUUID -> synchronizeUserAccount(userUUID, apiTemplate)); + if (exam.owner != null) { + synchronizeUserAccount(exam.owner, apiTemplate); + } } catch (final Exception e) { log.error("Failed to synchronize user accounts with SPS for exam: {}", exam); @@ -413,11 +407,10 @@ class ScreenProctoringAPIBinding { /** This is called when an exam has changed its parameter and needs data update on SPS side * - * @param exam The exam - * @return Result refer to the exam or to an error when happened */ - Result updateExam(final Exam exam) { - return Result.tryCatch(() -> { + * @param exam The exam*/ + void updateExam(final Exam exam) { + try { final SPSData spsData = this.getSPSData(exam.id); final ScreenProctoringServiceOAuthTemplate apiTemplate = this.getAPITemplate(exam.id); @@ -428,13 +421,19 @@ class ScreenProctoringAPIBinding { .build() .toUriString(); + final List userIds = new ArrayList<>(exam.supporter); + if (exam.owner != null) { + userIds.add(exam.owner); + } + final ExamUpdate examUpdate = new ExamUpdate( exam.name, exam.getDescription(), exam.getStartURL(), exam.getType().name(), exam.startTime != null ? exam.startTime.getMillis() : null, - exam.endTime != null ? exam.endTime.getMillis() : null); + exam.endTime != null ? exam.endTime.getMillis() : null, + userIds); final String jsonExamUpdate = this.jsonMapper.writeValueAsString(examUpdate); @@ -447,8 +446,9 @@ class ScreenProctoringAPIBinding { log.error("Failed to update SPS exam data: {}", exchange); } - return exam; - }); + } catch (Exception e) { + log.error("Failed to update exam on SPS service for exam: {}", exam, e); + } } /** This is called when an exam finishes and deactivates the Exam, SEB Client Access and the ad-hoc User-Account @@ -482,19 +482,19 @@ class ScreenProctoringAPIBinding { /** This is called on exam delete and deletes the SEB Client Access and the ad-hoc User-Account * on Screen Proctoring Service side. + * Also sends a exam delete request where Exam on SPS gets deleted if there are no session data for the exam * * @param exam The exam * @return Result refer to the exam or to an error when happened */ - Result deleteScreenProctoring(final Exam exam) { - - return Result.tryCatch(() -> { + Exam deleteScreenProctoring(final Exam exam) { + try { if (!BooleanUtils.toBoolean(exam.additionalAttributes.get(SPSData.ATTR_SPS_ACTIVE))) { return exam; } if (log.isDebugEnabled()) { - log.debug("Delete screen proctoring exam, groups and access on SPS for exam: {}", exam); + log.debug("Deactivate exam and groups on SPS site and send deletion request for exam {}", exam); } final ScreenProctoringServiceOAuthTemplate apiTemplate = this.getAPITemplate(exam.id); @@ -502,6 +502,20 @@ class ScreenProctoringAPIBinding { deletion(SPS_API.SEB_ACCESS_ENDPOINT, spsData.spsSEBAccessUUID, apiTemplate); activation(exam, SPS_API.EXAM_ENDPOINT, spsData.spsExamUUID, false, apiTemplate); + // exam delete request on SPS + final String uri = UriComponentsBuilder + .fromUriString(apiTemplate.spsAPIAccessData.getSpsServiceURL()) + .path(SPS_API.EXAM_ENDPOINT) + .pathSegment(spsData.spsExamUUID) + .pathSegment(SPS_API.EXAM_DELETE_REQUEST_ENDPOINT) + .build() + .toUriString(); + + final ResponseEntity exchange = apiTemplate.exchange(uri, HttpMethod.DELETE); + if (exchange.getStatusCode() != HttpStatus.OK) { + log.error("Failed to request delete on SPS for Exam: {} with response: {}", exam, exchange); + } + // mark successfully dispose on SPS side this.additionalAttributesDAO.saveAdditionalAttribute( EntityType.EXAM, @@ -509,8 +523,11 @@ class ScreenProctoringAPIBinding { SPSData.ATTR_SPS_ACTIVE, Constants.FALSE_STRING); - return exam; - }); + + } catch (final Exception e) { + log.warn("Failed to apply SPS deletion of exam: {} error: {}", exam, e.getMessage()); + } + return exam; } Result createGroup( @@ -587,18 +604,6 @@ class ScreenProctoringAPIBinding { } } - void createExamReadPrivileges(final Exam exam) { - try { - final ScreenProctoringServiceOAuthTemplate apiTemplate = this.getAPITemplate(exam.id); - final SPSData spsData = this.getSPSData(exam.id); - - exam.supporter.forEach(userUUID -> createExamReadPrivilege(userUUID, spsData.spsExamUUID, apiTemplate)); - - } catch (final Exception e) { - log.error("Failed to synchronize user accounts exam privileges with SPS for exam: {}", exam); - } - } - private void synchronizeUserAccount( final String userUUID, final ScreenProctoringServiceOAuthTemplate apiTemplate) { @@ -641,6 +646,11 @@ class ScreenProctoringAPIBinding { activityURI, HttpMethod.POST, jsonBody, apiTemplate.getHeaders()); if (activityRequest.getStatusCode() != HttpStatus.OK) { + final String body = activityRequest.getBody(); + if (body != null && body.contains("Activation argument mismatch")) { + return; + } + log.warn("Failed to synchronize activity for user account on SPS: {}", activityRequest); } else { log.info("Successfully synchronize activity for user account on SPS for user: {}", userUUID); @@ -675,51 +685,6 @@ class ScreenProctoringAPIBinding { spsUserRoles); } - private void createExamReadPrivilege( - final String userUUID, - final String examUUID, - final ScreenProctoringServiceOAuthTemplate apiTemplate) { - - try { - - final UserInfo userInfo = this.userDAO - .byModelId(userUUID) - .getOrThrow(); - - final String uri = UriComponentsBuilder - .fromUriString(apiTemplate.spsAPIAccessData.getSpsServiceURL()) - .path(SPS_API.ENTITY_PRIVILEGES_ENDPOINT) - .build() - .toUriString(); - - final MultiValueMap params = new LinkedMultiValueMap<>(); - params.add(SPS_API.ENTITY_PRIVILEGE.ATTR_ENTITY_TYPE, EntityType.EXAM.name()); - params.add(SPS_API.ENTITY_PRIVILEGE.ATTR_ENTITY_ID, examUUID); - params.add(SPS_API.ENTITY_PRIVILEGE.ATTR_USERNAME, userInfo.username); - params.add(SPS_API.ENTITY_PRIVILEGE.ATTR_PRIVILEGES, SPS_API.PRIVILEGE_FLAGS.READ); - final String paramsFormEncoded = Utils.toAppFormUrlEncodedBody(params); - - final ResponseEntity exchange = apiTemplate.exchange(uri, paramsFormEncoded, HttpMethod.POST); - if (exchange.getStatusCode() != HttpStatus.OK) { - log.warn( - "Failed to apply exam read privilege on SPS side for exam: {} and user: {}", - examUUID, - userUUID); - } else { - log.info( - "Successfully apply exam read privilege on SPS side for exam: {} and user: {}", - examUUID, - userUUID); - } - } catch (final Exception e) { - log.error( - "Failed to apply exam read privilege on SPS side for exam: {} and user: {} error: {}", - examUUID, - userUUID, - e.getMessage()); - } - } - private Collection initializeGroups( final Exam exam, final ScreenProctoringServiceOAuthTemplate apiTemplate, @@ -815,6 +780,11 @@ class ScreenProctoringAPIBinding { try { + final List userIds = new ArrayList<>(exam.supporter); + if (exam.owner != null) { + userIds.add(exam.owner); + } + final String uri = UriComponentsBuilder .fromUriString(apiTemplate.spsAPIAccessData.getSpsServiceURL()) .path(SPS_API.EXAM_ENDPOINT) @@ -824,6 +794,9 @@ class ScreenProctoringAPIBinding { params.add(SPS_API.EXAM.ATTR_NAME, exam.name); params.add(SPS_API.EXAM.ATTR_DESCRIPTION, exam.getDescription()); params.add(SPS_API.EXAM.ATTR_URL, exam.getStartURL()); + if (!userIds.isEmpty()) { + params.add(SPS_API.EXAM.ATTR_USER_IDS, StringUtils.join(userIds, Constants.LIST_SEPARATOR)); + } params.add(SPS_API.EXAM.ATTR_TYPE, exam.getType().name()); params.add(SPS_API.EXAM.ATTR_START_TIME, String.valueOf(exam.startTime.getMillis())); @@ -834,7 +807,10 @@ class ScreenProctoringAPIBinding { final ResponseEntity exchange = apiTemplate.exchange(uri, paramsFormEncoded, HttpMethod.POST); if (exchange.getStatusCode() != HttpStatus.OK) { - log.error("Failed to update SPS exam data: {}", exchange); + throw new RuntimeException("Error response from Screen Proctoring Service: " + + exchange.getStatusCodeValue() + + " " + + exchange.getBody()); } final JsonNode requestJSON = this.jsonMapper.readTree(exchange.getBody()); @@ -890,7 +866,6 @@ class ScreenProctoringAPIBinding { rollbackOnSPS(exam, spsData, apiTemplate); throw new RuntimeException("Failed to apply screen proctoring:", e); } - } private void activation( @@ -994,7 +969,7 @@ class ScreenProctoringAPIBinding { log.debug("Create new ScreenProctoringServiceOAuthTemplate for exam: {}", examId); } - WebserviceInfo.ScreenProctoringServiceBundle bundle = this.webserviceInfo + final WebserviceInfo.ScreenProctoringServiceBundle bundle = this.webserviceInfo .getScreenProctoringServiceBundle(); this.testConnection(bundle).getOrThrow(); @@ -1017,9 +992,6 @@ class ScreenProctoringAPIBinding { private final SPSAPIAccessData spsAPIAccessData; private final CircuitBreaker> circuitBreaker; - private final ResourceOwnerPasswordResourceDetails resource; - private final ClientCredentials clientCredentials; - private final ClientCredentials userCredentials; private final OAuth2RestTemplate restTemplate; ScreenProctoringServiceOAuthTemplate( @@ -1032,34 +1004,34 @@ class ScreenProctoringAPIBinding { 10 * Constants.SECOND_IN_MILLIS, 10 * Constants.SECOND_IN_MILLIS); - this.clientCredentials = new ClientCredentials( + ClientCredentials clientCredentials = new ClientCredentials( spsAPIAccessData.getSpsAPIKey(), spsAPIAccessData.getSpsAPISecret()); CharSequence decryptedSecret = sebScreenProctoringService.cryptor - .decrypt(this.clientCredentials.secret) + .decrypt(clientCredentials.secret) .getOrThrow(); - this.resource = new ResourceOwnerPasswordResourceDetails(); - this.resource.setAccessTokenUri(spsAPIAccessData.getSpsServiceURL() + SPS_API.TOKEN_ENDPOINT); - this.resource.setClientId(this.clientCredentials.clientIdAsString()); - this.resource.setClientSecret(decryptedSecret.toString()); - this.resource.setGrantType(GRANT_TYPE); - this.resource.setScope(SCOPES); - this.userCredentials = new ClientCredentials( + final ResourceOwnerPasswordResourceDetails resource = new ResourceOwnerPasswordResourceDetails(); + resource.setAccessTokenUri(spsAPIAccessData.getSpsServiceURL() + SPS_API.TOKEN_ENDPOINT); + resource.setClientId(clientCredentials.clientIdAsString()); + resource.setClientSecret(decryptedSecret.toString()); + resource.setGrantType(GRANT_TYPE); + resource.setScope(SCOPES); + final ClientCredentials userCredentials = new ClientCredentials( spsAPIAccessData.getSpsAccountId(), spsAPIAccessData.getSpsAccountPassword()); decryptedSecret = sebScreenProctoringService.cryptor - .decrypt(this.userCredentials.secret) + .decrypt(userCredentials.secret) .getOrThrow(); - this.resource.setUsername(this.userCredentials.clientIdAsString()); - this.resource.setPassword(decryptedSecret.toString()); + resource.setUsername(userCredentials.clientIdAsString()); + resource.setPassword(decryptedSecret.toString()); final SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); requestFactory.setOutputStreaming(false); - final OAuth2RestTemplate oAuth2RestTemplate = new OAuth2RestTemplate(this.resource); + final OAuth2RestTemplate oAuth2RestTemplate = new OAuth2RestTemplate(resource); oAuth2RestTemplate.setRequestFactory(requestFactory); this.restTemplate = oAuth2RestTemplate; } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringServiceImpl.java index e1a5b5a2..408e251f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringServiceImpl.java @@ -184,7 +184,6 @@ public class ScreenProctoringServiceImpl implements ScreenProctoringService { exam, error)) .getOrThrow() - .stream() .forEach(newGroup -> createNewLocalGroup(exam, newGroup)); this.examDAO.markUpdate(exam.id); @@ -225,9 +224,8 @@ public class ScreenProctoringServiceImpl implements ScreenProctoringService { log.debug("Update exam data on screen proctoring service for exam: {}", exam); } - this.screenProctoringAPIBinding.updateExam(exam); + this.notifyExamSaved(exam); return exam; - }); } @@ -239,7 +237,6 @@ public class ScreenProctoringServiceImpl implements ScreenProctoringService { .allIdsOfRunningWithScreenProctoringEnabled() .flatMap(this.clientConnectionDAO::getAllForScreenProctoringUpdate) .getOrThrow() - .stream() .forEach(this::applyScreenProctoringSession); } catch (final Exception e) { @@ -257,7 +254,7 @@ public class ScreenProctoringServiceImpl implements ScreenProctoringService { } this.screenProctoringAPIBinding.synchronizeUserAccounts(exam); - this.screenProctoringAPIBinding.createExamReadPrivileges(exam); + this.screenProctoringAPIBinding.updateExam(exam); } @Override @@ -445,7 +442,7 @@ public class ScreenProctoringServiceImpl implements ScreenProctoringService { private Result deleteForExam(final Long examId) { return this.examDAO .byPK(examId) - .flatMap(this.screenProctoringAPIBinding::deleteScreenProctoring) + .map(this.screenProctoringAPIBinding::deleteScreenProctoring) .map(this::cleanupAllLocalGroups) .onError(error -> log.error("Failed to delete SPS integration for exam: {}", examId, error)); }