Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ee0f9f6
Add Review and ReviewMessage entities, DAO layer, and system clock
schluebbo Feb 24, 2025
bcbb97e
Add DTOs and mappers for Review and ReviewMessage
schluebbo Feb 24, 2025
77c5b39
Add ReviewController, ReviewService, and review list UI
schluebbo Feb 24, 2025
dcc2cfa
Implement review request functionality
schluebbo Feb 24, 2025
15eeb11
Add email preview functionality for review requests
schluebbo Feb 24, 2025
48e0ce2
Implement review deletion functionality
schluebbo Feb 24, 2025
d36b277
Add "Manage reviews" to navigation
schluebbo Feb 24, 2025
2b70e4d
Add review details view and decision handling
schluebbo Feb 24, 2025
0c87cc0
Refactored review system and questionnaire approval workflow
schluebbo Mar 6, 2025
213039c
Refactor review management: Clean URLs & UI updates
schluebbo Mar 10, 2025
3993e6a
Refactor ReviewDTO & ReviewMessageDTO, improve mail handling
schluebbo Mar 11, 2025
3c5e60f
Add new tests for ReviewService and CreateReviewValidator, expand Que…
schluebbo Mar 11, 2025
a4a8133
Restrict review requests to questionnaire creators, allow moderators …
schluebbo Mar 13, 2025
847a93c
Adjust questionnaire approval permissions: Allow moderators to approv…
schluebbo Mar 13, 2025
7d25b1b
Allow moderators to review questionnaires and restrict review request…
schluebbo Mar 14, 2025
e1f2ff9
Refactor ReviewService and ReviewController, improve validation, and …
schluebbo Mar 18, 2025
3474bd9
Fix: Admins can now approve/reject reviews via UI; pending-details.ht…
schluebbo Mar 24, 2025
97729e6
**refactor(questionnaire): centralize and secure questionnaire deleti…
schluebbo Mar 24, 2025
8739575
feat(db): add review feature schema changes and questionnaire metadat…
schluebbo Mar 24, 2025
5f16e8a
Fix: exclude self from reviewer selection & set createdBy in FHIR import
schluebbo Mar 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
USE `moPat`;

-- Add new column approval_status to questionnaire table
ALTER TABLE questionnaire ADD COLUMN approval_status VARCHAR(20) NOT NULL DEFAULT 'DRAFT';

-- Set approval_status to APPROVED for existing questionnaires
UPDATE questionnaire SET approval_status = 'APPROVED';

-- Add new column created_by to questionnaire table
ALTER TABLE `questionnaire` ADD COLUMN `created_by` BIGINT(20) NOT NULL;

-- Set created_by to changed_by for existing questionnaires (assumes changed_by is the closest reference)
UPDATE `questionnaire` SET `created_by` = `changed_by`;

-- Add column 'main_questionnaire_id' to 'questionnaire_version_group'
ALTER TABLE `questionnaire_version_group`
ADD COLUMN `main_questionnaire_id` BIGINT(20) NULL;

-- Set main_questionnaire_id to the questionnaire with the highest version in each group
UPDATE questionnaire_version_group qvg
SET main_questionnaire_id = (
SELECT q.id FROM questionnaire q
WHERE q.version_group_id = qvg.id
ORDER BY q.version DESC LIMIT 1
);

-- Create 'review' table
CREATE TABLE IF NOT EXISTS `review` (
`id` BIGINT(20) AUTO_INCREMENT PRIMARY KEY,
`questionnaire_id` BIGINT(20) NOT NULL,
`status` NOT NULL DEFAULT 'PENDING',
`editor_id` BIGINT(20) NOT NULL,
`reviewer_id` BIGINT(20) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
FOREIGN KEY (`questionnaire_id`) REFERENCES `questionnaire` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;

-- Create 'review_message' table
CREATE TABLE IF NOT EXISTS `review_message` (
`id` BIGINT(20) AUTO_INCREMENT PRIMARY KEY,
`review_id` BIGINT(20) NOT NULL,
`sender_id` BIGINT(20) NOT NULL,
`receiver_id` BIGINT(20) NOT NULL,
`message` TEXT NOT NULL,
`sent_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
FOREIGN KEY (`review_id`) REFERENCES `review` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
5 changes: 5 additions & 0 deletions src/main/java/de/imi/mopat/config/AppConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.spring6.view.ThymeleafViewResolver;

import java.time.Clock;
import java.util.List;
import java.util.Locale;
import java.util.Map;
Expand Down Expand Up @@ -339,4 +340,8 @@ public void setEnvironment(final Environment environment) {
this.environment = environment;
}

@Bean
public Clock systemClock() {
return Clock.systemDefaultZone();
}
}
144 changes: 54 additions & 90 deletions src/main/java/de/imi/mopat/controller/QuestionnaireController.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import de.imi.mopat.dao.ConditionDao;
import de.imi.mopat.dao.ConfigurationDao;
import de.imi.mopat.dao.ConfigurationGroupDao;
import de.imi.mopat.dao.EncounterDao;
import de.imi.mopat.dao.ExportTemplateDao;
import de.imi.mopat.dao.OperatorDao;
import de.imi.mopat.dao.QuestionDao;
Expand All @@ -20,14 +19,14 @@
import de.imi.mopat.helper.controller.Constants;
import de.imi.mopat.helper.controller.FHIRHelper;
import de.imi.mopat.helper.controller.FHIRToMoPatConverter;
import de.imi.mopat.helper.controller.GraphicsUtilities;
import de.imi.mopat.helper.controller.ImportQuestionnaireResult;
import de.imi.mopat.helper.controller.LocaleHelper;
import de.imi.mopat.helper.controller.ODMProcessingBean;
import de.imi.mopat.helper.controller.ODMv132ToMoPatConverter;
import de.imi.mopat.helper.controller.QuestionnaireService;
import de.imi.mopat.helper.controller.AuthService;
import de.imi.mopat.helper.controller.QuestionnaireVersionGroupService;
import de.imi.mopat.helper.controller.ReviewService;
import de.imi.mopat.helper.controller.StringUtilities;
import de.imi.mopat.io.MetadataExporter;
import de.imi.mopat.io.impl.MetadataExporterFactory;
Expand Down Expand Up @@ -56,16 +55,14 @@
import de.imi.mopat.model.score.Score;
import de.imi.mopat.model.score.UnaryExpression;
import de.imi.mopat.model.user.User;
import de.imi.mopat.validator.MoPatValidator;
import de.imi.mopat.model.user.UserRole;
import de.imi.mopat.validator.QuestionValidator;
import de.imi.mopat.validator.QuestionnaireDTOValidator;
import de.unimuenster.imi.org.cdisc.odm.v132.ODM;
import de.unimuenster.imi.org.cdisc.odm.v132.ODMcomplexTypeDefinitionFormDef;
import de.unimuenster.imi.org.cdisc.odm.v132.ODMcomplexTypeDefinitionMetaDataVersion;
import de.unimuenster.imi.org.cdisc.odm.v132.ODMcomplexTypeDefinitionStudy;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
Expand All @@ -84,7 +81,6 @@
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import javax.imageio.ImageIO;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
Expand Down Expand Up @@ -162,6 +158,9 @@ public class QuestionnaireController {
@Autowired
private ODMProcessingBean odmReader;

@Autowired
private ReviewService reviewService;

/**
* Controls the HTTP GET requests for the URL <i>/questionnaire/list</i>. Shows the list of
* questionnaires.
Expand Down Expand Up @@ -205,6 +204,8 @@ public String listQuestionnaires(final Model model) {
questionnaire.setHasConditions(questionnaireTargetIds.contains(questionnaire.getId()));
}

model.addAttribute("isModOrAbove", authService.hasRoleOrAbove(UserRole.ROLE_MODERATOR));
model.addAttribute("currentUserId", authService.getAuthenticatedUserId());
model.addAttribute("allQuestionnaires", questionnaireService.sortQuestionnairesByCreatedAtDesc(allQuestionnaires));
model.addAttribute("availableLanguagesInQuestionForQuestionnaires",
availableLanguagesInQuestionForQuestionnaires);
Expand Down Expand Up @@ -307,80 +308,9 @@ private void fillModelForValidationErrors(QuestionnaireDTO questionnaireDTO, Mod
@RequestMapping(value = "/questionnaire/remove")
@PreAuthorize("hasRole('ROLE_EDITOR')")
public String removeQuestionnaire(@RequestParam(value = "id", required = true) final Long id,
final Model model) {
Questionnaire questionnaire = questionnaireDao.getElementById(id);
if (questionnaire != null) {
if (questionnaire.isDeletable()) {
// Delete the associated conditions
for (Condition condition : conditionDao.getConditionsByTarget(questionnaire)) {
if (condition instanceof SelectAnswerCondition
|| condition instanceof SliderAnswerThresholdCondition) {
// Refresh the trigger so that multiple conditions of
// the same trigger will be deleted correctly
ConditionTrigger conditionTrigger = answerDao.getElementById(
condition.getTrigger().getId());
conditionTrigger.removeCondition(condition);
answerDao.merge((Answer) conditionTrigger);
}
conditionDao.remove(condition);
}

// Collect all scores in an array list to make sure they will
// be removed in correct order
List<Score> scoresToDelete = new ArrayList<>();
for (Score scoreToDelete : questionnaire.getScores()) {
List<Score> dependingScores = scoreToDelete.getDependingScores();
// Sort depending scores by amount of their depending
// scores to prevent database errors
Collections.sort(dependingScores,
(Score o1, Score o2) -> o1.getDependingScores().size()
- o2.getDependingScores().size());
// First add all depending scores
for (Score dependingScore : dependingScores) {
if (!scoresToDelete.contains(dependingScore)) {
scoresToDelete.add(dependingScore);
}
}
// Add the score that actually has to be deleted
if (!scoresToDelete.contains(scoreToDelete)) {
scoresToDelete.add(scoreToDelete);
}
}

// Delete the associated scores
Iterator<Score> iterator = scoresToDelete.iterator();
while (iterator.hasNext()) {
Score scoreToDelete = iterator.next();
iterator.remove();
scoreDao.remove(scoreToDelete);
}

// Delete connection to the bundles
for (BundleQuestionnaire bundleQuestionnaire : questionnaire.getBundleQuestionnaires()) {
Bundle bundle = bundleQuestionnaire.getBundle();
bundle.removeBundleQuestionnaire(bundleQuestionnaire);
//Update the position of all following bundleQuestionnaires
for (BundleQuestionnaire bundleQuestionnaireToChangePosition : bundle.getBundleQuestionnaires()) {
if (bundleQuestionnaireToChangePosition.getPosition()
> bundleQuestionnaire.getPosition()) {
bundleQuestionnaireToChangePosition.setPosition(
bundleQuestionnaireToChangePosition.getPosition() - 1);
}
}
bundleDao.merge(bundle);
}
questionnaire.removeAllBundleQuestionnaires();
questionnaireVersionGroupService.removeQuestionnaire(questionnaire.getQuestionnaireVersionGroupId(), questionnaire);
questionnaireDao.remove(questionnaire);
model.addAttribute("messageSuccess",
messageSource.getMessage("questionnaire.error" + ".deleteQuestionnairePossible",
new Object[]{questionnaire.getName()}, LocaleContextHolder.getLocale()));
} else {
model.addAttribute("messageFail", messageSource.getMessage(
"questionnaire.error" + ".deleteQuestionnaireNotPossible",
new Object[]{questionnaire.getName()}, LocaleContextHolder.getLocale()));
}
}
final Model model) {
Pair<Boolean, String> removeResult = questionnaireService.removeQuestionnaire(id);
model.addAttribute(removeResult.getLeft() ? "messageSuccess" : "messageFail", removeResult.getRight());
return listQuestionnaires(model);
}

Expand Down Expand Up @@ -523,6 +453,7 @@ public String postImportUpload(
questionnaire = jsonQuestionnaireDTO.convertToQuestionnaire();
User currentUser = (User) SecurityContextHolder.getContext().getAuthentication()
.getPrincipal();
questionnaire.setCreatedBy(currentUser.getId());
questionnaire.setChangedBy(currentUser.getId());
// Collect all questions and answers in a map to access those
// ones who are target and trigger of a condition easily
Expand Down Expand Up @@ -652,13 +583,13 @@ public String postImportUpload(
questionnaire.setName(
questionnaire.getName() + " " + new Timestamp(new Date().getTime()));
}

if (authService.hasRoleOrAbove(UserRole.ROLE_MODERATOR)) {
questionnaire.setStatusApprove();
}
questionnaireDao.merge(questionnaire);

QuestionnaireVersionGroup questionnaireVersionGroup = questionnaireVersionGroupService.createQuestionnaireGroup(questionnaire.getName());
QuestionnaireVersionGroup questionnaireVersionGroup = questionnaireVersionGroupService.getOrCreateQuestionnaireGroup(questionnaire);
questionnaire.setQuestionnaireVersionGroup(questionnaireVersionGroup);
questionnaireVersionGroup.addQuestionnaire(questionnaire);
questionnaireVersionGroupService.add(questionnaireVersionGroup);

//Loop through all persisted questions to get the
// imageAnswers and save the images
Expand Down Expand Up @@ -818,12 +749,13 @@ public String postImportUpload(
questionnaire.getName() + " " + dateFormat.format(
calendar.getTime()));
}
if (authService.hasRoleOrAbove(UserRole.ROLE_MODERATOR)) {
questionnaire.setStatusApprove();
}
questionnaireDao.merge(questionnaire);

QuestionnaireVersionGroup questionnaireVersionGroup = questionnaireVersionGroupService.createQuestionnaireGroup(questionnaire.getName());
QuestionnaireVersionGroup questionnaireVersionGroup = questionnaireVersionGroupService.getOrCreateQuestionnaireGroup(questionnaire);
questionnaire.setQuestionnaireVersionGroup(questionnaireVersionGroup);
questionnaireVersionGroup.addQuestionnaire(questionnaire);
questionnaireVersionGroupService.add(questionnaireVersionGroup);

for (ExportTemplate exportTemplate : exportTemplates) {
exportTemplate.setQuestionnaire(questionnaire);
Expand Down Expand Up @@ -996,14 +928,15 @@ public String postImportUpload(
questionnaire.setName(
questionnaire.getName() + " " + dateFormat.format(new Date()));
}
if (authService.hasRoleOrAbove(UserRole.ROLE_MODERATOR)) {
questionnaire.setStatusApprove();
}

// Merge questionnaire
questionnaireDao.merge(questionnaire);

QuestionnaireVersionGroup questionnaireVersionGroup = questionnaireVersionGroupService.createQuestionnaireGroup(questionnaire.getName());
QuestionnaireVersionGroup questionnaireVersionGroup = questionnaireVersionGroupService.getOrCreateQuestionnaireGroup(questionnaire);
questionnaire.setQuestionnaireVersionGroup(questionnaireVersionGroup);
questionnaireVersionGroup.addQuestionnaire(questionnaire);
questionnaireVersionGroupService.add(questionnaireVersionGroup);

// Merge the export templates
for (ExportTemplate exportTemplate : exportTemplates) {
Expand Down Expand Up @@ -1041,4 +974,35 @@ public String postImportUpload(
}
return "questionnaire/import/result";
}

@RequestMapping("/questionnaire/disapprove")
@PreAuthorize("hasRole('ROLE_MODERATOR')")
public String disapproveQuestionnaire(@RequestParam("id") final Long questionnaireId,
RedirectAttributes redirectAttributes) {
Locale locale = LocaleContextHolder.getLocale();
try {
questionnaireService.disapproveQuestionnaire(questionnaireId, locale);
String successMessage = messageSource.getMessage("questionnaire.success.disapproved", null, locale);
redirectAttributes.addFlashAttribute("messageSuccess", successMessage);
} catch (Exception e) {
redirectAttributes.addFlashAttribute("messageFail", e.getMessage());
}
return "redirect:/questionnaire/list";
}

@RequestMapping("/questionnaire/approve")
@PreAuthorize("hasRole('ROLE_MODERATOR')")
public String approveQuestionnaire(@RequestParam("id") final Long questionnaireId,
RedirectAttributes redirectAttributes) {
Locale locale = LocaleContextHolder.getLocale();
try {
questionnaireService.approveQuestionnaire(questionnaireId, locale);
String successMessage = messageSource.getMessage("questionnaire.success.approved", null, locale);
redirectAttributes.addFlashAttribute("messageSuccess", successMessage);
} catch (Exception e) {
redirectAttributes.addFlashAttribute("messageFail", e.getMessage());
}
return "redirect:/questionnaire/list";
}

}
Loading