From 186a97a2bc8efd5d5f350eacdb9288cad2dafc64 Mon Sep 17 00:00:00 2001 From: Zhang Ziqing Date: Tue, 12 Mar 2024 09:56:35 +0800 Subject: [PATCH 01/95] Enable CI on account request form branch --- .github/workflows/axe.yml | 2 ++ .github/workflows/component.yml | 2 ++ .github/workflows/e2e-sql.yml | 2 ++ .github/workflows/e2e.yml | 2 ++ .github/workflows/jdk17.yml | 1 + .github/workflows/lnp.yml | 1 + .github/workflows/pr.yml | 1 + 7 files changed, 11 insertions(+) diff --git a/.github/workflows/axe.yml b/.github/workflows/axe.yml index 771dd8a3edc..bee66fbcfc3 100644 --- a/.github/workflows/axe.yml +++ b/.github/workflows/axe.yml @@ -5,10 +5,12 @@ on: branches: - master - release + - account-request-form pull_request: branches: - master - release + - account-request-form jobs: axe-testing: runs-on: ubuntu-latest diff --git a/.github/workflows/component.yml b/.github/workflows/component.yml index 11ecfb7357f..bb814a02c0e 100644 --- a/.github/workflows/component.yml +++ b/.github/workflows/component.yml @@ -5,10 +5,12 @@ on: branches: - master - release + - account-request-form pull_request: branches: - master - release + - account-request-form schedule: - cron: "0 0 * * *" #end of every day jobs: diff --git a/.github/workflows/e2e-sql.yml b/.github/workflows/e2e-sql.yml index 0a67cbac52d..267cd0d219f 100644 --- a/.github/workflows/e2e-sql.yml +++ b/.github/workflows/e2e-sql.yml @@ -5,10 +5,12 @@ on: branches: - master - release + - account-request-form pull_request: branches: - master - release + - account-request-form schedule: - cron: "0 0 * * *" #end of every day jobs: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 52ab76019ed..71cfa72bd24 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -5,10 +5,12 @@ on: branches: - master - release + - account-request-form pull_request: branches: - master - release + - account-request-form schedule: - cron: "0 0 * * *" #end of every day jobs: diff --git a/.github/workflows/jdk17.yml b/.github/workflows/jdk17.yml index 29ab59bb561..f0d68910de5 100644 --- a/.github/workflows/jdk17.yml +++ b/.github/workflows/jdk17.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - account-request-form jobs: lint: diff --git a/.github/workflows/lnp.yml b/.github/workflows/lnp.yml index 2ff33dda920..4389bac81ff 100644 --- a/.github/workflows/lnp.yml +++ b/.github/workflows/lnp.yml @@ -5,6 +5,7 @@ on: branches: - master - release + - account-request-form schedule: - cron: "0 0 * * *" # end of every day jobs: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 03f11d9b6d3..f55fd672fe0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -6,6 +6,7 @@ on: - reopened branches: - master + - account-request-form jobs: check-pr: From 17866eb4b93c3d773296ea2022cbf3ad96585a16 Mon Sep 17 00:00:00 2001 From: DS Date: Mon, 18 Mar 2024 11:14:43 +0800 Subject: [PATCH 02/95] [#11843] Create FeedbackSessionLog entity and cron job action (#12895) * Create FeedbackSessionLog entity * fix lint * Create UpdateFeedbackSessionLogsAction * Sort query results from logging service * Update type of feedbackSessionLogType * Fix naming * Fix enum in entity * Update filter to differentiate by session * Add Uri Info * Add tests * Update test case * Update to getOrderedFeedbackSessionLogs --- .../datatransfer/FeedbackSessionLogEntry.java | 7 +- .../java/teammates/common/util/Const.java | 2 + .../teammates/logic/api/LogsProcessor.java | 6 +- .../external/GoogleCloudLoggingService.java | 3 +- .../logic/external/LocalLoggingService.java | 3 +- .../teammates/logic/external/LogService.java | 4 +- .../java/teammates/sqllogic/api/Logic.java | 9 + .../storage/sqlentity/FeedbackSessionLog.java | 123 +++++++++++ .../teammates/ui/webapi/ActionFactory.java | 1 + .../webapi/GetFeedbackSessionLogsAction.java | 2 +- .../UpdateFeedbackSessionLogsAction.java | 64 ++++++ .../logic/api/MockLogsProcessor.java | 3 +- .../UpdateFeedbackSessionLogsActionTest.java | 207 ++++++++++++++++++ .../ui/webapi/GetActionClassesActionTest.java | 3 +- 14 files changed, 426 insertions(+), 11 deletions(-) create mode 100644 src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java create mode 100644 src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java create mode 100644 src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java diff --git a/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java b/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java index 1502071050e..ea95a6a951d 100644 --- a/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java +++ b/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java @@ -3,7 +3,7 @@ /** * Represents a log entry of a feedback session. */ -public class FeedbackSessionLogEntry { +public class FeedbackSessionLogEntry implements Comparable { private final String studentEmail; private final String feedbackSessionName; private final String feedbackSessionLogType; @@ -32,4 +32,9 @@ public String getFeedbackSessionLogType() { public long getTimestamp() { return this.timestamp; } + + @Override + public int compareTo(FeedbackSessionLogEntry o) { + return Long.compare(this.getTimestamp(), o.getTimestamp()); + } } diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index b24d0ded648..4aad059a211 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -401,6 +401,8 @@ public static class CronJobURIs { URI_PREFIX + "/feedbackSessionPublishedReminders"; public static final String AUTOMATED_USAGE_STATISTICS_COLLECTION = URI_PREFIX + "/calculateUsageStatistics"; + public static final String AUTOMATED_FEEDBACK_SESSION_LOGS_PROCESSING = + URI_PREFIX + "/updateFeedbackSessionLogs"; } /** diff --git a/src/main/java/teammates/logic/api/LogsProcessor.java b/src/main/java/teammates/logic/api/LogsProcessor.java index eac5f73f755..2f95021e441 100644 --- a/src/main/java/teammates/logic/api/LogsProcessor.java +++ b/src/main/java/teammates/logic/api/LogsProcessor.java @@ -51,12 +51,12 @@ public void createFeedbackSessionLog(String courseId, String email, String fsNam } /** - * Gets the feedback session logs as filtered by the given parameters. + * Gets the feedback session logs as filtered by the given parameters ordered by ascending timestamp. * @param email Can be null */ - public List getFeedbackSessionLogs(String courseId, String email, + public List getOrderedFeedbackSessionLogs(String courseId, String email, long startTime, long endTime, String fsName) { - return service.getFeedbackSessionLogs(courseId, email, startTime, endTime, fsName); + return service.getOrderedFeedbackSessionLogs(courseId, email, startTime, endTime, fsName); } /** diff --git a/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java b/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java index 3c2b7000ce7..e48dbe6dad8 100644 --- a/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java +++ b/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java @@ -115,7 +115,7 @@ public void createFeedbackSessionLog(String courseId, String email, String fsNam } @Override - public List getFeedbackSessionLogs(String courseId, String email, + public List getOrderedFeedbackSessionLogs(String courseId, String email, long startTime, long endTime, String fsName) { List filters = new ArrayList<>(); if (courseId != null) { @@ -131,6 +131,7 @@ public List getFeedbackSessionLogs(String courseId, Str .withLogEvent(LogEvent.FEEDBACK_SESSION_AUDIT.name()) .withSeverityLevel(LogSeverity.INFO) .withExtraFilters(String.join("\n", filters)) + .withOrder(ASCENDING_ORDER) .build(); LogSearchParams logSearchParams = LogSearchParams.from(queryLogsParams) .addLogName(STDOUT_LOG_NAME) diff --git a/src/main/java/teammates/logic/external/LocalLoggingService.java b/src/main/java/teammates/logic/external/LocalLoggingService.java index 03c90f52b4a..16c04a3d0d0 100644 --- a/src/main/java/teammates/logic/external/LocalLoggingService.java +++ b/src/main/java/teammates/logic/external/LocalLoggingService.java @@ -209,7 +209,7 @@ public void createFeedbackSessionLog(String courseId, String email, String fsNam } @Override - public List getFeedbackSessionLogs(String courseId, String email, + public List getOrderedFeedbackSessionLogs(String courseId, String email, long startTime, long endTime, String fsName) { return FEEDBACK_SESSION_LOG_ENTRIES .getOrDefault(courseId, new ArrayList<>()) @@ -218,6 +218,7 @@ public List getFeedbackSessionLogs(String courseId, Str .filter(log -> fsName == null || log.getFeedbackSessionName().equals(fsName)) .filter(log -> log.getTimestamp() >= startTime) .filter(log -> log.getTimestamp() <= endTime) + .sorted() .collect(Collectors.toList()); } diff --git a/src/main/java/teammates/logic/external/LogService.java b/src/main/java/teammates/logic/external/LogService.java index 5a85c59fafb..ac79d13ab9d 100644 --- a/src/main/java/teammates/logic/external/LogService.java +++ b/src/main/java/teammates/logic/external/LogService.java @@ -22,8 +22,8 @@ public interface LogService { void createFeedbackSessionLog(String courseId, String email, String fsName, String fslType); /** - * Gets the feedback session logs as filtered by the given parameters. + * Gets the feedback session logs as filtered by the given parameters ordered by ascending timestamp. */ - List getFeedbackSessionLogs(String courseId, String email, + List getOrderedFeedbackSessionLogs(String courseId, String email, long startTime, long endTime, String fsName); } diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index a10fe1bae21..5cd50a10268 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -41,6 +41,7 @@ import teammates.storage.sqlentity.FeedbackResponse; import teammates.storage.sqlentity.FeedbackResponseComment; import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; import teammates.storage.sqlentity.Instructor; import teammates.storage.sqlentity.Notification; import teammates.storage.sqlentity.Section; @@ -1595,4 +1596,12 @@ public List getFeedbackSessionsClosingWithinTimeLimit() { public List getFeedbackSessionsOpeningWithinTimeLimit() { return feedbackSessionsLogic.getFeedbackSessionsOpeningWithinTimeLimit(); } + + /** + * Create feedback session logs. + */ + public void createFeedbackSessionLogs(List feedbackSessionLogs) + throws EntityAlreadyExistsException, InvalidParametersException { + // TODO: implement logic layer + } } diff --git a/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java b/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java new file mode 100644 index 00000000000..61d54f3c8a1 --- /dev/null +++ b/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java @@ -0,0 +1,123 @@ +package teammates.storage.sqlentity; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import teammates.common.datatransfer.logs.FeedbackSessionLogType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Represents a feedback session log. + */ +@Entity +@Table(name = "FeedbackSessionLogs") +public class FeedbackSessionLog extends BaseEntity { + @Id + private UUID id; + + @Column(nullable = false) + private String studentEmail; + + @Column(nullable = false) + private String feedbackSessionName; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private FeedbackSessionLogType feedbackSessionLogType; + + @Column(nullable = false) + private Instant timestamp; + + protected FeedbackSessionLog() { + // required by Hibernate + } + + public FeedbackSessionLog(String email, String feedbackSessionName, FeedbackSessionLogType feedbackSessionLogType, + Instant timestamp) { + this.setId(UUID.randomUUID()); + this.studentEmail = email; + this.feedbackSessionName = feedbackSessionName; + this.feedbackSessionLogType = feedbackSessionLogType; + this.timestamp = timestamp; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getStudentEmail() { + return studentEmail; + } + + public void setStudentEmail(String email) { + this.studentEmail = email; + } + + public String getFeedbackSessionName() { + return feedbackSessionName; + } + + public void setFeedbackSessionName(String feedbackSessionName) { + this.feedbackSessionName = feedbackSessionName; + } + + public FeedbackSessionLogType getFeedbackSessionLogType() { + return feedbackSessionLogType; + } + + public void setFeedbackSessionLogType(FeedbackSessionLogType feedbackSessionLogType) { + this.feedbackSessionLogType = feedbackSessionLogType; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } + + @Override + public String toString() { + return "FeedbackSessionLog [id=" + id + ", email=" + studentEmail + ", feedbackSessionName=" + + feedbackSessionName + + ", feedbackSessionLogType=" + feedbackSessionLogType.getLabel() + ", timestamp=" + timestamp + "]"; + } + + @Override + public int hashCode() { + return this.getId().hashCode(); + } + + @Override + public boolean equals(Object other) { + if (other == null) { + return false; + } else if (this == other) { + return true; + } else if (this.getClass() == other.getClass()) { + FeedbackSessionLog otherFeedbackSessionLog = (FeedbackSessionLog) other; + return Objects.equals(this.getId(), otherFeedbackSessionLog.getId()); + } else { + return false; + } + } + + @Override + public List getInvalidityInfo() { + return new ArrayList<>(); + } +} diff --git a/src/main/java/teammates/ui/webapi/ActionFactory.java b/src/main/java/teammates/ui/webapi/ActionFactory.java index 38c4b00b753..48f595a7008 100644 --- a/src/main/java/teammates/ui/webapi/ActionFactory.java +++ b/src/main/java/teammates/ui/webapi/ActionFactory.java @@ -151,6 +151,7 @@ public final class ActionFactory { map(CronJobURIs.AUTOMATED_FEEDBACK_OPENING_SOON_REMINDERS, GET, FeedbackSessionOpeningSoonRemindersAction.class); map(CronJobURIs.AUTOMATED_USAGE_STATISTICS_COLLECTION, GET, CalculateUsageStatisticsAction.class); + map(CronJobURIs.AUTOMATED_FEEDBACK_SESSION_LOGS_PROCESSING, GET, UpdateFeedbackSessionLogsAction.class); // Task queue workers; use POST request // Reference: https://cloud.google.com/tasks/docs/creating-appengine-tasks diff --git a/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java b/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java index 72332d439e5..bddaeb6c780 100644 --- a/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java +++ b/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java @@ -135,7 +135,7 @@ public JsonResult execute() { } List fsLogEntries = - logsProcessor.getFeedbackSessionLogs(courseId, email, startTime, endTime, feedbackSessionName); + logsProcessor.getOrderedFeedbackSessionLogs(courseId, email, startTime, endTime, feedbackSessionName); if (isCourseMigrated(courseId)) { Map studentsMap = new HashMap<>(); diff --git a/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java b/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java new file mode 100644 index 00000000000..568852dd032 --- /dev/null +++ b/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java @@ -0,0 +1,64 @@ +package teammates.ui.webapi; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import teammates.common.datatransfer.FeedbackSessionLogEntry; +import teammates.common.datatransfer.logs.FeedbackSessionLogType; +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Logger; +import teammates.common.util.TimeHelper; +import teammates.storage.sqlentity.FeedbackSessionLog; + +/** + * Process feedback session logs in the past defined time period and store in the database. + */ +public class UpdateFeedbackSessionLogsAction extends AdminOnlyAction { + + static final int COLLECTION_TIME_PERIOD = 60; // represents one hour + static final long SPAM_FILTER = 2000L; // in ms + private static final Logger log = Logger.getLogger(); + + @Override + public JsonResult execute() { + List filteredLogs = new ArrayList<>(); + + Instant endTime = TimeHelper.getInstantNearestHourBefore(Instant.now()); + Instant startTime = endTime.minus(COLLECTION_TIME_PERIOD, ChronoUnit.MINUTES); + + List logEntries = logsProcessor.getOrderedFeedbackSessionLogs(null, null, + startTime.toEpochMilli(), endTime.toEpochMilli(), null); + + Map>> lastSavedTimestamps = new HashMap<>(); + for (FeedbackSessionLogEntry logEntry : logEntries) { + String email = logEntry.getStudentEmail(); + String fbSessionName = logEntry.getFeedbackSessionName(); + String type = logEntry.getFeedbackSessionLogType(); + Long timestamp = logEntry.getTimestamp(); + + lastSavedTimestamps.putIfAbsent(email, new HashMap<>()); + lastSavedTimestamps.get(email).putIfAbsent(fbSessionName, new HashMap<>()); + Long lastSaved = lastSavedTimestamps.get(email).get(fbSessionName).getOrDefault(type, 0L); + + if (Math.abs(timestamp - lastSaved) > SPAM_FILTER) { + lastSavedTimestamps.get(email).get(fbSessionName).put(type, timestamp); + FeedbackSessionLog fslEntity = new FeedbackSessionLog(email, fbSessionName, + FeedbackSessionLogType.valueOfLabel(type), Instant.ofEpochMilli(timestamp)); + filteredLogs.add(fslEntity); + } + } + + try { + sqlLogic.createFeedbackSessionLogs(filteredLogs); + } catch (InvalidParametersException | EntityAlreadyExistsException e) { + log.severe("Unexpected error", e); + } + + return new JsonResult("Successful"); + } +} diff --git a/src/test/java/teammates/logic/api/MockLogsProcessor.java b/src/test/java/teammates/logic/api/MockLogsProcessor.java index dc8a90ed9fd..f07a15d7701 100644 --- a/src/test/java/teammates/logic/api/MockLogsProcessor.java +++ b/src/test/java/teammates/logic/api/MockLogsProcessor.java @@ -102,8 +102,9 @@ public void createFeedbackSessionLog(String courseId, String email, String fsNam } @Override - public List getFeedbackSessionLogs(String courseId, String email, + public List getOrderedFeedbackSessionLogs(String courseId, String email, long startTime, long endTime, String fsName) { + feedbackSessionLogs.sort((x, y) -> x.compareTo(y)); return feedbackSessionLogs; } diff --git a/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java b/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java new file mode 100644 index 00000000000..d244f7eeb62 --- /dev/null +++ b/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java @@ -0,0 +1,207 @@ +package teammates.sqlui.webapi; + +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.FeedbackSessionLogEntry; +import teammates.common.datatransfer.logs.FeedbackSessionLogType; +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Const; +import teammates.common.util.TimeHelper; +import teammates.storage.sqlentity.FeedbackSessionLog; +import teammates.ui.webapi.UpdateFeedbackSessionLogsAction; + +/** + * SUT: {@link UpdateFeedbackSessionLogsAction}. + */ +public class UpdateFeedbackSessionLogsActionTest + extends BaseActionTest { + + static final int COLLECTION_TIME_PERIOD = 60; // represents one hour + static final long SPAM_FILTER = 2000L; // in ms + + String student1 = "student1"; + String student2 = "student2"; + + String feedbackSession1 = "fs1"; + String feedbackSession2 = "fs2"; + + Instant endTime; + Instant startTime; + + @Override + protected String getActionUri() { + return Const.CronJobURIs.AUTOMATED_FEEDBACK_SESSION_LOGS_PROCESSING; + } + + @Override + String getRequestMethod() { + return GET; + } + + @BeforeMethod + void setUp() { + endTime = TimeHelper.getInstantNearestHourBefore(Instant.now()); + startTime = endTime.minus(COLLECTION_TIME_PERIOD, ChronoUnit.MINUTES); + mockLogsProcessor.getOrderedFeedbackSessionLogs("", "", 0, 0, "").clear(); + } + + @Test + public void testExecute_noRecentLogs_noLogsCreated() + throws EntityAlreadyExistsException, InvalidParametersException { + UpdateFeedbackSessionLogsAction action = getAction(); + action.execute(); + + verify(mockLogic) + .createFeedbackSessionLogs(argThat(filteredLogs -> filteredLogs.size() == 0)); + } + + @Test + public void testExecute_recentLogsNoSpam_allLogsCreated() + throws EntityAlreadyExistsException, InvalidParametersException { + // Different Types + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(300).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.SUBMISSION.getLabel(), + startTime.plusSeconds(300).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.VIEW_RESULT.getLabel(), + startTime.plusSeconds(300).toEpochMilli()); + + // Different feedback sessions + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(600).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession2, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(600).toEpochMilli()); + + // Different Student + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(900).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(student2, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(900).toEpochMilli()); + + // Gap is larger than spam filter + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); + + UpdateFeedbackSessionLogsAction action = getAction(); + action.execute(); + + // method returns all logs regardless of params + List expected = + mockLogsProcessor.getOrderedFeedbackSessionLogs("", "", 0, 0, ""); + + verify(mockLogic).createFeedbackSessionLogs( + argThat(filteredLogs -> isEqualExceptId(expected, filteredLogs))); + } + + @Test + public void testExecute_recentLogsWithSpam_someLogsCreated() + throws EntityAlreadyExistsException, InvalidParametersException { + // Gap is smaller than spam filter + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER - 2).toEpochMilli()); + + // Filters multiple logs within one spam window + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER - 1).toEpochMilli()); + + // Correctly adds new log after filtering + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); + + // Filters out spam in the new window + mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER + 2).toEpochMilli()); + + UpdateFeedbackSessionLogsAction action = getAction(); + action.execute(); + + List expected = new ArrayList<>(); + expected.add(new FeedbackSessionLogEntry(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli())); + expected.add(new FeedbackSessionLogEntry(student1, feedbackSession1, + FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli())); + + verify(mockLogic).createFeedbackSessionLogs( + argThat(filteredLogs -> isEqualExceptId(expected, filteredLogs))); + } + + @Test + public void testSpecificAccessControl_isAdmin_canAccess() { + loginAsAdmin(); + verifyCanAccess(); + } + + @Test + public void testSpecificAccessControl_isInstructor_cannotAccess() { + loginAsInstructor("user-id"); + verifyCannotAccess(); + } + + @Test + public void testSpecificAccessControl_isStudent_cannotAccess() { + loginAsStudent("user-id"); + verifyCannotAccess(); + } + + @Test + public void testSpecificAccessControl_loggedOut_cannotAccess() { + logoutUser(); + verifyCannotAccess(); + } + + private Boolean isEqualExceptId(List expected, + List actual) { + if (expected.size() != actual.size()) { + return false; + } + + for (int i = 0; i < expected.size(); i++) { + FeedbackSessionLogEntry expectedEntry = expected.get(i); + FeedbackSessionLog actualLog = actual.get(i); + + if (!expectedEntry.getStudentEmail().equals(actualLog.getStudentEmail())) { + return false; + } + if (!expectedEntry.getFeedbackSessionName() + .equals(actualLog.getFeedbackSessionName())) { + return false; + } + if (!expectedEntry.getFeedbackSessionLogType() + .equals(actualLog.getFeedbackSessionLogType().getLabel())) { + return false; + } + if (expectedEntry.getTimestamp() != actualLog.getTimestamp().toEpochMilli()) { + return false; + } + } + + return true; + } +} diff --git a/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java b/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java index 2c989868edc..fa8f5f9f2ef 100644 --- a/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java +++ b/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java @@ -140,7 +140,8 @@ protected void testExecute() { GetDeadlineExtensionAction.class, SendLoginEmailAction.class, PutSqlDataBundleAction.class, - DeleteSqlDataBundleAction.class + DeleteSqlDataBundleAction.class, + UpdateFeedbackSessionLogsAction.class ); List expectedActionClassesNames = expectedActionClasses.stream() .map(Class::getSimpleName) From 395bdd7bcdffdbdbbdf474eefa96fa09270541cd Mon Sep 17 00:00:00 2001 From: Jay Ting <65202977+jayasting98@users.noreply.github.com> Date: Tue, 19 Mar 2024 02:19:52 +0800 Subject: [PATCH 03/95] [#11878] Remove AccountRequest unique constraint (#12899) * Remove AccountRequest unique constraint * Remove EntityAlreadyExistsException from the throws clause * Remove unused import of EntityAlreadyExistsException * Fix failing checks * Remove EntityAlreadyExistsException in dependents * Remove assertion that is now incorrect * Remove mysterious trailing whitespaces that appeared out of nowhere * Remove parts in E2E test that are no longer relevant * Remove unused import * Improve clarity of test case Co-authored-by: EuniceSim142 <77243938+EuniceSim142@users.noreply.github.com> --------- Co-authored-by: EuniceSim142 <77243938+EuniceSim142@users.noreply.github.com> --- .../e2e/cases/AdminHomePageE2ETest.java | 23 ------------------- .../storage/sqlapi/AccountRequestsDbIT.java | 11 +++++---- .../java/teammates/sqllogic/api/Logic.java | 2 +- .../sqllogic/core/AccountRequestsLogic.java | 6 ++--- .../storage/sqlapi/AccountRequestsDb.java | 12 +--------- .../storage/sqlentity/AccountRequest.java | 1 - .../ui/webapi/CreateAccountRequestAction.java | 4 ---- .../storage/sqlapi/AccountRequestsDbTest.java | 15 ++++-------- 8 files changed, 15 insertions(+), 59 deletions(-) diff --git a/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java b/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java index 6cfb6b478e0..56953689329 100644 --- a/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java +++ b/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java @@ -5,7 +5,6 @@ import teammates.common.util.AppUrl; import teammates.common.util.Const; import teammates.e2e.pageobjects.AdminHomePage; -import teammates.storage.sqlentity.AccountRequest; /** * SUT: {@link Const.WebPageURIs#ADMIN_HOME_PAGE}. @@ -50,28 +49,6 @@ public void testAll() { assertNotNull(BACKDOOR.getAccountRequest(email, institute)); BACKDOOR.deleteAccountRequest(email, institute); - - ______TS("Failure case: Instructor is already registered"); - AccountRequest registeredAccountRequest = sqlTestData.accountRequests.get("AHome.instructor1OfCourse1"); - homePage.queueInstructorForAdding(registeredAccountRequest.getName(), - registeredAccountRequest.getEmail(), registeredAccountRequest.getInstitute()); - - homePage.addAllInstructors(); - - failureMessage = homePage.getMessageForInstructor(2); - assertTrue(failureMessage.contains("Cannot create account request as instructor has already registered.")); - - ______TS("Success case: Reset account request"); - - homePage.clickMoreInfoButtonForRegisteredInstructor(2); - homePage.clickResetAccountRequestLink(); - - successMessage = homePage.getMessageForInstructor(2); - assertTrue(successMessage.contains( - "Instructor \"" + registeredAccountRequest.getName() + "\" has been successfully created")); - - assertNull(BACKDOOR.getAccountRequest( - registeredAccountRequest.getEmail(), registeredAccountRequest.getInstitute()).getRegisteredAt()); } } diff --git a/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java b/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java index 8af4c8065df..c2aa8aef078 100644 --- a/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java +++ b/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java @@ -4,7 +4,6 @@ import org.testng.annotations.Test; -import teammates.common.exception.EntityAlreadyExistsException; import teammates.common.exception.EntityDoesNotExistException; import teammates.it.test.BaseTestCaseWithSqlDatabaseAccess; import teammates.storage.sqlapi.AccountRequestsDb; @@ -51,21 +50,23 @@ public void testCreateReadDeleteAccountRequest() throws Exception { accountRequest.getCreatedAt().minusMillis(2000)); assertEquals(0, actualAccReqCreatedAtOutside.size()); - ______TS("Create acccount request, already exists, execption thrown"); + ______TS("Create account request, same email address and institute already exist, creates successfully"); AccountRequest identicalAccountRequest = new AccountRequest("test@gmail.com", "name", "institute"); assertNotSame(accountRequest, identicalAccountRequest); - assertThrows(EntityAlreadyExistsException.class, - () -> accountRequestDb.createAccountRequest(identicalAccountRequest)); + accountRequestDb.createAccountRequest(identicalAccountRequest); + AccountRequest actualIdenticalAccountRequest = + accountRequestDb.getAccountRequestByRegistrationKey(identicalAccountRequest.getRegistrationKey()); + verifyEquals(identicalAccountRequest, actualIdenticalAccountRequest); ______TS("Delete account request that was created"); accountRequestDb.deleteAccountRequest(accountRequest); AccountRequest actualAccountRequest = - accountRequestDb.getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute()); + accountRequestDb.getAccountRequestByRegistrationKey(accountRequest.getRegistrationKey()); assertNull(actualAccountRequest); } diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index a10fe1bae21..121257a6601 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -89,7 +89,7 @@ public static Logic inst() { * @throws EntityAlreadyExistsException if the account request already exists. */ public AccountRequest createAccountRequest(String name, String email, String institute) - throws InvalidParametersException, EntityAlreadyExistsException { + throws InvalidParametersException { return accountRequestLogic.createAccountRequest(name, email, institute); } diff --git a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java index fd7742b3a73..0465e061519 100644 --- a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java +++ b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java @@ -2,7 +2,6 @@ import java.util.List; -import teammates.common.exception.EntityAlreadyExistsException; import teammates.common.exception.EntityDoesNotExistException; import teammates.common.exception.InvalidParametersException; import teammates.common.exception.SearchServiceException; @@ -51,8 +50,7 @@ public void putDocument(AccountRequest accountRequest) throws SearchServiceExcep /** * Creates an account request. */ - public AccountRequest createAccountRequest(AccountRequest accountRequest) - throws InvalidParametersException, EntityAlreadyExistsException { + public AccountRequest createAccountRequest(AccountRequest accountRequest) throws InvalidParametersException { return accountRequestDb.createAccountRequest(accountRequest); } @@ -60,7 +58,7 @@ public AccountRequest createAccountRequest(AccountRequest accountRequest) * Creates an account request. */ public AccountRequest createAccountRequest(String name, String email, String institute) - throws InvalidParametersException, EntityAlreadyExistsException { + throws InvalidParametersException { AccountRequest toCreate = new AccountRequest(email, name, institute); return accountRequestDb.createAccountRequest(toCreate); diff --git a/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java b/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java index a315cb18484..068ed3ed253 100644 --- a/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java +++ b/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java @@ -1,6 +1,5 @@ package teammates.storage.sqlapi; -import static teammates.common.util.Const.ERROR_CREATE_ENTITY_ALREADY_EXISTS; import static teammates.common.util.Const.ERROR_UPDATE_NON_EXISTENT; import java.time.Instant; @@ -9,7 +8,6 @@ import java.util.List; import java.util.UUID; -import teammates.common.exception.EntityAlreadyExistsException; import teammates.common.exception.EntityDoesNotExistException; import teammates.common.exception.InvalidParametersException; import teammates.common.exception.SearchServiceException; @@ -46,20 +44,12 @@ public AccountRequestSearchManager getSearchManager() { /** * Creates an AccountRequest in the database. */ - public AccountRequest createAccountRequest(AccountRequest accountRequest) - throws InvalidParametersException, EntityAlreadyExistsException { + public AccountRequest createAccountRequest(AccountRequest accountRequest) throws InvalidParametersException { assert accountRequest != null; if (!accountRequest.isValid()) { throw new InvalidParametersException(accountRequest.getInvalidityInfo()); } - - // don't need to check registrationKey for uniqueness since it is generated using email + institute - if (getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute()) != null) { - throw new EntityAlreadyExistsException( - String.format(ERROR_CREATE_ENTITY_ALREADY_EXISTS, accountRequest.toString())); - } - persist(accountRequest); return accountRequest; } diff --git a/src/main/java/teammates/storage/sqlentity/AccountRequest.java b/src/main/java/teammates/storage/sqlentity/AccountRequest.java index 2389fbf352d..70d74e8b910 100644 --- a/src/main/java/teammates/storage/sqlentity/AccountRequest.java +++ b/src/main/java/teammates/storage/sqlentity/AccountRequest.java @@ -27,7 +27,6 @@ @Table(name = "AccountRequests", uniqueConstraints = { @UniqueConstraint(name = "Unique registration key", columnNames = "registrationKey"), - @UniqueConstraint(name = "Unique name and institute", columnNames = {"email", "institute"}) }) public class AccountRequest extends BaseEntity { @Id diff --git a/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java index 27de0e8437b..5f13178f426 100644 --- a/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java @@ -1,6 +1,5 @@ package teammates.ui.webapi; -import teammates.common.exception.EntityAlreadyExistsException; import teammates.common.exception.InvalidParametersException; import teammates.common.util.EmailWrapper; import teammates.storage.sqlentity.AccountRequest; @@ -29,9 +28,6 @@ public JsonResult execute() taskQueuer.scheduleAccountRequestForSearchIndexing(instructorEmail, instructorInstitution); } catch (InvalidParametersException ipe) { throw new InvalidHttpRequestBodyException(ipe); - } catch (EntityAlreadyExistsException eaee) { - // Use existing account request - accountRequest = sqlLogic.getAccountRequest(instructorEmail, instructorInstitution); } assert accountRequest != null; diff --git a/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java b/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java index ef31306aef9..9d150133689 100644 --- a/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java +++ b/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java @@ -16,7 +16,6 @@ import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; -import teammates.common.exception.EntityAlreadyExistsException; import teammates.common.exception.EntityDoesNotExistException; import teammates.common.exception.InvalidParametersException; import teammates.common.exception.SearchServiceException; @@ -49,8 +48,7 @@ public void teardownMethod() { } @Test - public void testCreateAccountRequest_accountRequestDoesNotExist_success() - throws InvalidParametersException, EntityAlreadyExistsException { + public void testCreateAccountRequest_accountRequestDoesNotExist_success() throws InvalidParametersException { AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); doReturn(null).when(accountRequestDb).getAccountRequest(anyString(), anyString()); @@ -60,16 +58,13 @@ public void testCreateAccountRequest_accountRequestDoesNotExist_success() } @Test - public void testCreateAccountRequest_accountRequestAlreadyExists_throwsEntityAlreadyExistsException() { + public void testCreateAccountRequest_accountRequestAlreadyExists_createsSuccessfully() + throws InvalidParametersException { AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); doReturn(new AccountRequest("test@gmail.com", "name", "institute")) .when(accountRequestDb).getAccountRequest(anyString(), anyString()); - - EntityAlreadyExistsException ex = assertThrows(EntityAlreadyExistsException.class, - () -> accountRequestDb.createAccountRequest(accountRequest)); - - assertEquals(ex.getMessage(), "Trying to create an entity that exists: " + accountRequest.toString()); - mockHibernateUtil.verify(() -> HibernateUtil.persist(accountRequest), never()); + accountRequestDb.createAccountRequest(accountRequest); + mockHibernateUtil.verify(() -> HibernateUtil.persist(accountRequest)); } @Test From fc1342faf82484868b839c249155658e978c81a1 Mon Sep 17 00:00:00 2001 From: Jay Ting <65202977+jayasting98@users.noreply.github.com> Date: Tue, 19 Mar 2024 04:04:04 +0800 Subject: [PATCH 04/95] [#11878] Add status and comments to AccountRequest (#12898) * Add AccountRequestStatus * Add AccountRequest status attribute * Add status to AccountRequest constructor * Add AccountRequest comments attribute * Add comments to AccountRequest constructor * Wrap lines * Remove mysterious unnecessary imports that appeared out of nowhere * Use non-null placeholder * Use literal placeholder --- .../DataMigrationForAccountRequestSql.java | 5 ++- .../sql/VerifyDataMigrationConnection.java | 5 ++- .../sqllogic/core/AccountRequestsLogicIT.java | 5 ++- .../it/sqllogic/core/DataBundleLogicIT.java | 3 +- .../storage/sqlapi/AccountRequestsDbIT.java | 36 ++++++++++++------- src/it/resources/data/DataBundleLogicIT.json | 2 ++ .../datatransfer/AccountRequestStatus.java | 27 ++++++++++++++ .../java/teammates/sqllogic/api/Logic.java | 7 ++-- .../sqllogic/core/AccountRequestsLogic.java | 7 ++-- .../storage/sqlentity/AccountRequest.java | 34 ++++++++++++++++-- .../ui/webapi/CreateAccountRequestAction.java | 6 +++- .../storage/sqlapi/AccountRequestsDbTest.java | 21 +++++++---- 12 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 src/main/java/teammates/common/datatransfer/AccountRequestStatus.java diff --git a/src/client/java/teammates/client/scripts/sql/DataMigrationForAccountRequestSql.java b/src/client/java/teammates/client/scripts/sql/DataMigrationForAccountRequestSql.java index b42be34734e..dbe37b9760e 100644 --- a/src/client/java/teammates/client/scripts/sql/DataMigrationForAccountRequestSql.java +++ b/src/client/java/teammates/client/scripts/sql/DataMigrationForAccountRequestSql.java @@ -2,6 +2,7 @@ import com.googlecode.objectify.cmd.Query; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.storage.sqlentity.AccountRequest; /** @@ -50,7 +51,9 @@ protected void migrateEntity(teammates.storage.entity.AccountRequest oldEntity) AccountRequest newEntity = new AccountRequest( oldEntity.getEmail(), oldEntity.getName(), - oldEntity.getInstitute()); + oldEntity.getInstitute(), + AccountRequestStatus.APPROVED, + null); // set registration key to the old value if exists if (oldEntity.getRegistrationKey() != null) { diff --git a/src/client/java/teammates/client/scripts/sql/VerifyDataMigrationConnection.java b/src/client/java/teammates/client/scripts/sql/VerifyDataMigrationConnection.java index 909c8ac649e..c26147b4016 100644 --- a/src/client/java/teammates/client/scripts/sql/VerifyDataMigrationConnection.java +++ b/src/client/java/teammates/client/scripts/sql/VerifyDataMigrationConnection.java @@ -7,6 +7,7 @@ import teammates.client.connector.DatastoreClient; import teammates.client.util.ClientProperties; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.util.HibernateUtil; import teammates.storage.entity.UsageStatistics; import teammates.storage.sqlentity.Notification; @@ -43,7 +44,9 @@ protected void verifySqlConnection() { teammates.storage.sqlentity.AccountRequest newEntity = new teammates.storage.sqlentity.AccountRequest( "dummy-teammates-account-request-email@gmail.com", "dummy-teammates-account-request", - "dummy-teammates-institute"); + "dummy-teammates-institute", + AccountRequestStatus.PENDING, + "dummy-comments"); HibernateUtil.beginTransaction(); HibernateUtil.persist(newEntity); HibernateUtil.commitTransaction(); diff --git a/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java b/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java index c449f3358a0..f95a6cfa682 100644 --- a/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java +++ b/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java @@ -4,6 +4,7 @@ import org.testng.annotations.Test; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.exception.EntityAlreadyExistsException; import teammates.common.exception.EntityDoesNotExistException; import teammates.common.exception.InvalidParametersException; @@ -28,8 +29,10 @@ public void testResetAccountRequest() String name = "name lee"; String email = "email@gmail.com"; String institute = "institute"; + AccountRequestStatus status = AccountRequestStatus.PENDING; + String comments = "comments"; - AccountRequest toReset = accountRequestsLogic.createAccountRequest(name, email, institute); + AccountRequest toReset = accountRequestsLogic.createAccountRequest(name, email, institute, status, comments); AccountRequestsDb accountRequestsDb = AccountRequestsDb.inst(); toReset.setRegisteredAt(Instant.now()); diff --git a/src/it/java/teammates/it/sqllogic/core/DataBundleLogicIT.java b/src/it/java/teammates/it/sqllogic/core/DataBundleLogicIT.java index f8571037632..079f95b13e7 100644 --- a/src/it/java/teammates/it/sqllogic/core/DataBundleLogicIT.java +++ b/src/it/java/teammates/it/sqllogic/core/DataBundleLogicIT.java @@ -8,6 +8,7 @@ import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.datatransfer.FeedbackParticipantType; import teammates.common.datatransfer.InstructorPermissionRole; import teammates.common.datatransfer.InstructorPrivileges; @@ -62,7 +63,7 @@ public void testCreateDataBundle_typicalValues_createdCorrectly() throws Excepti AccountRequest actualAccountRequest = dataBundle.accountRequests.get("instructor1"); AccountRequest expectedAccountRequest = new AccountRequest("instr1@teammates.tmt", "Instructor 1", - "TEAMMATES Test Institute 1"); + "TEAMMATES Test Institute 1", AccountRequestStatus.REGISTERED, "These are some comments."); expectedAccountRequest.setId(actualAccountRequest.getId()); expectedAccountRequest.setRegisteredAt(Instant.parse("2015-02-14T00:00:00Z")); expectedAccountRequest.setRegistrationKey(actualAccountRequest.getRegistrationKey()); diff --git a/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java b/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java index c2aa8aef078..a4c25763e40 100644 --- a/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java +++ b/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java @@ -4,6 +4,7 @@ import org.testng.annotations.Test; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.exception.EntityDoesNotExistException; import teammates.it.test.BaseTestCaseWithSqlDatabaseAccess; import teammates.storage.sqlapi.AccountRequestsDb; @@ -20,7 +21,8 @@ public class AccountRequestsDbIT extends BaseTestCaseWithSqlDatabaseAccess { public void testCreateReadDeleteAccountRequest() throws Exception { ______TS("Create account request, does not exists, succeeds"); - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); accountRequestDb.createAccountRequest(accountRequest); ______TS("Read account request using the given email and institute"); @@ -53,7 +55,7 @@ public void testCreateReadDeleteAccountRequest() throws Exception { ______TS("Create account request, same email address and institute already exist, creates successfully"); AccountRequest identicalAccountRequest = - new AccountRequest("test@gmail.com", "name", "institute"); + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); assertNotSame(accountRequest, identicalAccountRequest); accountRequestDb.createAccountRequest(identicalAccountRequest); @@ -74,7 +76,8 @@ public void testCreateReadDeleteAccountRequest() throws Exception { public void testUpdateAccountRequest() throws Exception { ______TS("Update account request, does not exists, exception thrown"); - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); assertThrows(EntityDoesNotExistException.class, () -> accountRequestDb.updateAccountRequest(accountRequest)); @@ -96,7 +99,8 @@ public void testSqlInjectionInCreateAccountRequestEmailField() throws Exception // Attempt to use SQL commands in email field String email = "email'/**/OR/**/1=1/**/@gmail.com"; - AccountRequest accountRequest = new AccountRequest(email, "name", "institute"); + AccountRequest accountRequest = + new AccountRequest(email, "name", "institute", AccountRequestStatus.PENDING, "comments"); // The system should treat the input as a plain text string accountRequestDb.createAccountRequest(accountRequest); @@ -110,7 +114,8 @@ public void testSqlInjectionInCreateAccountRequestNameField() throws Exception { // Attempt to use SQL commands in name field String name = "name'; SELECT * FROM account_requests; --"; - AccountRequest accountRequest = new AccountRequest("test@gmail.com", name, "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", name, "institute", AccountRequestStatus.PENDING, "comments"); // The system should treat the input as a plain text string accountRequestDb.createAccountRequest(accountRequest); @@ -124,7 +129,8 @@ public void testSqlInjectionInCreateAccountRequestInstituteField() throws Except // Attempt to use SQL commands in institute field String institute = "institute'; DROP TABLE account_requests; --"; - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", institute); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", institute, AccountRequestStatus.PENDING, "comments"); // The system should treat the input as a plain text string accountRequestDb.createAccountRequest(accountRequest); @@ -136,7 +142,8 @@ public void testSqlInjectionInCreateAccountRequestInstituteField() throws Except public void testSqlInjectionInGetAccountRequest() throws Exception { ______TS("SQL Injection test in getAccountRequest"); - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); accountRequestDb.createAccountRequest(accountRequest); String instituteInjection = "institute'; DROP TABLE account_requests; --"; @@ -151,7 +158,8 @@ public void testSqlInjectionInGetAccountRequest() throws Exception { public void testSqlInjectionInGetAccountRequestByRegistrationKey() throws Exception { ______TS("SQL Injection test in getAccountRequestByRegistrationKey"); - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); accountRequestDb.createAccountRequest(accountRequest); String regKeyInjection = "regKey'; DROP TABLE account_requests; --"; @@ -166,7 +174,8 @@ public void testSqlInjectionInGetAccountRequestByRegistrationKey() throws Except public void testSqlInjectionInUpdateAccountRequest() throws Exception { ______TS("SQL Injection test in updateAccountRequest"); - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); accountRequestDb.createAccountRequest(accountRequest); String nameInjection = "newName'; DROP TABLE account_requests; --"; @@ -181,13 +190,15 @@ public void testSqlInjectionInUpdateAccountRequest() throws Exception { public void testSqlInjectionInDeleteAccountRequest() throws Exception { ______TS("SQL Injection test in deleteAccountRequest"); - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); accountRequestDb.createAccountRequest(accountRequest); String emailInjection = "email'/**/OR/**/1=1/**/@gmail.com"; String nameInjection = "name'; DROP TABLE account_requests; --"; String instituteInjection = "institute'; DROP TABLE account_requests; --"; - AccountRequest accountRequestInjection = new AccountRequest(emailInjection, nameInjection, instituteInjection); + AccountRequest accountRequestInjection = new AccountRequest(emailInjection, nameInjection, instituteInjection, + AccountRequestStatus.PENDING, "comments"); accountRequestDb.deleteAccountRequest(accountRequestInjection); AccountRequest actual = accountRequestDb.getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute()); @@ -198,7 +209,8 @@ public void testSqlInjectionInDeleteAccountRequest() throws Exception { public void testSqlInjectionSearchAccountRequestsInWholeSystem() throws Exception { ______TS("SQL Injection test in searchAccountRequestsInWholeSystem"); - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); accountRequestDb.createAccountRequest(accountRequest); String searchInjection = "institute'; DROP TABLE account_requests; --"; diff --git a/src/it/resources/data/DataBundleLogicIT.json b/src/it/resources/data/DataBundleLogicIT.json index 49e7cdec993..371a87c963b 100644 --- a/src/it/resources/data/DataBundleLogicIT.json +++ b/src/it/resources/data/DataBundleLogicIT.json @@ -19,6 +19,8 @@ "name": "Instructor 1", "email": "instr1@teammates.tmt", "institute": "TEAMMATES Test Institute 1", + "status": "REGISTERED", + "comments": "These are some comments.", "registeredAt": "2015-02-14T00:00:00Z" } }, diff --git a/src/main/java/teammates/common/datatransfer/AccountRequestStatus.java b/src/main/java/teammates/common/datatransfer/AccountRequestStatus.java new file mode 100644 index 00000000000..db80e7cb830 --- /dev/null +++ b/src/main/java/teammates/common/datatransfer/AccountRequestStatus.java @@ -0,0 +1,27 @@ +package teammates.common.datatransfer; + +/** + * The status of an account request. + */ +public enum AccountRequestStatus { + + /** + * The account request has not yet been processed by the admin. + */ + PENDING, + + /** + * The account request has been rejected by the admin. + */ + REJECTED, + + /** + * The account request has been approved by the admin but the instructor has not created an account yet. + */ + APPROVED, + + /** + * The account request has been approved by the admin and the instructor has created an account. + */ + REGISTERED +} diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index 121257a6601..3ffa0458042 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -8,6 +8,7 @@ import javax.annotation.Nullable; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.datatransfer.FeedbackQuestionRecipient; import teammates.common.datatransfer.FeedbackResultFetchType; import teammates.common.datatransfer.NotificationStyle; @@ -88,10 +89,10 @@ public static Logic inst() { * @throws InvalidParametersException if the account request details are invalid. * @throws EntityAlreadyExistsException if the account request already exists. */ - public AccountRequest createAccountRequest(String name, String email, String institute) - throws InvalidParametersException { + public AccountRequest createAccountRequest(String name, String email, String institute, AccountRequestStatus status, + String comments) throws InvalidParametersException { - return accountRequestLogic.createAccountRequest(name, email, institute); + return accountRequestLogic.createAccountRequest(name, email, institute, status, comments); } /** diff --git a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java index 0465e061519..5766fa6c224 100644 --- a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java +++ b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java @@ -2,6 +2,7 @@ import java.util.List; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.exception.EntityDoesNotExistException; import teammates.common.exception.InvalidParametersException; import teammates.common.exception.SearchServiceException; @@ -57,9 +58,9 @@ public AccountRequest createAccountRequest(AccountRequest accountRequest) throws /** * Creates an account request. */ - public AccountRequest createAccountRequest(String name, String email, String institute) - throws InvalidParametersException { - AccountRequest toCreate = new AccountRequest(email, name, institute); + public AccountRequest createAccountRequest(String name, String email, String institute, AccountRequestStatus status, + String comments) throws InvalidParametersException { + AccountRequest toCreate = new AccountRequest(email, name, institute, status, comments); return accountRequestDb.createAccountRequest(toCreate); } diff --git a/src/main/java/teammates/storage/sqlentity/AccountRequest.java b/src/main/java/teammates/storage/sqlentity/AccountRequest.java index 70d74e8b910..97a65e6b468 100644 --- a/src/main/java/teammates/storage/sqlentity/AccountRequest.java +++ b/src/main/java/teammates/storage/sqlentity/AccountRequest.java @@ -9,13 +9,17 @@ import org.hibernate.annotations.UpdateTimestamp; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.util.Config; import teammates.common.util.Const; import teammates.common.util.FieldValidator; import teammates.common.util.SanitizationHelper; import teammates.common.util.StringHelper; +import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; @@ -40,6 +44,12 @@ public class AccountRequest extends BaseEntity { private String institute; + @Enumerated(EnumType.STRING) + private AccountRequestStatus status; + + @Column(columnDefinition = "TEXT") + private String comments; + private Instant registeredAt; @UpdateTimestamp @@ -49,11 +59,13 @@ protected AccountRequest() { // required by Hibernate } - public AccountRequest(String email, String name, String institute) { + public AccountRequest(String email, String name, String institute, AccountRequestStatus status, String comments) { this.setId(UUID.randomUUID()); this.setEmail(email); this.setName(name); this.setInstitute(institute); + this.setStatus(status); + this.setComments(comments); this.generateNewRegistrationKey(); this.setCreatedAt(Instant.now()); this.setRegisteredAt(null); @@ -128,6 +140,22 @@ public void setInstitute(String institute) { this.institute = SanitizationHelper.sanitizeTitle(institute); } + public AccountRequestStatus getStatus() { + return this.status; + } + + public void setStatus(AccountRequestStatus status) { + this.status = status; + } + + public String getComments() { + return this.comments; + } + + public void setComments(String comments) { + this.comments = comments; + } + public Instant getRegisteredAt() { return this.registeredAt; } @@ -166,8 +194,8 @@ public int hashCode() { @Override public String toString() { return "AccountRequest [id=" + id + ", registrationKey=" + registrationKey + ", name=" + name + ", email=" - + email + ", institute=" + institute + ", registeredAt=" + registeredAt + ", createdAt=" + getCreatedAt() - + ", updatedAt=" + updatedAt + "]"; + + email + ", institute=" + institute + ", status=" + status + ", comments=" + comments + + ", registeredAt=" + registeredAt + ", createdAt=" + getCreatedAt() + ", updatedAt=" + updatedAt + "]"; } public String getRegistrationUrl() { diff --git a/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java index 5f13178f426..30a4e9da8a7 100644 --- a/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java @@ -1,5 +1,6 @@ package teammates.ui.webapi; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.exception.InvalidParametersException; import teammates.common.util.EmailWrapper; import teammates.storage.sqlentity.AccountRequest; @@ -20,11 +21,14 @@ public JsonResult execute() String instructorName = createRequest.getInstructorName().trim(); String instructorEmail = createRequest.getInstructorEmail().trim(); String instructorInstitution = createRequest.getInstructorInstitution().trim(); + // TODO: This is a placeholder. It should be obtained from AccountCreateRequest, in a separate PR. + String comments = "PLACEHOLDER"; AccountRequest accountRequest; try { - accountRequest = sqlLogic.createAccountRequest(instructorName, instructorEmail, instructorInstitution); + accountRequest = sqlLogic.createAccountRequest(instructorName, instructorEmail, instructorInstitution, + AccountRequestStatus.PENDING, comments); taskQueuer.scheduleAccountRequestForSearchIndexing(instructorEmail, instructorInstitution); } catch (InvalidParametersException ipe) { throw new InvalidHttpRequestBodyException(ipe); diff --git a/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java b/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java index 9d150133689..61f9f8e0d24 100644 --- a/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java +++ b/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java @@ -16,6 +16,7 @@ import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.exception.EntityDoesNotExistException; import teammates.common.exception.InvalidParametersException; import teammates.common.exception.SearchServiceException; @@ -49,7 +50,8 @@ public void teardownMethod() { @Test public void testCreateAccountRequest_accountRequestDoesNotExist_success() throws InvalidParametersException { - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); doReturn(null).when(accountRequestDb).getAccountRequest(anyString(), anyString()); accountRequestDb.createAccountRequest(accountRequest); @@ -60,8 +62,9 @@ public void testCreateAccountRequest_accountRequestDoesNotExist_success() throws @Test public void testCreateAccountRequest_accountRequestAlreadyExists_createsSuccessfully() throws InvalidParametersException { - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); - doReturn(new AccountRequest("test@gmail.com", "name", "institute")) + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); + doReturn(new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments")) .when(accountRequestDb).getAccountRequest(anyString(), anyString()); accountRequestDb.createAccountRequest(accountRequest); mockHibernateUtil.verify(() -> HibernateUtil.persist(accountRequest)); @@ -69,7 +72,8 @@ public void testCreateAccountRequest_accountRequestAlreadyExists_createsSuccessf @Test public void testUpdateAccountRequest_invalidEmail_throwsInvalidParametersException() { - AccountRequest accountRequestWithInvalidEmail = new AccountRequest("testgmail.com", "name", "institute"); + AccountRequest accountRequestWithInvalidEmail = + new AccountRequest("testgmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); assertThrows(InvalidParametersException.class, () -> accountRequestDb.updateAccountRequest(accountRequestWithInvalidEmail)); @@ -79,7 +83,8 @@ public void testUpdateAccountRequest_invalidEmail_throwsInvalidParametersExcepti @Test public void testUpdateAccountRequest_accountRequestDoesNotExist_throwsEntityDoesNotExistException() { - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); doReturn(null).when(accountRequestDb).getAccountRequest(anyString(), anyString()); assertThrows(EntityDoesNotExistException.class, @@ -90,7 +95,8 @@ public void testUpdateAccountRequest_accountRequestDoesNotExist_throwsEntityDoes @Test public void testUpdateAccountRequest_success() throws InvalidParametersException, EntityDoesNotExistException { - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); doReturn(accountRequest).when(accountRequestDb).getAccountRequest(anyString(), anyString()); accountRequestDb.updateAccountRequest(accountRequest); @@ -100,7 +106,8 @@ public void testUpdateAccountRequest_success() throws InvalidParametersException @Test public void testDeleteAccountRequest_success() { - AccountRequest accountRequest = new AccountRequest("test@gmail.com", "name", "institute"); + AccountRequest accountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); accountRequestDb.deleteAccountRequest(accountRequest); From 24a914df33f17f1cfc8def8bcb7fafb5e7adc2db Mon Sep 17 00:00:00 2001 From: Jay Aljelo Ting <65202977+jayasting98@users.noreply.github.com> Date: Mon, 25 Mar 2024 08:51:08 +0800 Subject: [PATCH 05/95] [#11878] Add new account request alert email for admins (#12926) * Add admin alert email * Add email type * Add subject * Add email content * Indicate that action is needed in the email subject --- .../webapi/CreateAccountRequestActionIT.java | 5 +- .../java/teammates/common/util/EmailType.java | 1 + .../java/teammates/common/util/Templates.java | 2 + .../sqllogic/api/SqlEmailGenerator.java | 28 +++++++++ .../ui/webapi/CreateAccountRequestAction.java | 3 +- ...nEmailTemplate-newAccountRequestAlert.html | 60 +++++++++++++++++++ .../sqllogic/api/SqlEmailGeneratorTest.java | 58 ++++++++++++++++++ ...wAccountRequestAlertEmailWithComments.html | 60 +++++++++++++++++++ ...ccountRequestAlertEmailWithNoComments.html | 60 +++++++++++++++++++ 9 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/adminEmailTemplate-newAccountRequestAlert.html create mode 100644 src/test/java/teammates/sqllogic/api/SqlEmailGeneratorTest.java create mode 100644 src/test/resources/emails/adminNewAccountRequestAlertEmailWithComments.html create mode 100644 src/test/resources/emails/adminNewAccountRequestAlertEmailWithNoComments.html diff --git a/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java index 70aa4ecc521..c2af0829c69 100644 --- a/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java @@ -43,13 +43,16 @@ protected void testExecute() throws Exception { assertEquals("The Fellowship of the Ring", accountRequest.getInstitute()); assertNull(accountRequest.getRegisteredAt()); assertEquals(accountRequest.getRegistrationUrl(), output.getJoinLink()); - verifyNumberOfEmailsSent(1); verifySpecifiedTasksAdded(Const.TaskQueue.SEARCH_INDEXING_QUEUE_NAME, 1); + verifyNumberOfEmailsSent(2); EmailWrapper emailSent = mockEmailSender.getEmailsSent().get(0); assertEquals(String.format(EmailType.NEW_INSTRUCTOR_ACCOUNT.getSubject(), "Frodo Baggins"), emailSent.getSubject()); assertEquals("ring-bearer@fellowship.net", emailSent.getRecipient()); assertTrue(emailSent.getContent().contains(output.getJoinLink())); + EmailWrapper sentAdminAlertEmail = mockEmailSender.getEmailsSent().get(1); + // Check only the email type. The content of the email is not tested here, but in the email generator test(s). + assertEquals(EmailType.NEW_ACCOUNT_REQUEST_ADMIN_ALERT, sentAdminAlertEmail.getType()); } @Override diff --git a/src/main/java/teammates/common/util/EmailType.java b/src/main/java/teammates/common/util/EmailType.java index af649debb7b..5f0cd5df568 100644 --- a/src/main/java/teammates/common/util/EmailType.java +++ b/src/main/java/teammates/common/util/EmailType.java @@ -23,6 +23,7 @@ public enum EmailType { NEW_INSTRUCTOR_ACCOUNT("TEAMMATES: Welcome to TEAMMATES! %s"), STUDENT_COURSE_JOIN("TEAMMATES: Invitation to join course [%s][Course ID: %s]"), STUDENT_COURSE_REJOIN_AFTER_GOOGLE_ID_RESET("TEAMMATES: Your account has been reset for course [%s][Course ID: %s]"), + NEW_ACCOUNT_REQUEST_ADMIN_ALERT("TEAMMATES (Action Needed): New Account Request Received"), INSTRUCTOR_COURSE_JOIN("TEAMMATES: Invitation to join course as an instructor [%s][Course ID: %s]"), INSTRUCTOR_COURSE_REJOIN_AFTER_GOOGLE_ID_RESET("TEAMMATES: Your account has been reset for course [%s][Course ID: %s]"), USER_COURSE_REGISTER("TEAMMATES: Registered for Course [%s][Course ID: %s]"), diff --git a/src/main/java/teammates/common/util/Templates.java b/src/main/java/teammates/common/util/Templates.java index 524c84bc2c6..75cf95a2d38 100644 --- a/src/main/java/teammates/common/util/Templates.java +++ b/src/main/java/teammates/common/util/Templates.java @@ -32,6 +32,8 @@ public static String populateTemplate(String template, String... keyValuePairs) * Collection of templates of emails to be sent by the system. */ public static class EmailTemplates { + public static final String ADMIN_NEW_ACCOUNT_REQUEST_ALERT = + FileHelper.readResourceFile("adminEmailTemplate-newAccountRequestAlert.html"); public static final String USER_COURSE_JOIN = FileHelper.readResourceFile("userEmailTemplate-courseJoin.html"); public static final String USER_COURSE_REGISTER = diff --git a/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java b/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java index 2bd8f2bfb08..f89eb4008a7 100644 --- a/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java +++ b/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java @@ -26,6 +26,7 @@ import teammates.sqllogic.core.FeedbackSessionsLogic; import teammates.sqllogic.core.UsersLogic; import teammates.storage.sqlentity.Account; +import teammates.storage.sqlentity.AccountRequest; import teammates.storage.sqlentity.Course; import teammates.storage.sqlentity.DeadlineExtension; import teammates.storage.sqlentity.FeedbackSession; @@ -973,6 +974,33 @@ public EmailWrapper generateInstructorCourseRejoinEmailAfterGoogleIdReset( return email; } + /** + * Generates the email to alert the admin of the new {@code accountRequest}. + */ + public EmailWrapper generateNewAccountRequestAdminAlertEmail(AccountRequest accountRequest) { + String name = accountRequest.getName(); + String institute = accountRequest.getInstitute(); + String emailAddress = accountRequest.getEmail(); + String comments = accountRequest.getComments(); + if (comments == null) { + comments = ""; + } + String adminAccountRequestsPageUrl = Config.getFrontEndAppUrl(Const.WebPageURIs.ADMIN_HOME_PAGE).toAbsoluteString(); + String[] templateKeyValuePairs = new String[] { + "${name}", name, + "${institute}", institute, + "${emailAddress}", emailAddress, + "${comments}", comments, + "${adminAccountRequestsPageUrl}", adminAccountRequestsPageUrl, + }; + String content = Templates.populateTemplate(EmailTemplates.ADMIN_NEW_ACCOUNT_REQUEST_ALERT, templateKeyValuePairs); + EmailWrapper email = getEmptyEmailAddressedToEmail(Config.SUPPORT_EMAIL); + email.setType(EmailType.NEW_ACCOUNT_REQUEST_ADMIN_ALERT); + email.setSubjectFromType(); + email.setContent(content); + return email; + } + /** * Generates the course registered email for the user with the given details in {@code course}. */ diff --git a/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java index 30a4e9da8a7..b6243a0016f 100644 --- a/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java @@ -45,7 +45,8 @@ public JsonResult execute() EmailWrapper email = emailGenerator.generateNewInstructorAccountJoinEmail( instructorEmail, instructorName, joinLink); emailSender.sendEmail(email); - + EmailWrapper adminAlertEmail = sqlEmailGenerator.generateNewAccountRequestAdminAlertEmail(accountRequest); + emailSender.sendEmail(adminAlertEmail); JoinLinkData output = new JoinLinkData(joinLink); return new JsonResult(output); } diff --git a/src/main/resources/adminEmailTemplate-newAccountRequestAlert.html b/src/main/resources/adminEmailTemplate-newAccountRequestAlert.html new file mode 100644 index 00000000000..a91c1250a26 --- /dev/null +++ b/src/main/resources/adminEmailTemplate-newAccountRequestAlert.html @@ -0,0 +1,60 @@ +

Hello, Admin

+ +

+ A new instructor account request has been submitted: +

+ +
+ + + + + + + + + + + + + + + + + + + + +
+ + Full Name + + + ${name} +
+ + Institute + + + ${institute} +
+ + Email Address + + + ${emailAddress} +
+ + Comments + + + ${comments} +
+
+ +Accept/reject this request on the admin panel: ${adminAccountRequestsPageUrl} + +

+ Regards,
+ TEAMMATES Team. +

diff --git a/src/test/java/teammates/sqllogic/api/SqlEmailGeneratorTest.java b/src/test/java/teammates/sqllogic/api/SqlEmailGeneratorTest.java new file mode 100644 index 00000000000..24a77f91ac7 --- /dev/null +++ b/src/test/java/teammates/sqllogic/api/SqlEmailGeneratorTest.java @@ -0,0 +1,58 @@ +package teammates.sqllogic.api; + +import java.io.IOException; + +import org.testng.annotations.Test; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.util.Config; +import teammates.common.util.EmailType; +import teammates.common.util.EmailWrapper; +import teammates.storage.sqlentity.AccountRequest; +import teammates.test.BaseTestCase; +import teammates.test.EmailChecker; + +/** + * SUT: {@link SqlEmailGenerator}. + */ +public class SqlEmailGeneratorTest extends BaseTestCase { + private final SqlEmailGenerator sqlEmailGenerator = SqlEmailGenerator.inst(); + + @Test + void testGenerateNewAccountRequestAdminAlertEmail_withComments_generatesSuccessfully() throws IOException { + AccountRequest accountRequest = new AccountRequest("chosen-one@jedi.org", "Anakin Skywalker", "Jedi Order", + AccountRequestStatus.PENDING, + "I don't like sand. It's coarse and rough and irritating... and it gets everywhere."); + EmailWrapper email = sqlEmailGenerator.generateNewAccountRequestAdminAlertEmail(accountRequest); + verifyEmail(email, Config.SUPPORT_EMAIL, EmailType.NEW_ACCOUNT_REQUEST_ADMIN_ALERT, + "TEAMMATES (Action Needed): New Account Request Received", + "/adminNewAccountRequestAlertEmailWithComments.html"); + } + + @Test + void testGenerateNewAccountRequestAdminAlertEmail_withNoComments_generatesSuccessfully() throws IOException { + AccountRequest accountRequest = new AccountRequest("maul@sith.org", "Maul", "Sith Order", + AccountRequestStatus.PENDING, null); + EmailWrapper email = sqlEmailGenerator.generateNewAccountRequestAdminAlertEmail(accountRequest); + verifyEmail(email, Config.SUPPORT_EMAIL, EmailType.NEW_ACCOUNT_REQUEST_ADMIN_ALERT, + "TEAMMATES (Action Needed): New Account Request Received", + "/adminNewAccountRequestAlertEmailWithNoComments.html"); + } + + private void verifyEmail(EmailWrapper email, String expectedRecipientEmailAddress, EmailType expectedEmailType, + String expectedSubject, String expectedEmailContentFilePathname) throws IOException { + assertEquals(expectedRecipientEmailAddress, email.getRecipient()); + assertEquals(Config.EMAIL_SENDEREMAIL, email.getSenderEmail()); + assertEquals(Config.EMAIL_SENDERNAME, email.getSenderName()); + assertEquals(Config.EMAIL_REPLYTO, email.getReplyTo()); + assertEquals(expectedEmailType, email.getType()); + assertEquals(expectedSubject, email.getSubject()); + String emailContent = email.getContent(); + EmailChecker.verifyEmailContent(emailContent, expectedEmailContentFilePathname); + verifyEmailContentHasNoPlaceholders(emailContent); + } + + private void verifyEmailContentHasNoPlaceholders(String emailContent) { + assertFalse(emailContent.contains("${")); + } +} diff --git a/src/test/resources/emails/adminNewAccountRequestAlertEmailWithComments.html b/src/test/resources/emails/adminNewAccountRequestAlertEmailWithComments.html new file mode 100644 index 00000000000..301fd024a3e --- /dev/null +++ b/src/test/resources/emails/adminNewAccountRequestAlertEmailWithComments.html @@ -0,0 +1,60 @@ +

Hello, Admin

+ +

+ A new instructor account request has been submitted: +

+ +
+ + + + + + + + + + + + + + + + + + + + +
+ + Full Name + + + Anakin Skywalker +
+ + Institute + + + Jedi Order +
+ + Email Address + + + chosen-one@jedi.org +
+ + Comments + + + I don't like sand. It's coarse and rough and irritating... and it gets everywhere. +
+
+ +Accept/reject this request on the admin panel: ${app.url}/web/admin/home + +

+ Regards,
+ TEAMMATES Team. +

diff --git a/src/test/resources/emails/adminNewAccountRequestAlertEmailWithNoComments.html b/src/test/resources/emails/adminNewAccountRequestAlertEmailWithNoComments.html new file mode 100644 index 00000000000..a2f62ae17b1 --- /dev/null +++ b/src/test/resources/emails/adminNewAccountRequestAlertEmailWithNoComments.html @@ -0,0 +1,60 @@ +

Hello, Admin

+ +

+ A new instructor account request has been submitted: +

+ +
+ + + + + + + + + + + + + + + + + + + + +
+ + Full Name + + + Maul +
+ + Institute + + + Sith Order +
+ + Email Address + + + maul@sith.org +
+ + Comments + + + +
+
+ +Accept/reject this request on the admin panel: ${app.url}/web/admin/home + +

+ Regards,
+ TEAMMATES Team. +

From 126d2c45fd281f2ea8ff6640088f04587e4b65e7 Mon Sep 17 00:00:00 2001 From: EuniceSim142 <77243938+EuniceSim142@users.noreply.github.com> Date: Mon, 25 Mar 2024 16:00:48 +0800 Subject: [PATCH 06/95] [#11878] Add GetAllPendingAccountRequests API (#12927) * add endpoint * remove 'all' in class and method names * fix checkstyle * add it test * fix checkstyle * fix checkstyle * fix failing test * update endpoint url * update it tests * fix linting * update param name * update request param condition --- .../ui/webapi/GetAccountRequestsActionIT.java | 109 ++++++++++++++++++ .../java/teammates/common/util/Const.java | 2 + .../java/teammates/sqllogic/api/Logic.java | 7 ++ .../sqllogic/core/AccountRequestsLogic.java | 7 ++ .../storage/sqlapi/AccountRequestsDb.java | 14 +++ .../ui/constants/ResourceEndpoints.java | 1 + .../teammates/ui/webapi/ActionFactory.java | 1 + .../ui/webapi/GetAccountRequestsAction.java | 34 ++++++ .../ui/webapi/GetActionClassesActionTest.java | 1 + 9 files changed, 176 insertions(+) create mode 100644 src/it/java/teammates/it/ui/webapi/GetAccountRequestsActionIT.java create mode 100644 src/main/java/teammates/ui/webapi/GetAccountRequestsAction.java diff --git a/src/it/java/teammates/it/ui/webapi/GetAccountRequestsActionIT.java b/src/it/java/teammates/it/ui/webapi/GetAccountRequestsActionIT.java new file mode 100644 index 00000000000..565fda387a5 --- /dev/null +++ b/src/it/java/teammates/it/ui/webapi/GetAccountRequestsActionIT.java @@ -0,0 +1,109 @@ +package teammates.it.ui.webapi; + +import java.util.List; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Const; +import teammates.common.util.HibernateUtil; +import teammates.storage.sqlentity.AccountRequest; +import teammates.storage.sqlentity.Course; +import teammates.ui.output.AccountRequestData; +import teammates.ui.output.AccountRequestsData; +import teammates.ui.webapi.GetAccountRequestsAction; +import teammates.ui.webapi.JsonResult; + +/** + * SUT: {@link GetAccountRequestsAction}. + */ +public class GetAccountRequestsActionIT extends BaseActionIT { + private final String[] validParams = { Const.ParamsNames.ACCOUNT_REQUEST_STATUS, "pending" }; + + @Override + @BeforeMethod + protected void setUp() throws Exception { + super.setUp(); + persistDataBundle(typicalBundle); + HibernateUtil.flushSession(); + } + + @Override + protected String getActionUri() { + return Const.ResourceURIs.ACCOUNT_REQUESTS; + } + + @Override + protected String getRequestMethod() { + return GET; + } + + @Override + @Test + public void testExecute() { + ______TS("accountrequeststatus param is null"); + + verifyHttpParameterFailure(); + verifyHttpParameterFailure(Const.ParamsNames.ACCOUNT_REQUEST_STATUS, "pendin"); + + ______TS("No pending account requests initially"); + + GetAccountRequestsAction action = getAction(this.validParams); + JsonResult result = getJsonResult(action); + AccountRequestsData data = (AccountRequestsData) result.getOutput(); + List arData = data.getAccountRequests(); + + assertEquals(0, arData.size()); + + ______TS("1 pending account request, case insensitive match for status request param"); + + AccountRequest accountRequest1 = typicalBundle.accountRequests.get("instructor1"); + accountRequest1.setStatus(AccountRequestStatus.PENDING); + + String[] params = { Const.ParamsNames.ACCOUNT_REQUEST_STATUS, "PendinG" }; + action = getAction(params); + result = getJsonResult(action); + data = (AccountRequestsData) result.getOutput(); + arData = data.getAccountRequests(); + + assertEquals(1, arData.size()); + + ______TS("Get 2 pending account requests, ignore 1 approved account request"); + AccountRequest approvedAccountRequest1 = typicalBundle.accountRequests.get("instructor2"); + approvedAccountRequest1.setStatus(AccountRequestStatus.APPROVED); + + accountRequest1 = typicalBundle.accountRequests.get("instructor1"); + AccountRequest accountRequest2 = typicalBundle.accountRequests.get("instructor1OfCourse2"); + accountRequest1.setStatus(AccountRequestStatus.PENDING); + accountRequest2.setStatus(AccountRequestStatus.PENDING); + + action = getAction(this.validParams); + result = getJsonResult(action); + data = (AccountRequestsData) result.getOutput(); + arData = data.getAccountRequests(); + + assertEquals(2, arData.size()); + + // account request 1 + assertEquals(arData.get(0).getEmail(), accountRequest1.getEmail()); + assertEquals(arData.get(0).getInstitute(), accountRequest1.getInstitute()); + assertEquals(arData.get(0).getName(), accountRequest1.getName()); + assertEquals(arData.get(0).getRegistrationKey(), accountRequest1.getRegistrationKey()); + + // account request 2 + assertEquals(arData.get(1).getEmail(), accountRequest2.getEmail()); + assertEquals(arData.get(1).getInstitute(), accountRequest2.getInstitute()); + assertEquals(arData.get(1).getName(), accountRequest2.getName()); + assertEquals(arData.get(1).getRegistrationKey(), accountRequest2.getRegistrationKey()); + } + + @Override + @Test + public void testAccessControl() throws InvalidParametersException, EntityAlreadyExistsException { + Course course = typicalBundle.courses.get("course1"); + verifyOnlyAdminCanAccess(course); + } +} diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index b24d0ded648..34b5a369479 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -122,6 +122,7 @@ public static class ParamsNames { public static final String INSTRUCTOR_INSTITUTION = "instructorinstitution"; public static final String IS_CREATING_ACCOUNT = "iscreatingaccount"; public static final String IS_INSTRUCTOR = "isinstructor"; + public static final String ACCOUNT_REQUEST_STATUS = "status"; public static final String FEEDBACK_SESSION_NAME = "fsname"; public static final String FEEDBACK_SESSION_STARTTIME = "starttime"; @@ -332,6 +333,7 @@ public static class ResourceURIs { public static final String ACCOUNT = URI_PREFIX + "/account"; public static final String ACCOUNT_RESET = URI_PREFIX + "/account/reset"; public static final String ACCOUNT_REQUEST = URI_PREFIX + "/account/request"; + public static final String ACCOUNT_REQUESTS = URI_PREFIX + "/account/requests"; public static final String ACCOUNT_REQUEST_RESET = ACCOUNT_REQUEST + "/reset"; public static final String ACCOUNTS = URI_PREFIX + "/accounts"; public static final String RESPONSE_COMMENT = URI_PREFIX + "/responsecomment"; diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index 3ffa0458042..a6f785e442e 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -149,6 +149,13 @@ public void deleteAccountRequest(String email, String institute) { accountRequestLogic.deleteAccountRequest(email, institute); } + /** + * Gets all pending account requests. + */ + public List getPendingAccountRequests() { + return accountRequestLogic.getPendingAccountRequests(); + } + /** * Gets an account. */ diff --git a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java index 5766fa6c224..6c0f34efb1a 100644 --- a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java +++ b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java @@ -88,6 +88,13 @@ public AccountRequest getAccountRequestByRegistrationKey(String regkey) { return accountRequestDb.getAccountRequestByRegistrationKey(regkey); } + /** + * Gets all pending account requests. + */ + public List getPendingAccountRequests() { + return accountRequestDb.getPendingAccountRequests(); + } + /** * Creates/resets the account request with the given email and institute such that it is not registered. */ diff --git a/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java b/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java index 068ed3ed253..e3ced48d6af 100644 --- a/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java +++ b/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.UUID; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.exception.EntityDoesNotExistException; import teammates.common.exception.InvalidParametersException; import teammates.common.exception.SearchServiceException; @@ -68,6 +69,19 @@ public AccountRequest getAccountRequest(String email, String institute) { return query.getResultStream().findFirst().orElse(null); } + /** + * Get all Account Requests with {@code status} of 'pending'. + */ + public List getPendingAccountRequests() { + CriteriaBuilder cb = HibernateUtil.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(AccountRequest.class); + Root root = cr.from(AccountRequest.class); + cr.select(root).where(cb.equal(root.get("status"), AccountRequestStatus.PENDING)); + + TypedQuery query = HibernateUtil.createQuery(cr); + return query.getResultList(); + } + /** * Get AccountRequest by {@code registrationKey} from database. */ diff --git a/src/main/java/teammates/ui/constants/ResourceEndpoints.java b/src/main/java/teammates/ui/constants/ResourceEndpoints.java index 2401fad8efe..8e288eb6264 100644 --- a/src/main/java/teammates/ui/constants/ResourceEndpoints.java +++ b/src/main/java/teammates/ui/constants/ResourceEndpoints.java @@ -15,6 +15,7 @@ public enum ResourceEndpoints { ACCOUNT(ResourceURIs.ACCOUNT), ACCOUNT_RESET(ResourceURIs.ACCOUNT_RESET), ACCOUNT_REQUEST(ResourceURIs.ACCOUNT_REQUEST), + ACCOUNT_REQUESTS(ResourceURIs.ACCOUNT_REQUESTS), ACCOUNT_REQUEST_RESET(ResourceURIs.ACCOUNT_REQUEST_RESET), ACCOUNTS(ResourceURIs.ACCOUNTS), RESPONSE_COMMENT(ResourceURIs.RESPONSE_COMMENT), diff --git a/src/main/java/teammates/ui/webapi/ActionFactory.java b/src/main/java/teammates/ui/webapi/ActionFactory.java index 38c4b00b753..72d16ad9973 100644 --- a/src/main/java/teammates/ui/webapi/ActionFactory.java +++ b/src/main/java/teammates/ui/webapi/ActionFactory.java @@ -50,6 +50,7 @@ public final class ActionFactory { map(ResourceURIs.ACCOUNT_REQUEST, GET, GetAccountRequestAction.class); map(ResourceURIs.ACCOUNT_REQUEST, POST, CreateAccountRequestAction.class); map(ResourceURIs.ACCOUNT_REQUEST, DELETE, DeleteAccountRequestAction.class); + map(ResourceURIs.ACCOUNT_REQUESTS, GET, GetAccountRequestsAction.class); map(ResourceURIs.ACCOUNT_REQUEST_RESET, PUT, ResetAccountRequestAction.class); map(ResourceURIs.ACCOUNTS, GET, GetAccountsAction.class); map(ResourceURIs.COURSE, GET, GetCourseAction.class); diff --git a/src/main/java/teammates/ui/webapi/GetAccountRequestsAction.java b/src/main/java/teammates/ui/webapi/GetAccountRequestsAction.java new file mode 100644 index 00000000000..7cac4331b98 --- /dev/null +++ b/src/main/java/teammates/ui/webapi/GetAccountRequestsAction.java @@ -0,0 +1,34 @@ +package teammates.ui.webapi; + +import java.util.List; +import java.util.stream.Collectors; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.util.Const; +import teammates.storage.sqlentity.AccountRequest; +import teammates.ui.output.AccountRequestData; +import teammates.ui.output.AccountRequestsData; + +/** + * Action: Gets pending account requests. + */ +public class GetAccountRequestsAction extends AdminOnlyAction { + @Override + public JsonResult execute() { + String accountRequestStatus = getNonNullRequestParamValue(Const.ParamsNames.ACCOUNT_REQUEST_STATUS); + String pending = AccountRequestStatus.PENDING.name(); // 'PENDING' + if (!pending.equalsIgnoreCase(accountRequestStatus)) { + throw new InvalidHttpParameterException("Only 'pending' is allowed for account request status."); + } + + List accountRequests = sqlLogic.getPendingAccountRequests(); + List accountRequestDatas = accountRequests + .stream() + .map(ar -> new AccountRequestData(ar)) + .collect(Collectors.toList()); + + AccountRequestsData output = new AccountRequestsData(); + output.setAccountRequests(accountRequestDatas); + return new JsonResult(output); + } +} diff --git a/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java b/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java index 2c989868edc..752a74d9101 100644 --- a/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java +++ b/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java @@ -86,6 +86,7 @@ protected void testExecute() { CreateAccountRequestAction.class, GetAccountRequestAction.class, DeleteAccountRequestAction.class, + GetAccountRequestsAction.class, GetAccountAction.class, GetAccountsAction.class, FeedbackSessionPublishedRemindersAction.class, From cb29108134cfe44f025d2de5793ad835c47c6b50 Mon Sep 17 00:00:00 2001 From: Jay Aljelo Ting <65202977+jayasting98@users.noreply.github.com> Date: Mon, 25 Mar 2024 16:25:50 +0800 Subject: [PATCH 07/95] [#11878] Modify CreateAccountRequestAction (#12913) * Add AccountCreateRequest instructorComments attribute * Add new AccountRequestData attributes * Remove check for registered instructor * Remove sending of registration email * Use AccountCreateRequest comments * Change output of CreateAccountRequestAction to AccountRequestData * Add CreateAccountRequestActionIT * Test execute with null arguments * Test execute with valid requests * Test execute on invalid arguments * Allow anybody to create an account request * Fix architecture test * Fix test * Update tests to verify search indexing --- .../webapi/CreateAccountRequestActionIT.java | 179 ++++++++++++++++-- .../attributes/AccountRequestAttributes.java | 8 +- .../ui/output/AccountRequestData.java | 27 ++- .../ui/request/AccountCreateRequest.java | 12 ++ .../ui/webapi/CreateAccountRequestAction.java | 33 ++-- .../logic/core/AccountRequestsLogicTest.java | 6 +- .../CreateAccountRequestActionTest.java | 42 +--- .../admin-home-page.component.spec.ts | 14 +- .../admin-home-page.component.ts | 6 +- src/web/services/account.service.ts | 4 +- src/web/services/search.service.spec.ts | 9 +- 11 files changed, 256 insertions(+), 84 deletions(-) diff --git a/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java index c2af0829c69..f1acd18c78c 100644 --- a/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java @@ -2,12 +2,15 @@ import org.testng.annotations.Test; +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.exception.InvalidParametersException; import teammates.common.util.Const; import teammates.common.util.EmailType; import teammates.common.util.EmailWrapper; import teammates.storage.sqlentity.AccountRequest; -import teammates.ui.output.JoinLinkData; +import teammates.ui.output.AccountRequestData; import teammates.ui.request.AccountCreateRequest; +import teammates.ui.request.InvalidHttpRequestBodyException; import teammates.ui.webapi.CreateAccountRequestAction; import teammates.ui.webapi.JsonResult; @@ -27,37 +30,171 @@ String getRequestMethod() { } @Override - @Test protected void testExecute() throws Exception { - // This is a minimal test; other cases are not tested due to upcoming changes in behaviour. + // This is separated into different test methods. + } + + @Test + void testExecute_nullEmail_throwsInvalidHttpRequestBodyException() { + AccountCreateRequest request = new AccountCreateRequest(); + request.setInstructorName("Paul Atreides"); + request.setInstructorInstitution("House Atreides"); + InvalidHttpRequestBodyException ihrbException = verifyHttpRequestBodyFailure(request); + assertEquals("email cannot be null", ihrbException.getMessage()); + } + + @Test + void testExecute_nullName_throwsInvalidHttpRequestBodyException() { + AccountCreateRequest request = new AccountCreateRequest(); + request.setInstructorEmail("kwisatz.haderach@atreides.org"); + request.setInstructorInstitution("House Atreides"); + InvalidHttpRequestBodyException ihrbException = verifyHttpRequestBodyFailure(request); + assertEquals("name cannot be null", ihrbException.getMessage()); + } + + @Test + void testExecute_nullInstitute_throwsInvalidHttpRequestBodyException() { + AccountCreateRequest request = new AccountCreateRequest(); + request.setInstructorEmail("kwisatz.haderach@atreides.org"); + request.setInstructorName("Paul Atreides"); + InvalidHttpRequestBodyException ihrbException = verifyHttpRequestBodyFailure(request); + assertEquals("institute cannot be null", ihrbException.getMessage()); + } + + @Test + void testExecute_invalidEmail_throwsInvalidHttpRequestBodyException() { + AccountCreateRequest request = new AccountCreateRequest(); + request.setInstructorEmail("invalid email address"); + request.setInstructorName("Paul Atreides"); + request.setInstructorInstitution("House Atreides"); + InvalidHttpRequestBodyException ihrbException = verifyHttpRequestBodyFailure(request); + String expectedMessage = "\"invalid email address\" is not acceptable to TEAMMATES as a/an email because it is not " + + "in the correct format. An email address contains some text followed by one '@' sign followed by some " + + "more text, and should end with a top level domain address like .com. It cannot be longer than 254 " + + "characters, cannot be empty and cannot contain spaces."; + assertEquals(expectedMessage, ihrbException.getMessage()); + } + + @Test + void testExecute_invalidName_throwsInvalidHttpRequestBodyException() { + AccountCreateRequest request = new AccountCreateRequest(); + request.setInstructorEmail("kwisatz.haderach@atreides.org"); + request.setInstructorName("Pau| Atreides"); + request.setInstructorInstitution("House Atreides"); + InvalidHttpRequestBodyException ihrbException = verifyHttpRequestBodyFailure(request); + String expectedMessage = "\"Pau| Atreides\" is not acceptable to TEAMMATES as a/an person name because it contains " + + "invalid characters. A/An person name must start with an alphanumeric character, and cannot contain any " + + "vertical bar (|) or percent sign (%)."; + assertEquals(expectedMessage, ihrbException.getMessage()); + } + + @Test + void testExecute_invalidInstitute_throwsInvalidHttpRequestBodyException() { AccountCreateRequest request = new AccountCreateRequest(); - request.setInstructorEmail("ring-bearer@fellowship.net"); - request.setInstructorName("Frodo Baggins"); - request.setInstructorInstitution("The Fellowship of the Ring"); + request.setInstructorEmail("kwisatz.haderach@atreides.org"); + request.setInstructorName("Paul Atreides"); + request.setInstructorInstitution("House Atreide%"); + InvalidHttpRequestBodyException ihrbException = verifyHttpRequestBodyFailure(request); + String expectedMessage = "\"House Atreide%\" is not acceptable to TEAMMATES as a/an institute name because it " + + "contains invalid characters. A/An institute name must start with an alphanumeric character, and cannot " + + "contain any vertical bar (|) or percent sign (%)."; + assertEquals(expectedMessage, ihrbException.getMessage()); + } + + @Test + void testExecute_typicalCase_createsSuccessfully() { + AccountCreateRequest request = new AccountCreateRequest(); + request.setInstructorEmail("kwisatz.haderach@atreides.org"); + request.setInstructorName("Paul Atreides"); + request.setInstructorInstitution("House Atreides"); + request.setInstructorComments("My road leads into the desert. I can see it."); CreateAccountRequestAction action = getAction(request); JsonResult result = getJsonResult(action); - JoinLinkData output = (JoinLinkData) result.getOutput(); - AccountRequest accountRequest = logic.getAccountRequest("ring-bearer@fellowship.net", "The Fellowship of the Ring"); - assertEquals("ring-bearer@fellowship.net", accountRequest.getEmail()); - assertEquals("Frodo Baggins", accountRequest.getName()); - assertEquals("The Fellowship of the Ring", accountRequest.getInstitute()); + AccountRequestData output = (AccountRequestData) result.getOutput(); + assertEquals("kwisatz.haderach@atreides.org", output.getEmail()); + assertEquals("Paul Atreides", output.getName()); + assertEquals("House Atreides", output.getInstitute()); + assertEquals(AccountRequestStatus.PENDING, output.getStatus()); + assertEquals("My road leads into the desert. I can see it.", output.getComments()); + assertNull(output.getRegisteredAt()); + AccountRequest accountRequest = logic.getAccountRequestByRegistrationKey(output.getRegistrationKey()); + assertEquals("kwisatz.haderach@atreides.org", accountRequest.getEmail()); + assertEquals("Paul Atreides", accountRequest.getName()); + assertEquals("House Atreides", accountRequest.getInstitute()); + assertEquals(AccountRequestStatus.PENDING, accountRequest.getStatus()); + assertEquals("My road leads into the desert. I can see it.", accountRequest.getComments()); assertNull(accountRequest.getRegisteredAt()); - assertEquals(accountRequest.getRegistrationUrl(), output.getJoinLink()); verifySpecifiedTasksAdded(Const.TaskQueue.SEARCH_INDEXING_QUEUE_NAME, 1); - verifyNumberOfEmailsSent(2); - EmailWrapper emailSent = mockEmailSender.getEmailsSent().get(0); - assertEquals(String.format(EmailType.NEW_INSTRUCTOR_ACCOUNT.getSubject(), "Frodo Baggins"), - emailSent.getSubject()); - assertEquals("ring-bearer@fellowship.net", emailSent.getRecipient()); - assertTrue(emailSent.getContent().contains(output.getJoinLink())); - EmailWrapper sentAdminAlertEmail = mockEmailSender.getEmailsSent().get(1); - // Check only the email type. The content of the email is not tested here, but in the email generator test(s). + verifyNumberOfEmailsSent(1); + EmailWrapper sentAdminAlertEmail = mockEmailSender.getEmailsSent().get(0); + assertEquals(EmailType.NEW_ACCOUNT_REQUEST_ADMIN_ALERT, sentAdminAlertEmail.getType()); + } + + @Test + void testExecute_leadingAndTrailingSpacesAndNullComments_createsSuccessfully() { + AccountCreateRequest request = new AccountCreateRequest(); + request.setInstructorEmail(" kwisatz.haderach@atreides.org "); + request.setInstructorName(" Paul Atreides "); + request.setInstructorInstitution(" House Atreides "); + CreateAccountRequestAction action = getAction(request); + JsonResult result = getJsonResult(action); + AccountRequestData output = (AccountRequestData) result.getOutput(); + assertEquals("kwisatz.haderach@atreides.org", output.getEmail()); + assertEquals("Paul Atreides", output.getName()); + assertEquals("House Atreides", output.getInstitute()); + assertEquals(AccountRequestStatus.PENDING, output.getStatus()); + assertNull(output.getComments()); + assertNull(output.getRegisteredAt()); + AccountRequest accountRequest = logic.getAccountRequestByRegistrationKey(output.getRegistrationKey()); + assertEquals("kwisatz.haderach@atreides.org", accountRequest.getEmail()); + assertEquals("Paul Atreides", accountRequest.getName()); + assertEquals("House Atreides", accountRequest.getInstitute()); + assertEquals(AccountRequestStatus.PENDING, accountRequest.getStatus()); + assertNull(accountRequest.getComments()); + assertNull(accountRequest.getRegisteredAt()); + verifySpecifiedTasksAdded(Const.TaskQueue.SEARCH_INDEXING_QUEUE_NAME, 1); + verifyNumberOfEmailsSent(1); + EmailWrapper sentAdminAlertEmail = mockEmailSender.getEmailsSent().get(0); + assertEquals(EmailType.NEW_ACCOUNT_REQUEST_ADMIN_ALERT, sentAdminAlertEmail.getType()); + } + + @Test + void testExecute_accountRequestWithSameEmailAddressAndInstituteAlreadyExists_createsSuccessfully() + throws InvalidParametersException { + AccountRequest existingAccountRequest = logic.createAccountRequest("Paul Atreides", "kwisatz.haderach@atreides.org", + "House Atreides", AccountRequestStatus.PENDING, "My road leads into the desert. I can see it."); + AccountCreateRequest request = new AccountCreateRequest(); + request.setInstructorEmail("kwisatz.haderach@atreides.org"); + request.setInstructorName("Paul Atreides"); + request.setInstructorInstitution("House Atreides"); + request.setInstructorComments("My road leads into the desert. I can see it."); + CreateAccountRequestAction action = getAction(request); + JsonResult result = getJsonResult(action); + AccountRequestData output = (AccountRequestData) result.getOutput(); + assertEquals("kwisatz.haderach@atreides.org", output.getEmail()); + assertEquals("Paul Atreides", output.getName()); + assertEquals("House Atreides", output.getInstitute()); + assertEquals(AccountRequestStatus.PENDING, output.getStatus()); + assertEquals("My road leads into the desert. I can see it.", output.getComments()); + assertNull(output.getRegisteredAt()); + assertNotEquals(output.getRegistrationKey(), existingAccountRequest.getRegistrationKey()); + AccountRequest accountRequest = logic.getAccountRequestByRegistrationKey(output.getRegistrationKey()); + assertEquals("kwisatz.haderach@atreides.org", accountRequest.getEmail()); + assertEquals("Paul Atreides", accountRequest.getName()); + assertEquals("House Atreides", accountRequest.getInstitute()); + assertEquals(AccountRequestStatus.PENDING, accountRequest.getStatus()); + assertEquals("My road leads into the desert. I can see it.", accountRequest.getComments()); + assertNull(accountRequest.getRegisteredAt()); + verifySpecifiedTasksAdded(Const.TaskQueue.SEARCH_INDEXING_QUEUE_NAME, 1); + verifyNumberOfEmailsSent(1); + EmailWrapper sentAdminAlertEmail = mockEmailSender.getEmailsSent().get(0); assertEquals(EmailType.NEW_ACCOUNT_REQUEST_ADMIN_ALERT, sentAdminAlertEmail.getType()); } @Override + @Test protected void testAccessControl() throws Exception { - // This is not tested due to upcoming changes in behaviour. + verifyAccessibleWithoutLogin(); } } diff --git a/src/main/java/teammates/common/datatransfer/attributes/AccountRequestAttributes.java b/src/main/java/teammates/common/datatransfer/attributes/AccountRequestAttributes.java index 70f97471d6f..95550962ed5 100644 --- a/src/main/java/teammates/common/datatransfer/attributes/AccountRequestAttributes.java +++ b/src/main/java/teammates/common/datatransfer/attributes/AccountRequestAttributes.java @@ -15,7 +15,7 @@ * The data transfer object for {@link AccountRequest} entities. */ public final class AccountRequestAttributes extends EntityAttributes { - + private String id; private String email; private String name; private String institute; @@ -38,7 +38,7 @@ private AccountRequestAttributes(String email, String institute, String name) { public static AccountRequestAttributes valueOf(AccountRequest accountRequest) { AccountRequestAttributes accountRequestAttributes = new AccountRequestAttributes(accountRequest.getEmail(), accountRequest.getInstitute(), accountRequest.getName()); - + accountRequestAttributes.id = accountRequest.getId(); accountRequestAttributes.registrationKey = accountRequest.getRegistrationKey(); accountRequestAttributes.registeredAt = accountRequest.getRegisteredAt(); accountRequestAttributes.createdAt = accountRequest.getCreatedAt(); @@ -53,6 +53,10 @@ public static Builder builder(String email, String institute, String name) { return new Builder(email, institute, name); } + public String getId() { + return id; + } + public String getRegistrationKey() { return registrationKey; } diff --git a/src/main/java/teammates/ui/output/AccountRequestData.java b/src/main/java/teammates/ui/output/AccountRequestData.java index 2d50bcb1360..92dc77ed50a 100644 --- a/src/main/java/teammates/ui/output/AccountRequestData.java +++ b/src/main/java/teammates/ui/output/AccountRequestData.java @@ -2,6 +2,7 @@ import javax.annotation.Nullable; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.datatransfer.attributes.AccountRequestAttributes; import teammates.storage.sqlentity.AccountRequest; @@ -9,36 +10,44 @@ * Output format of account request data. */ public class AccountRequestData extends ApiOutput { - + private final String id; private final String email; private final String name; private final String institute; private final String registrationKey; + private final AccountRequestStatus status; + @Nullable + private final String comments; @Nullable private final Long registeredAt; private final long createdAt; public AccountRequestData(AccountRequestAttributes accountRequestInfo) { - + this.id = accountRequestInfo.getId(); this.name = accountRequestInfo.getName(); this.email = accountRequestInfo.getEmail(); this.institute = accountRequestInfo.getInstitute(); this.registrationKey = accountRequestInfo.getRegistrationKey(); + this.comments = null; this.createdAt = accountRequestInfo.getCreatedAt().toEpochMilli(); if (accountRequestInfo.getRegisteredAt() == null) { + this.status = AccountRequestStatus.APPROVED; this.registeredAt = null; } else { + this.status = AccountRequestStatus.REGISTERED; this.registeredAt = accountRequestInfo.getRegisteredAt().toEpochMilli(); } } public AccountRequestData(AccountRequest accountRequest) { - + this.id = accountRequest.getId().toString(); this.name = accountRequest.getName(); this.email = accountRequest.getEmail(); this.institute = accountRequest.getInstitute(); this.registrationKey = accountRequest.getRegistrationKey(); + this.status = accountRequest.getStatus(); + this.comments = accountRequest.getComments(); this.createdAt = accountRequest.getCreatedAt().toEpochMilli(); if (accountRequest.getRegisteredAt() == null) { @@ -48,6 +57,10 @@ public AccountRequestData(AccountRequest accountRequest) { } } + public String getId() { + return id; + } + public String getInstitute() { return institute; } @@ -64,6 +77,14 @@ public String getRegistrationKey() { return registrationKey; } + public AccountRequestStatus getStatus() { + return status; + } + + public String getComments() { + return comments; + } + public Long getRegisteredAt() { return registeredAt; } diff --git a/src/main/java/teammates/ui/request/AccountCreateRequest.java b/src/main/java/teammates/ui/request/AccountCreateRequest.java index f3097ce1152..9e39b524549 100644 --- a/src/main/java/teammates/ui/request/AccountCreateRequest.java +++ b/src/main/java/teammates/ui/request/AccountCreateRequest.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.List; +import javax.annotation.Nullable; + import teammates.common.util.FieldValidator; import teammates.common.util.StringHelper; @@ -14,6 +16,8 @@ public class AccountCreateRequest extends BasicRequest { private String instructorEmail; private String instructorName; private String instructorInstitution; + @Nullable + private String instructorComments; public String getInstructorEmail() { return instructorEmail; @@ -27,6 +31,10 @@ public String getInstructorInstitution() { return this.instructorInstitution; } + public String getInstructorComments() { + return this.instructorComments; + } + public void setInstructorName(String name) { this.instructorName = name; } @@ -39,6 +47,10 @@ public void setInstructorEmail(String instructorEmail) { this.instructorEmail = instructorEmail; } + public void setInstructorComments(String instructorComments) { + this.instructorComments = instructorComments; + } + @Override public void validate() throws InvalidHttpRequestBodyException { assertTrue(this.instructorEmail != null, "email cannot be null"); diff --git a/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java index b6243a0016f..34a450f3b65 100644 --- a/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java @@ -4,14 +4,24 @@ import teammates.common.exception.InvalidParametersException; import teammates.common.util.EmailWrapper; import teammates.storage.sqlentity.AccountRequest; -import teammates.ui.output.JoinLinkData; +import teammates.ui.output.AccountRequestData; import teammates.ui.request.AccountCreateRequest; import teammates.ui.request.InvalidHttpRequestBodyException; /** * Creates a new account request. */ -public class CreateAccountRequestAction extends AdminOnlyAction { +public class CreateAccountRequestAction extends Action { + + @Override + AuthType getMinAuthLevel() { + return AuthType.PUBLIC; + } + + @Override + void checkSpecificAccessControl() throws UnauthorizedAccessException { + // Nothing needs to be done here because anybody should be able to create an account request. + } @Override public JsonResult execute() @@ -21,9 +31,10 @@ public JsonResult execute() String instructorName = createRequest.getInstructorName().trim(); String instructorEmail = createRequest.getInstructorEmail().trim(); String instructorInstitution = createRequest.getInstructorInstitution().trim(); - // TODO: This is a placeholder. It should be obtained from AccountCreateRequest, in a separate PR. - String comments = "PLACEHOLDER"; - + String comments = createRequest.getInstructorComments(); + if (comments != null) { + comments = comments.trim(); + } AccountRequest accountRequest; try { @@ -35,19 +46,9 @@ public JsonResult execute() } assert accountRequest != null; - - if (accountRequest.getRegisteredAt() != null) { - throw new InvalidOperationException("Cannot create account request as instructor has already registered."); - } - - String joinLink = accountRequest.getRegistrationUrl(); - - EmailWrapper email = emailGenerator.generateNewInstructorAccountJoinEmail( - instructorEmail, instructorName, joinLink); - emailSender.sendEmail(email); EmailWrapper adminAlertEmail = sqlEmailGenerator.generateNewAccountRequestAdminAlertEmail(accountRequest); emailSender.sendEmail(adminAlertEmail); - JoinLinkData output = new JoinLinkData(joinLink); + AccountRequestData output = new AccountRequestData(accountRequest); return new JsonResult(output); } diff --git a/src/test/java/teammates/logic/core/AccountRequestsLogicTest.java b/src/test/java/teammates/logic/core/AccountRequestsLogicTest.java index 88cfd8430b3..24a66afbe6d 100644 --- a/src/test/java/teammates/logic/core/AccountRequestsLogicTest.java +++ b/src/test/java/teammates/logic/core/AccountRequestsLogicTest.java @@ -9,6 +9,7 @@ import teammates.common.exception.InvalidParametersException; import teammates.common.util.Const; import teammates.common.util.FieldValidator; +import teammates.storage.entity.AccountRequest; import teammates.test.AssertHelper; /** @@ -108,7 +109,10 @@ public void testUpdateAccountRequest() throws Exception { @Test public void testDeleteAccountRequest() throws Exception { - AccountRequestAttributes a = dataBundle.accountRequests.get("unregisteredInstructor1"); + // This ensures the AccountRequestAttributes has the correct ID. + AccountRequestAttributes accountRequestAttributes = dataBundle.accountRequests.get("unregisteredInstructor1"); + AccountRequest accountRequest = accountRequestAttributes.toEntity(); + AccountRequestAttributes a = AccountRequestAttributes.valueOf(accountRequest); ______TS("silent deletion of non-existent account request"); diff --git a/src/test/java/teammates/ui/webapi/CreateAccountRequestActionTest.java b/src/test/java/teammates/ui/webapi/CreateAccountRequestActionTest.java index 1fb58f95c6b..2e1cfa96b05 100644 --- a/src/test/java/teammates/ui/webapi/CreateAccountRequestActionTest.java +++ b/src/test/java/teammates/ui/webapi/CreateAccountRequestActionTest.java @@ -4,9 +4,7 @@ import teammates.common.datatransfer.attributes.AccountRequestAttributes; import teammates.common.util.Const; -import teammates.common.util.EmailType; -import teammates.common.util.EmailWrapper; -import teammates.ui.output.JoinLinkData; +import teammates.ui.output.AccountRequestData; import teammates.ui.request.AccountCreateRequest; import teammates.ui.request.InvalidHttpRequestBodyException; @@ -64,45 +62,23 @@ protected void testExecute() { assertEquals(institute, accountRequestAttributes.getInstitute()); assertNotNull(accountRequestAttributes.getRegistrationKey()); - String joinLink = accountRequestAttributes.getRegistrationUrl(); - JoinLinkData output = (JoinLinkData) r.getOutput(); - assertEquals(joinLink, output.getJoinLink()); + String registrationKey = accountRequestAttributes.getRegistrationKey(); + AccountRequestData output = (AccountRequestData) r.getOutput(); + assertEquals(registrationKey, output.getRegistrationKey()); - verifyNumberOfEmailsSent(1); + verifyNoEmailsSent(); verifySpecifiedTasksAdded(Const.TaskQueue.SEARCH_INDEXING_QUEUE_NAME, 1); - EmailWrapper emailSent = mockEmailSender.getEmailsSent().get(0); - assertEquals(String.format(EmailType.NEW_INSTRUCTOR_ACCOUNT.getSubject(), name), - emailSent.getSubject()); - assertEquals(email, emailSent.getRecipient()); - assertTrue(emailSent.getContent().contains(joinLink)); - - ______TS("Account request already exists: instructor unregistered, email sent again"); + ______TS("Account request already exists: instructor unregistered"); a = getAction(req); r = getJsonResult(a); - output = (JoinLinkData) r.getOutput(); - assertEquals(joinLink, output.getJoinLink()); + output = (AccountRequestData) r.getOutput(); + assertEquals(registrationKey, output.getRegistrationKey()); - verifyNumberOfEmailsSent(1); + verifyNoEmailsSent(); verifyNoTasksAdded(); // Account request not added to search indexing queue - emailSent = mockEmailSender.getEmailsSent().get(0); - assertEquals(String.format(EmailType.NEW_INSTRUCTOR_ACCOUNT.getSubject(), name), - emailSent.getSubject()); - assertEquals(email, emailSent.getRecipient()); - assertTrue(emailSent.getContent().contains(joinLink)); - - ______TS("Account request already exists: instructor registered, InvalidOperationException thrown"); - - accountRequestAttributes = typicalBundle.accountRequests.get("instructor1OfCourse1"); - - req = buildCreateRequest(accountRequestAttributes.getName(), - accountRequestAttributes.getInstitute(), accountRequestAttributes.getEmail()); - - InvalidOperationException ioe = verifyInvalidOperation(req); - assertEquals("Cannot create account request as instructor has already registered.", ioe.getMessage()); - ______TS("Error: invalid parameter"); String invalidName = "James%20Bond99"; diff --git a/src/web/app/pages-admin/admin-home-page/admin-home-page.component.spec.ts b/src/web/app/pages-admin/admin-home-page/admin-home-page.component.spec.ts index e512fa76819..3b25ac9d1a4 100644 --- a/src/web/app/pages-admin/admin-home-page/admin-home-page.component.spec.ts +++ b/src/web/app/pages-admin/admin-home-page/admin-home-page.component.spec.ts @@ -12,6 +12,7 @@ import { LinkService } from '../../../services/link.service'; import { SimpleModalService } from '../../../services/simple-modal.service'; import { StatusMessageService } from '../../../services/status-message.service'; import { createMockNgbModalRef } from '../../../test-helpers/mock-ngb-modal-ref'; +import { AccountRequestStatus } from '../../../types/api-output'; import { AjaxLoadingModule } from '../../components/ajax-loading/ajax-loading.module'; import { LoadingSpinnerModule } from '../../components/loading-spinner/loading-spinner.module'; @@ -186,15 +187,24 @@ describe('AdminHomePageComponent', () => { }, ]; jest.spyOn(accountService, 'createAccountRequest').mockReturnValue(of({ - joinLink: 'http://localhost:4200/web/join', + id: 'some.person@example.com%NUS', + email: 'some.person@example.com', + name: 'Some Person', + institute: 'NUS', + status: AccountRequestStatus.APPROVED, + registrationKey: 'registrationKey', + createdAt: 528, })); + jest.spyOn(linkService, 'generateAccountRegistrationLink') + .mockReturnValue('http://localhost:4200/web/join?iscreatingaccount=true&key=registrationKey'); fixture.detectChanges(); const index: number = 0; component.addInstructor(index); expect(component.instructorsConsolidated[index].status).toEqual('SUCCESS'); - expect(component.instructorsConsolidated[index].joinLink).toEqual('http://localhost:4200/web/join'); + expect(component.instructorsConsolidated[index].joinLink) + .toEqual('http://localhost:4200/web/join?iscreatingaccount=true&key=registrationKey'); expect(component.activeRequests).toEqual(0); }); diff --git a/src/web/app/pages-admin/admin-home-page/admin-home-page.component.ts b/src/web/app/pages-admin/admin-home-page/admin-home-page.component.ts index 1e29bd2bdb8..e6544a0e7b7 100644 --- a/src/web/app/pages-admin/admin-home-page/admin-home-page.component.ts +++ b/src/web/app/pages-admin/admin-home-page/admin-home-page.component.ts @@ -8,7 +8,7 @@ import { CourseService } from '../../../services/course.service'; import { LinkService } from '../../../services/link.service'; import { SimpleModalService } from '../../../services/simple-modal.service'; import { StatusMessageService } from '../../../services/status-message.service'; -import { Account, Accounts, Courses, JoinLink } from '../../../types/api-output'; +import { Account, AccountRequest, Accounts, Courses, JoinLink } from '../../../types/api-output'; import { SimpleModalType } from '../../components/simple-modal/simple-modal-type'; import { ErrorMessageOutput } from '../../error-message-output'; @@ -117,10 +117,10 @@ export class AdminHomePageComponent { this.isAddingInstructors = false; })) .subscribe({ - next: (resp: JoinLink) => { + next: (resp: AccountRequest) => { instructor.status = 'SUCCESS'; instructor.statusCode = 200; - instructor.joinLink = resp.joinLink; + instructor.joinLink = this.linkService.generateAccountRegistrationLink(resp.registrationKey); this.activeRequests -= 1; }, error: (resp: ErrorMessageOutput) => { diff --git a/src/web/services/account.service.ts b/src/web/services/account.service.ts index f278335db55..914a825a21a 100644 --- a/src/web/services/account.service.ts +++ b/src/web/services/account.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { HttpRequestService } from './http-request.service'; import { ResourceEndpoints } from '../types/api-const'; -import { Account, Accounts, JoinLink, MessageOutput } from '../types/api-output'; +import { Account, AccountRequest, Accounts, JoinLink, MessageOutput } from '../types/api-output'; import { AccountCreateRequest } from '../types/api-request'; /** @@ -29,7 +29,7 @@ export class AccountService { /** * Creates an account request by calling API. */ - createAccountRequest(request: AccountCreateRequest): Observable { + createAccountRequest(request: AccountCreateRequest): Observable { return this.httpRequestService.post(ResourceEndpoints.ACCOUNT_REQUEST, {}, request); } diff --git a/src/web/services/search.service.spec.ts b/src/web/services/search.service.spec.ts index affbf259b4a..e548b693cff 100644 --- a/src/web/services/search.service.spec.ts +++ b/src/web/services/search.service.spec.ts @@ -13,6 +13,7 @@ import createSpyFromClass from '../test-helpers/create-spy-from-class'; import { ResourceEndpoints } from '../types/api-const'; import { AccountRequest, + AccountRequestStatus, Course, FeedbackSession, FeedbackSessionPublishStatus, @@ -184,11 +185,13 @@ describe('SearchService', () => { }; const mockAccountRequest: AccountRequest = { + id: 'test@example.com%Test Institute', registrationKey: 'regkey', createdAt: 1585487897502, name: 'Test Instructor', institute: 'Test Institute', email: 'test@example.com', + status: AccountRequestStatus.APPROVED, }; beforeEach(() => { @@ -294,7 +297,11 @@ describe('SearchService', () => { it('should join account requests accurately when timezone can be guessed and instructor is registered', () => { jest.spyOn(timezoneService, 'guessTimezone').mockReturnValue('Asia/Singapore'); - const accountRequest: AccountRequest = { ...mockAccountRequest, registeredAt: 1685487897502 }; + const accountRequest: AccountRequest = { + ...mockAccountRequest, + registeredAt: 1685487897502, + status: AccountRequestStatus.REGISTERED, + }; const result: AccountRequestSearchResult = service.joinAdminAccountRequest(accountRequest); expect(result.email).toBe('test@example.com'); From f7eaa617f1e129a828bc4eb5b3f2776629ef9a3f Mon Sep 17 00:00:00 2001 From: Xenos F Date: Wed, 27 Mar 2024 11:30:59 +0800 Subject: [PATCH 08/95] [#11878] Upgrade instructor request form UI (#12929) * Add confirmation prompt * Remove old form iframe * Improve declaration view spacing * Edit page heading phrasing for clarity * Create request form * Add validation messages * Fix form validation * Set up form submission confirmation * Create submission acknowledgement view * Fix URL checking regex * Fix initial state * Display placeholder when optional field is empty * Fix code style * Edit comment for clarity * Fix institution and country combination Co-authored-by: Jay Aljelo Ting <65202977+jayasting98@users.noreply.github.com> * Fix naming * Remove hard line break * Add explanatory comment for regex * Remove newline * Add newlines at end of file * Clear styles file * Re-add styles file * Include test * Add test cases for requestSubmissionEvent * Improve test case readability * Edit test case name for clarity * Add snapshot tests * Revert "Add snapshot tests" This reverts commit ec7395d4e8bd0d956f3c355b9906ec0ab3ae0f58. * Fix lint errors * Rename methods to be clearer * Disable submit button when not ready to submit --------- Co-authored-by: Jay Aljelo Ting <65202977+jayasting98@users.noreply.github.com> --- .../instructor-request-form-model.ts | 8 ++ .../instructor-request-form.component.html | 92 +++++++++++++++++ .../instructor-request-form.component.scss | 22 +++++ .../instructor-request-form.component.spec.ts | 76 ++++++++++++++ .../instructor-request-form.component.ts | 98 +++++++++++++++++++ .../request-page/request-page.component.html | 74 +++++++++++--- .../request-page/request-page.component.scss | 5 + .../request-page/request-page.component.ts | 10 ++ .../request-page/request-page.module.ts | 6 ++ 9 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form-model.ts create mode 100644 src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.html create mode 100644 src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.scss create mode 100644 src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.spec.ts create mode 100644 src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.ts diff --git a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form-model.ts b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form-model.ts new file mode 100644 index 00000000000..5b42bddc792 --- /dev/null +++ b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form-model.ts @@ -0,0 +1,8 @@ +export type InstructorRequestFormModel = { + name: string, + institution: string, + country: string, + email: string, + homePage: string, + comments: string, +}; diff --git a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.html b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.html new file mode 100644 index 00000000000..a3aa5af576d --- /dev/null +++ b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.html @@ -0,0 +1,92 @@ + +
+
+ +

+ This is the name that will be shown to your students. You may include salutation (Dr. Prof. etc.) +

+ + +
+
+
+ +

+ Please give full name of the university/institution. +

+ + +
+
+
+ +

+ Which country is your university/institution based in? +

+ + +
+
+
+ +

+ Please use the email address given to you by your school/university + (not your personal Gmail/Hotmail address). + Note that this email address will be visible to the students you enroll in TEAMMATES. +

+ + +
+
+
+ + + +
+
+
+ + +
+
+ +
diff --git a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.scss b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.scss new file mode 100644 index 00000000000..addb0b11c7a --- /dev/null +++ b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.scss @@ -0,0 +1,22 @@ +label.qn { + font-weight: bold; + font-size: 1rem; + margin-bottom: 0.3rem; +} + +.form-group { + margin-bottom: 0.5rem; +} + +.form-group.required > label::after { + content:"*"; + color: red; +} + +.help-block { + margin-bottom: 0.8rem; +} + +.red-font { + color: red; +} diff --git a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.spec.ts b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.spec.ts new file mode 100644 index 00000000000..9d56de840b9 --- /dev/null +++ b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { first } from 'rxjs'; +import { InstructorRequestFormModel } from './instructor-request-form-model'; +import { InstructorRequestFormComponent } from './instructor-request-form.component'; + +describe('InstructorRequestFormComponent', () => { + let component: InstructorRequestFormComponent; + let fixture: ComponentFixture; + const typicalModel: InstructorRequestFormModel = { + name: 'John Doe', + institution: 'Example Institution', + country: 'Example Country', + email: 'jd@example.edu', + homePage: 'xyz.example.edu/john', + comments: '', + }; + + /** + * Fills in form fields with the given data. + * + * @param data Data to fill form with. + */ + function fillFormWith(data: InstructorRequestFormModel): void { + component.name.setValue(data.name); + component.institution.setValue(data.institution); + component.country.setValue(data.country); + component.email.setValue(data.email); + component.homePage.setValue(data.homePage); + component.comments.setValue(data.comments); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [InstructorRequestFormComponent], + imports: [ReactiveFormsModule], + }); + fixture = TestBed.createComponent(InstructorRequestFormComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should emit requestSubmissionEvent once when submit button is clicked', () => { + jest.spyOn(component.requestSubmissionEvent, 'emit'); + + fillFormWith(typicalModel); + const submitButton = fixture.debugElement.query(By.css('#submit-button')); + submitButton.nativeElement.click(); + + expect(component.requestSubmissionEvent.emit).toHaveBeenCalledTimes(1); + }); + + it('should emit requestSubmissionEvent with the correct data when form is submitted', () => { + // Listen for emitted value + let actualModel: InstructorRequestFormModel | null = null; + component.requestSubmissionEvent.pipe(first()) + .subscribe((data: InstructorRequestFormModel) => { actualModel = data; }); + + fillFormWith(typicalModel); + component.onSubmit(); + + expect(actualModel).toBeTruthy(); + expect(actualModel!.name).toBe(typicalModel.name); + expect(actualModel!.institution).toBe(typicalModel.institution); + expect(actualModel!.country).toBe(typicalModel.country); + expect(actualModel!.email).toBe(typicalModel.email); + expect(actualModel!.homePage).toBe(typicalModel.homePage); + expect(actualModel!.comments).toBe(typicalModel.comments); + }); +}); diff --git a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.ts b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.ts new file mode 100644 index 00000000000..fff881cc607 --- /dev/null +++ b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.ts @@ -0,0 +1,98 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { InstructorRequestFormModel } from './instructor-request-form-model'; + +// Use regex to validate URL field as Angular does not have a built-in URL validator +// eslint-disable-next-line +const URL_REGEX = /(https?:\/\/)?(www\.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)|(https?:\/\/)?(www\.)?(?!ww)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/; + +@Component({ + selector: 'tm-instructor-request-form', + templateUrl: './instructor-request-form.component.html', + styleUrls: ['./instructor-request-form.component.scss'], +}) +export class InstructorRequestFormComponent { + + arf = new FormGroup({ + name: new FormControl('', [Validators.required]), + institution: new FormControl('', [Validators.required]), + country: new FormControl('', [Validators.required]), + email: new FormControl('', [Validators.required, Validators.email]), + homePage: new FormControl('', [Validators.pattern(URL_REGEX)]), + comments: new FormControl(''), + }, { updateOn: 'submit' }); + + // Create members for easier access of arf controls + name = this.arf.controls.name; + institution = this.arf.controls.institution; + country = this.arf.controls.country; + email = this.arf.controls.email; + homePage = this.arf.controls.homePage; + comments = this.arf.controls.comments; + + hasSubmitAttempt = false; + + @Output() requestSubmissionEvent = new EventEmitter(); + + checkIsFieldRequired(field: FormControl): boolean { + return field.hasValidator(Validators.required); + } + + checkIsFieldInvalid(field: FormControl): boolean { + return field.invalid; + } + + checkCanSubmit(): boolean { + return true; // TODO: API integration + } + + getFieldValidationClasses(field: FormControl): string { + let str = ''; + if (this.hasSubmitAttempt) { + if (field.invalid) { + str = 'is-invalid'; + } else if (field.value !== '') { + str = 'is-valid'; + } + } + return str; + } + + onSubmit(): void { + this.hasSubmitAttempt = true; + + if (this.arf.invalid) { + // Do not submit form + return; + } + + const name = this.name.value!.trim(); + const email = this.email.value!.trim(); + const country = this.country.value!.trim(); + const institution = this.institution.value!.trim(); + const combinedInstitution = `${institution}, ${country}`; + const homePage = this.homePage.value!; + const comments = this.comments.value!.trim(); + + const submittedData = { + name, + email, + institution: combinedInstitution, + homePage, + comments, + }; + // TODO: connect to API + // eslint-disable-next-line + submittedData; // PLACEHOLDER + + // Pass form input to parent to display confirmation + this.requestSubmissionEvent.emit({ + name, + institution, + country, + email, + homePage, + comments, + }); + } +} diff --git a/src/web/app/pages-static/request-page/request-page.component.html b/src/web/app/pages-static/request-page/request-page.component.html index 6314f2563bd..315ecb5b534 100644 --- a/src/web/app/pages-static/request-page/request-page.component.html +++ b/src/web/app/pages-static/request-page/request-page.component.html @@ -1,14 +1,66 @@

- Request for an Account + Request for an Instructor Account

-
-

- Cannot see the request form below? Click here. -

- -
-
- The URL for the account request form is not set. +
+
+

+ Request for an instructor account using this form if you are an instructor and want to use TEAMMATES to manage peer evaluations and/or other feedback paths of your students. +

+
+
+

+ Note: Students should not use this form to request for TEAMMATES accounts, as students do not need accounts to use TEAMMATES. Instead, TEAMMATES will email students (who have been added to TEAMMATES by a course instructor) an access link when there is a TEAMMATES session available for them to access. +

+ Back to home page + +
+
+ +
+
+
+
+

+ Your request has been submitted successfully: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Full Name{{submittedFormData.name}}
Institution{{submittedFormData.institution}}
Country{{submittedFormData.country}}
Email{{submittedFormData.email}}
Home Page URL + {{submittedFormData.homePage}} + +
Comments + {{submittedFormData.comments}} + +
+

+ We have sent an acknowledgement email to your email address {{submittedFormData.email}}. + Please check your email inbox or spam folder. + If you do not receive the acknowledgement email within 1 hour, please contact us. +

+
diff --git a/src/web/app/pages-static/request-page/request-page.component.scss b/src/web/app/pages-static/request-page/request-page.component.scss index e69de29bb2d..3d1f1c83751 100644 --- a/src/web/app/pages-static/request-page/request-page.component.scss +++ b/src/web/app/pages-static/request-page/request-page.component.scss @@ -0,0 +1,5 @@ +.empty-field-placeholder::after { + content: "(empty)"; + opacity: 0.5; + font-style: italic; +} diff --git a/src/web/app/pages-static/request-page/request-page.component.ts b/src/web/app/pages-static/request-page/request-page.component.ts index 6cb307baa4b..7f0c2f15524 100644 --- a/src/web/app/pages-static/request-page/request-page.component.ts +++ b/src/web/app/pages-static/request-page/request-page.component.ts @@ -1,5 +1,6 @@ import { Component } from '@angular/core'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { InstructorRequestFormModel } from './instructor-request-form/instructor-request-form-model'; import { environment } from '../../../environments/environment'; /** @@ -13,6 +14,8 @@ import { environment } from '../../../environments/environment'; export class RequestPageComponent { accountRequestFormUrl: SafeResourceUrl | null; + isDeclarationDone: boolean = false; + submittedFormData: InstructorRequestFormModel | null = null; constructor(private sanitizer: DomSanitizer) { this.accountRequestFormUrl = environment.accountRequestFormUrl @@ -20,4 +23,11 @@ export class RequestPageComponent { : null; } + onDeclarationButtonClicked(): void { + this.isDeclarationDone = true; + } + + onRequestSubmitted(data: InstructorRequestFormModel): void { + this.submittedFormData = data; + } } diff --git a/src/web/app/pages-static/request-page/request-page.module.ts b/src/web/app/pages-static/request-page/request-page.module.ts index 9c16ee6fc4b..7333207fc0b 100644 --- a/src/web/app/pages-static/request-page/request-page.module.ts +++ b/src/web/app/pages-static/request-page/request-page.module.ts @@ -1,7 +1,10 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; import { RouterModule, Routes } from '@angular/router'; +import { InstructorRequestFormComponent } from './instructor-request-form/instructor-request-form.component'; import { RequestPageComponent } from './request-page.component'; +import { TeammatesRouterModule } from '../../components/teammates-router/teammates-router.module'; const routes: Routes = [ { @@ -16,6 +19,7 @@ const routes: Routes = [ @NgModule({ declarations: [ RequestPageComponent, + InstructorRequestFormComponent, ], exports: [ RequestPageComponent, @@ -23,6 +27,8 @@ const routes: Routes = [ imports: [ CommonModule, RouterModule.forChild(routes), + TeammatesRouterModule, + ReactiveFormsModule, ], }) export class RequestPageModule { } From 40613dff7486e2224c1375a218f55f442279a4e9 Mon Sep 17 00:00:00 2001 From: domoberzin <74132255+domoberzin@users.noreply.github.com> Date: Wed, 27 Mar 2024 19:23:23 +0800 Subject: [PATCH 09/95] [#11878] Update Admin Home Page UI for ARF (#12933) * create component for account request table * cherry pick admin home page changes * remove testing code * fix lint and css issues * fix admin home page snaps * update admin home snaps * remove edit approve and reject components * modify css * delete edit and reject modal components * revert spec file changes * integrate new types * fix lint * use enum for status * fix lint * fix css lint * fix lint * fix lint * use enum and remove infinite scroll * remove approve account request code * remove extra div * fix url * modify comments * revert extra formatting * remove plural form and use date pipe * fix naming * fix spec file and update institute formatting * fix lint * combine institute and country columns --- .../account-request-table-model.ts | 14 +++ .../account-request-table.component.html | 83 ++++++++++++++ .../account-request-table.component.scss | 63 +++++++++++ .../account-request-table.component.ts | 105 ++++++++++++++++++ .../account-request-table.module.ts | 26 +++++ .../admin-home-page.component.spec.ts.snap | 18 +++ .../admin-home-page.component.html | 1 + .../admin-home-page.component.spec.ts | 4 + .../admin-home-page.component.ts | 48 +++++++- .../admin-home-page/admin-home-page.module.ts | 6 + .../admin-search-page.component.spec.ts | 15 ++- src/web/services/account.service.ts | 21 +++- src/web/services/search.service.spec.ts | 1 + src/web/services/search.service.ts | 12 +- 14 files changed, 408 insertions(+), 9 deletions(-) create mode 100644 src/web/app/components/account-requests-table/account-request-table-model.ts create mode 100644 src/web/app/components/account-requests-table/account-request-table.component.html create mode 100644 src/web/app/components/account-requests-table/account-request-table.component.scss create mode 100755 src/web/app/components/account-requests-table/account-request-table.component.ts create mode 100644 src/web/app/components/account-requests-table/account-request-table.module.ts diff --git a/src/web/app/components/account-requests-table/account-request-table-model.ts b/src/web/app/components/account-requests-table/account-request-table-model.ts new file mode 100644 index 00000000000..ef8b8732f0b --- /dev/null +++ b/src/web/app/components/account-requests-table/account-request-table-model.ts @@ -0,0 +1,14 @@ +/** + * Model for the row entries in the account requests table. + */ +export interface AccountRequestTableRowModel { + name: string; + email: string; + status: string; + instituteAndCountry: string; + createdAtText: string; + registeredAtText: string; + comments: string; + registrationLink: string; + showLinks: boolean; +} diff --git a/src/web/app/components/account-requests-table/account-request-table.component.html b/src/web/app/components/account-requests-table/account-request-table.component.html new file mode 100644 index 00000000000..7dcb95031c7 --- /dev/null +++ b/src/web/app/components/account-requests-table/account-request-table.component.html @@ -0,0 +1,83 @@ +
+
+
+ Account Requests Found +
+ + Pending Account Requests + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailStatusInstitute, CountryCreated AtRegistered AtCommentsOptions
+
+
+ + +
+
{{ accountRequest.email }}{{ accountRequest.status }}{{ accountRequest.instituteAndCountry }}{{ accountRequest.createdAtText }}{{ accountRequest.registeredAtText || 'Not Registered Yet' }} +
+ {{ accountRequest.comments }} +
+
+
+
+ + + +
+
+ + + +
+
+ +
+
+
+
    +
  • + Account Registration Link + +
  • +
+
+
diff --git a/src/web/app/components/account-requests-table/account-request-table.component.scss b/src/web/app/components/account-requests-table/account-request-table.component.scss new file mode 100644 index 00000000000..ce0f5d400b3 --- /dev/null +++ b/src/web/app/components/account-requests-table/account-request-table.component.scss @@ -0,0 +1,63 @@ +::ng-deep .highlighted-text { + background-color: yellow; +} + +.table-responsive { + overflow: -moz-scrollbars-horizontal; +} + +.table-responsive > table > thead > tr > th { + white-space: nowrap; +} + +/* stylelint-disable property-no-vendor-prefix */ +::-webkit-scrollbar { + -webkit-appearance: none; + width: 1px; +} + +::-webkit-scrollbar-thumb { + border-radius: 0; + background-color: rgb(0 0 0 / 50%); + box-shadow: 0 0 1px rgb(255 255 255 / 50%); +} + + +#search-table-account-request { + border-collapse: collapse; +} + + +#search-table-account-request th:last-child, +#search-table-account-request td:last-child { + min-width: 10vw; + position: sticky; + right: 0; + z-index: 1; + background-color: #F8F9FA; +} + +#search-table-account-request th:last-child::after, +#search-table-account-request td:last-child::after { + content: ""; + position: absolute; + left: -1px; + top: 0; + bottom: 0; + width: 1px; + background: #c8c7c7; + z-index: 1; +} + +#comment-box { + min-height: 5vh; + width: max(800px, 35vw); + max-width: max-content; + word-break: break-word; + word-wrap: break-all; + +} + +.dropdown-item { + border: none; +} diff --git a/src/web/app/components/account-requests-table/account-request-table.component.ts b/src/web/app/components/account-requests-table/account-request-table.component.ts new file mode 100755 index 00000000000..dc3ef132795 --- /dev/null +++ b/src/web/app/components/account-requests-table/account-request-table.component.ts @@ -0,0 +1,105 @@ +import { Component, Input } from '@angular/core'; +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { AccountRequestTableRowModel } from './account-request-table-model'; +import { AccountService } from '../../../services/account.service'; +import { SimpleModalService } from '../../../services/simple-modal.service'; +import { StatusMessageService } from '../../../services/status-message.service'; +import { MessageOutput } from '../../../types/api-output'; +import { ErrorMessageOutput } from '../../error-message-output'; +import { SimpleModalType } from '../simple-modal/simple-modal-type'; +import { collapseAnim } from '../teammates-common/collapse-anim'; + +/** + * Account requests table component. + */ +@Component({ + selector: 'tm-account-request-table', + templateUrl: './account-request-table.component.html', + styleUrls: ['./account-request-table.component.scss'], + animations: [collapseAnim], +}) + +export class AccountRequestTableComponent { + + @Input() + accountRequests: AccountRequestTableRowModel[] = []; + + @Input() + searchString = ''; + + constructor( + private statusMessageService: StatusMessageService, + private simpleModalService: SimpleModalService, + private accountService: AccountService, + ) {} + + /** + * Shows all account requests' links in the page. + */ + showAllAccountRequestsLinks(): void { + for (const accountRequest of this.accountRequests) { + accountRequest.showLinks = true; + } + } + + /** + * Hides all account requests' links in the page. + */ + hideAllAccountRequestsLinks(): void { + for (const accountRequest of this.accountRequests) { + accountRequest.showLinks = false; + } + } + + resetAccountRequest(accountRequest: AccountRequestTableRowModel): void { + const modalContent = `Are you sure you want to reset the account request for + ${accountRequest.name} with email ${accountRequest.email} from + ${accountRequest.instituteAndCountry}? + An email with the account registration link will also be sent to the instructor.`; + const modalRef: NgbModalRef = this.simpleModalService.openConfirmationModal( + `Reset account request for ${accountRequest.name}?`, SimpleModalType.WARNING, modalContent); + + modalRef.result.then(() => { + this.accountService.resetAccountRequest(accountRequest.email, accountRequest.instituteAndCountry) + .subscribe({ + next: () => { + this.statusMessageService + .showSuccessToast(`Reset successful. An email has been sent to ${accountRequest.email}.`); + accountRequest.registeredAtText = ''; + }, + error: (resp: ErrorMessageOutput) => { + this.statusMessageService.showErrorToast(resp.error.message); + }, + }); + }, () => {}); + } + + deleteAccountRequest(accountRequest: AccountRequestTableRowModel): void { + const modalContent: string = `Are you sure you want to delete the account request for + ${accountRequest.name} with email ${accountRequest.email} from + ${accountRequest.instituteAndCountry}?`; + const modalRef: NgbModalRef = this.simpleModalService.openConfirmationModal( + `Delete account request for ${accountRequest.name}?`, SimpleModalType.DANGER, modalContent); + + modalRef.result.then(() => { + this.accountService.deleteAccountRequest(accountRequest.email, accountRequest.instituteAndCountry) + .subscribe({ + next: (resp: MessageOutput) => { + this.statusMessageService.showSuccessToast(resp.message); + this.accountRequests = this.accountRequests.filter((x: AccountRequestTableRowModel) => x !== accountRequest); + }, + error: (resp: ErrorMessageOutput) => { + this.statusMessageService.showErrorToast(resp.error.message); + }, + }); + }, () => {}); + } + + viewAccountRequest(accountRequest: AccountRequestTableRowModel): void { + const modalContent: string = `Comment: ${accountRequest.comments || ''}`; + const modalRef: NgbModalRef = this.simpleModalService.openInformationModal( + `Comments for ${accountRequest.name} Request`, SimpleModalType.INFO, modalContent); + + modalRef.result.then(() => {}, () => {}); + } +} diff --git a/src/web/app/components/account-requests-table/account-request-table.module.ts b/src/web/app/components/account-requests-table/account-request-table.module.ts new file mode 100644 index 00000000000..1a05086cc4b --- /dev/null +++ b/src/web/app/components/account-requests-table/account-request-table.module.ts @@ -0,0 +1,26 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { NgbTooltipModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { AccountRequestTableComponent } from './account-request-table.component'; +import { Pipes } from '../../pipes/pipes.module'; + +/** + * Module for account requests table. + */ +@NgModule({ + declarations: [ + AccountRequestTableComponent, + ], + exports: [ + AccountRequestTableComponent, + ], + imports: [ + CommonModule, + FormsModule, + NgbTooltipModule, + NgbDropdownModule, + Pipes, + ], +}) +export class AccountRequestTableModule { } diff --git a/src/web/app/pages-admin/admin-home-page/__snapshots__/admin-home-page.component.spec.ts.snap b/src/web/app/pages-admin/admin-home-page/__snapshots__/admin-home-page.component.spec.ts.snap index db32b59235a..bc11da02df6 100644 --- a/src/web/app/pages-admin/admin-home-page/__snapshots__/admin-home-page.component.spec.ts.snap +++ b/src/web/app/pages-admin/admin-home-page/__snapshots__/admin-home-page.component.spec.ts.snap @@ -2,9 +2,12 @@ exports[`AdminHomePageComponent should snap with default view 1`] = `
Student for the following {{ account.studentCours
+ diff --git a/src/web/app/pages-admin/admin-home-page/admin-home-page.component.spec.ts b/src/web/app/pages-admin/admin-home-page/admin-home-page.component.spec.ts index 3b25ac9d1a4..00dbb94d783 100644 --- a/src/web/app/pages-admin/admin-home-page/admin-home-page.component.spec.ts +++ b/src/web/app/pages-admin/admin-home-page/admin-home-page.component.spec.ts @@ -13,8 +13,10 @@ import { SimpleModalService } from '../../../services/simple-modal.service'; import { StatusMessageService } from '../../../services/status-message.service'; import { createMockNgbModalRef } from '../../../test-helpers/mock-ngb-modal-ref'; import { AccountRequestStatus } from '../../../types/api-output'; +import { AccountRequestTableModule } from '../../components/account-requests-table/account-request-table.module'; import { AjaxLoadingModule } from '../../components/ajax-loading/ajax-loading.module'; import { LoadingSpinnerModule } from '../../components/loading-spinner/loading-spinner.module'; +import { FormatDateDetailPipe } from '../../components/teammates-common/format-date-detail.pipe'; describe('AdminHomePageComponent', () => { let component: AdminHomePageComponent; @@ -34,12 +36,14 @@ describe('AdminHomePageComponent', () => { FormsModule, HttpClientTestingModule, LoadingSpinnerModule, + AccountRequestTableModule, AjaxLoadingModule, RouterTestingModule, ], providers: [ AccountService, CourseService, + FormatDateDetailPipe, SimpleModalService, StatusMessageService, LinkService, diff --git a/src/web/app/pages-admin/admin-home-page/admin-home-page.component.ts b/src/web/app/pages-admin/admin-home-page/admin-home-page.component.ts index e6544a0e7b7..efe69122c39 100644 --- a/src/web/app/pages-admin/admin-home-page/admin-home-page.component.ts +++ b/src/web/app/pages-admin/admin-home-page/admin-home-page.component.ts @@ -1,4 +1,4 @@ -import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Component, TemplateRef, ViewChild, OnInit } from '@angular/core'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { forkJoin, Observable, of, throwError } from 'rxjs'; import { catchError, finalize, map, mergeMap } from 'rxjs/operators'; @@ -8,8 +8,11 @@ import { CourseService } from '../../../services/course.service'; import { LinkService } from '../../../services/link.service'; import { SimpleModalService } from '../../../services/simple-modal.service'; import { StatusMessageService } from '../../../services/status-message.service'; -import { Account, AccountRequest, Accounts, Courses, JoinLink } from '../../../types/api-output'; +import { TimezoneService } from '../../../services/timezone.service'; +import { Account, AccountRequest, Accounts, AccountRequests, Courses, JoinLink } from '../../../types/api-output'; +import { AccountRequestTableRowModel } from '../../components/account-requests-table/account-request-table-model'; import { SimpleModalType } from '../../components/simple-modal/simple-modal-type'; +import { FormatDateDetailPipe } from '../../components/teammates-common/format-date-detail.pipe'; import { ErrorMessageOutput } from '../../error-message-output'; /** @@ -20,7 +23,7 @@ import { ErrorMessageOutput } from '../../error-message-output'; templateUrl: './admin-home-page.component.html', styleUrls: ['./admin-home-page.component.scss'], }) -export class AdminHomePageComponent { +export class AdminHomePageComponent implements OnInit { instructorDetails: string = ''; instructorName: string = ''; @@ -28,7 +31,11 @@ export class AdminHomePageComponent { instructorInstitution: string = ''; instructorsConsolidated: InstructorData[] = []; + accountReqs: AccountRequestTableRowModel[] = []; activeRequests: number = 0; + currentPage: number = 1; + pageSize: number = 20; + items$: Observable = of([]); isAddingInstructors: boolean = false; @@ -43,10 +50,16 @@ export class AdminHomePageComponent { private courseService: CourseService, private simpleModalService: SimpleModalService, private statusMessageService: StatusMessageService, + private timezoneService: TimezoneService, private linkService: LinkService, private ngbModal: NgbModal, + private formatDateDetailPipe: FormatDateDetailPipe, ) {} + ngOnInit(): void { + this.fetchAccountRequests(); + } + /** * Validates and adds the instructor details filled with first form. */ @@ -236,6 +249,35 @@ export class AdminHomePageComponent { ); } + private formatAccountRequests(requests: AccountRequests): AccountRequestTableRowModel[] { + const timezone: string = this.timezoneService.guessTimezone() || 'UTC'; + return requests.accountRequests.map((request) => { + return { + name: request.name, + email: request.email, + status: request.status, + instituteAndCountry: request.institute, + createdAtText: this.formatDateDetailPipe.transform(request.createdAt, timezone), + registeredAtText: request.registeredAt + ? this.formatDateDetailPipe.transform(request.registeredAt, timezone) : '', + comments: request.comments || '', + registrationLink: '', + showLinks: false, + }; + }); + } + + fetchAccountRequests(): void { + this.accountService.getPendingAccountRequests().subscribe({ + next: (resp: AccountRequests) => { + this.accountReqs = this.formatAccountRequests(resp); + }, + error: (resp: ErrorMessageOutput) => { + this.statusMessageService.showErrorToast(resp.error.message); + }, + }); + } + resetAccountRequest(i: number): void { const modalContent = `Are you sure you want to reset the account request for ${this.instructorsConsolidated[i].name} with email diff --git a/src/web/app/pages-admin/admin-home-page/admin-home-page.module.ts b/src/web/app/pages-admin/admin-home-page/admin-home-page.module.ts index 163b70e981e..d336c46e5ba 100644 --- a/src/web/app/pages-admin/admin-home-page/admin-home-page.module.ts +++ b/src/web/app/pages-admin/admin-home-page/admin-home-page.module.ts @@ -4,8 +4,10 @@ import { FormsModule } from '@angular/forms'; import { RouterModule, Routes } from '@angular/router'; import { AdminHomePageComponent } from './admin-home-page.component'; import { NewInstructorDataRowComponent } from './new-instructor-data-row/new-instructor-data-row.component'; +import { AccountRequestTableModule } from '../../components/account-requests-table/account-request-table.module'; import { AjaxLoadingModule } from '../../components/ajax-loading/ajax-loading.module'; import { LoadingSpinnerModule } from '../../components/loading-spinner/loading-spinner.module'; +import { FormatDateDetailPipe } from '../../components/teammates-common/format-date-detail.pipe'; const routes: Routes = [ { @@ -29,8 +31,12 @@ const routes: Routes = [ CommonModule, FormsModule, RouterModule.forChild(routes), + AccountRequestTableModule, AjaxLoadingModule, LoadingSpinnerModule, ], + providers: [ + FormatDateDetailPipe, + ], }) export class AdminHomePageModule { } diff --git a/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts b/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts index 6b076b170bd..ba3a2abbad5 100644 --- a/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts +++ b/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts @@ -19,6 +19,7 @@ import { import { StatusMessageService } from '../../../services/status-message.service'; import { StudentService } from '../../../services/student.service'; import { createMockNgbModalRef } from '../../../test-helpers/mock-ngb-modal-ref'; +import { AccountRequestStatus } from '../../../types/api-output'; const DEFAULT_FEEDBACK_SESSION_GROUP: FeedbackSessionsGroup = { sessionName: { @@ -72,10 +73,12 @@ const DEFAULT_ACCOUNT_REQUEST_SEARCH_RESULT: AccountRequestSearchResult = { name: 'name', email: 'email', institute: 'institute', + status: AccountRequestStatus.PENDING, registrationLink: 'registrationLink', createdAtText: 'Tue, 08 Feb 2022, 08:23 AM +00:00', registeredAtText: null, showLinks: false, + comments: '', }; describe('AdminSearchPageComponent', () => { @@ -239,10 +242,12 @@ describe('AdminSearchPageComponent', () => { name: 'name', email: 'email', institute: 'institute', + status: AccountRequestStatus.PENDING, registrationLink: 'registrationLink', createdAtText: 'Tue, 08 Feb 2022, 08:23 AM +00:00', registeredAtText: null, showLinks: true, + comments: '', }, ]; @@ -409,18 +414,22 @@ describe('AdminSearchPageComponent', () => { name: 'name1', email: 'email1', institute: 'institute1', + status: AccountRequestStatus.PENDING, registrationLink: 'registrationLink1', createdAtText: 'Tue, 08 Feb 2022, 08:23 AM +00:00', - registeredAtText: null, - showLinks: true, + registeredAtText: '', + showLinks: false, + comments: '', }, { name: 'name2', email: 'email2', institute: 'institute2', + status: AccountRequestStatus.PENDING, registrationLink: 'registrationLink2', createdAtText: 'Tue, 08 Feb 2022, 08:23 AM +00:00', registeredAtText: 'Wed, 09 Feb 2022, 10:23 AM +00:00', - showLinks: true, + showLinks: false, + comments: '', }]; jest.spyOn(searchService, 'searchAdmin').mockReturnValue(of({ diff --git a/src/web/services/account.service.ts b/src/web/services/account.service.ts index 914a825a21a..8877da6ec29 100644 --- a/src/web/services/account.service.ts +++ b/src/web/services/account.service.ts @@ -2,7 +2,15 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { HttpRequestService } from './http-request.service'; import { ResourceEndpoints } from '../types/api-const'; -import { Account, AccountRequest, Accounts, JoinLink, MessageOutput } from '../types/api-output'; +import { + Account, + AccountRequest, + Accounts, + AccountRequests, + JoinLink, + MessageOutput, + AccountRequestStatus, +} from '../types/api-output'; import { AccountCreateRequest } from '../types/api-request'; /** @@ -107,4 +115,15 @@ export class AccountService { return this.httpRequestService.get(ResourceEndpoints.ACCOUNTS, paramMap); } + /** + * Gets account requests by calling API. + */ + getPendingAccountRequests(): Observable { + const paramMap = { + status: AccountRequestStatus.PENDING, + }; + + return this.httpRequestService.get(ResourceEndpoints.ACCOUNT_REQUESTS, paramMap); + } + } diff --git a/src/web/services/search.service.spec.ts b/src/web/services/search.service.spec.ts index e548b693cff..a58e525e2a2 100644 --- a/src/web/services/search.service.spec.ts +++ b/src/web/services/search.service.spec.ts @@ -191,6 +191,7 @@ describe('SearchService', () => { name: 'Test Instructor', institute: 'Test Institute', email: 'test@example.com', + comments: 'This is a test account request', status: AccountRequestStatus.APPROVED, }; diff --git a/src/web/services/search.service.ts b/src/web/services/search.service.ts index 7542da645cc..5d4dfdc0d53 100644 --- a/src/web/services/search.service.ts +++ b/src/web/services/search.service.ts @@ -306,16 +306,22 @@ export class SearchService { registeredAtText: '', registrationLink: '', showLinks: false, + status: '', + comments: '', }; - const { registrationKey, createdAt, registeredAt, name, institute, email }: AccountRequest = accountRequest; + const { + registrationKey, createdAt, registeredAt, + name, institute, email, status, comments, + }: AccountRequest = accountRequest; const timezone: string = this.timezoneService.guessTimezone() || 'UTC'; accountRequestResult.createdAtText = this.formatTimestampAsString(createdAt, timezone); accountRequestResult.registeredAtText = registeredAt ? this.formatTimestampAsString(registeredAt, timezone) : null; + accountRequestResult.comments = comments || ''; const registrationLink: string = this.linkService.generateAccountRegistrationLink(registrationKey); - accountRequestResult = { ...accountRequestResult, name, email, institute, registrationLink }; + accountRequestResult = { ...accountRequestResult, name, email, institute, registrationLink, status }; return accountRequestResult; } @@ -466,11 +472,13 @@ export interface AdminSearchResult { export interface AccountRequestSearchResult { name: string; email: string; + status: string; institute: string; createdAtText: string; registeredAtText: string | null; registrationLink: string; showLinks: boolean; + comments: string; } /** From 561837052d6810ac904eb5cd34454e0cb98fa530 Mon Sep 17 00:00:00 2001 From: domoberzin <74132255+domoberzin@users.noreply.github.com> Date: Thu, 28 Mar 2024 00:54:43 +0800 Subject: [PATCH 10/95] [#11878] Admin Search UI Update for ARF (#12945) * update admin search page to use acc req component * fix selector for e2e test * fix spec files and imports * update e2e selector * fix column numbers --- .../e2e/pageobjects/AdminSearchPage.java | 10 +- .../account-request-table.component.html | 2 +- .../admin-search-page.component.spec.ts.snap | 195 +++++++++--------- .../admin-search-page.component.html | 64 +----- .../admin-search-page.component.spec.ts | 29 +-- .../admin-search-page.component.ts | 88 ++------ .../admin-search-page.module.ts | 4 + 7 files changed, 143 insertions(+), 249 deletions(-) diff --git a/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java b/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java index 706e9ab5a20..b0e9049f481 100644 --- a/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java +++ b/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java @@ -38,9 +38,9 @@ public class AdminSearchPage extends AppPage { private static final int ACCOUNT_REQUEST_COL_NAME = 1; private static final int ACCOUNT_REQUEST_COL_EMAIL = 2; - private static final int ACCOUNT_REQUEST_COL_INSTITUTE = 3; - private static final int ACCOUNT_REQUEST_COL_CREATED_AT = 4; - private static final int ACCOUNT_REQUEST_COL_REGISTERED_AT = 5; + private static final int ACCOUNT_REQUEST_COL_INSTITUTE = 4; + private static final int ACCOUNT_REQUEST_COL_CREATED_AT = 5; + private static final int ACCOUNT_REQUEST_COL_REGISTERED_AT = 6; private static final String EXPANDED_ROWS_HEADER_EMAIL = "Email"; private static final String EXPANDED_ROWS_HEADER_COURSE_JOIN_LINK = "Course Join Link"; @@ -273,7 +273,7 @@ public void resetInstructorGoogleId(InstructorAttributes instructor) { public WebElement getAccountRequestRow(AccountRequestAttributes accountRequest) { String email = accountRequest.getEmail(); String institute = accountRequest.getInstitute(); - List rows = browser.driver.findElements(By.cssSelector("#search-table-account-request tbody tr")); + List rows = browser.driver.findElements(By.cssSelector("tm-account-request-table tbody tr")); for (WebElement row : rows) { List columns = row.findElements(By.tagName("td")); if (removeSpanFromText(columns.get(ACCOUNT_REQUEST_COL_EMAIL - 1) @@ -289,7 +289,7 @@ && removeSpanFromText(columns.get(ACCOUNT_REQUEST_COL_INSTITUTE - 1) public WebElement getAccountRequestRow(AccountRequest accountRequest) { String email = accountRequest.getEmail(); String institute = accountRequest.getInstitute(); - List rows = browser.driver.findElements(By.cssSelector("#search-table-account-request tbody tr")); + List rows = browser.driver.findElements(By.cssSelector("tm-account-request-table tbody tr")); for (WebElement row : rows) { List columns = row.findElements(By.tagName("td")); if (removeSpanFromText(columns.get(ACCOUNT_REQUEST_COL_EMAIL - 1) diff --git a/src/web/app/components/account-requests-table/account-request-table.component.html b/src/web/app/components/account-requests-table/account-request-table.component.html index 7dcb95031c7..2959ee3b480 100644 --- a/src/web/app/components/account-requests-table/account-request-table.component.html +++ b/src/web/app/components/account-requests-table/account-request-table.component.html @@ -68,7 +68,7 @@ - +
  • Account Registration Link diff --git a/src/web/app/pages-admin/admin-search-page/__snapshots__/admin-search-page.component.spec.ts.snap b/src/web/app/pages-admin/admin-search-page/__snapshots__/admin-search-page.component.spec.ts.snap index 4a38f4cbca3..2ffcc3759be 100644 --- a/src/web/app/pages-admin/admin-search-page/__snapshots__/admin-search-page.component.spec.ts.snap +++ b/src/web/app/pages-admin/admin-search-page/__snapshots__/admin-search-page.component.spec.ts.snap @@ -433,119 +433,110 @@ exports[`AdminSearchPageComponent should snap with an expanded account requests 100 characters left
-
+
- - Account Requests Found -
- - + + Pending Account Requests +
-
-
- - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - + - - -
- Name - - Email - - Institute - - Created At - - Registered At - - Options -
- name - - email - - institute - - Tue, 08 Feb 2022, 08:23 AM +00:00 - - Not Registered Yet - -
+
+ Name + + Email + + Status + + Institute, Country + + Created At + + Comments + + Options +
+ name + + email + + PENDING + + institute + - +
- Delete - -
-
-
    +
-
  • - - Account Registration Link - - -
  • - -
    +
    + + + +
    +
    + + + +
    +
    + + + + +
    - + `; diff --git a/src/web/app/pages-admin/admin-search-page/admin-search-page.component.html b/src/web/app/pages-admin/admin-search-page/admin-search-page.component.html index a76f9796f0e..e6573dbff76 100644 --- a/src/web/app/pages-admin/admin-search-page/admin-search-page.component.html +++ b/src/web/app/pages-admin/admin-search-page/admin-search-page.component.html @@ -203,66 +203,4 @@ -
    -
    - Account Requests Found -
    - - -
    -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameEmailInstituteCreated AtRegistered AtOptions
    -
    -
    - - -
    -
    {{ accountRequest.email }}{{ accountRequest.institute }}{{ accountRequest.createdAtText }}{{ accountRequest.registeredAtText || 'Not Registered Yet' }} - - Reset Account Request - -
    - -
    -
    -
      -
    • - Account Registration Link - -
    • -
    -
    -
    -
    + diff --git a/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts b/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts index ba3a2abbad5..4697bb518a1 100644 --- a/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts +++ b/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts @@ -12,7 +12,6 @@ import { AccountService } from '../../../services/account.service'; import { EmailGenerationService } from '../../../services/email-generation.service'; import { InstructorService } from '../../../services/instructor.service'; import { - AccountRequestSearchResult, FeedbackSessionsGroup, InstructorAccountSearchResult, SearchService, StudentAccountSearchResult, } from '../../../services/search.service'; @@ -20,6 +19,7 @@ import { StatusMessageService } from '../../../services/status-message.service'; import { StudentService } from '../../../services/student.service'; import { createMockNgbModalRef } from '../../../test-helpers/mock-ngb-modal-ref'; import { AccountRequestStatus } from '../../../types/api-output'; +import { AccountRequestTableRowModel } from '../../components/account-requests-table/account-request-table-model'; const DEFAULT_FEEDBACK_SESSION_GROUP: FeedbackSessionsGroup = { sessionName: { @@ -69,14 +69,14 @@ const DEFAULT_INSTRUCTOR_SEARCH_RESULT: InstructorAccountSearchResult = { publishedSessions: DEFAULT_FEEDBACK_SESSION_GROUP, }; -const DEFAULT_ACCOUNT_REQUEST_SEARCH_RESULT: AccountRequestSearchResult = { +const DEFAULT_ACCOUNT_REQUEST_SEARCH_RESULT: AccountRequestTableRowModel = { name: 'name', email: 'email', - institute: 'institute', + instituteAndCountry: 'institute', status: AccountRequestStatus.PENDING, registrationLink: 'registrationLink', createdAtText: 'Tue, 08 Feb 2022, 08:23 AM +00:00', - registeredAtText: null, + registeredAtText: '', showLinks: false, comments: '', }; @@ -241,11 +241,11 @@ describe('AdminSearchPageComponent', () => { { name: 'name', email: 'email', - institute: 'institute', + instituteAndCountry: 'institute', status: AccountRequestStatus.PENDING, registrationLink: 'registrationLink', createdAtText: 'Tue, 08 Feb 2022, 08:23 AM +00:00', - registeredAtText: null, + registeredAtText: '', showLinks: true, comments: '', }, @@ -409,11 +409,11 @@ describe('AdminSearchPageComponent', () => { }); it('should display account request results', () => { - const accountRequestResults: AccountRequestSearchResult[] = [ + const accountRequestResults: AccountRequestTableRowModel[] = [ { name: 'name1', email: 'email1', - institute: 'institute1', + instituteAndCountry: 'institute1', status: AccountRequestStatus.PENDING, registrationLink: 'registrationLink1', createdAtText: 'Tue, 08 Feb 2022, 08:23 AM +00:00', @@ -423,7 +423,7 @@ describe('AdminSearchPageComponent', () => { }, { name: 'name2', email: 'email2', - institute: 'institute2', + instituteAndCountry: 'institute2', status: AccountRequestStatus.PENDING, registrationLink: 'registrationLink2', createdAtText: 'Tue, 08 Feb 2022, 08:23 AM +00:00', @@ -435,10 +435,12 @@ describe('AdminSearchPageComponent', () => { jest.spyOn(searchService, 'searchAdmin').mockReturnValue(of({ students: [], instructors: [], - accountRequests: accountRequestResults, + accountRequests: accountRequestResults.map((result) => ({ + ...result, + institute: result.instituteAndCountry, + })), })); - component.searchQuery = 'name'; const button: any = fixture.debugElement.nativeElement.querySelector('#search-button'); button.click(); @@ -487,8 +489,9 @@ describe('AdminSearchPageComponent', () => { }); it('should show account request links when expand all button clicked', () => { - const accountRequestResult: AccountRequestSearchResult = DEFAULT_ACCOUNT_REQUEST_SEARCH_RESULT; + const accountRequestResult: AccountRequestTableRowModel = DEFAULT_ACCOUNT_REQUEST_SEARCH_RESULT; component.accountRequests = [accountRequestResult]; + component.searchQuery = 'test'; // To show the account request table fixture.detectChanges(); const button: any = fixture.debugElement.nativeElement.querySelector('#show-account-request-links'); @@ -954,6 +957,7 @@ describe('AdminSearchPageComponent', () => { it('should show error message when resetting account request is unsuccessful', () => { component.accountRequests = [DEFAULT_ACCOUNT_REQUEST_SEARCH_RESULT]; component.accountRequests[0].registeredAtText = 'Wed, 09 Feb 2022, 10:23 AM +00:00'; + component.searchQuery = 'test'; fixture.detectChanges(); jest.spyOn(ngbModal, 'open').mockImplementation(() => { @@ -980,6 +984,7 @@ describe('AdminSearchPageComponent', () => { it('should show success message when resetting account request is successful', () => { component.accountRequests = [DEFAULT_ACCOUNT_REQUEST_SEARCH_RESULT]; component.accountRequests[0].registeredAtText = 'Wed, 09 Feb 2022, 10:23 AM +00:00'; + component.searchQuery = 'test'; fixture.detectChanges(); jest.spyOn(ngbModal, 'open').mockImplementation(() => { diff --git a/src/web/app/pages-admin/admin-search-page/admin-search-page.component.ts b/src/web/app/pages-admin/admin-search-page/admin-search-page.component.ts index 702cbea0791..bf5f023e4b4 100755 --- a/src/web/app/pages-admin/admin-search-page/admin-search-page.component.ts +++ b/src/web/app/pages-admin/admin-search-page/admin-search-page.component.ts @@ -17,7 +17,10 @@ import { SimpleModalService } from '../../../services/simple-modal.service'; import { StatusMessageService } from '../../../services/status-message.service'; import { StudentService } from '../../../services/student.service'; import { ApiConst } from '../../../types/api-const'; -import { Email, MessageOutput, RegenerateKey } from '../../../types/api-output'; +import { Email, RegenerateKey } from '../../../types/api-output'; +import { + AccountRequestTableRowModel, +} from '../../components/account-requests-table/account-request-table-model'; import { SimpleModalType } from '../../components/simple-modal/simple-modal-type'; import { collapseAnim } from '../../components/teammates-common/collapse-anim'; import { ErrorMessageOutput } from '../../error-message-output'; @@ -37,7 +40,7 @@ export class AdminSearchPageComponent { searchString: string = ''; instructors: InstructorAccountSearchResult[] = []; students: StudentAccountSearchResult[] = []; - accountRequests: AccountRequestSearchResult[] = []; + accountRequests: AccountRequestTableRowModel[] = []; characterLimit = 100; constructor( @@ -76,10 +79,9 @@ export class AdminSearchPageComponent { this.instructors = resp.instructors; this.students = resp.students; - this.accountRequests = resp.accountRequests; + this.accountRequests = this.formatAccountRequests(resp.accountRequests); this.hideAllInstructorsLinks(); this.hideAllStudentsLinks(); - this.hideAllAccountRequestsLinks(); // prompt user to use more specific terms if search results limit reached const limit: number = ApiConst.SEARCH_QUERY_SIZE_LIMIT; @@ -109,6 +111,22 @@ export class AdminSearchPageComponent { }); } + private formatAccountRequests(accountRequests: AccountRequestSearchResult[]): AccountRequestTableRowModel[] { + return accountRequests.map((accountRequest: AccountRequestSearchResult): AccountRequestTableRowModel => { + return { + name: accountRequest.name, + email: accountRequest.email, + status: accountRequest.status, + instituteAndCountry: accountRequest.institute, + createdAtText: accountRequest.createdAtText, + registeredAtText: accountRequest.registeredAtText || '', + comments: accountRequest.comments, + registrationLink: accountRequest.registrationLink, + showLinks: accountRequest.showLinks, + }; + }); + } + /** * Shows all instructors' links in the page. */ @@ -145,24 +163,6 @@ export class AdminSearchPageComponent { } } - /** - * Shows all account requests' links in the page. - */ - showAllAccountRequestsLinks(): void { - for (const accountRequest of this.accountRequests) { - accountRequest.showLinks = true; - } - } - - /** - * Hides all account requests' links in the page. - */ - hideAllAccountRequestsLinks(): void { - for (const accountRequest of this.accountRequests) { - accountRequest.showLinks = false; - } - } - /** * Resets the instructor's Google ID. */ @@ -266,50 +266,6 @@ export class AdminSearchPageComponent { }, () => {}); } - resetAccountRequest(accountRequest: AccountRequestSearchResult): void { - const modalContent = `Are you sure you want to reset the account request for - ${accountRequest.name} with email ${accountRequest.email} from - ${accountRequest.institute}? - An email with the account registration link will also be sent to the instructor.`; - const modalRef: NgbModalRef = this.simpleModalService.openConfirmationModal( - `Reset account request for ${accountRequest.name}?`, SimpleModalType.WARNING, modalContent); - - modalRef.result.then(() => { - this.accountService.resetAccountRequest(accountRequest.email, accountRequest.institute) - .subscribe({ - next: () => { - this.statusMessageService - .showSuccessToast(`Reset successful. An email has been sent to ${accountRequest.email}.`); - accountRequest.registeredAtText = ''; - }, - error: (resp: ErrorMessageOutput) => { - this.statusMessageService.showErrorToast(resp.error.message); - }, - }); - }, () => {}); - } - - deleteAccountRequest(accountRequest: AccountRequestSearchResult): void { - const modalContent: string = `Are you sure you want to delete the account request for - ${accountRequest.name} with email ${accountRequest.email} from - ${accountRequest.institute}?`; - const modalRef: NgbModalRef = this.simpleModalService.openConfirmationModal( - `Delete account request for ${accountRequest.name}?`, SimpleModalType.WARNING, modalContent); - - modalRef.result.then(() => { - this.accountService.deleteAccountRequest(accountRequest.email, accountRequest.institute) - .subscribe({ - next: (resp: MessageOutput) => { - this.statusMessageService.showSuccessToast(resp.message); - this.accountRequests = this.accountRequests.filter((x: AccountRequestSearchResult) => x !== accountRequest); - }, - error: (resp: ErrorMessageOutput) => { - this.statusMessageService.showErrorToast(resp.error.message); - }, - }); - }, () => {}); - } - /** * Updates the student's displayed course join and feedback session links with the value of the newKey. */ diff --git a/src/web/app/pages-admin/admin-search-page/admin-search-page.module.ts b/src/web/app/pages-admin/admin-search-page/admin-search-page.module.ts index 06278b7ad5f..6b70a93077a 100644 --- a/src/web/app/pages-admin/admin-search-page/admin-search-page.module.ts +++ b/src/web/app/pages-admin/admin-search-page/admin-search-page.module.ts @@ -4,6 +4,9 @@ import { FormsModule } from '@angular/forms'; import { RouterModule, Routes } from '@angular/router'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { AdminSearchPageComponent } from './admin-search-page.component'; +import { + AccountRequestTableModule, +} from '../../components/account-requests-table/account-request-table.module'; import { Pipes } from '../../pipes/pipes.module'; const routes: Routes = [ @@ -27,6 +30,7 @@ const routes: Routes = [ CommonModule, FormsModule, NgbTooltipModule, + AccountRequestTableModule, RouterModule.forChild(routes), Pipes, ], From 0aa7dfbb4d302bb5afaf26ac5945d0bfcc2b93ad Mon Sep 17 00:00:00 2001 From: Jay Aljelo Ting <65202977+jayasting98@users.noreply.github.com> Date: Thu, 28 Mar 2024 10:57:27 +0800 Subject: [PATCH 11/95] [#11878] Add methods to get an account request by ID (#12953) * Add facade logic method to get an account request by ID * Add storage method to get an account request by ID * Add logic method to get an account request by ID --- .../sqllogic/core/AccountRequestsLogicIT.java | 18 +++++++ .../storage/sqlapi/AccountRequestsDbIT.java | 19 +++++++ .../java/teammates/sqllogic/api/Logic.java | 9 ++++ .../sqllogic/core/AccountRequestsLogic.java | 8 +++ .../storage/sqlapi/AccountRequestsDb.java | 8 +++ .../core/AccountRequestsLogicTest.java | 51 +++++++++++++++++++ .../storage/sqlapi/AccountRequestsDbTest.java | 21 ++++++++ 7 files changed, 134 insertions(+) create mode 100644 src/test/java/teammates/sqllogic/core/AccountRequestsLogicTest.java diff --git a/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java b/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java index f95a6cfa682..566c967a7e7 100644 --- a/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java +++ b/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java @@ -1,6 +1,7 @@ package teammates.it.sqllogic.core; import java.time.Instant; +import java.util.UUID; import org.testng.annotations.Test; @@ -20,6 +21,23 @@ public class AccountRequestsLogicIT extends BaseTestCaseWithSqlDatabaseAccess { private AccountRequestsLogic accountRequestsLogic = AccountRequestsLogic.inst(); + @Test + public void testGetAccountRequest_nonExistentAccountRequest_returnsNull() { + UUID id = UUID.randomUUID(); + AccountRequest actualAccountRequest = accountRequestsLogic.getAccountRequest(id); + assertNull(actualAccountRequest); + } + + @Test + public void testGetAccountRequest_existingAccountRequest_getsSuccessfully() throws InvalidParametersException { + AccountRequest expectedAccountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); + UUID id = expectedAccountRequest.getId(); + accountRequestsLogic.createAccountRequest(expectedAccountRequest); + AccountRequest actualAccountRequest = accountRequestsLogic.getAccountRequest(id); + assertEquals(expectedAccountRequest, actualAccountRequest); + } + @Test public void testResetAccountRequest() throws EntityAlreadyExistsException, InvalidParametersException, EntityDoesNotExistException { diff --git a/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java b/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java index a4c25763e40..e989d6fb3cb 100644 --- a/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java +++ b/src/it/java/teammates/it/storage/sqlapi/AccountRequestsDbIT.java @@ -1,11 +1,13 @@ package teammates.it.storage.sqlapi; import java.util.List; +import java.util.UUID; import org.testng.annotations.Test; import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.exception.EntityDoesNotExistException; +import teammates.common.exception.InvalidParametersException; import teammates.it.test.BaseTestCaseWithSqlDatabaseAccess; import teammates.storage.sqlapi.AccountRequestsDb; import teammates.storage.sqlentity.AccountRequest; @@ -72,6 +74,23 @@ public void testCreateReadDeleteAccountRequest() throws Exception { assertNull(actualAccountRequest); } + @Test + public void testGetAccountRequest_nonExistentAccountRequest_returnsNull() { + UUID id = UUID.randomUUID(); + AccountRequest actualAccountRequest = accountRequestDb.getAccountRequest(id); + assertNull(actualAccountRequest); + } + + @Test + public void testGetAccountRequest_existingAccountRequest_getsSuccessfully() throws InvalidParametersException { + AccountRequest expectedAccountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); + UUID id = expectedAccountRequest.getId(); + accountRequestDb.createAccountRequest(expectedAccountRequest); + AccountRequest actualAccountRequest = accountRequestDb.getAccountRequest(id); + assertEquals(expectedAccountRequest, actualAccountRequest); + } + @Test public void testUpdateAccountRequest() throws Exception { ______TS("Update account request, does not exists, exception thrown"); diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index a6f785e442e..75243340fdd 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -95,6 +95,15 @@ public AccountRequest createAccountRequest(String name, String email, String ins return accountRequestLogic.createAccountRequest(name, email, institute, status, comments); } + /** + * Gets the account request with the given {@code id}. + * + * @return account request with the given {@code id}. + */ + public AccountRequest getAccountRequest(UUID id) { + return accountRequestLogic.getAccountRequest(id); + } + /** * Gets the account request with the given email and institute. * diff --git a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java index 6c0f34efb1a..ef46f0b7fc7 100644 --- a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java +++ b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java @@ -1,6 +1,7 @@ package teammates.sqllogic.core; import java.util.List; +import java.util.UUID; import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.exception.EntityDoesNotExistException; @@ -65,6 +66,13 @@ public AccountRequest createAccountRequest(String name, String email, String ins return accountRequestDb.createAccountRequest(toCreate); } + /** + * Gets the account request associated with the {@code id}. + */ + public AccountRequest getAccountRequest(UUID id) { + return accountRequestDb.getAccountRequest(id); + } + /** * Gets account request associated with the {@code email} and {@code institute}. */ diff --git a/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java b/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java index e3ced48d6af..6d9f7312fe4 100644 --- a/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java +++ b/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java @@ -55,6 +55,14 @@ public AccountRequest createAccountRequest(AccountRequest accountRequest) throws return accountRequest; } + /** + * Get AccountRequest by {@code id} from the database. + */ + public AccountRequest getAccountRequest(UUID id) { + assert id != null; + return HibernateUtil.get(AccountRequest.class, id); + } + /** * Get AccountRequest by {@code email} and {@code institute} from database. */ diff --git a/src/test/java/teammates/sqllogic/core/AccountRequestsLogicTest.java b/src/test/java/teammates/sqllogic/core/AccountRequestsLogicTest.java new file mode 100644 index 00000000000..6886acc8007 --- /dev/null +++ b/src/test/java/teammates/sqllogic/core/AccountRequestsLogicTest.java @@ -0,0 +1,51 @@ +package teammates.sqllogic.core; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.storage.sqlapi.AccountRequestsDb; +import teammates.storage.sqlentity.AccountRequest; +import teammates.test.BaseTestCase; + +/** + * SUT: {@link AccountRequestsLogic}. + */ +public class AccountRequestsLogicTest extends BaseTestCase { + + private AccountRequestsLogic accountRequestsLogic = AccountRequestsLogic.inst(); + + private AccountRequestsDb accountRequestsDb; + + @BeforeMethod + public void setUpMethod() { + accountRequestsDb = mock(AccountRequestsDb.class); + accountRequestsLogic.initLogicDependencies(accountRequestsDb); + } + + @Test + public void testGetAccountRequest_nonExistentAccountRequest_returnsNull() { + UUID id = UUID.randomUUID(); + when(accountRequestsDb.getAccountRequest(id)).thenReturn(null); + AccountRequest actualAccountRequest = accountRequestsLogic.getAccountRequest(id); + verify(accountRequestsDb).getAccountRequest(id); + assertNull(actualAccountRequest); + } + + @Test + public void testGetAccountRequest_existingAccountRequest_getsSuccessfully() { + AccountRequest expectedAccountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); + UUID id = expectedAccountRequest.getId(); + when(accountRequestsDb.getAccountRequest(id)).thenReturn(expectedAccountRequest); + AccountRequest actualAccountRequest = accountRequestsLogic.getAccountRequest(id); + verify(accountRequestsDb).getAccountRequest(id); + assertEquals(expectedAccountRequest, actualAccountRequest); + } +} diff --git a/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java b/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java index 61f9f8e0d24..31f6cf77873 100644 --- a/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java +++ b/src/test/java/teammates/storage/sqlapi/AccountRequestsDbTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.verify; import java.util.List; +import java.util.UUID; import org.mockito.MockedStatic; import org.testng.annotations.AfterMethod; @@ -70,6 +71,26 @@ public void testCreateAccountRequest_accountRequestAlreadyExists_createsSuccessf mockHibernateUtil.verify(() -> HibernateUtil.persist(accountRequest)); } + @Test + public void testGetAccountRequest_nonExistentAccountRequest_returnsNull() { + UUID id = UUID.randomUUID(); + mockHibernateUtil.when(() -> HibernateUtil.get(AccountRequest.class, id)).thenReturn(null); + AccountRequest actualAccountRequest = accountRequestDb.getAccountRequest(id); + mockHibernateUtil.verify(() -> HibernateUtil.get(AccountRequest.class, id)); + assertNull(actualAccountRequest); + } + + @Test + public void testGetAccountRequest_existingAccountRequest_getsSuccessfully() { + AccountRequest expectedAccountRequest = + new AccountRequest("test@gmail.com", "name", "institute", AccountRequestStatus.PENDING, "comments"); + UUID id = expectedAccountRequest.getId(); + mockHibernateUtil.when(() -> HibernateUtil.get(AccountRequest.class, id)).thenReturn(expectedAccountRequest); + AccountRequest actualAccountRequest = accountRequestDb.getAccountRequest(id); + mockHibernateUtil.verify(() -> HibernateUtil.get(AccountRequest.class, id)); + assertEquals(expectedAccountRequest, actualAccountRequest); + } + @Test public void testUpdateAccountRequest_invalidEmail_throwsInvalidParametersException() { AccountRequest accountRequestWithInvalidEmail = From 9d0cf375b8e5b6e7c8c348630a4efec91f30532f Mon Sep 17 00:00:00 2001 From: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> Date: Thu, 28 Mar 2024 23:37:45 +0800 Subject: [PATCH 12/95] Update developers.json (#12958) --- src/web/data/developers.json | 47 +++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/web/data/developers.json b/src/web/data/developers.json index 0482ddedcdb..842e83e1356 100644 --- a/src/web/data/developers.json +++ b/src/web/data/developers.json @@ -215,6 +215,10 @@ "name": "Andy Daehn", "username": "andydaehn" }, + { + "name": "Andy", + "username": "Andy-W-Developer" + }, { "multiple": true, "name": "Ang Ji Kai", @@ -450,6 +454,11 @@ "name": "Chin Yong Wei", "username": "vertigogarden" }, + { + "multiple": true, + "name": "Ching Ming Yuan", + "username": "mingyuanc" + }, { "multiple": true, "name": "Chloe Stapleton", @@ -599,11 +608,6 @@ "name": "Dishant Sheth", "username": "dishant-sheth" }, - { - "multiple": true, - "name": "Yeo Di Sheng", - "username": "dishenggg" - }, { "multiple": true, "name": "Divya Pandilla", @@ -1219,6 +1223,10 @@ "name": "Leonardo Vitoriano", "username": "leonardomilv3" }, + { + "name": "Leyan Guan", + "username": "leyguan" + }, { "multiple": true, "name": "Lian Wenhui Florine" @@ -1337,11 +1345,6 @@ "name": "Marlon Calvo", "username": "marloncalvo" }, - { - "multiple": true, - "name": "Tye Jia Jun, Marques", - "username": "marquestye" - }, { "name": "Martin Larsson", "username": "leddy231" @@ -1370,6 +1373,7 @@ "username": "mattlim1207" }, { + "multiple": true, "name": "Maureen Chang", "username": "techMedMau" }, @@ -1412,10 +1416,6 @@ "name": "Miguel Araújo", "username": "miguelarauj1o" }, - { - "name": "Ching Ming Yuan", - "username": "mingyuanc" - }, { "name": "Minsung Joh", "username": "jms5049" @@ -1448,6 +1448,10 @@ "name": "Mukesh Gupta", "username": "mukesh14149" }, + { + "name": "Nada Ayesh", + "username": "nadasuhailAyesh12" + }, { "name": "Naga Rani", "username": "Nagureddy" @@ -2254,6 +2258,11 @@ "name": "Truong Hoang Phuoc", "username": "hoangphuoc25" }, + { + "multiple": true, + "name": "Tye Jia Jun, Marques", + "username": "marquestye" + }, { "username": "u6867511" }, @@ -2335,6 +2344,10 @@ "multiple": true, "name": "Wang Chao" }, + { + "name": "Wang JingTing", + "username": "jingting1412" + }, { "name": "Wang Yuqing", "username": "yuqingw" @@ -2356,6 +2369,7 @@ "username": "a0129998" }, { + "multiple": true, "name": "Xenos Fiorenzo Anong", "username": "xenosf" }, @@ -2412,6 +2426,11 @@ "multiple": true, "name": "Yen Zi Shyun" }, + { + "multiple": true, + "name": "Yeo Di Sheng", + "username": "dishenggg" + }, { "name": "Yi Chen", "username": "g3chenyigmailcom" From ccad41b26b4c62529fda29bc2d4b556e7098c356 Mon Sep 17 00:00:00 2001 From: DS Date: Thu, 28 Mar 2024 23:53:59 +0800 Subject: [PATCH 13/95] [#11843] Create Logic and Db layer for FeedbackSessionLogs (#12914) * Create FeedbackSessionLog entity * fix lint * Create UpdateFeedbackSessionLogsAction * Sort query results from logging service * Update type of feedbackSessionLogType * Fix naming * Fix enum in entity * Update filter to differentiate by session * Add Uri Info * Add tests * Update test case * Update to getOrderedFeedbackSessionLogs * Create skeleton * Implement logic and db layer * fix lint * Update entity * Fix tests * Fix bugs and optimize action * Prevent courseId from being null * Update GCP logs to store ids * Fix tests * Update action to use reference * Add some error handling * Fix tests * Add ids to api output --- .../sqlapi/FeedbackSessionLogsDbIT.java | 170 +++++++++++ .../GetFeedbackSessionLogsActionIT.java | 10 +- .../UpdateFeedbackSessionLogsActionIT.java | 267 ++++++++++++++++++ .../datatransfer/FeedbackSessionLogEntry.java | 35 ++- .../logs/FeedbackSessionAuditLogDetails.java | 22 ++ .../teammates/common/util/HibernateUtil.java | 14 +- .../teammates/logic/api/LogsProcessor.java | 9 + .../external/GoogleCloudLoggingService.java | 15 +- .../logic/external/LocalLoggingService.java | 11 +- .../teammates/logic/external/LogService.java | 7 + .../java/teammates/sqllogic/api/Logic.java | 40 ++- .../core/FeedbackSessionLogsLogic.java | 67 +++++ .../sqllogic/core/FeedbackSessionsLogic.java | 10 + .../teammates/sqllogic/core/LogicStarter.java | 3 + .../teammates/sqllogic/core/UsersLogic.java | 12 + .../storage/sqlapi/FeedbackSessionLogsDb.java | 86 ++++++ .../storage/sqlapi/FeedbackSessionsDb.java | 11 + .../teammates/storage/sqlapi/UsersDb.java | 9 + .../storage/sqlentity/FeedbackSessionLog.java | 44 +-- .../ui/output/FeedbackSessionData.java | 11 + .../java/teammates/ui/output/StudentData.java | 11 + .../CreateFeedbackSessionLogAction.java | 30 +- .../UpdateFeedbackSessionLogsAction.java | 41 +-- .../logic/api/MockLogsProcessor.java | 18 +- .../UpdateFeedbackSessionLogsActionTest.java | 170 ++++++----- .../sqlapi/FeedbackSessionLogsDbTest.java | 46 +++ .../GetFeedbackSessionLogsActionTest.java | 10 +- 27 files changed, 1055 insertions(+), 124 deletions(-) create mode 100644 src/it/java/teammates/it/storage/sqlapi/FeedbackSessionLogsDbIT.java create mode 100644 src/it/java/teammates/it/ui/webapi/UpdateFeedbackSessionLogsActionIT.java create mode 100644 src/main/java/teammates/sqllogic/core/FeedbackSessionLogsLogic.java create mode 100644 src/main/java/teammates/storage/sqlapi/FeedbackSessionLogsDb.java create mode 100644 src/test/java/teammates/storage/sqlapi/FeedbackSessionLogsDbTest.java diff --git a/src/it/java/teammates/it/storage/sqlapi/FeedbackSessionLogsDbIT.java b/src/it/java/teammates/it/storage/sqlapi/FeedbackSessionLogsDbIT.java new file mode 100644 index 00000000000..14d922e611f --- /dev/null +++ b/src/it/java/teammates/it/storage/sqlapi/FeedbackSessionLogsDbIT.java @@ -0,0 +1,170 @@ +package teammates.it.storage.sqlapi; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.SqlDataBundle; +import teammates.common.datatransfer.logs.FeedbackSessionLogType; +import teammates.common.util.HibernateUtil; +import teammates.it.test.BaseTestCaseWithSqlDatabaseAccess; +import teammates.storage.sqlapi.FeedbackSessionLogsDb; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; +import teammates.storage.sqlentity.Student; + +/** + * SUT: {@link FeedbackSessionLogsDb}. + */ +public class FeedbackSessionLogsDbIT extends BaseTestCaseWithSqlDatabaseAccess { + + private final FeedbackSessionLogsDb fslDb = FeedbackSessionLogsDb.inst(); + + private SqlDataBundle typicalDataBundle; + + @Override + @BeforeClass + public void setupClass() { + super.setupClass(); + typicalDataBundle = getTypicalSqlDataBundle(); + } + + @Override + @BeforeMethod + protected void setUp() throws Exception { + super.setUp(); + persistDataBundle(typicalDataBundle); + HibernateUtil.flushSession(); + } + + @Test + public void test_createFeedbackSessionLog_success() { + Course course = typicalDataBundle.courses.get("course1"); + FeedbackSession feedbackSession = typicalDataBundle.feedbackSessions.get("session1InCourse1"); + Student student = typicalDataBundle.students.get("student1InCourse1"); + + Instant logTimestamp = Instant.parse("2011-01-01T00:00:00Z"); + FeedbackSessionLog expected = new FeedbackSessionLog(student, feedbackSession, FeedbackSessionLogType.ACCESS, + logTimestamp); + + fslDb.createFeedbackSessionLog(expected); + + List actualLogs = fslDb.getOrderedFeedbackSessionLogs(course.getId(), student.getId(), + feedbackSession.getId(), logTimestamp, logTimestamp.plusSeconds(1)); + + assertEquals(actualLogs.size(), 1); + assertEquals(expected, actualLogs.get(0)); + } + + @Test + public void test_getOrderedFeedbackSessionLogs_success() { + Instant startTime = Instant.parse("2011-01-01T00:00:00Z"); + Instant endTime = Instant.parse("2011-01-01T01:00:00Z"); + + Course course1 = typicalDataBundle.courses.get("course1"); + + FeedbackSession session1 = typicalDataBundle.feedbackSessions.get("session1InCourse1"); + FeedbackSession session2 = typicalDataBundle.feedbackSessions.get("session2InTypicalCourse"); + FeedbackSession sessionInAnotherCourse = typicalDataBundle.feedbackSessions.get("ongoingSession1InCourse3"); + + Student student1 = typicalDataBundle.students.get("student1InCourse1"); + Student student2 = typicalDataBundle.students.get("student2InCourse1"); + + FeedbackSessionLog student1Session1Log1 = new FeedbackSessionLog(student1, session1, + FeedbackSessionLogType.ACCESS, startTime); + FeedbackSessionLog student1Session1Log2 = new FeedbackSessionLog(student1, session1, + FeedbackSessionLogType.SUBMISSION, startTime.plusSeconds(1)); + FeedbackSessionLog student1Session1Log3 = new FeedbackSessionLog(student1, session1, + FeedbackSessionLogType.VIEW_RESULT, startTime.plusSeconds(2)); + FeedbackSessionLog student1Session2Log1 = new FeedbackSessionLog(student1, session2, + FeedbackSessionLogType.ACCESS, startTime.plusSeconds(3)); + + FeedbackSessionLog student2Session1Log1 = new FeedbackSessionLog(student2, session1, + FeedbackSessionLogType.ACCESS, startTime.plusSeconds(4)); + FeedbackSessionLog student2Session2Log1 = new FeedbackSessionLog(student2, session2, + FeedbackSessionLogType.ACCESS, startTime.plusSeconds(5)); + + FeedbackSessionLog student1AnotherCourseLog1 = new FeedbackSessionLog(student1, sessionInAnotherCourse, + FeedbackSessionLogType.ACCESS, startTime.plusSeconds(6)); + + FeedbackSessionLog outOfRangeLog1 = new FeedbackSessionLog(student1, session1, FeedbackSessionLogType.ACCESS, + startTime.minusSeconds(1)); + FeedbackSessionLog outOfRangeLog2 = new FeedbackSessionLog(student1, session1, FeedbackSessionLogType.ACCESS, + endTime); + + List newLogs = new ArrayList<>(); + newLogs.add(student1Session1Log1); + newLogs.add(student1Session1Log2); + newLogs.add(student1Session1Log3); + newLogs.add(student1Session2Log1); + + newLogs.add(student2Session1Log1); + newLogs.add(student2Session2Log1); + + newLogs.add(student1AnotherCourseLog1); + + newLogs.add(outOfRangeLog1); + newLogs.add(outOfRangeLog2); + + for (FeedbackSessionLog log : newLogs) { + fslDb.createFeedbackSessionLog(log); + } + + ______TS("Return logs belonging to a course in time range"); + List expectedLogs = new ArrayList<>(); + expectedLogs.add(student1Session1Log1); + expectedLogs.add(student1Session1Log2); + expectedLogs.add(student1Session1Log3); + expectedLogs.add(student1Session2Log1); + + expectedLogs.add(student2Session1Log1); + expectedLogs.add(student2Session2Log1); + + List actualLogs = fslDb.getOrderedFeedbackSessionLogs(course1.getId(), null, null, + startTime, endTime); + + assertEquals(expectedLogs, actualLogs); + + ______TS("Return logs belonging to a student in time range"); + expectedLogs = new ArrayList<>(); + expectedLogs.add(student1Session1Log1); + expectedLogs.add(student1Session1Log2); + expectedLogs.add(student1Session1Log3); + expectedLogs.add(student1Session2Log1); + + actualLogs = fslDb.getOrderedFeedbackSessionLogs(course1.getId(), student1.getId(), null, startTime, endTime); + + assertEquals(expectedLogs, actualLogs); + + ______TS("Return logs belonging to a feedback session in time range"); + expectedLogs = new ArrayList<>(); + expectedLogs.add(student1Session2Log1); + expectedLogs.add(student2Session2Log1); + + actualLogs = fslDb.getOrderedFeedbackSessionLogs(course1.getId(), null, session2.getId(), startTime, endTime); + + assertEquals(expectedLogs, actualLogs); + + ______TS("Return logs belonging to a student in a feedback session in time range"); + expectedLogs = new ArrayList<>(); + expectedLogs.add(student2Session2Log1); + + actualLogs = fslDb.getOrderedFeedbackSessionLogs(course1.getId(), student2.getId(), session2.getId(), startTime, + endTime); + + assertEquals(expectedLogs, actualLogs); + + ______TS("No logs in time range, return empty list"); + expectedLogs = new ArrayList<>(); + + actualLogs = fslDb.getOrderedFeedbackSessionLogs(course1.getId(), null, null, endTime.plusSeconds(3600), + endTime.plusSeconds(7200)); + + assertEquals(expectedLogs, actualLogs); + } +} diff --git a/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java b/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java index 2ee319637ae..9aca4fed202 100644 --- a/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java @@ -60,15 +60,15 @@ protected void testExecute() { long startTime = endTime - (Const.LOGS_RETENTION_PERIOD.toDays() - 1) * 24 * 60 * 60 * 1000; long invalidStartTime = endTime - (Const.LOGS_RETENTION_PERIOD.toDays() + 1) * 24 * 60 * 60 * 1000; - mockLogsProcessor.insertFeedbackSessionLog(student1Email, fsa1Name, + mockLogsProcessor.insertFeedbackSessionLog(courseId, student1Email, fsa1Name, FeedbackSessionLogType.ACCESS.getLabel(), startTime); - mockLogsProcessor.insertFeedbackSessionLog(student1Email, fsa2Name, + mockLogsProcessor.insertFeedbackSessionLog(courseId, student1Email, fsa2Name, FeedbackSessionLogType.ACCESS.getLabel(), startTime + 1000); - mockLogsProcessor.insertFeedbackSessionLog(student1Email, fsa2Name, + mockLogsProcessor.insertFeedbackSessionLog(courseId, student1Email, fsa2Name, FeedbackSessionLogType.SUBMISSION.getLabel(), startTime + 2000); - mockLogsProcessor.insertFeedbackSessionLog(student2Email, fsa1Name, + mockLogsProcessor.insertFeedbackSessionLog(courseId, student2Email, fsa1Name, FeedbackSessionLogType.ACCESS.getLabel(), startTime + 3000); - mockLogsProcessor.insertFeedbackSessionLog(student2Email, fsa1Name, + mockLogsProcessor.insertFeedbackSessionLog(courseId, student2Email, fsa1Name, FeedbackSessionLogType.SUBMISSION.getLabel(), startTime + 4000); ______TS("Failure case: not enough parameters"); diff --git a/src/it/java/teammates/it/ui/webapi/UpdateFeedbackSessionLogsActionIT.java b/src/it/java/teammates/it/ui/webapi/UpdateFeedbackSessionLogsActionIT.java new file mode 100644 index 00000000000..ce30c978426 --- /dev/null +++ b/src/it/java/teammates/it/ui/webapi/UpdateFeedbackSessionLogsActionIT.java @@ -0,0 +1,267 @@ +package teammates.it.ui.webapi; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.FeedbackSessionLogEntry; +import teammates.common.datatransfer.logs.FeedbackSessionLogType; +import teammates.common.util.Const; +import teammates.common.util.HibernateUtil; +import teammates.common.util.TimeHelper; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; +import teammates.storage.sqlentity.Student; +import teammates.storage.sqlsearch.SearchManagerFactory; +import teammates.ui.webapi.UpdateFeedbackSessionLogsAction; + +/** + * SUT: {@link UpdateFeedbackSessionLogsAction}. + */ +public class UpdateFeedbackSessionLogsActionIT extends BaseActionIT { + + static final int COLLECTION_TIME_PERIOD = 60; // represents one hour + static final long SPAM_FILTER = 2000L; // in ms + + Student student1InCourse1; + Student student2InCourse1; + Student student1InCourse3; + + Course course1; + Course course3; + + FeedbackSession session1InCourse1; + FeedbackSession session2InCourse1; + FeedbackSession session1InCourse3; + + Instant endTime; + Instant startTime; + + @Override + @BeforeMethod + protected void setUp() throws Exception { + super.setUp(); + persistDataBundle(typicalBundle); + HibernateUtil.flushSession(); + SearchManagerFactory.getStudentSearchManager().resetCollections(); + + endTime = TimeHelper.getInstantNearestHourBefore(Instant.now()); + startTime = endTime.minus(COLLECTION_TIME_PERIOD, ChronoUnit.MINUTES); + + course1 = typicalBundle.courses.get("course1"); + course3 = typicalBundle.courses.get("course3"); + + student1InCourse1 = typicalBundle.students.get("student1InCourse1"); + student2InCourse1 = typicalBundle.students.get("student2InCourse1"); + student1InCourse3 = typicalBundle.students.get("student1InCourse3"); + + session1InCourse1 = typicalBundle.feedbackSessions.get("session1InCourse1"); + session2InCourse1 = typicalBundle.feedbackSessions.get("session2InTypicalCourse"); + session1InCourse3 = typicalBundle.feedbackSessions.get("ongoingSession1InCourse3"); + + mockLogsProcessor.getOrderedFeedbackSessionLogs("", "GET", 0, 0, "DELETE").clear(); + } + + @Override + String getActionUri() { + return Const.CronJobURIs.AUTOMATED_FEEDBACK_SESSION_LOGS_PROCESSING; + } + + @Override + String getRequestMethod() { + return GET; + } + + @Test + @Override + protected void testExecute() { + ______TS("No spam all logs added"); + // Different Types + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(300).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.SUBMISSION.getLabel(), + startTime.plusSeconds(300).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.VIEW_RESULT.getLabel(), + startTime.plusSeconds(300).toEpochMilli()); + + // Different feedback sessions + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(600).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session2InCourse1.getId(), session2InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(600).toEpochMilli()); + + // Different Student + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(900).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student2InCourse1.getId(), + student2InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(900).toEpochMilli()); + + // Different course + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(1200).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course3.getId(), student1InCourse3.getId(), + student1InCourse3.getEmail(), + session1InCourse3.getId(), session1InCourse3.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(1200).toEpochMilli()); + + // Gap is larger than spam filter + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); + + UpdateFeedbackSessionLogsAction action = getAction(); + getJsonResult(action); + + // method returns all logs regardless of params + List expected = mockLogsProcessor.getOrderedFeedbackSessionLogs("", "", 0, 0, ""); + List actual = logic.getOrderedFeedbackSessionLogs(course1.getId(), null, null, startTime, + endTime); + List actualCourse3 = logic.getOrderedFeedbackSessionLogs(course3.getId(), null, null, + startTime, endTime); + actual.addAll(actualCourse3); + assertTrue(isEqual(expected, actual)); + } + + @Test + protected void testExecute_recentLogsWithSpam_someLogsCreated() { + // Gap is smaller than spam filter + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER - 2).toEpochMilli()); + + // Filters multiple logs within one spam window + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER - 1).toEpochMilli()); + + // Correctly adds new log after filtering + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); + + // Filters out spam in the new window + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER + 2).toEpochMilli()); + + UpdateFeedbackSessionLogsAction action = getAction(); + action.execute(); + + List expected = new ArrayList<>(); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.toEpochMilli())); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli())); + + List actual = logic.getOrderedFeedbackSessionLogs(course1.getId(), null, null, startTime, + endTime); + assertTrue(isEqual(expected, actual)); + } + + @Test + protected void testExecute_badLogs_otherLogsCreated() { + UUID badUuid = UUID.fromString("00000000-0000-0000-0000-000000000000"); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(300).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(900).toEpochMilli()); + + // bad student id + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), badUuid, student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(600).toEpochMilli()); + + // bad session id + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + badUuid, session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(600).toEpochMilli()); + + UpdateFeedbackSessionLogsAction action = getAction(); + action.execute(); + + List expected = new ArrayList<>(); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(300).toEpochMilli())); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), + student1InCourse1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(900).toEpochMilli())); + + List actual = logic.getOrderedFeedbackSessionLogs(course1.getId(), null, null, startTime, + endTime); + assertTrue(isEqual(expected, actual)); + } + + @Test + @Override + protected void testAccessControl() throws Exception { + Course course = typicalBundle.courses.get("course1"); + verifyOnlyAdminCanAccess(course); + } + + private Boolean isEqual(List expected, List actual) { + + assertEquals(expected.size(), actual.size()); + + for (int i = 0; i < expected.size(); i++) { + FeedbackSessionLogEntry expectedEntry = expected.get(i); + FeedbackSessionLog actualLog = actual.get(i); + + assertEquals(expectedEntry.getStudentId(), actualLog.getStudent().getId()); + + assertEquals(expectedEntry.getFeedbackSessionId(), actualLog.getFeedbackSession().getId()); + + assertEquals(expectedEntry.getFeedbackSessionLogType(), actualLog.getFeedbackSessionLogType().getLabel()); + + assertEquals(expectedEntry.getTimestamp(), actualLog.getTimestamp().toEpochMilli()); + } + + return true; + } +} diff --git a/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java b/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java index ea95a6a951d..d0aef5ce481 100644 --- a/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java +++ b/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java @@ -1,26 +1,57 @@ package teammates.common.datatransfer; +import java.util.UUID; + /** * Represents a log entry of a feedback session. */ public class FeedbackSessionLogEntry implements Comparable { + private final String courseId; + private final UUID studentId; private final String studentEmail; + private final UUID feedbackSessionId; private final String feedbackSessionName; private final String feedbackSessionLogType; private final long timestamp; - public FeedbackSessionLogEntry(String studentEmail, String feedbackSessionName, - String feedbackSessionLogType, long timestamp) { + public FeedbackSessionLogEntry(String courseId, String studentEmail, + String feedbackSessionName, String feedbackSessionLogType, long timestamp) { + this.courseId = courseId; + this.studentId = null; + this.studentEmail = studentEmail; + this.feedbackSessionId = null; + this.feedbackSessionName = feedbackSessionName; + this.feedbackSessionLogType = feedbackSessionLogType; + this.timestamp = timestamp; + } + + public FeedbackSessionLogEntry(String courseId, UUID studentId, String studentEmail, UUID feedbackSessionId, + String feedbackSessionName, String feedbackSessionLogType, long timestamp) { + this.courseId = courseId; + this.studentId = studentId; this.studentEmail = studentEmail; + this.feedbackSessionId = feedbackSessionId; this.feedbackSessionName = feedbackSessionName; this.feedbackSessionLogType = feedbackSessionLogType; this.timestamp = timestamp; } + public String getCourseId() { + return courseId; + } + + public UUID getStudentId() { + return studentId; + } + public String getStudentEmail() { return studentEmail; } + public UUID getFeedbackSessionId() { + return feedbackSessionId; + } + public String getFeedbackSessionName() { return feedbackSessionName; } diff --git a/src/main/java/teammates/common/datatransfer/logs/FeedbackSessionAuditLogDetails.java b/src/main/java/teammates/common/datatransfer/logs/FeedbackSessionAuditLogDetails.java index 0563cada134..f3e765eb03e 100644 --- a/src/main/java/teammates/common/datatransfer/logs/FeedbackSessionAuditLogDetails.java +++ b/src/main/java/teammates/common/datatransfer/logs/FeedbackSessionAuditLogDetails.java @@ -10,8 +10,12 @@ public class FeedbackSessionAuditLogDetails extends LogDetails { @Nullable private String courseId; @Nullable + private String feedbackSessionId; + @Nullable private String feedbackSessionName; @Nullable + private String studentId; + @Nullable private String studentEmail; private String accessType; @@ -51,11 +55,29 @@ public void setAccessType(String accessType) { this.accessType = accessType; } + public String getFeedbackSessionId() { + return feedbackSessionId; + } + + public void setFeedbackSessionId(String feedbackSessionId) { + this.feedbackSessionId = feedbackSessionId; + } + + public String getStudentId() { + return studentId; + } + + public void setStudentId(String studentId) { + this.studentId = studentId; + } + @Override public void hideSensitiveInformation() { courseId = null; feedbackSessionName = null; studentEmail = null; + studentId = null; + feedbackSessionId = null; } } diff --git a/src/main/java/teammates/common/util/HibernateUtil.java b/src/main/java/teammates/common/util/HibernateUtil.java index 694a3b5c9c1..5935e872340 100644 --- a/src/main/java/teammates/common/util/HibernateUtil.java +++ b/src/main/java/teammates/common/util/HibernateUtil.java @@ -19,6 +19,7 @@ import teammates.storage.sqlentity.FeedbackResponse; import teammates.storage.sqlentity.FeedbackResponseComment; import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; import teammates.storage.sqlentity.Instructor; import teammates.storage.sqlentity.Notification; import teammates.storage.sqlentity.ReadNotification; @@ -91,7 +92,8 @@ public final class HibernateUtil { FeedbackRankRecipientsResponse.class, FeedbackRubricResponse.class, FeedbackTextResponse.class, - FeedbackResponseComment.class); + FeedbackResponseComment.class, + FeedbackSessionLog.class); private HibernateUtil() { // Utility class @@ -267,4 +269,14 @@ public static void executeDelete(CriteriaDelete cd) { HibernateUtil.getCurrentSession().createMutationQuery(cd).executeUpdate(); } + /** + * Return a reference to the persistent instance with the given class and + * identifier,making the assumption that the instance is still persistent in the + * database. + * @see Session#getReference(Class, Object) + */ + public static T getReference(Class entityType, Object id) { + return HibernateUtil.getCurrentSession().getReference(entityType, id); + } + } diff --git a/src/main/java/teammates/logic/api/LogsProcessor.java b/src/main/java/teammates/logic/api/LogsProcessor.java index 2f95021e441..1aca48e7147 100644 --- a/src/main/java/teammates/logic/api/LogsProcessor.java +++ b/src/main/java/teammates/logic/api/LogsProcessor.java @@ -2,6 +2,7 @@ import java.time.Instant; import java.util.List; +import java.util.UUID; import teammates.common.datatransfer.FeedbackSessionLogEntry; import teammates.common.datatransfer.QueryLogsResults; @@ -50,6 +51,14 @@ public void createFeedbackSessionLog(String courseId, String email, String fsNam service.createFeedbackSessionLog(courseId, email, fsName, fslType); } + /** + * Creates a feedback session log. + */ + public void createFeedbackSessionLog(String courseId, UUID studentId, String email, UUID fsId, String fsName, + String fslType) { + service.createFeedbackSessionLog(courseId, studentId, email, fsId, fsName, fslType); + } + /** * Gets the feedback session logs as filtered by the given parameters ordered by ascending timestamp. * @param email Can be null diff --git a/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java b/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java index e48dbe6dad8..6d49eb3f26a 100644 --- a/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java +++ b/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; import com.google.api.gax.paging.Page; @@ -114,6 +115,14 @@ public void createFeedbackSessionLog(String courseId, String email, String fsNam // However, this method is not removed as it is necessary to assist in local testing. } + @Override + public void createFeedbackSessionLog(String courseId, UUID studentId, String email, UUID fsId, String fsName, + String fslType) { + // This method is not necessary for production usage because a feedback session log + // is already separately created through the standardized logging infrastructure. + // However, this method is not removed as it is necessary to assist in local testing. + } + @Override public List getOrderedFeedbackSessionLogs(String courseId, String email, long startTime, long endTime, String fsName) { @@ -154,8 +163,10 @@ public List getOrderedFeedbackSessionLogs(String course continue; } - FeedbackSessionLogEntry fslEntry = new FeedbackSessionLogEntry(details.getStudentEmail(), - details.getFeedbackSessionName(), details.getAccessType(), timestamp); + FeedbackSessionLogEntry fslEntry = new FeedbackSessionLogEntry(details.getCourseId(), + UUID.fromString(details.getStudentId()), details.getStudentEmail(), + UUID.fromString(details.getFeedbackSessionId()), details.getFeedbackSessionName(), + details.getAccessType(), timestamp); fsLogEntries.add(fslEntry); } diff --git a/src/main/java/teammates/logic/external/LocalLoggingService.java b/src/main/java/teammates/logic/external/LocalLoggingService.java index 16c04a3d0d0..ac6c4c10fc2 100644 --- a/src/main/java/teammates/logic/external/LocalLoggingService.java +++ b/src/main/java/teammates/logic/external/LocalLoggingService.java @@ -6,6 +6,7 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -203,7 +204,15 @@ private boolean isRequestFilterSatisfied(LogDetails details, String actionClassF @Override public void createFeedbackSessionLog(String courseId, String email, String fsName, String fslType) { - FeedbackSessionLogEntry logEntry = new FeedbackSessionLogEntry(email, fsName, + FeedbackSessionLogEntry logEntry = new FeedbackSessionLogEntry(courseId, email, + fsName, fslType, Instant.now().toEpochMilli()); + FEEDBACK_SESSION_LOG_ENTRIES.computeIfAbsent(courseId, k -> new ArrayList<>()).add(logEntry); + } + + @Override + public void createFeedbackSessionLog(String courseId, UUID studentId, String email, UUID fsId, String fsName, + String fslType) { + FeedbackSessionLogEntry logEntry = new FeedbackSessionLogEntry(courseId, studentId, email, fsId, fsName, fslType, Instant.now().toEpochMilli()); FEEDBACK_SESSION_LOG_ENTRIES.computeIfAbsent(courseId, k -> new ArrayList<>()).add(logEntry); } diff --git a/src/main/java/teammates/logic/external/LogService.java b/src/main/java/teammates/logic/external/LogService.java index ac79d13ab9d..ab0b705fef4 100644 --- a/src/main/java/teammates/logic/external/LogService.java +++ b/src/main/java/teammates/logic/external/LogService.java @@ -1,6 +1,7 @@ package teammates.logic.external; import java.util.List; +import java.util.UUID; import teammates.common.datatransfer.FeedbackSessionLogEntry; import teammates.common.datatransfer.QueryLogsResults; @@ -21,6 +22,12 @@ public interface LogService { */ void createFeedbackSessionLog(String courseId, String email, String fsName, String fslType); + /** + * Creates a feedback session log. + */ + void createFeedbackSessionLog(String courseId, UUID studentId, String email, UUID fsId, String fsName, + String fslType); + /** * Gets the feedback session logs as filtered by the given parameters ordered by ascending timestamp. */ diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index 5cd50a10268..a6a52777d4f 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -29,6 +29,7 @@ import teammates.sqllogic.core.FeedbackQuestionsLogic; import teammates.sqllogic.core.FeedbackResponseCommentsLogic; import teammates.sqllogic.core.FeedbackResponsesLogic; +import teammates.sqllogic.core.FeedbackSessionLogsLogic; import teammates.sqllogic.core.FeedbackSessionsLogic; import teammates.sqllogic.core.NotificationsLogic; import teammates.sqllogic.core.UsageStatisticsLogic; @@ -69,6 +70,7 @@ public class Logic { final FeedbackResponsesLogic feedbackResponsesLogic = FeedbackResponsesLogic.inst(); final FeedbackResponseCommentsLogic feedbackResponseCommentsLogic = FeedbackResponseCommentsLogic.inst(); final FeedbackSessionsLogic feedbackSessionsLogic = FeedbackSessionsLogic.inst(); + final FeedbackSessionLogsLogic feedbackSessionLogsLogic = FeedbackSessionLogsLogic.inst(); final UsageStatisticsLogic usageStatisticsLogic = UsageStatisticsLogic.inst(); final UsersLogic usersLogic = UsersLogic.inst(); final NotificationsLogic notificationsLogic = NotificationsLogic.inst(); @@ -443,6 +445,15 @@ public FeedbackSession getFeedbackSession(String feedbackSessionName, String cou return feedbackSessionsLogic.getFeedbackSession(feedbackSessionName, courseId); } + /** + * Gets a feedback session reference. + * + * @return Returns a proxy for the feedback session. + */ + public FeedbackSession getFeedbackSessionReference(UUID id) { + return feedbackSessionsLogic.getFeedbackSessionReference(id); + } + /** * Gets a feedback session from the recycle bin. * @@ -924,6 +935,16 @@ public Student getStudent(UUID id) { return usersLogic.getStudent(id); } + /** + * Gets student reference associated with {@code id}. + * + * @param id Id of Student. + * @return Returns a proxy for the Student. + */ + public Student getStudentReference(UUID id) { + return usersLogic.getStudentReference(id); + } + /** * Gets student associated with {@code courseId} and {@code email}. */ @@ -1600,8 +1621,21 @@ public List getFeedbackSessionsOpeningWithinTimeLimit() { /** * Create feedback session logs. */ - public void createFeedbackSessionLogs(List feedbackSessionLogs) - throws EntityAlreadyExistsException, InvalidParametersException { - // TODO: implement logic layer + public void createFeedbackSessionLogs(List feedbackSessionLogs) { + feedbackSessionLogsLogic.createFeedbackSessionLogs(feedbackSessionLogs); + } + + /** + * Gets the feedback session logs as filtered by the given parameters ordered by + * ascending timestamp. Logs with the same timestamp will be ordered by the + * student's email. + * + * @param studentId Can be null + * @param feedbackSessionId Can be null + */ + public List getOrderedFeedbackSessionLogs(String courseId, UUID studentId, + UUID feedbackSessionId, Instant startTime, Instant endTime) { + return feedbackSessionLogsLogic.getOrderedFeedbackSessionLogs(courseId, studentId, feedbackSessionId, startTime, + endTime); } } diff --git a/src/main/java/teammates/sqllogic/core/FeedbackSessionLogsLogic.java b/src/main/java/teammates/sqllogic/core/FeedbackSessionLogsLogic.java new file mode 100644 index 00000000000..8ea0c4f3fe5 --- /dev/null +++ b/src/main/java/teammates/sqllogic/core/FeedbackSessionLogsLogic.java @@ -0,0 +1,67 @@ +package teammates.sqllogic.core; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import org.hibernate.ObjectNotFoundException; + +import teammates.common.util.Logger; +import teammates.storage.sqlapi.FeedbackSessionLogsDb; +import teammates.storage.sqlentity.FeedbackSessionLog; + +/** + * Handles operations related to feedback sessions. + * + * @see FeedbackSessionLog + * @see FeedbackSessionLogsDb + */ +public final class FeedbackSessionLogsLogic { + + private static final Logger log = Logger.getLogger(); + + private static final FeedbackSessionLogsLogic instance = new FeedbackSessionLogsLogic(); + + private static final String ERROR_FAILED_TO_CREATE_LOG = "Failed to create session activity log"; + + private FeedbackSessionLogsDb fslDb; + + private FeedbackSessionLogsLogic() { + // prevent initialization + } + + public static FeedbackSessionLogsLogic inst() { + return instance; + } + + void initLogicDependencies(FeedbackSessionLogsDb fslDb) { + this.fslDb = fslDb; + } + + /** + * Creates feedback session logs. + */ + public void createFeedbackSessionLogs(List fsLogs) { + for (FeedbackSessionLog fsLog : fsLogs) { + try { + fslDb.createFeedbackSessionLog(fsLog); + } catch (ObjectNotFoundException e) { + log.severe(String.format(ERROR_FAILED_TO_CREATE_LOG), e); + } + } + } + + /** + * Gets the feedback session logs as filtered by the given parameters ordered by + * ascending timestamp. Logs with the same timestamp will be ordered by the + * student's email. + * + * @param studentId Can be null + * @param feedbackSessionId Can be null + */ + public List getOrderedFeedbackSessionLogs(String courseId, UUID studentId, + UUID feedbackSessionId, Instant startTime, Instant endTime) { + return fslDb.getOrderedFeedbackSessionLogs(courseId, studentId, feedbackSessionId, startTime, + endTime); + } +} diff --git a/src/main/java/teammates/sqllogic/core/FeedbackSessionsLogic.java b/src/main/java/teammates/sqllogic/core/FeedbackSessionsLogic.java index 84ea61b2a0c..a707990d083 100644 --- a/src/main/java/teammates/sqllogic/core/FeedbackSessionsLogic.java +++ b/src/main/java/teammates/sqllogic/core/FeedbackSessionsLogic.java @@ -87,6 +87,16 @@ public FeedbackSession getFeedbackSession(String feedbackSessionName, String cou return fsDb.getFeedbackSession(feedbackSessionName, courseId); } + /** + * Gets a feedback session reference. + * + * @return Returns a proxy for the feedback session. + */ + public FeedbackSession getFeedbackSessionReference(UUID id) { + assert id != null; + return fsDb.getFeedbackSessionReference(id); + } + /** * Gets all feedback sessions of a course, except those that are soft-deleted. */ diff --git a/src/main/java/teammates/sqllogic/core/LogicStarter.java b/src/main/java/teammates/sqllogic/core/LogicStarter.java index b474cac8980..4ae35a61a52 100644 --- a/src/main/java/teammates/sqllogic/core/LogicStarter.java +++ b/src/main/java/teammates/sqllogic/core/LogicStarter.java @@ -11,6 +11,7 @@ import teammates.storage.sqlapi.FeedbackQuestionsDb; import teammates.storage.sqlapi.FeedbackResponseCommentsDb; import teammates.storage.sqlapi.FeedbackResponsesDb; +import teammates.storage.sqlapi.FeedbackSessionLogsDb; import teammates.storage.sqlapi.FeedbackSessionsDb; import teammates.storage.sqlapi.NotificationsDb; import teammates.storage.sqlapi.UsageStatisticsDb; @@ -33,6 +34,7 @@ public static void initializeDependencies() { DataBundleLogic dataBundleLogic = DataBundleLogic.inst(); DeadlineExtensionsLogic deadlineExtensionsLogic = DeadlineExtensionsLogic.inst(); FeedbackSessionsLogic fsLogic = FeedbackSessionsLogic.inst(); + FeedbackSessionLogsLogic fslLogic = FeedbackSessionLogsLogic.inst(); FeedbackResponsesLogic frLogic = FeedbackResponsesLogic.inst(); FeedbackResponseCommentsLogic frcLogic = FeedbackResponseCommentsLogic.inst(); FeedbackQuestionsLogic fqLogic = FeedbackQuestionsLogic.inst(); @@ -48,6 +50,7 @@ public static void initializeDependencies() { notificationsLogic, usersLogic); deadlineExtensionsLogic.initLogicDependencies(DeadlineExtensionsDb.inst(), fsLogic); fsLogic.initLogicDependencies(FeedbackSessionsDb.inst(), coursesLogic, frLogic, fqLogic, usersLogic); + fslLogic.initLogicDependencies(FeedbackSessionLogsDb.inst()); frLogic.initLogicDependencies(FeedbackResponsesDb.inst(), usersLogic, fqLogic, frcLogic); frcLogic.initLogicDependencies(FeedbackResponseCommentsDb.inst()); fqLogic.initLogicDependencies(FeedbackQuestionsDb.inst(), coursesLogic, frLogic, usersLogic, fsLogic); diff --git a/src/main/java/teammates/sqllogic/core/UsersLogic.java b/src/main/java/teammates/sqllogic/core/UsersLogic.java index e0486616a69..bfac68a3f36 100644 --- a/src/main/java/teammates/sqllogic/core/UsersLogic.java +++ b/src/main/java/teammates/sqllogic/core/UsersLogic.java @@ -482,6 +482,18 @@ public Student getStudent(UUID id) { return usersDb.getStudent(id); } + /** + * Gets student reference associated with {@code id}. + * + * @param id Id of Student. + * @return Returns a proxy for the Student. + */ + public Student getStudentReference(UUID id) { + assert id != null; + + return usersDb.getStudentReference(id); + } + /** * Gets the student with the specified email. */ diff --git a/src/main/java/teammates/storage/sqlapi/FeedbackSessionLogsDb.java b/src/main/java/teammates/storage/sqlapi/FeedbackSessionLogsDb.java new file mode 100644 index 00000000000..c9f6af098ff --- /dev/null +++ b/src/main/java/teammates/storage/sqlapi/FeedbackSessionLogsDb.java @@ -0,0 +1,86 @@ +package teammates.storage.sqlapi; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import teammates.common.util.HibernateUtil; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; +import teammates.storage.sqlentity.Student; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +/** + * Handles CRUD operations for feedback session logs. + * + * @see FeedbackSessionLog + */ +public final class FeedbackSessionLogsDb extends EntitiesDb { + + private static final FeedbackSessionLogsDb instance = new FeedbackSessionLogsDb(); + + private FeedbackSessionLogsDb() { + // prevent initialization + } + + public static FeedbackSessionLogsDb inst() { + return instance; + } + + /** + * Gets the feedback session logs as filtered by the given parameters ordered by + * ascending timestamp. Logs with the same timestamp will be ordered by the + * student's email. + * + * @param studentId Can be null + * @param feedbackSessionId Can be null + */ + public List getOrderedFeedbackSessionLogs(String courseId, UUID studentId, + UUID feedbackSessionId, Instant startTime, Instant endTime) { + + assert courseId != null; + assert startTime != null; + assert endTime != null; + + CriteriaBuilder cb = HibernateUtil.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(FeedbackSessionLog.class); + Root root = cr.from(FeedbackSessionLog.class); + Join feedbackSessionJoin = root.join("feedbackSession"); + Join studentJoin = root.join("student"); + + List predicates = new ArrayList<>(); + + if (studentId != null) { + predicates.add(cb.equal(studentJoin.get("id"), studentId)); + } + + if (feedbackSessionId != null) { + predicates.add(cb.equal(feedbackSessionJoin.get("id"), feedbackSessionId)); + } + + predicates.add(cb.equal(feedbackSessionJoin.get("course").get("id"), courseId)); + predicates.add(cb.greaterThanOrEqualTo(root.get("timestamp"), startTime)); + predicates.add(cb.lessThan(root.get("timestamp"), endTime)); + + cr.select(root).where(predicates.toArray(new Predicate[0])).orderBy(cb.asc(root.get("timestamp")), + cb.asc(studentJoin.get("email"))); + return HibernateUtil.createQuery(cr).getResultList(); + } + + /** + * Creates feedback session logs. + */ + public FeedbackSessionLog createFeedbackSessionLog(FeedbackSessionLog log) { + assert log != null; + + persist(log); + + return log; + } +} diff --git a/src/main/java/teammates/storage/sqlapi/FeedbackSessionsDb.java b/src/main/java/teammates/storage/sqlapi/FeedbackSessionsDb.java index 389407e9f28..078d453da2c 100644 --- a/src/main/java/teammates/storage/sqlapi/FeedbackSessionsDb.java +++ b/src/main/java/teammates/storage/sqlapi/FeedbackSessionsDb.java @@ -65,6 +65,17 @@ public FeedbackSession getFeedbackSession(String feedbackSessionName, String cou return HibernateUtil.createQuery(cq).getResultStream().findFirst().orElse(null); } + /** + * Gets a feedback session reference. + * + * @return Returns a proxy for the feedback session. + */ + public FeedbackSession getFeedbackSessionReference(UUID id) { + assert id != null; + + return HibernateUtil.getReference(FeedbackSession.class, id); + } + /** * Gets a soft-deleted feedback session. * diff --git a/src/main/java/teammates/storage/sqlapi/UsersDb.java b/src/main/java/teammates/storage/sqlapi/UsersDb.java index 5d3b8571071..3621f5c6224 100644 --- a/src/main/java/teammates/storage/sqlapi/UsersDb.java +++ b/src/main/java/teammates/storage/sqlapi/UsersDb.java @@ -146,6 +146,15 @@ public Student getStudent(UUID id) { return HibernateUtil.get(Student.class, id); } + /** + * Gets a student reference by its {@code id}. + */ + public Student getStudentReference(UUID id) { + assert id != null; + + return HibernateUtil.getReference(Student.class, id); + } + /** * Gets a student by {@code regKey}. */ diff --git a/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java b/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java index 61d54f3c8a1..9c6a7ed2825 100644 --- a/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java +++ b/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java @@ -6,6 +6,9 @@ import java.util.Objects; import java.util.UUID; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; + import teammates.common.datatransfer.logs.FeedbackSessionLogType; import jakarta.persistence.Column; @@ -13,6 +16,8 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; /** @@ -24,11 +29,15 @@ public class FeedbackSessionLog extends BaseEntity { @Id private UUID id; - @Column(nullable = false) - private String studentEmail; + @ManyToOne + @JoinColumn(name = "studentId") + @NotFound(action = NotFoundAction.IGNORE) + private Student student; - @Column(nullable = false) - private String feedbackSessionName; + @ManyToOne + @JoinColumn(name = "sessionId") + @NotFound(action = NotFoundAction.IGNORE) + private FeedbackSession feedbackSession; @Column(nullable = false) @Enumerated(EnumType.STRING) @@ -41,11 +50,11 @@ protected FeedbackSessionLog() { // required by Hibernate } - public FeedbackSessionLog(String email, String feedbackSessionName, FeedbackSessionLogType feedbackSessionLogType, - Instant timestamp) { + public FeedbackSessionLog(Student student, FeedbackSession feedbackSession, + FeedbackSessionLogType feedbackSessionLogType, Instant timestamp) { this.setId(UUID.randomUUID()); - this.studentEmail = email; - this.feedbackSessionName = feedbackSessionName; + this.student = student; + this.feedbackSession = feedbackSession; this.feedbackSessionLogType = feedbackSessionLogType; this.timestamp = timestamp; } @@ -58,20 +67,20 @@ public void setId(UUID id) { this.id = id; } - public String getStudentEmail() { - return studentEmail; + public Student getStudent() { + return student; } - public void setStudentEmail(String email) { - this.studentEmail = email; + public void setStudent(Student student) { + this.student = student; } - public String getFeedbackSessionName() { - return feedbackSessionName; + public FeedbackSession getFeedbackSession() { + return feedbackSession; } - public void setFeedbackSessionName(String feedbackSessionName) { - this.feedbackSessionName = feedbackSessionName; + public void setFeedbackSession(FeedbackSession feedbackSession) { + this.feedbackSession = feedbackSession; } public FeedbackSessionLogType getFeedbackSessionLogType() { @@ -92,8 +101,7 @@ public void setTimestamp(Instant timestamp) { @Override public String toString() { - return "FeedbackSessionLog [id=" + id + ", email=" + studentEmail + ", feedbackSessionName=" - + feedbackSessionName + return "FeedbackSessionLog [id=" + id + ", student=" + student + ", feedbackSession=" + feedbackSession + ", feedbackSessionLogType=" + feedbackSessionLogType.getLabel() + ", timestamp=" + timestamp + "]"; } diff --git a/src/main/java/teammates/ui/output/FeedbackSessionData.java b/src/main/java/teammates/ui/output/FeedbackSessionData.java index 9b529f1b5b2..b5aef354d88 100644 --- a/src/main/java/teammates/ui/output/FeedbackSessionData.java +++ b/src/main/java/teammates/ui/output/FeedbackSessionData.java @@ -3,6 +3,7 @@ import java.time.Instant; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; import javax.annotation.Nullable; @@ -20,6 +21,10 @@ * The API output format of {@link FeedbackSessionAttributes}. */ public class FeedbackSessionData extends ApiOutput { + + @Nullable + private final UUID feedbackSessionId; + private final String courseId; private final String timeZone; private final String feedbackSessionName; @@ -60,6 +65,7 @@ public class FeedbackSessionData extends ApiOutput { public FeedbackSessionData(FeedbackSessionAttributes feedbackSessionAttributes) { String timeZone = feedbackSessionAttributes.getTimeZone(); + this.feedbackSessionId = null; this.courseId = feedbackSessionAttributes.getCourseId(); this.timeZone = timeZone; this.feedbackSessionName = feedbackSessionAttributes.getFeedbackSessionName(); @@ -148,6 +154,7 @@ public FeedbackSessionData(FeedbackSession feedbackSession) { assert feedbackSession != null; assert feedbackSession.getCourse() != null; String timeZone = feedbackSession.getCourse().getTimeZone(); + this.feedbackSessionId = feedbackSession.getId(); this.courseId = feedbackSession.getCourse().getId(); this.timeZone = timeZone; this.feedbackSessionName = feedbackSession.getName(); @@ -252,6 +259,10 @@ public FeedbackSessionData(FeedbackSession feedbackSession, Instant extendedDead } } + public UUID getFeedbackSessionId() { + return feedbackSessionId; + } + public String getCourseId() { return courseId; } diff --git a/src/main/java/teammates/ui/output/StudentData.java b/src/main/java/teammates/ui/output/StudentData.java index 933b9e9d025..21138ae9026 100644 --- a/src/main/java/teammates/ui/output/StudentData.java +++ b/src/main/java/teammates/ui/output/StudentData.java @@ -1,5 +1,7 @@ package teammates.ui.output; +import java.util.UUID; + import javax.annotation.Nullable; import teammates.common.datatransfer.attributes.StudentAttributes; @@ -10,6 +12,9 @@ */ public class StudentData extends ApiOutput { + @Nullable + private final UUID studentId; + private final String email; private final String courseId; @@ -29,6 +34,7 @@ public class StudentData extends ApiOutput { private final String sectionName; public StudentData(StudentAttributes studentAttributes) { + this.studentId = null; this.email = studentAttributes.getEmail(); this.courseId = studentAttributes.getCourse(); this.name = studentAttributes.getName(); @@ -39,6 +45,7 @@ public StudentData(StudentAttributes studentAttributes) { } public StudentData(Student student) { + this.studentId = student.getId(); this.email = student.getEmail(); this.courseId = student.getCourseId(); this.name = student.getName(); @@ -48,6 +55,10 @@ public StudentData(Student student) { this.sectionName = student.getSectionName(); } + public UUID getStudentId() { + return studentId; + } + public String getEmail() { return email; } diff --git a/src/main/java/teammates/ui/webapi/CreateFeedbackSessionLogAction.java b/src/main/java/teammates/ui/webapi/CreateFeedbackSessionLogAction.java index cf77994aa9f..69b5e43d0e6 100644 --- a/src/main/java/teammates/ui/webapi/CreateFeedbackSessionLogAction.java +++ b/src/main/java/teammates/ui/webapi/CreateFeedbackSessionLogAction.java @@ -1,9 +1,13 @@ package teammates.ui.webapi; +import java.util.UUID; + import teammates.common.datatransfer.logs.FeedbackSessionAuditLogDetails; import teammates.common.datatransfer.logs.FeedbackSessionLogType; import teammates.common.util.Const; import teammates.common.util.Logger; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.Student; /** * Action: creates a feedback session log for the purposes of tracking and auditing. @@ -35,15 +39,35 @@ public JsonResult execute() { String studentEmail = getNonNullRequestParamValue(Const.ParamsNames.STUDENT_EMAIL); // Skip rigorous validations to avoid incurring extra db reads and to keep the endpoint light - // Necessary to assist local testing. For production usage, this will be a no-op. - logsProcessor.createFeedbackSessionLog(courseId, studentEmail, fsName, fslType); - FeedbackSessionAuditLogDetails details = new FeedbackSessionAuditLogDetails(); details.setCourseId(courseId); details.setFeedbackSessionName(fsName); details.setStudentEmail(studentEmail); details.setAccessType(fslType); + if (isCourseMigrated(courseId)) { + // TODO: remove unnecessary db reads after updating the front end + Student student = sqlLogic.getStudentForEmail(courseId, studentEmail); + FeedbackSession feedbackSession = sqlLogic.getFeedbackSession(fsName, courseId); + UUID studentId = null; + UUID fsId = null; + + if (student != null) { + studentId = student.getId(); + details.setStudentId(studentId.toString()); + } + + if (feedbackSession != null) { + fsId = feedbackSession.getId(); + details.setFeedbackSessionId(fsId.toString()); + } + // Necessary to assist local testing. For production usage, this will be a no-op. + logsProcessor.createFeedbackSessionLog(courseId, studentId, studentEmail, fsId, fsName, fslType); + } else { + // Necessary to assist local testing. For production usage, this will be a no-op. + logsProcessor.createFeedbackSessionLog(courseId, null, studentEmail, null, fsName, fslType); + } + log.event("Feedback session audit event: " + fslType, details); return new JsonResult("Successful"); diff --git a/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java b/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java index 568852dd032..164db9fa344 100644 --- a/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java +++ b/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java @@ -6,23 +6,23 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import teammates.common.datatransfer.FeedbackSessionLogEntry; import teammates.common.datatransfer.logs.FeedbackSessionLogType; -import teammates.common.exception.EntityAlreadyExistsException; -import teammates.common.exception.InvalidParametersException; -import teammates.common.util.Logger; import teammates.common.util.TimeHelper; +import teammates.storage.sqlentity.FeedbackSession; import teammates.storage.sqlentity.FeedbackSessionLog; +import teammates.storage.sqlentity.Student; /** - * Process feedback session logs in the past defined time period and store in the database. + * Process feedback session logs from GCP in the past defined time period and + * store in the database. */ public class UpdateFeedbackSessionLogsAction extends AdminOnlyAction { static final int COLLECTION_TIME_PERIOD = 60; // represents one hour static final long SPAM_FILTER = 2000L; // in ms - private static final Logger log = Logger.getLogger(); @Override public JsonResult execute() { @@ -34,30 +34,35 @@ public JsonResult execute() { List logEntries = logsProcessor.getOrderedFeedbackSessionLogs(null, null, startTime.toEpochMilli(), endTime.toEpochMilli(), null); - Map>> lastSavedTimestamps = new HashMap<>(); + Map>>> lastSavedTimestamps = new HashMap<>(); for (FeedbackSessionLogEntry logEntry : logEntries) { - String email = logEntry.getStudentEmail(); - String fbSessionName = logEntry.getFeedbackSessionName(); + + if (!isCourseMigrated(logEntry.getCourseId())) { + continue; + } + + String courseId = logEntry.getCourseId(); + UUID studentId = logEntry.getStudentId(); + UUID fbSessionId = logEntry.getFeedbackSessionId(); String type = logEntry.getFeedbackSessionLogType(); Long timestamp = logEntry.getTimestamp(); - lastSavedTimestamps.putIfAbsent(email, new HashMap<>()); - lastSavedTimestamps.get(email).putIfAbsent(fbSessionName, new HashMap<>()); - Long lastSaved = lastSavedTimestamps.get(email).get(fbSessionName).getOrDefault(type, 0L); + lastSavedTimestamps.computeIfAbsent(studentId, k -> new HashMap<>()); + lastSavedTimestamps.get(studentId).computeIfAbsent(courseId, k -> new HashMap<>()); + lastSavedTimestamps.get(studentId).get(courseId).computeIfAbsent(fbSessionId, k -> new HashMap<>()); + Long lastSaved = lastSavedTimestamps.get(studentId).get(courseId).get(fbSessionId).getOrDefault(type, 0L); if (Math.abs(timestamp - lastSaved) > SPAM_FILTER) { - lastSavedTimestamps.get(email).get(fbSessionName).put(type, timestamp); - FeedbackSessionLog fslEntity = new FeedbackSessionLog(email, fbSessionName, + lastSavedTimestamps.get(studentId).get(courseId).get(fbSessionId).put(type, timestamp); + Student student = sqlLogic.getStudentReference(studentId); + FeedbackSession feedbackSession = sqlLogic.getFeedbackSessionReference(fbSessionId); + FeedbackSessionLog fslEntity = new FeedbackSessionLog(student, feedbackSession, FeedbackSessionLogType.valueOfLabel(type), Instant.ofEpochMilli(timestamp)); filteredLogs.add(fslEntity); } } - try { - sqlLogic.createFeedbackSessionLogs(filteredLogs); - } catch (InvalidParametersException | EntityAlreadyExistsException e) { - log.severe("Unexpected error", e); - } + sqlLogic.createFeedbackSessionLogs(filteredLogs); return new JsonResult("Successful"); } diff --git a/src/test/java/teammates/logic/api/MockLogsProcessor.java b/src/test/java/teammates/logic/api/MockLogsProcessor.java index f07a15d7701..fbff7904f4d 100644 --- a/src/test/java/teammates/logic/api/MockLogsProcessor.java +++ b/src/test/java/teammates/logic/api/MockLogsProcessor.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.UUID; import teammates.common.datatransfer.FeedbackSessionLogEntry; import teammates.common.datatransfer.QueryLogsResults; @@ -23,9 +24,19 @@ public class MockLogsProcessor extends LogsProcessor { /** * Simulates insertion of feedback session logs. */ - public void insertFeedbackSessionLog(String studentEmail, String feedbackSessionName, + public void insertFeedbackSessionLog(String courseId, String studentEmail, String feedbackSessionName, String fslType, long timestamp) { - feedbackSessionLogs.add(new FeedbackSessionLogEntry(studentEmail, feedbackSessionName, fslType, timestamp)); + feedbackSessionLogs + .add(new FeedbackSessionLogEntry(courseId, studentEmail, feedbackSessionName, fslType, timestamp)); + } + + /** + * Simulates insertion of feedback session logs. + */ + public void insertFeedbackSessionLog(String courseId, UUID studentId, String studentEmail, + UUID feedbackSessionId, String feedbackSessionName, String fslType, long timestamp) { + feedbackSessionLogs.add(new FeedbackSessionLogEntry(courseId, studentId, studentEmail, feedbackSessionId, + feedbackSessionName, fslType, timestamp)); } /** @@ -97,7 +108,8 @@ public QueryLogsResults queryLogs(QueryLogsParams queryLogsParams) { } @Override - public void createFeedbackSessionLog(String courseId, String email, String fsName, String fslType) { + public void createFeedbackSessionLog(String courseId, UUID studentId, String email, UUID fsId, String fsName, + String fslType) { // No-op } diff --git a/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java b/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java index d244f7eeb62..72a8ec36802 100644 --- a/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java +++ b/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java @@ -1,12 +1,15 @@ package teammates.sqlui.webapi; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -17,7 +20,10 @@ import teammates.common.exception.InvalidParametersException; import teammates.common.util.Const; import teammates.common.util.TimeHelper; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackSession; import teammates.storage.sqlentity.FeedbackSessionLog; +import teammates.storage.sqlentity.Student; import teammates.ui.webapi.UpdateFeedbackSessionLogsAction; /** @@ -29,11 +35,15 @@ public class UpdateFeedbackSessionLogsActionTest static final int COLLECTION_TIME_PERIOD = 60; // represents one hour static final long SPAM_FILTER = 2000L; // in ms - String student1 = "student1"; - String student2 = "student2"; + Student student1; + Student student2; - String feedbackSession1 = "fs1"; - String feedbackSession2 = "fs2"; + Course course1; + Course course2; + + FeedbackSession session1InCourse1; + FeedbackSession session2InCourse1; + FeedbackSession session1InCourse2; Instant endTime; Instant startTime; @@ -52,6 +62,42 @@ String getRequestMethod() { void setUp() { endTime = TimeHelper.getInstantNearestHourBefore(Instant.now()); startTime = endTime.minus(COLLECTION_TIME_PERIOD, ChronoUnit.MINUTES); + + course1 = getTypicalCourse(); + course1.setId("course1"); + + course2 = getTypicalCourse(); + course2.setId("course2"); + + student1 = getTypicalStudent(); + student1.setEmail("student1@teammates.tmt"); + student1.setId(UUID.randomUUID()); + + student2 = getTypicalStudent(); + student2.setEmail("student2@teammates.tmt"); + student2.setId(UUID.randomUUID()); + + session1InCourse1 = getTypicalFeedbackSessionForCourse(course1); + session1InCourse1.setName("session1"); + session1InCourse1.setId(UUID.randomUUID()); + + session2InCourse1 = getTypicalFeedbackSessionForCourse(course1); + session2InCourse1.setName("session2"); + session2InCourse1.setId(UUID.randomUUID()); + + session1InCourse2 = getTypicalFeedbackSessionForCourse(course2); + session1InCourse2.setName("session1"); + session1InCourse2.setId(UUID.randomUUID()); + + reset(mockLogic); + + when(mockLogic.getStudentReference(student1.getId())).thenReturn(student1); + when(mockLogic.getStudentReference(student2.getId())).thenReturn(student2); + + when(mockLogic.getFeedbackSessionReference(session1InCourse1.getId())).thenReturn(session1InCourse1); + when(mockLogic.getFeedbackSessionReference(session2InCourse1.getId())).thenReturn(session2InCourse1); + when(mockLogic.getFeedbackSessionReference(session1InCourse2.getId())).thenReturn(session1InCourse2); + mockLogsProcessor.getOrderedFeedbackSessionLogs("", "", 0, 0, "").clear(); } @@ -61,95 +107,102 @@ public void testExecute_noRecentLogs_noLogsCreated() UpdateFeedbackSessionLogsAction action = getAction(); action.execute(); - verify(mockLogic) - .createFeedbackSessionLogs(argThat(filteredLogs -> filteredLogs.size() == 0)); + verify(mockLogic).createFeedbackSessionLogs(argThat(filteredLogs -> filteredLogs.size() == 0)); } @Test public void testExecute_recentLogsNoSpam_allLogsCreated() throws EntityAlreadyExistsException, InvalidParametersException { // Different Types - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(300).toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.SUBMISSION.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.SUBMISSION.getLabel(), startTime.plusSeconds(300).toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.VIEW_RESULT.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.VIEW_RESULT.getLabel(), startTime.plusSeconds(300).toEpochMilli()); // Different feedback sessions - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(600).toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession2, - FeedbackSessionLogType.ACCESS.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session2InCourse1.getId(), session2InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(600).toEpochMilli()); // Different Student - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(900).toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(student2, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student2.getId(), student2.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(900).toEpochMilli()); + // Different course + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(1200).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course2.getId(), student1.getId(), student1.getEmail(), + session1InCourse2.getId(), session1InCourse2.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(1200).toEpochMilli()); + // Gap is larger than spam filter - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); UpdateFeedbackSessionLogsAction action = getAction(); action.execute(); // method returns all logs regardless of params - List expected = - mockLogsProcessor.getOrderedFeedbackSessionLogs("", "", 0, 0, ""); + List expected = mockLogsProcessor.getOrderedFeedbackSessionLogs("", "", 0, 0, ""); - verify(mockLogic).createFeedbackSessionLogs( - argThat(filteredLogs -> isEqualExceptId(expected, filteredLogs))); + verify(mockLogic).createFeedbackSessionLogs(argThat(filteredLogs -> isEqual(expected, filteredLogs))); } @Test public void testExecute_recentLogsWithSpam_someLogsCreated() throws EntityAlreadyExistsException, InvalidParametersException { // Gap is smaller than spam filter - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER - 2).toEpochMilli()); // Filters multiple logs within one spam window - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER - 1).toEpochMilli()); // Correctly adds new log after filtering - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); // Filters out spam in the new window - mockLogsProcessor.insertFeedbackSessionLog(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 2).toEpochMilli()); UpdateFeedbackSessionLogsAction action = getAction(); action.execute(); List expected = new ArrayList<>(); - expected.add(new FeedbackSessionLogEntry(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli())); - expected.add(new FeedbackSessionLogEntry(student1, feedbackSession1, - FeedbackSessionLogType.ACCESS.getLabel(), + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.toEpochMilli())); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1.getId(), student1.getEmail(), + session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli())); - verify(mockLogic).createFeedbackSessionLogs( - argThat(filteredLogs -> isEqualExceptId(expected, filteredLogs))); + verify(mockLogic).createFeedbackSessionLogs(argThat(filteredLogs -> isEqual(expected, filteredLogs))); } @Test @@ -176,30 +229,21 @@ public void testSpecificAccessControl_loggedOut_cannotAccess() { verifyCannotAccess(); } - private Boolean isEqualExceptId(List expected, - List actual) { - if (expected.size() != actual.size()) { - return false; - } + private Boolean isEqual(List expected, List actual) { + + assertEquals(expected.size(), actual.size()); for (int i = 0; i < expected.size(); i++) { FeedbackSessionLogEntry expectedEntry = expected.get(i); FeedbackSessionLog actualLog = actual.get(i); - if (!expectedEntry.getStudentEmail().equals(actualLog.getStudentEmail())) { - return false; - } - if (!expectedEntry.getFeedbackSessionName() - .equals(actualLog.getFeedbackSessionName())) { - return false; - } - if (!expectedEntry.getFeedbackSessionLogType() - .equals(actualLog.getFeedbackSessionLogType().getLabel())) { - return false; - } - if (expectedEntry.getTimestamp() != actualLog.getTimestamp().toEpochMilli()) { - return false; - } + assertEquals(expectedEntry.getStudentId(), actualLog.getStudent().getId()); + + assertEquals(expectedEntry.getFeedbackSessionId(), actualLog.getFeedbackSession().getId()); + + assertEquals(expectedEntry.getFeedbackSessionLogType(), actualLog.getFeedbackSessionLogType().getLabel()); + + assertEquals(expectedEntry.getTimestamp(), actualLog.getTimestamp().toEpochMilli()); } return true; diff --git a/src/test/java/teammates/storage/sqlapi/FeedbackSessionLogsDbTest.java b/src/test/java/teammates/storage/sqlapi/FeedbackSessionLogsDbTest.java new file mode 100644 index 00000000000..ee6bf29b1ff --- /dev/null +++ b/src/test/java/teammates/storage/sqlapi/FeedbackSessionLogsDbTest.java @@ -0,0 +1,46 @@ +package teammates.storage.sqlapi; + +import static org.mockito.Mockito.mockStatic; + +import java.time.Instant; + +import org.mockito.MockedStatic; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.logs.FeedbackSessionLogType; +import teammates.common.util.HibernateUtil; +import teammates.storage.sqlentity.FeedbackSessionLog; +import teammates.test.BaseTestCase; + +/** + * SUT: {@code FeedbackSessionLogsDb}. + */ +public class FeedbackSessionLogsDbTest extends BaseTestCase { + + private FeedbackSessionLogsDb feedbackSessionLogsDb = FeedbackSessionLogsDb.inst(); + + private MockedStatic mockHibernateUtil; + + @BeforeMethod + public void setUpMethod() { + mockHibernateUtil = mockStatic(HibernateUtil.class); + } + + @AfterMethod + public void teardownMethod() { + mockHibernateUtil.close(); + } + + @Test + public void testCreateFeedbackSessionLog_success() { + + FeedbackSessionLog logToAdd = new FeedbackSessionLog(getTypicalStudent(), + getTypicalFeedbackSessionForCourse(getTypicalCourse()), FeedbackSessionLogType.ACCESS, + Instant.parse("2011-01-01T00:00:00Z")); + feedbackSessionLogsDb.createFeedbackSessionLog(logToAdd); + + mockHibernateUtil.verify(() -> HibernateUtil.persist(logToAdd)); + } +} diff --git a/src/test/java/teammates/ui/webapi/GetFeedbackSessionLogsActionTest.java b/src/test/java/teammates/ui/webapi/GetFeedbackSessionLogsActionTest.java index c668a9394f8..41b1b0421bc 100644 --- a/src/test/java/teammates/ui/webapi/GetFeedbackSessionLogsActionTest.java +++ b/src/test/java/teammates/ui/webapi/GetFeedbackSessionLogsActionTest.java @@ -48,15 +48,15 @@ protected void testExecute() { long startTime = endTime - (Const.LOGS_RETENTION_PERIOD.toDays() - 1) * 24 * 60 * 60 * 1000; long invalidStartTime = endTime - (Const.LOGS_RETENTION_PERIOD.toDays() + 1) * 24 * 60 * 60 * 1000; - mockLogsProcessor.insertFeedbackSessionLog(student1Email, fsa1Name, + mockLogsProcessor.insertFeedbackSessionLog(courseId, student1Email, fsa1Name, FeedbackSessionLogType.ACCESS.getLabel(), startTime); - mockLogsProcessor.insertFeedbackSessionLog(student1Email, fsa2Name, + mockLogsProcessor.insertFeedbackSessionLog(courseId, student1Email, fsa2Name, FeedbackSessionLogType.ACCESS.getLabel(), startTime + 1000); - mockLogsProcessor.insertFeedbackSessionLog(student1Email, fsa2Name, + mockLogsProcessor.insertFeedbackSessionLog(courseId, student1Email, fsa2Name, FeedbackSessionLogType.SUBMISSION.getLabel(), startTime + 2000); - mockLogsProcessor.insertFeedbackSessionLog(student2Email, fsa1Name, + mockLogsProcessor.insertFeedbackSessionLog(courseId, student2Email, fsa1Name, FeedbackSessionLogType.ACCESS.getLabel(), startTime + 3000); - mockLogsProcessor.insertFeedbackSessionLog(student2Email, fsa1Name, + mockLogsProcessor.insertFeedbackSessionLog(courseId, student2Email, fsa1Name, FeedbackSessionLogType.SUBMISSION.getLabel(), startTime + 4000); ______TS("Failure case: not enough parameters"); From c4fe140451319096228eafe318476083ce256224 Mon Sep 17 00:00:00 2001 From: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> Date: Fri, 29 Mar 2024 00:28:37 +0800 Subject: [PATCH 14/95] Merge pull request #12960 from TEAMMATES/master (#12961) From f6329eb10233e07143153782451605524216c901 Mon Sep 17 00:00:00 2001 From: Xenos F Date: Fri, 29 Mar 2024 01:22:35 +0800 Subject: [PATCH 15/95] [#11878] Add snapshot tests for instructor request form UI (#12942) * Add snapshot tests * Change double quotes to single quotes --- .../request-page.component.spec.ts.snap | 181 +++++++++++++++ ...ructor-request-form.component.spec.ts.snap | 209 ++++++++++++++++++ .../instructor-request-form.component.spec.ts | 4 + .../request-page.component.spec.ts | 27 +++ 4 files changed, 421 insertions(+) create mode 100644 src/web/app/pages-static/request-page/__snapshots__/request-page.component.spec.ts.snap create mode 100644 src/web/app/pages-static/request-page/instructor-request-form/__snapshots__/instructor-request-form.component.spec.ts.snap diff --git a/src/web/app/pages-static/request-page/__snapshots__/request-page.component.spec.ts.snap b/src/web/app/pages-static/request-page/__snapshots__/request-page.component.spec.ts.snap new file mode 100644 index 00000000000..aab4f634f16 --- /dev/null +++ b/src/web/app/pages-static/request-page/__snapshots__/request-page.component.spec.ts.snap @@ -0,0 +1,181 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RequestPageComponent should render correctly after form is submitted 1`] = ` + +

    + Request for an Instructor Account +

    +
    +

    + Your request has been submitted successfully: +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Full Name + + Jane Smith +
    + Institution + + University of Example +
    + Country + + Example Republic +
    + Email + + js@exampleu.edu +
    + Home Page URL + + u.exampleu.edu/jsmith +
    + Comments + + +
    +

    + We have sent an acknowledgement email to your email address + + js@exampleu.edu + + . Please check your email inbox or spam folder. If you do not receive the acknowledgement email within 1 hour, please + + contact + + us. +

    +
    +
    +
    +`; + +exports[`RequestPageComponent should render correctly after instructor declaration is done 1`] = ` + +

    + Request for an Instructor Account +

    +
    +

    + Request for an instructor account using this form if you are an instructor and want to use TEAMMATES to manage peer evaluations and/or other feedback paths of your students. +

    +
    +
    + +
    +
    +
    +
    +
    +`; + +exports[`RequestPageComponent should render correctly before instructor declaration is done 1`] = ` + +

    + Request for an Instructor Account +

    +
    +

    + Request for an instructor account using this form if you are an instructor and want to use TEAMMATES to manage peer evaluations and/or other feedback paths of your students. +

    +
    +
    +

    + Note: + + Students should not use this form to request for TEAMMATES accounts + + , as students do not need accounts to use TEAMMATES. Instead, TEAMMATES will email students (who have been added to TEAMMATES by a course instructor) an access link when there is a TEAMMATES session available for them to access. +

    + + Back to home page + + +
    +
    +
    +
    +
    +`; diff --git a/src/web/app/pages-static/request-page/instructor-request-form/__snapshots__/instructor-request-form.component.spec.ts.snap b/src/web/app/pages-static/request-page/instructor-request-form/__snapshots__/instructor-request-form.component.spec.ts.snap new file mode 100644 index 00000000000..cae64607d64 --- /dev/null +++ b/src/web/app/pages-static/request-page/instructor-request-form/__snapshots__/instructor-request-form.component.spec.ts.snap @@ -0,0 +1,209 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InstructorRequestFormComponent should render correctly 1`] = ` + +
    +
    + +

    + This is the name that will be shown to your students. You may include salutation (Dr. Prof. etc.) +

    + + +
    +
    +
    + +

    + Please give full name of the university/institution. +

    + + +
    +
    +
    + +

    + Which country is your university/institution based in? +

    + + +
    +
    +
    + +

    + Please use the email address + + given to you by your school/university + + (not your personal Gmail/Hotmail address). Note that this email address will be visible to the students you enroll in TEAMMATES. +

    + + +
    +
    +
    + + +
    +
    +
    + + + [attr.aria-invalid]="comments.invalid">

    -
    diff --git a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.scss b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.scss index addb0b11c7a..3e5249ca353 100644 --- a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.scss +++ b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.scss @@ -20,3 +20,7 @@ label.qn { .red-font { color: red; } + +.error-box { + margin: 1rem 0; +} diff --git a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.spec.ts b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.spec.ts index 67e0e3b94b7..3a5f4fba2b8 100644 --- a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.spec.ts +++ b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.spec.ts @@ -1,21 +1,34 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; -import { first } from 'rxjs'; +import { Observable, first } from 'rxjs'; import { InstructorRequestFormModel } from './instructor-request-form-model'; import { InstructorRequestFormComponent } from './instructor-request-form.component'; +import { AccountService } from '../../../../services/account.service'; +import { AccountCreateRequest } from '../../../../types/api-request'; describe('InstructorRequestFormComponent', () => { let component: InstructorRequestFormComponent; let fixture: ComponentFixture; + let accountService: AccountService; const typicalModel: InstructorRequestFormModel = { name: 'John Doe', institution: 'Example Institution', country: 'Example Country', email: 'jd@example.edu', - homePage: 'xyz.example.edu/john', comments: '', }; + const typicalCreateRequest: AccountCreateRequest = { + instructorEmail: typicalModel.email, + instructorName: typicalModel.name, + instructorInstitution: `${typicalModel.institution}, ${typicalModel.country}`, + }; + + const accountServiceStub: Partial = { + createAccountRequest: () => new Observable((subscriber) => { + subscriber.next(); + }), + }; /** * Fills in form fields with the given data. @@ -27,19 +40,24 @@ describe('InstructorRequestFormComponent', () => { component.institution.setValue(data.institution); component.country.setValue(data.country); component.email.setValue(data.email); - component.homePage.setValue(data.homePage); component.comments.setValue(data.comments); } - beforeEach(() => { + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [InstructorRequestFormComponent], imports: [ReactiveFormsModule], - }); + providers: [{ provide: AccountService, useValue: accountServiceStub }], + }) + .compileComponents(); + })); + + beforeEach(() => { fixture = TestBed.createComponent(InstructorRequestFormComponent); component = fixture.componentInstance; - + accountService = TestBed.inject(AccountService); fixture.detectChanges(); + jest.clearAllMocks(); }); it('should create', () => { @@ -50,17 +68,20 @@ describe('InstructorRequestFormComponent', () => { expect(fixture).toMatchSnapshot(); }); - it('should emit requestSubmissionEvent once when submit button is clicked', () => { - jest.spyOn(component.requestSubmissionEvent, 'emit'); + it('should run onSubmit() when submit button is clicked', () => { + jest.spyOn(component, 'onSubmit'); fillFormWith(typicalModel); const submitButton = fixture.debugElement.query(By.css('#submit-button')); submitButton.nativeElement.click(); - expect(component.requestSubmissionEvent.emit).toHaveBeenCalledTimes(1); + expect(component.onSubmit).toHaveBeenCalledTimes(1); }); it('should emit requestSubmissionEvent with the correct data when form is submitted', () => { + jest.spyOn(accountService, 'createAccountRequest').mockReturnValue( + new Observable((subscriber) => { subscriber.next(); })); + // Listen for emitted value let actualModel: InstructorRequestFormModel | null = null; component.requestSubmissionEvent.pipe(first()) @@ -74,7 +95,17 @@ describe('InstructorRequestFormComponent', () => { expect(actualModel!.institution).toBe(typicalModel.institution); expect(actualModel!.country).toBe(typicalModel.country); expect(actualModel!.email).toBe(typicalModel.email); - expect(actualModel!.homePage).toBe(typicalModel.homePage); expect(actualModel!.comments).toBe(typicalModel.comments); }); + + it('should send the correct request data when form is submitted', () => { + jest.spyOn(accountService, 'createAccountRequest').mockReturnValue( + new Observable((subscriber) => { subscriber.next(); })); + + fillFormWith(typicalModel); + component.onSubmit(); + + expect(accountService.createAccountRequest).toHaveBeenCalledTimes(1); + expect(accountService.createAccountRequest).toHaveBeenCalledWith(expect.objectContaining(typicalCreateRequest)); + }); }); diff --git a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.ts b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.ts index fff881cc607..02b2ae00fd2 100644 --- a/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.ts +++ b/src/web/app/pages-static/request-page/instructor-request-form/instructor-request-form.component.ts @@ -1,10 +1,11 @@ import { Component, EventEmitter, Output } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { finalize } from 'rxjs'; import { InstructorRequestFormModel } from './instructor-request-form-model'; - -// Use regex to validate URL field as Angular does not have a built-in URL validator -// eslint-disable-next-line -const URL_REGEX = /(https?:\/\/)?(www\.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)|(https?:\/\/)?(www\.)?(?!ww)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/; +import { AccountService } from '../../../../services/account.service'; +import { AccountCreateRequest } from '../../../../types/api-request'; +import { FormValidator } from '../../../../types/form-validator'; +import { ErrorMessageOutput } from '../../../error-message-output'; @Component({ selector: 'tm-instructor-request-form', @@ -13,12 +14,35 @@ const URL_REGEX = /(https?:\/\/)?(www\.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4 }) export class InstructorRequestFormComponent { + constructor(private accountService: AccountService) {} + + // Create members to be accessed in template + readonly STUDENT_NAME_MAX_LENGTH = FormValidator.STUDENT_NAME_MAX_LENGTH; + readonly INSTITUTION_NAME_MAX_LENGTH = FormValidator.INSTITUTION_NAME_MAX_LENGTH; + readonly COUNTRY_NAME_MAX_LENGTH = FormValidator.COUNTRY_NAME_MAX_LENGTH; + readonly EMAIL_MAX_LENGTH = FormValidator.EMAIL_MAX_LENGTH; + arf = new FormGroup({ - name: new FormControl('', [Validators.required]), - institution: new FormControl('', [Validators.required]), - country: new FormControl('', [Validators.required]), - email: new FormControl('', [Validators.required, Validators.email]), - homePage: new FormControl('', [Validators.pattern(URL_REGEX)]), + name: new FormControl('', [ + Validators.required, + Validators.maxLength(FormValidator.STUDENT_NAME_MAX_LENGTH), + Validators.pattern(FormValidator.NAME_REGEX), + ]), + institution: new FormControl('', [ + Validators.required, + Validators.maxLength(FormValidator.INSTITUTION_NAME_MAX_LENGTH), + Validators.pattern(FormValidator.NAME_REGEX), + ]), + country: new FormControl('', [ + Validators.required, + Validators.maxLength(FormValidator.COUNTRY_NAME_MAX_LENGTH), + Validators.pattern(FormValidator.NAME_REGEX), + ]), + email: new FormControl('', [ + Validators.required, + Validators.pattern(FormValidator.EMAIL_REGEX), + Validators.maxLength(FormValidator.EMAIL_MAX_LENGTH), + ]), comments: new FormControl(''), }, { updateOn: 'submit' }); @@ -27,23 +51,20 @@ export class InstructorRequestFormComponent { institution = this.arf.controls.institution; country = this.arf.controls.country; email = this.arf.controls.email; - homePage = this.arf.controls.homePage; comments = this.arf.controls.comments; hasSubmitAttempt = false; - + isLoading = false; @Output() requestSubmissionEvent = new EventEmitter(); + serverErrorMessage = ''; + checkIsFieldRequired(field: FormControl): boolean { return field.hasValidator(Validators.required); } - checkIsFieldInvalid(field: FormControl): boolean { - return field.invalid; - } - - checkCanSubmit(): boolean { - return true; // TODO: API integration + get canSubmit(): boolean { + return !this.isLoading; } getFieldValidationClasses(field: FormControl): string { @@ -60,39 +81,50 @@ export class InstructorRequestFormComponent { onSubmit(): void { this.hasSubmitAttempt = true; + this.isLoading = true; + this.serverErrorMessage = ''; if (this.arf.invalid) { + this.isLoading = false; // Do not submit form return; } const name = this.name.value!.trim(); const email = this.email.value!.trim(); + const comments = this.comments.value!.trim(); + + // Combine country and institution const country = this.country.value!.trim(); const institution = this.institution.value!.trim(); const combinedInstitution = `${institution}, ${country}`; - const homePage = this.homePage.value!; - const comments = this.comments.value!.trim(); - const submittedData = { - name, - email, - institution: combinedInstitution, - homePage, - comments, + const requestData: AccountCreateRequest = { + instructorEmail: email, + instructorName: name, + instructorInstitution: combinedInstitution, }; - // TODO: connect to API - // eslint-disable-next-line - submittedData; // PLACEHOLDER - - // Pass form input to parent to display confirmation - this.requestSubmissionEvent.emit({ - name, - institution, - country, - email, - homePage, - comments, - }); + + if (comments) { + requestData.instructorComments = comments; + } + + this.accountService.createAccountRequest(requestData) + .pipe(finalize(() => { this.isLoading = false; })) + .subscribe({ + next: () => { + // Pass form input to parent to display confirmation + this.requestSubmissionEvent.emit({ + name, + institution, + country, + email, + comments, + }); + }, + error: (resp: ErrorMessageOutput) => { + this.serverErrorMessage = resp.error.message; + }, + }); } } diff --git a/src/web/app/pages-static/request-page/request-page.component.html b/src/web/app/pages-static/request-page/request-page.component.html index 315ecb5b534..877da7af395 100644 --- a/src/web/app/pages-static/request-page/request-page.component.html +++ b/src/web/app/pages-static/request-page/request-page.component.html @@ -41,13 +41,6 @@

    Email {{submittedFormData.email}} - - Home Page URL - - {{submittedFormData.homePage}} - - - Comments diff --git a/src/web/app/pages-static/request-page/request-page.component.spec.ts b/src/web/app/pages-static/request-page/request-page.component.spec.ts index 07b1c5d5dd2..9f4c3042247 100644 --- a/src/web/app/pages-static/request-page/request-page.component.spec.ts +++ b/src/web/app/pages-static/request-page/request-page.component.spec.ts @@ -42,7 +42,6 @@ describe('RequestPageComponent', () => { institution: 'University of Example', country: 'Example Republic', email: 'js@exampleu.edu', - homePage: 'u.exampleu.edu/jsmith', comments: '', }; fixture.detectChanges(); diff --git a/src/web/app/pages-static/request-page/request-page.module.ts b/src/web/app/pages-static/request-page/request-page.module.ts index 7333207fc0b..12a9d337875 100644 --- a/src/web/app/pages-static/request-page/request-page.module.ts +++ b/src/web/app/pages-static/request-page/request-page.module.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { RouterModule, Routes } from '@angular/router'; +import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; import { InstructorRequestFormComponent } from './instructor-request-form/instructor-request-form.component'; import { RequestPageComponent } from './request-page.component'; import { TeammatesRouterModule } from '../../components/teammates-router/teammates-router.module'; @@ -29,6 +30,7 @@ const routes: Routes = [ RouterModule.forChild(routes), TeammatesRouterModule, ReactiveFormsModule, + NgbAlertModule, ], }) export class RequestPageModule { } diff --git a/src/web/types/const.spec.ts b/src/web/types/const.spec.ts index f5f403835ec..6bc4e7112ec 100644 --- a/src/web/types/const.spec.ts +++ b/src/web/types/const.spec.ts @@ -1,4 +1,4 @@ -import { ApiConst } from './api-const'; +import { ApiConst, ApiStringConst } from './api-const'; import { FeedbackQuestionType } from './api-output'; import { DEFAULT_INSTRUCTOR_PRIVILEGE, @@ -68,6 +68,12 @@ describe('Constants', () => { expect(typeof ApiConst.NO_VALUE).toEqual('number'); }); + // Here we test that the constants are strings + it('should generate string constants correctly', () => { + expect(typeof ApiStringConst.EMAIL_REGEX).toEqual('string'); + expect(() => new RegExp(ApiStringConst.EMAIL_REGEX)).not.toThrow(); + }); + // Here we test that: // 1. The string is parseable to JSON // 2. The question type is correct diff --git a/src/web/types/form-validator.ts b/src/web/types/form-validator.ts index c2f44cf5beb..ea18815ca5d 100644 --- a/src/web/types/form-validator.ts +++ b/src/web/types/form-validator.ts @@ -1,4 +1,4 @@ -import { ApiConst } from './api-const'; +import { ApiConst, ApiStringConst } from './api-const'; /** * Represents the root FormValidator object of all form fields. @@ -33,4 +33,35 @@ export enum FormValidator { * Max length for the 'E-mail Address` field. */ EMAIL_MAX_LENGTH = ApiConst.EMAIL_MAX_LENGTH, + + /** + * Regex used to verify emails in the back-end. + */ + EMAIL_REGEX = ApiStringConst.EMAIL_REGEX, + + /** + * Regex used to verify names. + * + * Based on back-end's `FieldValidator.REGEX_NAME`. + * The back-end regex is not converted to use here as the pattern syntax is not accepted in JS. + */ + NAME_REGEX = '^[a-zA-Z0-9][^|%]*$', + + /** + * Regex used to verify country names. + * + * Based on back-end's `FieldValidator.REGEX_NAME`, but without needing to start with alphanumeric + * as the country is added to the end of the combined institute string. + */ + COUNTRY_REGEX = '^[^|%]*$', + + /** + * Max length for institution name in account request. (to be combined with country) + */ + INSTITUTION_NAME_MAX_LENGTH = 86, + + /** + * Max length for country in account request. (to be combined with institution name) + */ + COUNTRY_NAME_MAX_LENGTH = 40, } From a98630d6fe89136c4953bba2fd7b887e2e57b011 Mon Sep 17 00:00:00 2001 From: DS Date: Fri, 5 Apr 2024 20:25:45 +0800 Subject: [PATCH 27/95] [#11843] Update GetFeedbackSessionLogsAction to use SQL db (#12938) * Create FeedbackSessionLog entity * fix lint * Create UpdateFeedbackSessionLogsAction * Sort query results from logging service * Update type of feedbackSessionLogType * Fix naming * Fix enum in entity * Update filter to differentiate by session * Add Uri Info * Add tests * Update test case * Update to getOrderedFeedbackSessionLogs * Create skeleton * Implement logic and db layer * fix lint * Update entity * Fix tests * Update action to use fslDb * Fix tests * Update DbIT to use databundle * Fix bugs and optimize action * Prevent courseId from being null * Update GCP logs to store ids * Fix tests * Update action to use reference * Add some error handling * Fix tests * Add ids to api output * Fix lint * Update cron.yaml * Tidy up code * Update comments --- .../core/FeedbackSessionLogsLogicIT.java | 131 +++++++ .../sqlapi/FeedbackSessionLogsDbIT.java | 104 ++---- .../GetFeedbackSessionLogsActionIT.java | 77 ++-- src/it/resources/data/typicalDataBundle.json | 79 +++++ src/main/appengine/cron.yaml | 4 + .../common/datatransfer/SqlDataBundle.java | 2 + .../sqllogic/core/DataBundleLogic.java | 24 +- .../teammates/sqllogic/core/LogicStarter.java | 2 +- .../storage/sqlentity/FeedbackSessionLog.java | 4 + .../ui/output/FeedbackSessionLogData.java | 31 +- .../output/FeedbackSessionLogEntryData.java | 7 +- .../ui/output/FeedbackSessionLogsData.java | 8 +- .../webapi/GetFeedbackSessionLogsAction.java | 106 +++--- .../GetFeedbackSessionLogsActionTest.java | 330 ++++++++++++++++++ 14 files changed, 740 insertions(+), 169 deletions(-) create mode 100644 src/it/java/teammates/it/sqllogic/core/FeedbackSessionLogsLogicIT.java create mode 100644 src/test/java/teammates/sqlui/webapi/GetFeedbackSessionLogsActionTest.java diff --git a/src/it/java/teammates/it/sqllogic/core/FeedbackSessionLogsLogicIT.java b/src/it/java/teammates/it/sqllogic/core/FeedbackSessionLogsLogicIT.java new file mode 100644 index 00000000000..1007cd50328 --- /dev/null +++ b/src/it/java/teammates/it/sqllogic/core/FeedbackSessionLogsLogicIT.java @@ -0,0 +1,131 @@ +package teammates.it.sqllogic.core; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.SqlDataBundle; +import teammates.common.datatransfer.logs.FeedbackSessionLogType; +import teammates.common.util.HibernateUtil; +import teammates.it.test.BaseTestCaseWithSqlDatabaseAccess; +import teammates.sqllogic.core.FeedbackSessionLogsLogic; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; +import teammates.storage.sqlentity.Student; + +/** + * SUT: {@link FeedbackSessionLogsLogic}. + */ +public class FeedbackSessionLogsLogicIT extends BaseTestCaseWithSqlDatabaseAccess { + + private FeedbackSessionLogsLogic fslLogic = FeedbackSessionLogsLogic.inst(); + + private SqlDataBundle typicalDataBundle; + + @Override + @BeforeClass + public void setupClass() { + super.setupClass(); + typicalDataBundle = getTypicalSqlDataBundle(); + } + + @Override + @BeforeMethod + protected void setUp() throws Exception { + super.setUp(); + persistDataBundle(typicalDataBundle); + HibernateUtil.flushSession(); + HibernateUtil.clearSession(); + } + + @Test + public void test_createFeedbackSessionLog_success() { + Course course = typicalDataBundle.courses.get("course1"); + FeedbackSession fs = typicalDataBundle.feedbackSessions.get("session1InCourse1"); + Student student = typicalDataBundle.students.get("student1InCourse1"); + Instant timestamp = Instant.now(); + FeedbackSessionLog newLog1 = new FeedbackSessionLog(student, fs, FeedbackSessionLogType.ACCESS, timestamp); + FeedbackSessionLog newLog2 = new FeedbackSessionLog(student, fs, FeedbackSessionLogType.SUBMISSION, timestamp); + FeedbackSessionLog newLog3 = new FeedbackSessionLog(student, fs, FeedbackSessionLogType.VIEW_RESULT, timestamp); + List expected = List.of(newLog1, newLog2, newLog3); + + fslLogic.createFeedbackSessionLogs(expected); + + List actual = fslLogic.getOrderedFeedbackSessionLogs(course.getId(), student.getId(), + fs.getId(), timestamp, timestamp.plusSeconds(1)); + + assertEquals(expected, actual); + } + + @Test + public void test_getOrderedFeedbackSessionLogs_success() { + Instant startTime = Instant.parse("2012-01-01T12:00:00Z"); + Instant endTime = Instant.parse("2012-01-01T23:59:59Z"); + Course course = typicalDataBundle.courses.get("course1"); + Student student1 = typicalDataBundle.students.get("student1InCourse1"); + FeedbackSession fs1 = typicalDataBundle.feedbackSessions.get("session1InCourse1"); + + FeedbackSessionLog student1Session1Log1 = typicalDataBundle.feedbackSessionLogs.get("student1Session1Log1"); + FeedbackSessionLog student1Session2Log1 = typicalDataBundle.feedbackSessionLogs.get("student1Session2Log1"); + FeedbackSessionLog student1Session2Log2 = typicalDataBundle.feedbackSessionLogs.get("student1Session2Log2"); + FeedbackSessionLog student2Session1Log1 = typicalDataBundle.feedbackSessionLogs.get("student2Session1Log1"); + FeedbackSessionLog student2Session1Log2 = typicalDataBundle.feedbackSessionLogs.get("student2Session1Log2"); + + ______TS("Return logs belonging to a course in time range"); + List expectedLogs = List.of( + student1Session1Log1, + student1Session2Log1, + student1Session2Log2, + student2Session1Log1, + student2Session1Log2); + + List actualLogs = fslLogic.getOrderedFeedbackSessionLogs(course.getId(), null, null, + startTime, endTime); + + assertEquals(expectedLogs, actualLogs); + + ______TS("Return logs belonging to a student in a course in time range"); + expectedLogs = List.of( + student1Session1Log1, + student1Session2Log1, + student1Session2Log2); + + actualLogs = fslLogic.getOrderedFeedbackSessionLogs(course.getId(), student1.getId(), null, startTime, + endTime); + + assertEquals(expectedLogs, actualLogs); + + ______TS("Return logs belonging to a feedback session in time range"); + expectedLogs = List.of( + student1Session1Log1, + student2Session1Log1, + student2Session1Log2); + + actualLogs = fslLogic.getOrderedFeedbackSessionLogs(course.getId(), null, fs1.getId(), startTime, endTime); + + assertEquals(expectedLogs, actualLogs); + + ______TS("Return logs belonging to a student in a feedback session in time range"); + expectedLogs = List.of(student1Session1Log1); + + actualLogs = fslLogic.getOrderedFeedbackSessionLogs(course.getId(), student1.getId(), fs1.getId(), + startTime, + endTime); + + assertEquals(expectedLogs, actualLogs); + + ______TS("No logs in time range, return empty list"); + expectedLogs = new ArrayList<>(); + + actualLogs = fslLogic.getOrderedFeedbackSessionLogs(course.getId(), null, null, endTime.plusSeconds(3600), + endTime.plusSeconds(7200)); + + assertEquals(expectedLogs, actualLogs); + } + +} diff --git a/src/it/java/teammates/it/storage/sqlapi/FeedbackSessionLogsDbIT.java b/src/it/java/teammates/it/storage/sqlapi/FeedbackSessionLogsDbIT.java index 14d922e611f..30b5276893b 100644 --- a/src/it/java/teammates/it/storage/sqlapi/FeedbackSessionLogsDbIT.java +++ b/src/it/java/teammates/it/storage/sqlapi/FeedbackSessionLogsDbIT.java @@ -63,98 +63,56 @@ public void test_createFeedbackSessionLog_success() { @Test public void test_getOrderedFeedbackSessionLogs_success() { - Instant startTime = Instant.parse("2011-01-01T00:00:00Z"); - Instant endTime = Instant.parse("2011-01-01T01:00:00Z"); - - Course course1 = typicalDataBundle.courses.get("course1"); - - FeedbackSession session1 = typicalDataBundle.feedbackSessions.get("session1InCourse1"); - FeedbackSession session2 = typicalDataBundle.feedbackSessions.get("session2InTypicalCourse"); - FeedbackSession sessionInAnotherCourse = typicalDataBundle.feedbackSessions.get("ongoingSession1InCourse3"); - + Instant startTime = Instant.parse("2012-01-01T12:00:00Z"); + Instant endTime = Instant.parse("2012-01-01T23:59:59Z"); + Course course = typicalDataBundle.courses.get("course1"); Student student1 = typicalDataBundle.students.get("student1InCourse1"); - Student student2 = typicalDataBundle.students.get("student2InCourse1"); - - FeedbackSessionLog student1Session1Log1 = new FeedbackSessionLog(student1, session1, - FeedbackSessionLogType.ACCESS, startTime); - FeedbackSessionLog student1Session1Log2 = new FeedbackSessionLog(student1, session1, - FeedbackSessionLogType.SUBMISSION, startTime.plusSeconds(1)); - FeedbackSessionLog student1Session1Log3 = new FeedbackSessionLog(student1, session1, - FeedbackSessionLogType.VIEW_RESULT, startTime.plusSeconds(2)); - FeedbackSessionLog student1Session2Log1 = new FeedbackSessionLog(student1, session2, - FeedbackSessionLogType.ACCESS, startTime.plusSeconds(3)); - - FeedbackSessionLog student2Session1Log1 = new FeedbackSessionLog(student2, session1, - FeedbackSessionLogType.ACCESS, startTime.plusSeconds(4)); - FeedbackSessionLog student2Session2Log1 = new FeedbackSessionLog(student2, session2, - FeedbackSessionLogType.ACCESS, startTime.plusSeconds(5)); - - FeedbackSessionLog student1AnotherCourseLog1 = new FeedbackSessionLog(student1, sessionInAnotherCourse, - FeedbackSessionLogType.ACCESS, startTime.plusSeconds(6)); - - FeedbackSessionLog outOfRangeLog1 = new FeedbackSessionLog(student1, session1, FeedbackSessionLogType.ACCESS, - startTime.minusSeconds(1)); - FeedbackSessionLog outOfRangeLog2 = new FeedbackSessionLog(student1, session1, FeedbackSessionLogType.ACCESS, - endTime); - - List newLogs = new ArrayList<>(); - newLogs.add(student1Session1Log1); - newLogs.add(student1Session1Log2); - newLogs.add(student1Session1Log3); - newLogs.add(student1Session2Log1); - - newLogs.add(student2Session1Log1); - newLogs.add(student2Session2Log1); + FeedbackSession fs1 = typicalDataBundle.feedbackSessions.get("session1InCourse1"); - newLogs.add(student1AnotherCourseLog1); - - newLogs.add(outOfRangeLog1); - newLogs.add(outOfRangeLog2); - - for (FeedbackSessionLog log : newLogs) { - fslDb.createFeedbackSessionLog(log); - } + FeedbackSessionLog student1Session1Log1 = typicalDataBundle.feedbackSessionLogs.get("student1Session1Log1"); + FeedbackSessionLog student1Session2Log1 = typicalDataBundle.feedbackSessionLogs.get("student1Session2Log1"); + FeedbackSessionLog student1Session2Log2 = typicalDataBundle.feedbackSessionLogs.get("student1Session2Log2"); + FeedbackSessionLog student2Session1Log1 = typicalDataBundle.feedbackSessionLogs.get("student2Session1Log1"); + FeedbackSessionLog student2Session1Log2 = typicalDataBundle.feedbackSessionLogs.get("student2Session1Log2"); ______TS("Return logs belonging to a course in time range"); - List expectedLogs = new ArrayList<>(); - expectedLogs.add(student1Session1Log1); - expectedLogs.add(student1Session1Log2); - expectedLogs.add(student1Session1Log3); - expectedLogs.add(student1Session2Log1); - - expectedLogs.add(student2Session1Log1); - expectedLogs.add(student2Session2Log1); - - List actualLogs = fslDb.getOrderedFeedbackSessionLogs(course1.getId(), null, null, + List expectedLogs = List.of( + student1Session1Log1, + student1Session2Log1, + student1Session2Log2, + student2Session1Log1, + student2Session1Log2 + ); + + List actualLogs = fslDb.getOrderedFeedbackSessionLogs(course.getId(), null, null, startTime, endTime); assertEquals(expectedLogs, actualLogs); ______TS("Return logs belonging to a student in time range"); - expectedLogs = new ArrayList<>(); - expectedLogs.add(student1Session1Log1); - expectedLogs.add(student1Session1Log2); - expectedLogs.add(student1Session1Log3); - expectedLogs.add(student1Session2Log1); + expectedLogs = List.of( + student1Session1Log1, + student1Session2Log1, + student1Session2Log2); - actualLogs = fslDb.getOrderedFeedbackSessionLogs(course1.getId(), student1.getId(), null, startTime, endTime); + actualLogs = fslDb.getOrderedFeedbackSessionLogs(course.getId(), student1.getId(), null, startTime, endTime); assertEquals(expectedLogs, actualLogs); ______TS("Return logs belonging to a feedback session in time range"); - expectedLogs = new ArrayList<>(); - expectedLogs.add(student1Session2Log1); - expectedLogs.add(student2Session2Log1); + expectedLogs = List.of( + student1Session1Log1, + student2Session1Log1, + student2Session1Log2); - actualLogs = fslDb.getOrderedFeedbackSessionLogs(course1.getId(), null, session2.getId(), startTime, endTime); + actualLogs = fslDb.getOrderedFeedbackSessionLogs(course.getId(), null, fs1.getId(), startTime, endTime); assertEquals(expectedLogs, actualLogs); ______TS("Return logs belonging to a student in a feedback session in time range"); - expectedLogs = new ArrayList<>(); - expectedLogs.add(student2Session2Log1); + expectedLogs = List.of(student1Session1Log1); - actualLogs = fslDb.getOrderedFeedbackSessionLogs(course1.getId(), student2.getId(), session2.getId(), startTime, + actualLogs = fslDb.getOrderedFeedbackSessionLogs(course.getId(), student1.getId(), fs1.getId(), startTime, endTime); assertEquals(expectedLogs, actualLogs); @@ -162,7 +120,7 @@ public void test_getOrderedFeedbackSessionLogs_success() { ______TS("No logs in time range, return empty list"); expectedLogs = new ArrayList<>(); - actualLogs = fslDb.getOrderedFeedbackSessionLogs(course1.getId(), null, null, endTime.plusSeconds(3600), + actualLogs = fslDb.getOrderedFeedbackSessionLogs(course.getId(), null, null, endTime.plusSeconds(3600), endTime.plusSeconds(7200)); assertEquals(expectedLogs, actualLogs); diff --git a/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java b/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java index 9aca4fed202..9413be9d691 100644 --- a/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java @@ -41,7 +41,7 @@ protected String getRequestMethod() { return GET; } - @Test + @Test(enabled = false) @Override protected void testExecute() { JsonResult actionOutput; @@ -49,27 +49,13 @@ protected void testExecute() { Course course = typicalBundle.courses.get("course1"); String courseId = course.getId(); FeedbackSession fsa1 = typicalBundle.feedbackSessions.get("session1InCourse1"); - FeedbackSession fsa2 = typicalBundle.feedbackSessions.get("session2InTypicalCourse"); String fsa1Name = fsa1.getName(); - String fsa2Name = fsa2.getName(); Student student1 = typicalBundle.students.get("student1InCourse1"); Student student2 = typicalBundle.students.get("student2InCourse1"); String student1Email = student1.getEmail(); String student2Email = student2.getEmail(); - long endTime = Instant.now().toEpochMilli(); + long endTime = Instant.parse("2012-01-02T12:00:00Z").toEpochMilli(); long startTime = endTime - (Const.LOGS_RETENTION_PERIOD.toDays() - 1) * 24 * 60 * 60 * 1000; - long invalidStartTime = endTime - (Const.LOGS_RETENTION_PERIOD.toDays() + 1) * 24 * 60 * 60 * 1000; - - mockLogsProcessor.insertFeedbackSessionLog(courseId, student1Email, fsa1Name, - FeedbackSessionLogType.ACCESS.getLabel(), startTime); - mockLogsProcessor.insertFeedbackSessionLog(courseId, student1Email, fsa2Name, - FeedbackSessionLogType.ACCESS.getLabel(), startTime + 1000); - mockLogsProcessor.insertFeedbackSessionLog(courseId, student1Email, fsa2Name, - FeedbackSessionLogType.SUBMISSION.getLabel(), startTime + 2000); - mockLogsProcessor.insertFeedbackSessionLog(courseId, student2Email, fsa1Name, - FeedbackSessionLogType.ACCESS.getLabel(), startTime + 3000); - mockLogsProcessor.insertFeedbackSessionLog(courseId, student2Email, fsa1Name, - FeedbackSessionLogType.SUBMISSION.getLabel(), startTime + 4000); ______TS("Failure case: not enough parameters"); verifyHttpParameterFailure( @@ -117,13 +103,6 @@ protected void testExecute() { }; verifyHttpParameterFailure(paramsInvalid4); - ______TS("Failure case: start time is before earliest search time"); - verifyHttpParameterFailure( - Const.ParamsNames.COURSE_ID, courseId, - Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(invalidStartTime), - Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime) - ); - ______TS("Success case: should group by feedback session"); String[] paramsSuccessful1 = { Const.ParamsNames.COURSE_ID, courseId, @@ -168,8 +147,56 @@ protected void testExecute() { Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), }; - getJsonResult(getAction(paramsSuccessful2)); - // No need to check output again here, it will be exactly the same as the previous case + actionOutput = getJsonResult(getAction(paramsSuccessful2)); + fslData = (FeedbackSessionLogsData) actionOutput.getOutput(); + fsLogs = fslData.getFeedbackSessionLogs(); + + assertEquals(fsLogs.size(), 6); + assertEquals(fsLogs.get(2).getFeedbackSessionLogEntries().size(), 0); + assertEquals(fsLogs.get(3).getFeedbackSessionLogEntries().size(), 0); + assertEquals(fsLogs.get(4).getFeedbackSessionLogEntries().size(), 0); + assertEquals(fsLogs.get(5).getFeedbackSessionLogEntries().size(), 0); + + fsLogEntries1 = fsLogs.get(0).getFeedbackSessionLogEntries(); + fsLogEntries2 = fsLogs.get(1).getFeedbackSessionLogEntries(); + + assertEquals(fsLogEntries1.size(), 1); + assertEquals(fsLogEntries1.get(0).getStudentData().getEmail(), student1Email); + assertEquals(fsLogEntries1.get(0).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + + assertEquals(fsLogEntries2.size(), 2); + assertEquals(fsLogEntries2.get(0).getStudentData().getEmail(), student1Email); + assertEquals(fsLogEntries2.get(0).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + assertEquals(fsLogEntries2.get(1).getStudentData().getEmail(), student1Email); + assertEquals(fsLogEntries2.get(1).getFeedbackSessionLogType(), FeedbackSessionLogType.SUBMISSION); + + ______TS("Success case: should accept feedback session"); + String[] paramsSuccessful3 = { + Const.ParamsNames.COURSE_ID, courseId, + Const.ParamsNames.FEEDBACK_SESSION_NAME, fsa1Name, + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), + Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), + }; + actionOutput = getJsonResult(getAction(paramsSuccessful3)); + fslData = (FeedbackSessionLogsData) actionOutput.getOutput(); + fsLogs = fslData.getFeedbackSessionLogs(); + + assertEquals(fsLogs.size(), 6); + assertEquals(fsLogs.get(1).getFeedbackSessionLogEntries().size(), 0); + assertEquals(fsLogs.get(2).getFeedbackSessionLogEntries().size(), 0); + assertEquals(fsLogs.get(3).getFeedbackSessionLogEntries().size(), 0); + assertEquals(fsLogs.get(4).getFeedbackSessionLogEntries().size(), 0); + assertEquals(fsLogs.get(5).getFeedbackSessionLogEntries().size(), 0); + + fsLogEntries1 = fsLogs.get(0).getFeedbackSessionLogEntries(); + + assertEquals(fsLogEntries1.size(), 3); + assertEquals(fsLogEntries1.get(0).getStudentData().getEmail(), student1Email); + assertEquals(fsLogEntries1.get(0).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + assertEquals(fsLogEntries1.get(1).getStudentData().getEmail(), student2Email); + assertEquals(fsLogEntries1.get(1).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + assertEquals(fsLogEntries1.get(2).getStudentData().getEmail(), student2Email); + assertEquals(fsLogEntries1.get(2).getFeedbackSessionLogType(), FeedbackSessionLogType.SUBMISSION); // TODO: if we restrict the range from start to end time, it should be tested here as well } diff --git a/src/it/resources/data/typicalDataBundle.json b/src/it/resources/data/typicalDataBundle.json index 2372b2fa35f..fcb1dc4ab16 100644 --- a/src/it/resources/data/typicalDataBundle.json +++ b/src/it/resources/data/typicalDataBundle.json @@ -1363,5 +1363,84 @@ "id": "00000000-0000-4000-8000-000000001101" } } + }, + "feedbackSessionLogs": { + "student1Session1Log1": { + "id": "00000000-0000-4000-8000-000000001301", + "student": { + "id" : "00000000-0000-4000-8000-000000000601" + }, + "feedbackSession": { + "id" : "00000000-0000-4000-8000-000000000701" + }, + "feedbackSessionLogType": "ACCESS", + "timestamp": "2012-01-01T12:00:00Z" + }, + "student1Session2Log1": { + "id": "00000000-0000-4000-8000-000000001302", + "student": { + "id" : "00000000-0000-4000-8000-000000000601" + }, + "feedbackSession": { + "id" : "00000000-0000-4000-8000-000000000702" + }, + "feedbackSessionLogType": "ACCESS", + "timestamp": "2012-01-01T12:00:01Z" + }, + "student1Session2Log2": { + "id": "00000000-0000-4000-8000-000000001303", + "student": { + "id" : "00000000-0000-4000-8000-000000000601" + }, + "feedbackSession": { + "id" : "00000000-0000-4000-8000-000000000702" + }, + "feedbackSessionLogType": "SUBMISSION", + "timestamp": "2012-01-01T12:00:02Z" + }, + "student2Session1Log1": { + "id": "00000000-0000-4000-8000-000000001304", + "student": { + "id" : "00000000-0000-4000-8000-000000000602" + }, + "feedbackSession": { + "id" : "00000000-0000-4000-8000-000000000701" + }, + "feedbackSessionLogType": "ACCESS", + "timestamp": "2012-01-01T12:00:03Z" + }, + "student2Session1Log2": { + "id": "00000000-0000-4000-8000-000000001305", + "student": { + "id" : "00000000-0000-4000-8000-000000000602" + }, + "feedbackSession": { + "id" : "00000000-0000-4000-8000-000000000701" + }, + "feedbackSessionLogType": "SUBMISSION", + "timestamp": "2012-01-01T12:00:04Z" + }, + "student1InAnotherCourse": { + "id": "00000000-0000-4000-8000-000000001306", + "student": { + "id" : "00000000-0000-4000-8000-000000000601" + }, + "feedbackSession": { + "id" : "00000000-0000-4000-8000-000000000707" + }, + "feedbackSessionLogType": "ACCESS", + "timestamp": "2012-01-01T12:00:05Z" + }, + "outOfRangeLog": { + "id": "00000000-0000-4000-8000-000000001307", + "student": { + "id" : "00000000-0000-4000-8000-000000000602" + }, + "feedbackSession": { + "id" : "00000000-0000-4000-8000-000000000701" + }, + "feedbackSessionLogType": "ACCESS", + "timestamp": "2010-01-01T00:00:00Z" + } } } diff --git a/src/main/appengine/cron.yaml b/src/main/appengine/cron.yaml index 07a94809826..996ff3d8401 100644 --- a/src/main/appengine/cron.yaml +++ b/src/main/appengine/cron.yaml @@ -33,3 +33,7 @@ cron: schedule: 'every 5 minutes synchronized' timezone: 'Asia/Singapore' description: 'Compile severe logs and sends out email notifications.' +- url: '/auto/updateFeedbackSessionLogs' + schedule: 'every 60 minutes from 00:15 to 23:59' + timezone: 'Asia/Singapore' + description: 'Process feedback session activity logs from logging service and store in the database.' diff --git a/src/main/java/teammates/common/datatransfer/SqlDataBundle.java b/src/main/java/teammates/common/datatransfer/SqlDataBundle.java index d3a027b2775..b411b4ea094 100644 --- a/src/main/java/teammates/common/datatransfer/SqlDataBundle.java +++ b/src/main/java/teammates/common/datatransfer/SqlDataBundle.java @@ -11,6 +11,7 @@ import teammates.storage.sqlentity.FeedbackResponse; import teammates.storage.sqlentity.FeedbackResponseComment; import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; import teammates.storage.sqlentity.Instructor; import teammates.storage.sqlentity.Notification; import teammates.storage.sqlentity.ReadNotification; @@ -37,6 +38,7 @@ public class SqlDataBundle { public Map feedbackQuestions = new LinkedHashMap<>(); public Map feedbackResponses = new LinkedHashMap<>(); public Map feedbackResponseComments = new LinkedHashMap<>(); + public Map feedbackSessionLogs = new LinkedHashMap<>(); public Map notifications = new LinkedHashMap<>(); public Map readNotifications = new LinkedHashMap<>(); } diff --git a/src/main/java/teammates/sqllogic/core/DataBundleLogic.java b/src/main/java/teammates/sqllogic/core/DataBundleLogic.java index 6b7376a7e9b..2dcfb9ac899 100644 --- a/src/main/java/teammates/sqllogic/core/DataBundleLogic.java +++ b/src/main/java/teammates/sqllogic/core/DataBundleLogic.java @@ -1,5 +1,6 @@ package teammates.sqllogic.core; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.Map; @@ -19,6 +20,7 @@ import teammates.storage.sqlentity.FeedbackResponse; import teammates.storage.sqlentity.FeedbackResponseComment; import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; import teammates.storage.sqlentity.Instructor; import teammates.storage.sqlentity.Notification; import teammates.storage.sqlentity.ReadNotification; @@ -41,6 +43,7 @@ public final class DataBundleLogic { private CoursesLogic coursesLogic; private DeadlineExtensionsLogic deadlineExtensionsLogic; private FeedbackSessionsLogic fsLogic; + private FeedbackSessionLogsLogic fslLogic; private FeedbackQuestionsLogic fqLogic; private FeedbackResponsesLogic frLogic; private FeedbackResponseCommentsLogic frcLogic; @@ -56,16 +59,15 @@ public static DataBundleLogic inst() { } void initLogicDependencies(AccountsLogic accountsLogic, AccountRequestsLogic accountRequestsLogic, - CoursesLogic coursesLogic, - DeadlineExtensionsLogic deadlineExtensionsLogic, FeedbackSessionsLogic fsLogic, - FeedbackQuestionsLogic fqLogic, FeedbackResponsesLogic frLogic, - FeedbackResponseCommentsLogic frcLogic, - NotificationsLogic notificationsLogic, UsersLogic usersLogic) { + CoursesLogic coursesLogic, DeadlineExtensionsLogic deadlineExtensionsLogic, FeedbackSessionsLogic fsLogic, + FeedbackSessionLogsLogic fslLogic, FeedbackQuestionsLogic fqLogic, FeedbackResponsesLogic frLogic, + FeedbackResponseCommentsLogic frcLogic, NotificationsLogic notificationsLogic, UsersLogic usersLogic) { this.accountsLogic = accountsLogic; this.accountRequestsLogic = accountRequestsLogic; this.coursesLogic = coursesLogic; this.deadlineExtensionsLogic = deadlineExtensionsLogic; this.fsLogic = fsLogic; + this.fslLogic = fslLogic; this.fqLogic = fqLogic; this.frLogic = frLogic; this.frcLogic = frcLogic; @@ -97,6 +99,7 @@ public static SqlDataBundle deserializeDataBundle(String jsonString) { Collection instructors = dataBundle.instructors.values(); Collection students = dataBundle.students.values(); Collection sessions = dataBundle.feedbackSessions.values(); + Collection sessionLogs = dataBundle.feedbackSessionLogs.values(); Collection questions = dataBundle.feedbackQuestions.values(); Collection responses = dataBundle.feedbackResponses.values(); Collection responseComments = dataBundle.feedbackResponseComments.values(); @@ -215,6 +218,14 @@ public static SqlDataBundle deserializeDataBundle(String jsonString) { student.generateNewRegistrationKey(); } + for (FeedbackSessionLog log : sessionLogs) { + log.setId(UUID.randomUUID()); + FeedbackSession fs = sessionsMap.get(log.getFeedbackSession().getId()); + log.setFeedbackSession(fs); + Student student = (Student) usersMap.get(log.getStudent().getId()); + log.setStudent(student); + } + for (Notification notification : notifications) { UUID placeholderId = notification.getId(); notification.setId(UUID.randomUUID()); @@ -262,6 +273,7 @@ public SqlDataBundle persistDataBundle(SqlDataBundle dataBundle) Collection instructors = dataBundle.instructors.values(); Collection students = dataBundle.students.values(); Collection sessions = dataBundle.feedbackSessions.values(); + Collection sessionLogs = dataBundle.feedbackSessionLogs.values(); Collection questions = dataBundle.feedbackQuestions.values(); Collection responses = dataBundle.feedbackResponses.values(); Collection responseComments = dataBundle.feedbackResponseComments.values(); @@ -318,6 +330,8 @@ public SqlDataBundle persistDataBundle(SqlDataBundle dataBundle) usersLogic.createStudent(student); } + fslLogic.createFeedbackSessionLogs(new ArrayList<>(sessionLogs)); + for (ReadNotification readNotification : readNotifications) { accountsLogic.updateReadNotifications(readNotification.getAccount().getGoogleId(), readNotification.getNotification().getId(), readNotification.getNotification().getEndTime()); diff --git a/src/main/java/teammates/sqllogic/core/LogicStarter.java b/src/main/java/teammates/sqllogic/core/LogicStarter.java index 4ae35a61a52..ba67aff710a 100644 --- a/src/main/java/teammates/sqllogic/core/LogicStarter.java +++ b/src/main/java/teammates/sqllogic/core/LogicStarter.java @@ -46,7 +46,7 @@ public static void initializeDependencies() { accountsLogic.initLogicDependencies(AccountsDb.inst(), notificationsLogic, usersLogic, coursesLogic); coursesLogic.initLogicDependencies(CoursesDb.inst(), fsLogic, usersLogic); dataBundleLogic.initLogicDependencies(accountsLogic, accountRequestsLogic, coursesLogic, - deadlineExtensionsLogic, fsLogic, fqLogic, frLogic, frcLogic, + deadlineExtensionsLogic, fsLogic, fslLogic, fqLogic, frLogic, frcLogic, notificationsLogic, usersLogic); deadlineExtensionsLogic.initLogicDependencies(DeadlineExtensionsDb.inst(), fsLogic); fsLogic.initLogicDependencies(FeedbackSessionsDb.inst(), coursesLogic, frLogic, fqLogic, usersLogic); diff --git a/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java b/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java index 9c6a7ed2825..15daeb607b5 100644 --- a/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java +++ b/src/main/java/teammates/storage/sqlentity/FeedbackSessionLog.java @@ -8,6 +8,8 @@ import org.hibernate.annotations.NotFound; import org.hibernate.annotations.NotFoundAction; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import teammates.common.datatransfer.logs.FeedbackSessionLogType; @@ -32,11 +34,13 @@ public class FeedbackSessionLog extends BaseEntity { @ManyToOne @JoinColumn(name = "studentId") @NotFound(action = NotFoundAction.IGNORE) + @OnDelete(action = OnDeleteAction.CASCADE) private Student student; @ManyToOne @JoinColumn(name = "sessionId") @NotFound(action = NotFoundAction.IGNORE) + @OnDelete(action = OnDeleteAction.CASCADE) private FeedbackSession feedbackSession; @Column(nullable = false) diff --git a/src/main/java/teammates/ui/output/FeedbackSessionLogData.java b/src/main/java/teammates/ui/output/FeedbackSessionLogData.java index 0825b0b5894..7afc398f0bf 100644 --- a/src/main/java/teammates/ui/output/FeedbackSessionLogData.java +++ b/src/main/java/teammates/ui/output/FeedbackSessionLogData.java @@ -8,6 +8,7 @@ import teammates.common.datatransfer.attributes.FeedbackSessionAttributes; import teammates.common.datatransfer.attributes.StudentAttributes; import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; import teammates.storage.sqlentity.Student; /** @@ -17,19 +18,24 @@ public class FeedbackSessionLogData { private final FeedbackSessionData feedbackSessionData; private final List feedbackSessionLogEntries; - // Remove generic types after migration is done (i.e. can just use FeedbackSession and Student) - public FeedbackSessionLogData(S feedbackSession, List logEntries, + // Remove generic types after migration is done (i.e. can just use FeedbackSession, Student, FeedbackSessionLog) + public FeedbackSessionLogData(S feedbackSession, List logEntries, Map studentsMap) { if (feedbackSession instanceof FeedbackSessionAttributes) { FeedbackSessionAttributes fs = (FeedbackSessionAttributes) feedbackSession; FeedbackSessionData fsData = new FeedbackSessionData(fs); List fsLogEntryDatas = logEntries.stream() .map(log -> { - T student = studentsMap.get(log.getStudentEmail()); - if (student instanceof StudentAttributes) { - return new FeedbackSessionLogEntryData(log, (StudentAttributes) student); + if (log instanceof FeedbackSessionLogEntry) { + FeedbackSessionLogEntry convertedLog = (FeedbackSessionLogEntry) log; + T student = studentsMap.get(convertedLog.getStudentEmail()); + if (student instanceof StudentAttributes) { + return new FeedbackSessionLogEntryData(convertedLog, (StudentAttributes) student); + } else { + throw new IllegalArgumentException("Invalid student type"); + } } else { - throw new IllegalArgumentException("Invalid student type"); + throw new IllegalArgumentException("Invalid log type"); } }) .collect(Collectors.toList()); @@ -40,11 +46,16 @@ public FeedbackSessionLogData(S feedbackSession, List fsLogEntryDatas = logEntries.stream() .map(log -> { - T student = studentsMap.get(log.getStudentEmail()); - if (student instanceof Student) { - return new FeedbackSessionLogEntryData(log, (Student) student); + if (log instanceof FeedbackSessionLog) { + FeedbackSessionLog convertedLog = (FeedbackSessionLog) log; + T student = studentsMap.get(convertedLog.getStudent().getEmail()); + if (student instanceof Student) { + return new FeedbackSessionLogEntryData(convertedLog, (Student) student); + } else { + throw new IllegalArgumentException("Invalid student type"); + } } else { - throw new IllegalArgumentException("Invalid student type"); + throw new IllegalArgumentException("Invalid log type"); } }) .collect(Collectors.toList()); diff --git a/src/main/java/teammates/ui/output/FeedbackSessionLogEntryData.java b/src/main/java/teammates/ui/output/FeedbackSessionLogEntryData.java index a70eaa7b505..99669d10e33 100644 --- a/src/main/java/teammates/ui/output/FeedbackSessionLogEntryData.java +++ b/src/main/java/teammates/ui/output/FeedbackSessionLogEntryData.java @@ -3,6 +3,7 @@ import teammates.common.datatransfer.FeedbackSessionLogEntry; import teammates.common.datatransfer.attributes.StudentAttributes; import teammates.common.datatransfer.logs.FeedbackSessionLogType; +import teammates.storage.sqlentity.FeedbackSessionLog; import teammates.storage.sqlentity.Student; /** @@ -22,10 +23,10 @@ public FeedbackSessionLogEntryData(FeedbackSessionLogEntry logEntry, StudentAttr this.timestamp = timestamp; } - public FeedbackSessionLogEntryData(FeedbackSessionLogEntry logEntry, Student student) { + public FeedbackSessionLogEntryData(FeedbackSessionLog logEntry, Student student) { StudentData studentData = new StudentData(student); - FeedbackSessionLogType logType = FeedbackSessionLogType.valueOfLabel(logEntry.getFeedbackSessionLogType()); - long timestamp = logEntry.getTimestamp(); + FeedbackSessionLogType logType = logEntry.getFeedbackSessionLogType(); + long timestamp = logEntry.getTimestamp().toEpochMilli(); this.studentData = studentData; this.feedbackSessionLogType = logType; this.timestamp = timestamp; diff --git a/src/main/java/teammates/ui/output/FeedbackSessionLogsData.java b/src/main/java/teammates/ui/output/FeedbackSessionLogsData.java index 3926e252817..b6f722dc770 100644 --- a/src/main/java/teammates/ui/output/FeedbackSessionLogsData.java +++ b/src/main/java/teammates/ui/output/FeedbackSessionLogsData.java @@ -4,8 +4,6 @@ import java.util.Map; import java.util.stream.Collectors; -import teammates.common.datatransfer.FeedbackSessionLogEntry; - /** * The API output format for logs on all feedback sessions in a course. */ @@ -13,13 +11,13 @@ public class FeedbackSessionLogsData extends ApiOutput { private final List feedbackSessionLogs; - // Remove generic types after migration is done (i.e. can just use FeedbackSession and Student) - public FeedbackSessionLogsData(Map> groupedEntries, + // Remove generic types after migration is done (i.e. can just use FeedbackSession and Student, FeedbackSessionLog) + public FeedbackSessionLogsData(Map> groupedEntries, Map studentsMap, Map sessionsMap) { this.feedbackSessionLogs = groupedEntries.entrySet().stream() .map(entry -> { T feedbackSession = sessionsMap.get(entry.getKey()); - List logEntries = entry.getValue(); + List logEntries = entry.getValue(); return new FeedbackSessionLogData(feedbackSession, logEntries, studentsMap); }) .collect(Collectors.toList()); diff --git a/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java b/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java index bddaeb6c780..8a3a5514514 100644 --- a/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java +++ b/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java @@ -1,5 +1,6 @@ package teammates.ui.webapi; +import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -17,6 +18,7 @@ import teammates.common.util.TimeHelper; import teammates.storage.sqlentity.Course; import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; import teammates.storage.sqlentity.Instructor; import teammates.storage.sqlentity.Student; import teammates.ui.output.FeedbackSessionLogsData; @@ -65,36 +67,6 @@ void checkSpecificAccessControl() throws UnauthorizedAccessException { @Override public JsonResult execute() { - String courseId = getNonNullRequestParamValue(Const.ParamsNames.COURSE_ID); - String email = getRequestParamValue(Const.ParamsNames.STUDENT_EMAIL); - String feedbackSessionName = getRequestParamValue(Const.ParamsNames.FEEDBACK_SESSION_NAME); - - if (isCourseMigrated(courseId)) { - if (sqlLogic.getCourse(courseId) == null) { - throw new EntityNotFoundException("Course not found"); - } - - if (email != null && sqlLogic.getStudentForEmail(courseId, email) == null) { - throw new EntityNotFoundException("Student not found"); - } - - if (feedbackSessionName != null && sqlLogic.getFeedbackSession(feedbackSessionName, courseId) == null) { - throw new EntityNotFoundException("Feedback session not found"); - } - } else { - if (logic.getCourse(courseId) == null) { - throw new EntityNotFoundException("Course not found"); - } - - if (email != null && logic.getStudentForEmail(courseId, email) == null) { - throw new EntityNotFoundException("Student not found"); - } - - if (feedbackSessionName != null && logic.getFeedbackSession(feedbackSessionName, courseId) == null) { - throw new EntityNotFoundException("Feedback session not found"); - } - } - String fslTypes = getRequestParamValue(Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE); List convertedFslTypes = new ArrayList<>(); if (fslTypes != null) { @@ -126,51 +98,81 @@ public JsonResult execute() { throw new InvalidHttpParameterException("The end time should be after the start time."); } - long earliestSearchTime = TimeHelper.getInstantDaysOffsetBeforeNow(Const.LOGS_RETENTION_PERIOD.toDays()) - .toEpochMilli(); - if (startTime < earliestSearchTime) { - throw new InvalidHttpParameterException( - "The earliest date you can search for is " + Const.LOGS_RETENTION_PERIOD.toDays() + " days before today." - ); + String courseId = getNonNullRequestParamValue(Const.ParamsNames.COURSE_ID); + + if (!isCourseMigrated(courseId)) { + long earliestSearchTime = TimeHelper.getInstantDaysOffsetBeforeNow(Const.LOGS_RETENTION_PERIOD.toDays()) + .toEpochMilli(); + if (startTime < earliestSearchTime) { + throw new InvalidHttpParameterException("The earliest date you can search for is " + + Const.LOGS_RETENTION_PERIOD.toDays() + " days before today."); + } } - List fsLogEntries = - logsProcessor.getOrderedFeedbackSessionLogs(courseId, email, startTime, endTime, feedbackSessionName); + String email = getRequestParamValue(Const.ParamsNames.STUDENT_EMAIL); + String feedbackSessionName = getRequestParamValue(Const.ParamsNames.FEEDBACK_SESSION_NAME); if (isCourseMigrated(courseId)) { + // TODO: replace null ids with value from request after FE changes and enable test + if (sqlLogic.getCourse(courseId) == null) { + throw new EntityNotFoundException("Course not found"); + } + + if (email != null && sqlLogic.getStudentForEmail(courseId, email) == null) { + throw new EntityNotFoundException("Student not found"); + } + + if (feedbackSessionName != null && sqlLogic.getFeedbackSession(feedbackSessionName, courseId) == null) { + throw new EntityNotFoundException("Feedback session not found"); + } + + List fsLogEntries = sqlLogic.getOrderedFeedbackSessionLogs(courseId, null, + null, Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime)); Map studentsMap = new HashMap<>(); Map sessionsMap = new HashMap<>(); List feedbackSessions = sqlLogic.getFeedbackSessionsForCourse(courseId); feedbackSessions.forEach(fs -> sessionsMap.put(fs.getName(), fs)); fsLogEntries = fsLogEntries.stream().filter(logEntry -> { - String logType = logEntry.getFeedbackSessionLogType(); - FeedbackSessionLogType convertedLogType = FeedbackSessionLogType.valueOfLabel(logType); - if (convertedLogType == null || fslTypes != null && !convertedFslTypes.contains(convertedLogType)) { + FeedbackSessionLogType logType = logEntry.getFeedbackSessionLogType(); + if (logType == null || fslTypes != null && !convertedFslTypes.contains(logType)) { // If the feedback session log type retrieved from the log is invalid // or not the type being queried, ignore the log return false; } - if (!studentsMap.containsKey(logEntry.getStudentEmail())) { - Student student = sqlLogic.getStudentForEmail(courseId, logEntry.getStudentEmail()); + if (!studentsMap.containsKey(logEntry.getStudent().getEmail())) { + Student student = sqlLogic.getStudentForEmail(courseId, logEntry.getStudent().getEmail()); if (student == null) { // If the student email retrieved from the log is invalid, ignore the log return false; } - studentsMap.put(logEntry.getStudentEmail(), student); + studentsMap.put(student.getEmail(), student); } // If the feedback session retrieved from the log is invalid, ignore the log - return sessionsMap.containsKey(logEntry.getFeedbackSessionName()); + return sessionsMap.containsKey(logEntry.getFeedbackSession().getName()); }).collect(Collectors.toList()); - Map> groupedEntries = - groupFeedbackSessionLogEntries(fsLogEntries); + Map> groupedEntries = groupFeedbackSessionLogs(fsLogEntries); feedbackSessions.forEach(fs -> groupedEntries.putIfAbsent(fs.getName(), new ArrayList<>())); FeedbackSessionLogsData fslData = new FeedbackSessionLogsData(groupedEntries, studentsMap, sessionsMap); return new JsonResult(fslData); } else { + if (logic.getCourse(courseId) == null) { + throw new EntityNotFoundException("Course not found"); + } + + if (email != null && logic.getStudentForEmail(courseId, email) == null) { + throw new EntityNotFoundException("Student not found"); + } + + if (feedbackSessionName != null && logic.getFeedbackSession(feedbackSessionName, courseId) == null) { + throw new EntityNotFoundException("Feedback session not found"); + } + + List fsLogEntries = + logsProcessor.getOrderedFeedbackSessionLogs(courseId, email, startTime, endTime, feedbackSessionName); Map studentsMap = new HashMap<>(); Map sessionsMap = new HashMap<>(); List feedbackSessions = logic.getFeedbackSessionsForCourse(courseId); @@ -215,4 +217,14 @@ private Map> groupFeedbackSessionLogEntrie } return groupedEntries; } + + private Map> groupFeedbackSessionLogs( + List fsLogEntries) { + Map> groupedEntries = new LinkedHashMap<>(); + for (FeedbackSessionLog fsLogEntry : fsLogEntries) { + String fsName = fsLogEntry.getFeedbackSession().getName(); + groupedEntries.computeIfAbsent(fsName, k -> new ArrayList<>()).add(fsLogEntry); + } + return groupedEntries; + } } diff --git a/src/test/java/teammates/sqlui/webapi/GetFeedbackSessionLogsActionTest.java b/src/test/java/teammates/sqlui/webapi/GetFeedbackSessionLogsActionTest.java new file mode 100644 index 00000000000..5c50ecf1638 --- /dev/null +++ b/src/test/java/teammates/sqlui/webapi/GetFeedbackSessionLogsActionTest.java @@ -0,0 +1,330 @@ +package teammates.sqlui.webapi; + +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.InstructorPrivileges; +import teammates.common.datatransfer.logs.FeedbackSessionLogType; +import teammates.common.util.Const; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.FeedbackSessionLog; +import teammates.storage.sqlentity.Instructor; +import teammates.storage.sqlentity.Student; +import teammates.ui.output.FeedbackSessionLogData; +import teammates.ui.output.FeedbackSessionLogEntryData; +import teammates.ui.output.FeedbackSessionLogsData; +import teammates.ui.webapi.GetFeedbackSessionLogsAction; +import teammates.ui.webapi.JsonResult; + +/** + * SUT: {@link GetFeedbackSessionLogsAction}. + */ +public class GetFeedbackSessionLogsActionTest extends BaseActionTest { + + private Course course; + + private Student student1; + private Student student2; + + private FeedbackSession fs1; + + private long startTime; + private long endTime; + + private String googleId = "google-id"; + + @Override + String getActionUri() { + return Const.ResourceURIs.SESSION_LOGS; + } + + @Override + String getRequestMethod() { + return GET; + } + + @BeforeMethod + void setUp() { + FeedbackSession fs2; + endTime = Instant.now().toEpochMilli(); + startTime = endTime - (Const.LOGS_RETENTION_PERIOD.toDays() - 1) * 24 * 60 * 60 * 1000; + + course = getTypicalCourse(); + + student1 = getTypicalStudent(); + student1.setEmail("student1@teammates.tmt"); + student1.setTeam(getTypicalTeam()); + + student2 = getTypicalStudent(); + student2.setEmail("student2@teammates.tmt"); + student2.setTeam(getTypicalTeam()); + + fs1 = getTypicalFeedbackSessionForCourse(course); + fs1.setName("fs1"); + fs1.setCreatedAt(Instant.now()); + + fs2 = getTypicalFeedbackSessionForCourse(course); + fs2.setName("fs2"); + fs2.setCreatedAt(Instant.now()); + + when(mockLogic.getCourse(course.getId())).thenReturn(course); + when(mockLogic.getFeedbackSession(fs1.getName(), course.getId())).thenReturn(fs1); + when(mockLogic.getStudentForEmail(course.getId(), student1.getEmail())).thenReturn(student1); + when(mockLogic.getStudentForEmail(course.getId(), student2.getEmail())).thenReturn(student2); + + List feedbackSessions = new ArrayList<>(); + feedbackSessions.add(fs1); + feedbackSessions.add(fs2); + when(mockLogic.getFeedbackSessionsForCourse(course.getId())).thenReturn(feedbackSessions); + + FeedbackSessionLog student1Session1Log1 = new FeedbackSessionLog(student1, fs1, FeedbackSessionLogType.ACCESS, + Instant.ofEpochMilli(startTime)); + FeedbackSessionLog student1Session2Log1 = new FeedbackSessionLog(student1, fs2, FeedbackSessionLogType.ACCESS, + Instant.ofEpochMilli(startTime + 1000)); + FeedbackSessionLog student1Session2Log2 = new FeedbackSessionLog(student1, fs2, + FeedbackSessionLogType.SUBMISSION, Instant.ofEpochMilli(startTime + 2000)); + FeedbackSessionLog student2Session1Log1 = new FeedbackSessionLog(student2, fs1, FeedbackSessionLogType.ACCESS, + Instant.ofEpochMilli(startTime + 3000)); + FeedbackSessionLog student2Session1Log2 = new FeedbackSessionLog(student2, fs1, + FeedbackSessionLogType.SUBMISSION, Instant.ofEpochMilli(startTime + 4000)); + + List allLogsInCourse = new ArrayList<>(); + allLogsInCourse.add(student1Session1Log1); + allLogsInCourse.add(student1Session2Log1); + allLogsInCourse.add(student1Session2Log2); + allLogsInCourse.add(student2Session1Log1); + allLogsInCourse.add(student2Session1Log2); + when(mockLogic.getOrderedFeedbackSessionLogs(course.getId(), null, null, Instant.ofEpochMilli(startTime), + Instant.ofEpochMilli(endTime))).thenReturn(allLogsInCourse); + + List student1Logs = new ArrayList<>(); + student1Logs.add(student1Session1Log1); + student1Logs.add(student1Session2Log1); + student1Logs.add(student1Session2Log2); + when(mockLogic.getOrderedFeedbackSessionLogs(course.getId(), student1.getId(), null, + Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime))).thenReturn(student1Logs); + + List fs1Logs = new ArrayList<>(); + fs1Logs.add(student1Session1Log1); + fs1Logs.add(student2Session1Log1); + fs1Logs.add(student2Session1Log2); + when(mockLogic.getOrderedFeedbackSessionLogs(course.getId(), null, fs1.getId(), + Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime))).thenReturn(fs1Logs); + + List student1Fs1Logs = new ArrayList<>(); + student1Fs1Logs.add(student1Session1Log1); + when(mockLogic.getOrderedFeedbackSessionLogs(course.getId(), student1.getId(), fs1.getId(), + Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime))).thenReturn(student1Fs1Logs); + } + + @Test(enabled = false) + protected void testExecute() { + JsonResult actionOutput; + + ______TS("Failure case: not enough parameters"); + verifyHttpParameterFailure( + Const.ParamsNames.COURSE_ID, course.getId()); + + verifyHttpParameterFailure( + Const.ParamsNames.COURSE_ID, course.getId(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime)); + verifyHttpParameterFailure( + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), + Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime)); + + ______TS("Failure case: invalid course id"); + String[] paramsInvalid1 = { + Const.ParamsNames.COURSE_ID, "fake-course-id", + Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), + Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), + }; + verifyEntityNotFound(paramsInvalid1); + + ______TS("Failure case: invalid student email"); + String[] paramsInvalid2 = { + Const.ParamsNames.COURSE_ID, course.getId(), + Const.ParamsNames.STUDENT_EMAIL, "fake-student-email@gmail.com", + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), + Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), + }; + verifyEntityNotFound(paramsInvalid2); + + ______TS("Failure case: invalid start or end times"); + String[] paramsInvalid3 = { + Const.ParamsNames.COURSE_ID, course.getId(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, "abc", + Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), + }; + verifyHttpParameterFailure(paramsInvalid3); + + String[] paramsInvalid4 = { + Const.ParamsNames.COURSE_ID, course.getId(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), + Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, " ", + }; + verifyHttpParameterFailure(paramsInvalid4); + + ______TS("Success case: should group by feedback session"); + String[] paramsSuccessful1 = { + Const.ParamsNames.COURSE_ID, course.getId(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), + Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), + }; + + actionOutput = getJsonResult(getAction(paramsSuccessful1)); + + FeedbackSessionLogsData fslData = (FeedbackSessionLogsData) actionOutput.getOutput(); + List fsLogs = fslData.getFeedbackSessionLogs(); + + // Course has 2 feedback sessions + assertEquals(fsLogs.size(), 2); + + List fsLogEntries1 = fsLogs.get(0).getFeedbackSessionLogEntries(); + List fsLogEntries2 = fsLogs.get(1).getFeedbackSessionLogEntries(); + + assertEquals(fsLogEntries1.size(), 3); + assertEquals(fsLogEntries1.get(0).getStudentData().getEmail(), student1.getEmail()); + assertEquals(fsLogEntries1.get(0).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + assertEquals(fsLogEntries1.get(1).getStudentData().getEmail(), student2.getEmail()); + assertEquals(fsLogEntries1.get(1).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + assertEquals(fsLogEntries1.get(2).getStudentData().getEmail(), student2.getEmail()); + assertEquals(fsLogEntries1.get(2).getFeedbackSessionLogType(), FeedbackSessionLogType.SUBMISSION); + + assertEquals(fsLogEntries2.size(), 2); + assertEquals(fsLogEntries2.get(0).getStudentData().getEmail(), student1.getEmail()); + assertEquals(fsLogEntries2.get(0).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + assertEquals(fsLogEntries2.get(1).getStudentData().getEmail(), student1.getEmail()); + assertEquals(fsLogEntries2.get(1).getFeedbackSessionLogType(), FeedbackSessionLogType.SUBMISSION); + + ______TS("Success case: should accept optional email"); + String[] paramsSuccessful2 = { + Const.ParamsNames.COURSE_ID, course.getId(), + Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), + Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), + }; + actionOutput = getJsonResult(getAction(paramsSuccessful2)); + fslData = (FeedbackSessionLogsData) actionOutput.getOutput(); + fsLogs = fslData.getFeedbackSessionLogs(); + + assertEquals(fsLogs.size(), 2); + + fsLogEntries1 = fsLogs.get(0).getFeedbackSessionLogEntries(); + fsLogEntries2 = fsLogs.get(1).getFeedbackSessionLogEntries(); + + assertEquals(fsLogEntries1.size(), 1); + assertEquals(fsLogEntries1.get(0).getStudentData().getEmail(), student1.getEmail()); + assertEquals(fsLogEntries1.get(0).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + + assertEquals(fsLogEntries2.size(), 2); + assertEquals(fsLogEntries2.get(0).getStudentData().getEmail(), student1.getEmail()); + assertEquals(fsLogEntries2.get(0).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + assertEquals(fsLogEntries2.get(1).getStudentData().getEmail(), student1.getEmail()); + assertEquals(fsLogEntries2.get(1).getFeedbackSessionLogType(), FeedbackSessionLogType.SUBMISSION); + + ______TS("Success case: should accept optional feedback session"); + String[] paramsSuccessful3 = { + Const.ParamsNames.COURSE_ID, course.getId(), + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), + Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), + }; + actionOutput = getJsonResult(getAction(paramsSuccessful3)); + fslData = (FeedbackSessionLogsData) actionOutput.getOutput(); + fsLogs = fslData.getFeedbackSessionLogs(); + + assertEquals(fsLogs.size(), 2); + assertEquals(fsLogs.get(1).getFeedbackSessionLogEntries().size(), 0); + + fsLogEntries1 = fsLogs.get(0).getFeedbackSessionLogEntries(); + + assertEquals(fsLogEntries1.size(), 3); + assertEquals(fsLogEntries1.get(0).getStudentData().getEmail(), student1.getEmail()); + assertEquals(fsLogEntries1.get(0).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + assertEquals(fsLogEntries1.get(1).getStudentData().getEmail(), student2.getEmail()); + assertEquals(fsLogEntries1.get(1).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + assertEquals(fsLogEntries1.get(2).getStudentData().getEmail(), student2.getEmail()); + assertEquals(fsLogEntries1.get(2).getFeedbackSessionLogType(), FeedbackSessionLogType.SUBMISSION); + + ______TS("Success case: should accept all optional params"); + String[] paramsSuccessful4 = { + Const.ParamsNames.COURSE_ID, course.getId(), + Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), + Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), + }; + actionOutput = getJsonResult(getAction(paramsSuccessful4)); + fslData = (FeedbackSessionLogsData) actionOutput.getOutput(); + fsLogs = fslData.getFeedbackSessionLogs(); + + assertEquals(fsLogs.size(), 2); + assertEquals(fsLogs.get(1).getFeedbackSessionLogEntries().size(), 0); + + fsLogEntries1 = fsLogs.get(0).getFeedbackSessionLogEntries(); + + assertEquals(fsLogEntries1.size(), 1); + assertEquals(fsLogEntries1.get(0).getStudentData().getEmail(), student1.getEmail()); + assertEquals(fsLogEntries1.get(0).getFeedbackSessionLogType(), FeedbackSessionLogType.ACCESS); + + // TODO: if we restrict the range from start to end time, it should be tested + // here as well + } + + @Test + void testSpecificAccessControl_instructorWithInvalidPermission_cannotAccess() { + + Instructor instructor = new Instructor(course, "name", "instructoremail@tm.tmt", + false, "", null, new InstructorPrivileges()); + + loginAsInstructor(googleId); + when(mockLogic.getCourse(course.getId())).thenReturn(course); + when(mockLogic.getInstructorByGoogleId(course.getId(), googleId)).thenReturn(instructor); + + String[] params = { + Const.ParamsNames.COURSE_ID, course.getId(), + }; + + verifyCannotAccess(params); + } + + @Test + void testSpecificAccessControl_instructorWithPermission_canAccess() { + InstructorPrivileges instructorPrivileges = new InstructorPrivileges(); + instructorPrivileges.updatePrivilege(Const.InstructorPermissions.CAN_MODIFY_SESSION, true); + instructorPrivileges.updatePrivilege(Const.InstructorPermissions.CAN_MODIFY_STUDENT, true); + instructorPrivileges.updatePrivilege(Const.InstructorPermissions.CAN_MODIFY_INSTRUCTOR, true); + Instructor instructor = new Instructor(course, "name", "instructoremail@tm.tmt", + false, "", null, instructorPrivileges); + + loginAsInstructor(googleId); + when(mockLogic.getCourse(course.getId())).thenReturn(course); + when(mockLogic.getInstructorByGoogleId(course.getId(), googleId)).thenReturn(instructor); + + String[] params = { + Const.ParamsNames.COURSE_ID, course.getId(), + }; + + verifyCanAccess(params); + } + + @Test + void testSpecificAccessControl_notInstructor_cannotAccess() { + String[] params = { + Const.ParamsNames.COURSE_ID, course.getId(), + }; + loginAsStudent(googleId); + verifyCannotAccess(params); + + logoutUser(); + verifyCannotAccess(params); + } +} From 5779d2f365abad11ed259714c8422bc66f82db1e Mon Sep 17 00:00:00 2001 From: domoberzin <74132255+domoberzin@users.noreply.github.com> Date: Fri, 5 Apr 2024 20:44:15 +0800 Subject: [PATCH 28/95] [#11878] Create Update Account Request Action (#12982) * create update action and IT * update javadocs * update tests * add more tests * simplify logic * remove unused string * fix test * allow null comments * add more tests * use EntityNotFoundException * cleanup after create account requests test * remove unncessary check --- .../webapi/CreateAccountRequestActionIT.java | 15 ++ .../webapi/UpdateAccountRequestActionIT.java | 223 ++++++++++++++++++ .../request/AccountRequestUpdateRequest.java | 63 +++++ .../teammates/ui/webapi/ActionFactory.java | 1 + .../ui/webapi/UpdateAccountRequestAction.java | 75 ++++++ .../ui/webapi/GetActionClassesActionTest.java | 1 + 6 files changed, 378 insertions(+) create mode 100644 src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java create mode 100644 src/main/java/teammates/ui/request/AccountRequestUpdateRequest.java create mode 100644 src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java diff --git a/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java index f73bf03f24a..85449452b5f 100644 --- a/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java @@ -1,5 +1,8 @@ package teammates.it.ui.webapi; +import java.util.List; + +import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -221,4 +224,16 @@ protected void testAccessControl() throws Exception { verifyAccessibleWithoutLogin(); } + @Override + @AfterMethod + protected void tearDown() { + HibernateUtil.beginTransaction(); + List accountRequests = logic.getPendingAccountRequests(); + for (AccountRequest ar : accountRequests) { + logic.deleteAccountRequest(ar.getEmail(), ar.getInstitute()); + } + accountRequests = logic.getPendingAccountRequests(); + HibernateUtil.commitTransaction(); + assert accountRequests.isEmpty(); + } } diff --git a/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java new file mode 100644 index 00000000000..207c042be41 --- /dev/null +++ b/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java @@ -0,0 +1,223 @@ +package teammates.it.ui.webapi; + +import java.util.UUID; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Const; +import teammates.common.util.FieldValidator; +import teammates.common.util.HibernateUtil; +import teammates.common.util.StringHelperExtension; +import teammates.storage.sqlentity.AccountRequest; +import teammates.storage.sqlentity.Course; +import teammates.ui.output.AccountRequestData; +import teammates.ui.request.AccountRequestUpdateRequest; +import teammates.ui.request.InvalidHttpRequestBodyException; +import teammates.ui.webapi.EntityNotFoundException; +import teammates.ui.webapi.InvalidHttpParameterException; +import teammates.ui.webapi.JsonResult; +import teammates.ui.webapi.UpdateAccountRequestAction; + +/** + * SUT: {@link UpdateAccountRequestAction}. + */ +public class UpdateAccountRequestActionIT extends BaseActionIT { + + @Override + @BeforeMethod + protected void setUp() throws Exception { + super.setUp(); + persistDataBundle(typicalBundle); + HibernateUtil.flushSession(); + } + + @Override + protected String getActionUri() { + return Const.ResourceURIs.ACCOUNT_REQUEST; + } + + @Override + protected String getRequestMethod() { + return PUT; + } + + @Override + @Test + public void testExecute() throws Exception { + ______TS("edit fields of an account request"); + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + accountRequest.setStatus(AccountRequestStatus.PENDING); + UUID id = accountRequest.getId(); + String name = "newName"; + String email = "newEmail@email.com"; + String institute = "newInstitute"; + String comments = "newComments"; + AccountRequestStatus status = accountRequest.getStatus(); + + AccountRequestUpdateRequest requestBody = new AccountRequestUpdateRequest(name, email, institute, status, comments); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + UpdateAccountRequestAction action = getAction(requestBody, params); + JsonResult result = action.execute(); + + assertEquals(result.getStatusCode(), 200); + AccountRequestData data = (AccountRequestData) result.getOutput(); + + assertEquals(name, data.getName()); + assertEquals(email, data.getEmail()); + assertEquals(institute, data.getInstitute()); + assertEquals(status, data.getStatus()); + assertEquals(comments, data.getComments()); + verifyNoEmailsSent(); + + ______TS("approve a pending account request"); + accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor2"); + accountRequest.setStatus(AccountRequestStatus.PENDING); + requestBody = new AccountRequestUpdateRequest(accountRequest.getName(), accountRequest.getEmail(), + accountRequest.getInstitute(), AccountRequestStatus.APPROVED, accountRequest.getComments()); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, accountRequest.getId().toString()}; + action = getAction(requestBody, params); + result = getJsonResult(action, 200); + data = (AccountRequestData) result.getOutput(); + + assertEquals(accountRequest.getName(), data.getName()); + assertEquals(accountRequest.getEmail(), data.getEmail()); + assertEquals(accountRequest.getInstitute(), data.getInstitute()); + assertEquals(AccountRequestStatus.APPROVED, data.getStatus()); + assertEquals(accountRequest.getComments(), data.getComments()); + verifyNumberOfEmailsSent(1); + + ______TS("already registered account request has no email sent when approved"); + accountRequest = typicalBundle.accountRequests.get("instructor2"); + requestBody = new AccountRequestUpdateRequest(name, email, institute, AccountRequestStatus.APPROVED, comments); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, accountRequest.getId().toString()}; + + action = getAction(requestBody, params); + result = getJsonResult(action, 200); + data = (AccountRequestData) result.getOutput(); + + assertEquals(name, data.getName()); + assertEquals(email, data.getEmail()); + assertEquals(institute, data.getInstitute()); + assertEquals(AccountRequestStatus.REGISTERED, data.getStatus()); + assertEquals(comments, data.getComments()); + verifyNumberOfEmailsSent(0); + + ______TS("non-existent but valid uuid"); + requestBody = new AccountRequestUpdateRequest("name", "email", + "institute", AccountRequestStatus.PENDING, "comments"); + String validUuid = UUID.randomUUID().toString(); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, validUuid}; + + EntityNotFoundException enfe = verifyEntityNotFound(requestBody, params); + + assertEquals(String.format("Account request with id = %s not found", validUuid), enfe.getMessage()); + + ______TS("invalid uuid"); + requestBody = new AccountRequestUpdateRequest("name", "email", + "institute", AccountRequestStatus.PENDING, "comments"); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, "invalid"}; + + InvalidHttpParameterException ihpe = verifyHttpParameterFailure(requestBody, params); + + assertEquals("Invalid UUID string: invalid", ihpe.getMessage()); + + ______TS("invalid email"); + accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + id = accountRequest.getId(); + email = "newEmail"; + status = accountRequest.getStatus(); + + requestBody = new AccountRequestUpdateRequest(name, email, institute, status, comments); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + InvalidHttpRequestBodyException ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals(getPopulatedErrorMessage(FieldValidator.EMAIL_ERROR_MESSAGE, email, + FieldValidator.EMAIL_FIELD_NAME, FieldValidator.REASON_INCORRECT_FORMAT, FieldValidator.EMAIL_MAX_LENGTH), + ihrbe.getMessage()); + + ______TS("invalid name alphanumeric"); + name = "@$@#$#@#@$#@$"; + email = "newEmail@email.com"; + + requestBody = new AccountRequestUpdateRequest(name, email, institute, status, comments); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals(getPopulatedErrorMessage(FieldValidator.INVALID_NAME_ERROR_MESSAGE, name, + FieldValidator.PERSON_NAME_FIELD_NAME, FieldValidator.REASON_START_WITH_NON_ALPHANUMERIC_CHAR), + ihrbe.getMessage()); + + ______TS("invalid name too long"); + name = StringHelperExtension.generateStringOfLength(FieldValidator.PERSON_NAME_MAX_LENGTH + 1); + + requestBody = new AccountRequestUpdateRequest(name, email, institute, status, comments); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals(getPopulatedErrorMessage(FieldValidator.SIZE_CAPPED_NON_EMPTY_STRING_ERROR_MESSAGE, name, + FieldValidator.PERSON_NAME_FIELD_NAME, FieldValidator.REASON_TOO_LONG, + FieldValidator.PERSON_NAME_MAX_LENGTH), ihrbe.getMessage()); + + ______TS("null email value"); + name = "newName"; + + requestBody = new AccountRequestUpdateRequest(name, null, institute, status, comments); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals("email cannot be null", ihrbe.getMessage()); + + ______TS("null name value"); + requestBody = new AccountRequestUpdateRequest(null, email, institute, status, comments); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals("name cannot be null", ihrbe.getMessage()); + + ______TS("null status value"); + requestBody = new AccountRequestUpdateRequest(name, email, institute, null, comments); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals("status cannot be null", ihrbe.getMessage()); + + ______TS("null institute value"); + requestBody = new AccountRequestUpdateRequest(name, email, null, status, comments); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals("institute cannot be null", ihrbe.getMessage()); + + ______TS("allow null comments in request"); + requestBody = new AccountRequestUpdateRequest(name, email, institute, status, null); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + action = getAction(requestBody, params); + result = getJsonResult(action, 200); + data = (AccountRequestData) result.getOutput(); + + assertEquals(name, data.getName()); + assertEquals(email, data.getEmail()); + assertEquals(institute, data.getInstitute()); + assertEquals(null, data.getComments()); + } + + @Override + @Test + protected void testAccessControl() throws InvalidParametersException, EntityAlreadyExistsException { + Course course = typicalBundle.courses.get("course1"); + verifyOnlyAdminCanAccess(course); + } +} diff --git a/src/main/java/teammates/ui/request/AccountRequestUpdateRequest.java b/src/main/java/teammates/ui/request/AccountRequestUpdateRequest.java new file mode 100644 index 00000000000..cc653d79ddb --- /dev/null +++ b/src/main/java/teammates/ui/request/AccountRequestUpdateRequest.java @@ -0,0 +1,63 @@ +package teammates.ui.request; + +import javax.annotation.Nullable; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.util.SanitizationHelper; + +/** + * The create request for an account request update request. + */ +public class AccountRequestUpdateRequest extends BasicRequest { + private String name; + private String email; + private String institute; + private AccountRequestStatus status; + + @Nullable + private String comments; + + public AccountRequestUpdateRequest(String name, String email, String institute, AccountRequestStatus status, + String comments) { + this.name = SanitizationHelper.sanitizeName(name); + this.email = SanitizationHelper.sanitizeEmail(email); + this.institute = SanitizationHelper.sanitizeName(institute); + this.status = status; + if (comments != null) { + this.comments = SanitizationHelper.sanitizeTextField(comments); + } + } + + @Override + public void validate() throws InvalidHttpRequestBodyException { + assertTrue(name != null, "name cannot be null"); + assertTrue(email != null, "email cannot be null"); + assertTrue(institute != null, "institute cannot be null"); + assertTrue(status != null, "status cannot be null"); + assertTrue(status == AccountRequestStatus.APPROVED + || status == AccountRequestStatus.REJECTED + || status == AccountRequestStatus.PENDING + || status == AccountRequestStatus.REGISTERED, + "status must be one of the following: APPROVED, REJECTED, PENDING, REGISTERED"); + } + + public String getName() { + return this.name; + } + + public String getEmail() { + return this.email; + } + + public String getInstitute() { + return this.institute; + } + + public AccountRequestStatus getStatus() { + return this.status; + } + + public String getComments() { + return this.comments; + } +} diff --git a/src/main/java/teammates/ui/webapi/ActionFactory.java b/src/main/java/teammates/ui/webapi/ActionFactory.java index 72d16ad9973..169d4ae5b07 100644 --- a/src/main/java/teammates/ui/webapi/ActionFactory.java +++ b/src/main/java/teammates/ui/webapi/ActionFactory.java @@ -50,6 +50,7 @@ public final class ActionFactory { map(ResourceURIs.ACCOUNT_REQUEST, GET, GetAccountRequestAction.class); map(ResourceURIs.ACCOUNT_REQUEST, POST, CreateAccountRequestAction.class); map(ResourceURIs.ACCOUNT_REQUEST, DELETE, DeleteAccountRequestAction.class); + map(ResourceURIs.ACCOUNT_REQUEST, PUT, UpdateAccountRequestAction.class); map(ResourceURIs.ACCOUNT_REQUESTS, GET, GetAccountRequestsAction.class); map(ResourceURIs.ACCOUNT_REQUEST_RESET, PUT, ResetAccountRequestAction.class); map(ResourceURIs.ACCOUNTS, GET, GetAccountsAction.class); diff --git a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java new file mode 100644 index 00000000000..761a18abd8f --- /dev/null +++ b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java @@ -0,0 +1,75 @@ +package teammates.ui.webapi; + +import java.util.UUID; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.exception.EntityDoesNotExistException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Const; +import teammates.common.util.EmailWrapper; +import teammates.storage.sqlentity.AccountRequest; +import teammates.ui.output.AccountRequestData; +import teammates.ui.request.AccountRequestUpdateRequest; +import teammates.ui.request.InvalidHttpRequestBodyException; + +/** + * Updates an account request. + */ +public class UpdateAccountRequestAction extends AdminOnlyAction { + + static final String ACCOUNT_REQUEST_NOT_FOUND = "Account request with id = %s not found"; + + @Override + public JsonResult execute() throws InvalidOperationException, InvalidHttpRequestBodyException { + String id = getNonNullRequestParamValue(Const.ParamsNames.ACCOUNT_REQUEST_ID); + UUID accountRequestId; + + try { + accountRequestId = UUID.fromString(id); + } catch (IllegalArgumentException e) { + throw new InvalidHttpParameterException(e.getMessage(), e); + } + + AccountRequest accountRequest = sqlLogic.getAccountRequest(accountRequestId); + + if (accountRequest == null) { + String errorMessage = String.format(ACCOUNT_REQUEST_NOT_FOUND, accountRequestId.toString()); + throw new EntityNotFoundException(errorMessage); + } + + AccountRequestUpdateRequest accountRequestUpdateRequest = + getAndValidateRequestBody(AccountRequestUpdateRequest.class); + + if (accountRequestUpdateRequest.getStatus() == AccountRequestStatus.APPROVED + && (accountRequest.getStatus() == AccountRequestStatus.PENDING + || accountRequest.getStatus() == AccountRequestStatus.REJECTED)) { + try { + // should not need to update other fields for an approval + accountRequest.setStatus(accountRequestUpdateRequest.getStatus()); + accountRequest = sqlLogic.updateAccountRequest(accountRequest); + EmailWrapper email = sqlEmailGenerator.generateNewInstructorAccountJoinEmail( + accountRequest.getRegistrationUrl(), accountRequest.getEmail(), accountRequest.getName()); + emailSender.sendEmail(email); + } catch (InvalidParametersException e) { + throw new InvalidHttpRequestBodyException(e); + } catch (EntityDoesNotExistException e) { + throw new EntityNotFoundException(e); + } + } else { + try { + accountRequest.setName(accountRequestUpdateRequest.getName()); + accountRequest.setEmail(accountRequestUpdateRequest.getEmail()); + accountRequest.setInstitute(accountRequestUpdateRequest.getInstitute()); + accountRequest.setStatus(accountRequest.getStatus()); + accountRequest.setComments(accountRequestUpdateRequest.getComments()); + sqlLogic.updateAccountRequest(accountRequest); + } catch (InvalidParametersException e) { + throw new InvalidHttpRequestBodyException(e); + } catch (EntityDoesNotExistException e) { + throw new EntityNotFoundException(e); + } + } + + return new JsonResult(new AccountRequestData(accountRequest)); + } +} diff --git a/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java b/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java index 752a74d9101..89ace4cdf78 100644 --- a/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java +++ b/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java @@ -87,6 +87,7 @@ protected void testExecute() { GetAccountRequestAction.class, DeleteAccountRequestAction.class, GetAccountRequestsAction.class, + UpdateAccountRequestAction.class, GetAccountAction.class, GetAccountsAction.class, FeedbackSessionPublishedRemindersAction.class, From 028c953a04f9563522cc5444aef7926bd536a165 Mon Sep 17 00:00:00 2001 From: Nicolas <25302138+NicolasCwy@users.noreply.github.com> Date: Sat, 6 Apr 2024 10:52:46 +0800 Subject: [PATCH 29/95] [#12048] Update liquibase configuration (#12930) * Update gradle config * Update liquibase config for v9 * Turn off table generate for prod * Update of changelog file * Add configuration for generating changelog * Add schema migration docs --------- Co-authored-by: FergusMok --- build.gradle | 22 + docs/schema-migration.md | 34 ++ .../teammates/common/util/HibernateUtil.java | 6 +- .../db/changelog/db.changelog-root.xml | 2 +- .../db/changelog/db.changelog-v9.0.0.xml | 536 ++++++++++++++++++ .../db/changelog/db.changelog-v9.xml | 82 --- 6 files changed, 598 insertions(+), 84 deletions(-) create mode 100644 docs/schema-migration.md create mode 100644 src/main/resources/db/changelog/db.changelog-v9.0.0.xml delete mode 100644 src/main/resources/db/changelog/db.changelog-v9.xml diff --git a/build.gradle b/build.gradle index 90abb909473..f448d003a8f 100644 --- a/build.gradle +++ b/build.gradle @@ -101,7 +101,9 @@ dependencies { exclude group: "org.apache.jmeter", module: "bom" } + liquibaseRuntime("org.liquibase:liquibase-core:4.19.0") liquibaseRuntime("info.picocli:picocli:4.7.1") + liquibaseRuntime("org.postgresql:postgresql:42.7.2") liquibaseRuntime(sourceSets.main.output) } @@ -136,6 +138,10 @@ sourceSets { } } +if (!project.hasProperty("runList")) { + project.ext.set("runList", "main") +} + liquibase { activities { main { @@ -145,7 +151,23 @@ liquibase { username project.properties['liquibaseUsername'] password project.properties['liquibasePassword'] } + snapshot { + url project.properties['liquibaseDbUrl'] + username project.properties['liquibaseUsername'] + password project.properties['liquibasePassword'] + snapshotFormat "json" + outputFile "liquibase-snapshot.json" + } + diffMain { + searchPath "${projectDir}" + changeLogFile "src/main/resources/db/changelog/db.changelog-new.xml" + referenceUrl project.properties['liquibaseDbUrl'] + referenceUsername project.properties['liquibaseUsername'] + referencePassword project.properties['liquibasePassword'] + url "offline:postgresql?snapshot=liquibase-snapshot.json" + } } + runList = project.ext.runList } tasks.withType(cz.habarta.typescript.generator.gradle.GenerateTask) { diff --git a/docs/schema-migration.md b/docs/schema-migration.md new file mode 100644 index 00000000000..a88f49ece0f --- /dev/null +++ b/docs/schema-migration.md @@ -0,0 +1,34 @@ + + title: "Schema Migration" + + +# SQL Schema Migration + +Teammates uses _[Liquibase]_(https://docs.liquibase.com/start/home.html), a database schema change management solution that enables developers to revise and release database changes to production. The maintainers in charge of releases (Release Leader) will be in charge of generating a _Liquibase_ changelog prior to each release to keep the production databases schema in sync with the code. Therefore this section is just for documentation purposes for contributors. + +## Liquibase in Teammates +_Liquibase_ is made available using the [gradle plugin](https://github.com/liquibase/liquibase-gradle-plugin), providing _liquibase_ functions as tasks. Try `gradle tasks | grep "liquibase"` to see all the tasks available. In teammates, change logs (more in the next section) are written in _XML_. + +### Liquibase connection +Amend the `liquibaseDbUrl`, `liquibaseUsername` and `liquibasePassword` in `gradle.properties` to allow the _Liquibase_ plugin to connect your database. + +## Change logs, change sets and change types +A _change log_ is a file that contains a series of _change sets_ (analagous to a transaction) which applies _change types_ (actions). You can refer to this page on liquibase on the types of [change types](https://docs.liquibase.com/change-types/home.html) that can be used. + +## Gradle Activities for Liquibase +Activities in Gradle are a way of specifying different variables provided by gradle to the Liquibase plugin. The argument `runList` provided by `-pRunList=` e.g `./gradlew liquibaseSnapshot -PrunList=snapshot` is used to specify which activity to be used for the Liquibase command. In this case the `liquibaseSnapshot` command is run using the `snapshot` activity. + +Here is a brief description of the activities defined for Liquibase +1. Main: The default activity used by Liquibase commands and is used for running changelogs against a database. This is used by default if a `runList` is not defined +2. Snapshot: Used to specify output format and name for snapshots i.e JSON +3. diffMain: Specify the reference and the target database to generate changelog that contains operations to update reference database to the state of the target database. i.e the reference is the JSON file generated by the snapshot command, this can be replaced with a live database which is used as reference. + +## Generating/ Updating liquibase change logs +1. Ensure `diff-main` activity in `build.gradle` is pointing to the latest release changelog `src/main/resources/db/changelog/db.changelog-.xml` +2. Delete the `postgres-data` folder to clear any old database schemas +3. Run `git checkout ` and +4. Run the server using `./gradlew serverRun` to generate tables found on branch +5. Generate snapshot of database by running `./gradlew liquibaseSnapshot -PrunList=snapshot`, the snapshot will be output to `liquibase-snapshot.json` +6. Checkout your branch and repeat steps 2 and 4 to generate the tables found on your branch +7. Run `./gradlew liquibaseDiffChangeLog -PrunList=diffMain` to generate changeLog to resolve database schema differences + diff --git a/src/main/java/teammates/common/util/HibernateUtil.java b/src/main/java/teammates/common/util/HibernateUtil.java index acad8a4d13a..8edcca6c807 100644 --- a/src/main/java/teammates/common/util/HibernateUtil.java +++ b/src/main/java/teammates/common/util/HibernateUtil.java @@ -116,7 +116,7 @@ public static void buildSessionFactory(String dbUrl, String username, String pas .setProperty("hibernate.connection.username", username) .setProperty("hibernate.connection.password", password) .setProperty("hibernate.connection.url", dbUrl) - .setProperty("hibernate.hbm2ddl.auto", "update") + .setProperty("hibernate.hbm2ddl.auto", "validate") .setProperty("show_sql", "true") .setProperty("hibernate.current_session_context_class", "thread") .setProperty("hibernate.hikari.minimumIdle", "10") @@ -130,6 +130,10 @@ public static void buildSessionFactory(String dbUrl, String username, String pas // .setProperty("hibernate.jdbc.fetch_size", "50") .addPackage("teammates.storage.sqlentity"); + if (Config.IS_DEV_SERVER) { + config.setProperty("hibernate.hbm2ddl.auto", "update"); + } + for (Class cls : ANNOTATED_CLASSES) { config = config.addAnnotatedClass(cls); } diff --git a/src/main/resources/db/changelog/db.changelog-root.xml b/src/main/resources/db/changelog/db.changelog-root.xml index 66d4b7d7c88..af5c5e34d7e 100644 --- a/src/main/resources/db/changelog/db.changelog-root.xml +++ b/src/main/resources/db/changelog/db.changelog-root.xml @@ -4,5 +4,5 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> - + diff --git a/src/main/resources/db/changelog/db.changelog-v9.0.0.xml b/src/main/resources/db/changelog/db.changelog-v9.0.0.xml new file mode 100644 index 00000000000..205645520ff --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-v9.0.0.xml @@ -0,0 +1,536 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/db.changelog-v9.xml b/src/main/resources/db/changelog/db.changelog-v9.xml deleted file mode 100644 index 57b6e7f9587..00000000000 --- a/src/main/resources/db/changelog/db.changelog-v9.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 4a54001bac3824cc8ac2fb66324d7061151f4504 Mon Sep 17 00:00:00 2001 From: domoberzin <74132255+domoberzin@users.noreply.github.com> Date: Sat, 6 Apr 2024 22:42:50 +0800 Subject: [PATCH 30/95] [#11878] Fix Account Request Update Search Indexing (#12984) * update account request indexing * add methods to test access control * refactoring for transactions --- ...ntRequestSearchIndexingWorkerActionIT.java | 5 +- .../teammates/it/ui/webapi/BaseActionIT.java | 106 +++++++++++++++++- .../webapi/UpdateAccountRequestActionIT.java | 35 ++++-- .../java/teammates/logic/api/TaskQueuer.java | 10 +- .../java/teammates/sqllogic/api/Logic.java | 26 +++++ .../sqllogic/core/AccountRequestsLogic.java | 38 +++++++ .../storage/sqlapi/AccountRequestsDb.java | 13 +++ ...ountRequestSearchIndexingWorkerAction.java | 14 ++- .../ui/webapi/CreateAccountRequestAction.java | 2 +- .../ui/webapi/UpdateAccountRequestAction.java | 13 ++- .../logic/api/MockUserProvision.java | 25 +++++ 11 files changed, 259 insertions(+), 28 deletions(-) diff --git a/src/it/java/teammates/it/ui/webapi/AccountRequestSearchIndexingWorkerActionIT.java b/src/it/java/teammates/it/ui/webapi/AccountRequestSearchIndexingWorkerActionIT.java index a90fb7c9421..6ae1e9e51c4 100644 --- a/src/it/java/teammates/it/ui/webapi/AccountRequestSearchIndexingWorkerActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/AccountRequestSearchIndexingWorkerActionIT.java @@ -1,6 +1,7 @@ package teammates.it.ui.webapi; import java.util.List; +import java.util.UUID; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -46,6 +47,7 @@ public void testExecute() throws Exception { } AccountRequest accountRequest = typicalBundle.accountRequests.get("instructor1"); + UUID accountRequestId = accountRequest.getId(); ______TS("account request not yet indexed should not be searchable"); @@ -56,8 +58,7 @@ public void testExecute() throws Exception { ______TS("account request indexed should be searchable"); String[] submissionParams = new String[] { - ParamsNames.INSTRUCTOR_EMAIL, accountRequest.getEmail(), - ParamsNames.INSTRUCTOR_INSTITUTION, accountRequest.getInstitute(), + ParamsNames.ACCOUNT_REQUEST_ID, accountRequestId.toString(), }; AccountRequestSearchIndexingWorkerAction action = getAction(submissionParams); diff --git a/src/it/java/teammates/it/ui/webapi/BaseActionIT.java b/src/it/java/teammates/it/ui/webapi/BaseActionIT.java index 950de8655e7..e489db0faf1 100644 --- a/src/it/java/teammates/it/ui/webapi/BaseActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/BaseActionIT.java @@ -22,6 +22,7 @@ import teammates.common.util.Config; import teammates.common.util.Const; import teammates.common.util.EmailWrapper; +import teammates.common.util.HibernateUtil; import teammates.common.util.JsonUtils; import teammates.it.test.BaseTestCaseWithSqlDatabaseAccess; import teammates.logic.api.MockEmailSender; @@ -169,6 +170,14 @@ protected void loginAsAdmin() { assertTrue(user.isAdmin); } + /** + * Logs in the user to the test environment as an admin. + */ + protected void loginAsAdminWithTransaction() { + UserInfo user = mockUserProvision.loginAsAdminWithTransaction(Config.APP_ADMINS.get(0)); + assertTrue(user.isAdmin); + } + /** * Logs in the user to the test environment as an unregistered user * (without any right). @@ -180,6 +189,17 @@ protected void loginAsUnregistered(String userId) { assertFalse(user.isAdmin); } + /** + * Logs in the user to the test environment as an unregistered user + * (without any right). + */ + protected void loginAsUnregisteredWithTransaction(String userId) { + UserInfo user = mockUserProvision.loginUserWithTransaction(userId); + assertFalse(user.isStudent); + assertFalse(user.isInstructor); + assertFalse(user.isAdmin); + } + /** * Logs in the user to the test environment as an instructor * (without admin rights or student rights). @@ -191,6 +211,17 @@ protected void loginAsInstructor(String userId) { assertFalse(user.isAdmin); } + /** + * Logs in the user to the test environment as an instructor + * (without admin rights or student rights). + */ + protected void loginAsInstructorWithTransaction(String userId) { + UserInfo user = mockUserProvision.loginUserWithTransaction(userId); + assertFalse(user.isStudent); + assertTrue(user.isInstructor); + assertFalse(user.isAdmin); + } + /** * Logs in the user to the test environment as a student * (without admin rights or instructor rights). @@ -202,6 +233,17 @@ protected void loginAsStudent(String userId) { assertFalse(user.isAdmin); } + /** + * Logs in the user to the test environment as a student + * (without admin rights or instructor rights). + */ + protected void loginAsStudentWithTransaction(String userId) { + UserInfo user = mockUserProvision.loginUserWithTransaction(userId); + assertTrue(user.isStudent); + assertFalse(user.isInstructor); + assertFalse(user.isAdmin); + } + /** * Logs in the user to the test environment as a student-instructor (without * admin rights). @@ -267,6 +309,24 @@ void verifyOnlyAdminCanAccess(Course course, String... params) verifyAccessibleForAdmin(params); } + void verifyOnlyAdminCanAccessWithTransaction(String... params) + throws InvalidParametersException, EntityAlreadyExistsException { + HibernateUtil.beginTransaction(); + Course course = getTypicalCourse(); + course = logic.createCourse(course); + HibernateUtil.commitTransaction(); + + verifyInaccessibleWithoutLogin(params); + verifyInaccessibleForUnregisteredUsersWithTransaction(params); + verifyInaccessibleForStudentsWithTransaction(course, params); + verifyInaccessibleForInstructorsWithTransaction(course, params); + verifyAccessibleForAdminWithTransaction(params); + + HibernateUtil.beginTransaction(); + logic.deleteCourseCascade(course.getId()); + HibernateUtil.commitTransaction(); + } + void verifyOnlyInstructorsCanAccess(Course course, String... params) throws InvalidParametersException, EntityAlreadyExistsException { verifyInaccessibleWithoutLogin(params); @@ -329,6 +389,14 @@ void verifyInaccessibleForUnregisteredUsers(String... params) { verifyCannotAccess(params); } + void verifyInaccessibleForUnregisteredUsersWithTransaction(String... params) { + ______TS("Non-registered users cannot access"); + + String unregUserId = "unreg.user"; + loginAsUnregisteredWithTransaction(unregUserId); + verifyCannotAccess(params); + } + void verifyAccessibleForAdmin(String... params) { ______TS("Admin can access"); @@ -336,6 +404,13 @@ void verifyAccessibleForAdmin(String... params) { verifyCanAccess(params); } + void verifyAccessibleForAdminWithTransaction(String... params) { + ______TS("Admin can access"); + + loginAsAdminWithTransaction(); + verifyCanAccess(params); + } + void verifyInaccessibleForAdmin(String... params) { ______TS("Admin cannot access"); @@ -353,6 +428,21 @@ void verifyInaccessibleForStudents(Course course, String... params) } + void verifyInaccessibleForStudentsWithTransaction(Course course, String... params) + throws InvalidParametersException, EntityAlreadyExistsException { + ______TS("Students cannot access"); + HibernateUtil.beginTransaction(); + Student student = createTypicalStudent(course, "InaccessibleForStudents@teammates.tmt"); + HibernateUtil.commitTransaction(); + + loginAsStudentWithTransaction(student.getAccount().getGoogleId()); + verifyCannotAccess(params); + + HibernateUtil.beginTransaction(); + logic.deleteAccountCascade(student.getAccount().getGoogleId()); + HibernateUtil.commitTransaction(); + } + void verifyInaccessibleForInstructors(Course course, String... params) throws InvalidParametersException, EntityAlreadyExistsException { ______TS("Instructors cannot access"); @@ -363,6 +453,21 @@ void verifyInaccessibleForInstructors(Course course, String... params) } + void verifyInaccessibleForInstructorsWithTransaction(Course course, String... params) + throws InvalidParametersException, EntityAlreadyExistsException { + ______TS("Instructors cannot access"); + HibernateUtil.beginTransaction(); + Instructor instructor = createTypicalInstructor(course, "InaccessibleForInstructors@teammates.tmt"); + HibernateUtil.commitTransaction(); + + loginAsInstructorWithTransaction(instructor.getAccount().getGoogleId()); + verifyCannotAccess(params); + + HibernateUtil.beginTransaction(); + logic.deleteAccountCascade(instructor.getAccount().getGoogleId()); + HibernateUtil.commitTransaction(); + } + void verifyAccessibleForAdminToMasqueradeAsInstructor( Instructor instructor, String[] submissionParams) { ______TS("admin can access"); @@ -738,5 +843,4 @@ private Student createTypicalStudent(Course course, String email) } return student; } - } diff --git a/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java index 207c042be41..8240421d27a 100644 --- a/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java @@ -1,7 +1,9 @@ package teammates.it.ui.webapi; +import java.util.List; import java.util.UUID; +import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -13,7 +15,6 @@ import teammates.common.util.HibernateUtil; import teammates.common.util.StringHelperExtension; import teammates.storage.sqlentity.AccountRequest; -import teammates.storage.sqlentity.Course; import teammates.ui.output.AccountRequestData; import teammates.ui.request.AccountRequestUpdateRequest; import teammates.ui.request.InvalidHttpRequestBodyException; @@ -30,9 +31,7 @@ public class UpdateAccountRequestActionIT extends BaseActionIT accountRequests = logic.getAllAccountRequests(); + for (AccountRequest ar : accountRequests) { + logic.deleteAccountRequest(ar.getEmail(), ar.getInstitute()); + } + HibernateUtil.commitTransaction(); } } diff --git a/src/main/java/teammates/logic/api/TaskQueuer.java b/src/main/java/teammates/logic/api/TaskQueuer.java index a3db7d7d359..2ea43cf850b 100644 --- a/src/main/java/teammates/logic/api/TaskQueuer.java +++ b/src/main/java/teammates/logic/api/TaskQueuer.java @@ -218,15 +218,13 @@ public void scheduleInstructorForSearchIndexing(String courseId, String email) { } /** - * Schedules for the search indexing of the account request identified by {@code email} and {@code institute}. + * Schedules for the search indexing of the account request identified by {@code id}. * - * @param email the email associated with the account request - * @param institute the institute associated with the account request + * @param id the id associated with the account request */ - public void scheduleAccountRequestForSearchIndexing(String email, String institute) { + public void scheduleAccountRequestForSearchIndexing(String id) { Map paramMap = new HashMap<>(); - paramMap.put(ParamsNames.INSTRUCTOR_EMAIL, email); - paramMap.put(ParamsNames.INSTRUCTOR_INSTITUTION, institute); + paramMap.put(ParamsNames.ACCOUNT_REQUEST_ID, id); addTask(TaskQueue.SEARCH_INDEXING_QUEUE_NAME, TaskQueue.ACCOUNT_REQUEST_SEARCH_INDEXING_WORKER_URL, paramMap, null); diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index 18f7bd40dd7..c840ef2834c 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -113,6 +113,15 @@ public AccountRequest getAccountRequest(String email, String institute) { return accountRequestLogic.getAccountRequest(email, institute); } + /** + * Gets the account request with the given {@code id}. + * + * @return account request with the given {@code id}. + */ + public AccountRequest getAccountRequestWithTransaction(UUID id) { + return accountRequestLogic.getAccountRequestWithTransaction(id); + } + /** * Creates a or gets an account request. * @@ -145,6 +154,16 @@ public AccountRequest updateAccountRequest(AccountRequest accountRequest) return accountRequestLogic.updateAccountRequest(accountRequest); } + /** + * Updates the given account request. + * + * @return the updated account request. + */ + public AccountRequest updateAccountRequestWithTransaction(AccountRequest accountRequest) + throws InvalidParametersException, EntityDoesNotExistException { + return accountRequestLogic.updateAccountRequestWithTransaction(accountRequest); + } + /** * Creates/Resets the account request with the given email and institute * such that it is not registered. @@ -178,6 +197,13 @@ public List getPendingAccountRequests() { return accountRequestLogic.getPendingAccountRequests(); } + /** + * Gets all pending account requests. + */ + public List getAllAccountRequests() { + return accountRequestLogic.getAllAccountRequests(); + } + /** * Gets an account. */ diff --git a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java index b9302b865c7..31956cd0101 100644 --- a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java +++ b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java @@ -82,6 +82,16 @@ public AccountRequest getAccountRequest(String email, String institute) { return accountRequestDb.getAccountRequest(email, institute); } + /** + * Gets the account request associated with the {@code id}. + */ + public AccountRequest getAccountRequestWithTransaction(UUID id) { + HibernateUtil.beginTransaction(); + AccountRequest request = accountRequestDb.getAccountRequest(id); + HibernateUtil.commitTransaction(); + return request; + } + /** * Updates an account request. */ @@ -90,6 +100,27 @@ public AccountRequest updateAccountRequest(AccountRequest accountRequest) return accountRequestDb.updateAccountRequest(accountRequest); } + /** + * Updates an account request. + */ + @SuppressWarnings("PMD") + public AccountRequest updateAccountRequestWithTransaction(AccountRequest accountRequest) + throws InvalidParametersException, EntityDoesNotExistException { + + HibernateUtil.beginTransaction(); + AccountRequest updatedRequest; + + try { + updatedRequest = accountRequestDb.updateAccountRequest(accountRequest); + HibernateUtil.commitTransaction(); + } catch (InvalidParametersException ipe) { + HibernateUtil.rollbackTransaction(); + throw new InvalidParametersException(ipe.getMessage()); + } + + return updatedRequest; + } + /** * Gets account request associated with the {@code regkey}. */ @@ -104,6 +135,13 @@ public List getPendingAccountRequests() { return accountRequestDb.getPendingAccountRequests(); } + /** + * Gets all account requests. + */ + public List getAllAccountRequests() { + return accountRequestDb.getAllAccountRequests(); + } + /** * Creates/resets the account request with the given email and institute such that it is not registered. */ diff --git a/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java b/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java index b05b544d5b9..92d61afb8eb 100644 --- a/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java +++ b/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java @@ -90,6 +90,19 @@ public List getPendingAccountRequests() { return query.getResultList(); } + /** + * Get all Account Requests. + */ + public List getAllAccountRequests() { + CriteriaBuilder cb = HibernateUtil.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(AccountRequest.class); + Root root = cr.from(AccountRequest.class); + cr.select(root); + + TypedQuery query = HibernateUtil.createQuery(cr); + return query.getResultList(); + } + /** * Get AccountRequest by {@code registrationKey} from database. */ diff --git a/src/main/java/teammates/ui/webapi/AccountRequestSearchIndexingWorkerAction.java b/src/main/java/teammates/ui/webapi/AccountRequestSearchIndexingWorkerAction.java index e543b012db4..214ca01f8fd 100644 --- a/src/main/java/teammates/ui/webapi/AccountRequestSearchIndexingWorkerAction.java +++ b/src/main/java/teammates/ui/webapi/AccountRequestSearchIndexingWorkerAction.java @@ -1,5 +1,7 @@ package teammates.ui.webapi; +import java.util.UUID; + import org.apache.http.HttpStatus; import teammates.common.exception.SearchServiceException; @@ -13,10 +15,16 @@ public class AccountRequestSearchIndexingWorkerAction extends AdminOnlyAction { @Override public ActionResult execute() { - String email = getNonNullRequestParamValue(ParamsNames.INSTRUCTOR_EMAIL); - String institute = getNonNullRequestParamValue(ParamsNames.INSTRUCTOR_INSTITUTION); + String id = getNonNullRequestParamValue(ParamsNames.ACCOUNT_REQUEST_ID); + UUID accountRequestId; + + try { + accountRequestId = UUID.fromString(id); + } catch (IllegalArgumentException e) { + throw new InvalidHttpParameterException(e.getMessage(), e); + } - AccountRequest accRequest = sqlLogic.getAccountRequest(email, institute); + AccountRequest accRequest = sqlLogic.getAccountRequest(accountRequestId); try { sqlLogic.putAccountRequestDocument(accRequest); diff --git a/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java index 7360bdd1d4f..63b753936ec 100644 --- a/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/CreateAccountRequestAction.java @@ -49,7 +49,7 @@ public JsonResult execute() throw new InvalidHttpRequestBodyException(ipe); } - taskQueuer.scheduleAccountRequestForSearchIndexing(instructorEmail, instructorInstitution); + taskQueuer.scheduleAccountRequestForSearchIndexing(accountRequest.getId().toString()); assert accountRequest != null; EmailWrapper adminAlertEmail = sqlEmailGenerator.generateNewAccountRequestAdminAlertEmail(accountRequest); diff --git a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java index 761a18abd8f..59c95ce7ef1 100644 --- a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java @@ -19,6 +19,11 @@ public class UpdateAccountRequestAction extends AdminOnlyAction { static final String ACCOUNT_REQUEST_NOT_FOUND = "Account request with id = %s not found"; + @Override + public boolean isTransactionNeeded() { + return false; + } + @Override public JsonResult execute() throws InvalidOperationException, InvalidHttpRequestBodyException { String id = getNonNullRequestParamValue(Const.ParamsNames.ACCOUNT_REQUEST_ID); @@ -30,7 +35,7 @@ public JsonResult execute() throws InvalidOperationException, InvalidHttpRequest throw new InvalidHttpParameterException(e.getMessage(), e); } - AccountRequest accountRequest = sqlLogic.getAccountRequest(accountRequestId); + AccountRequest accountRequest = sqlLogic.getAccountRequestWithTransaction(accountRequestId); if (accountRequest == null) { String errorMessage = String.format(ACCOUNT_REQUEST_NOT_FOUND, accountRequestId.toString()); @@ -46,9 +51,10 @@ public JsonResult execute() throws InvalidOperationException, InvalidHttpRequest try { // should not need to update other fields for an approval accountRequest.setStatus(accountRequestUpdateRequest.getStatus()); - accountRequest = sqlLogic.updateAccountRequest(accountRequest); + accountRequest = sqlLogic.updateAccountRequestWithTransaction(accountRequest); EmailWrapper email = sqlEmailGenerator.generateNewInstructorAccountJoinEmail( accountRequest.getRegistrationUrl(), accountRequest.getEmail(), accountRequest.getName()); + taskQueuer.scheduleAccountRequestForSearchIndexing(accountRequest.getId().toString()); emailSender.sendEmail(email); } catch (InvalidParametersException e) { throw new InvalidHttpRequestBodyException(e); @@ -62,7 +68,8 @@ public JsonResult execute() throws InvalidOperationException, InvalidHttpRequest accountRequest.setInstitute(accountRequestUpdateRequest.getInstitute()); accountRequest.setStatus(accountRequest.getStatus()); accountRequest.setComments(accountRequestUpdateRequest.getComments()); - sqlLogic.updateAccountRequest(accountRequest); + accountRequest = sqlLogic.updateAccountRequestWithTransaction(accountRequest); + taskQueuer.scheduleAccountRequestForSearchIndexing(accountRequest.getId().toString()); } catch (InvalidParametersException e) { throw new InvalidHttpRequestBodyException(e); } catch (EntityDoesNotExistException e) { diff --git a/src/test/java/teammates/logic/api/MockUserProvision.java b/src/test/java/teammates/logic/api/MockUserProvision.java index 7fa2fdb97f7..f6a88082bcd 100644 --- a/src/test/java/teammates/logic/api/MockUserProvision.java +++ b/src/test/java/teammates/logic/api/MockUserProvision.java @@ -30,6 +30,22 @@ public UserInfo loginUser(String userId) { return loginUser(userId, false); } + private UserInfo loginUserWithTransaction(String userId, boolean isAdmin) { + isLoggedIn = true; + mockUser.id = userId; + mockUser.isAdmin = isAdmin; + return getCurrentUserWithTransaction(null); + } + + /** + * Adds a logged-in user without admin rights. + * + * @return The user info after login process + */ + public UserInfo loginUserWithTransaction(String userId) { + return loginUserWithTransaction(userId, false); + } + /** * Adds a logged-in user as an admin. * @@ -39,6 +55,15 @@ public UserInfo loginAsAdmin(String userId) { return loginUser(userId, true); } + /** + * Adds a logged-in user as an admin. + * + * @return The user info after login process + */ + public UserInfo loginAsAdminWithTransaction(String userId) { + return loginUserWithTransaction(userId, true); + } + /** * Removes the logged-in user information. */ From 62750b08b4ea5169b7098e5071f1d58153152970 Mon Sep 17 00:00:00 2001 From: domoberzin <74132255+domoberzin@users.noreply.github.com> Date: Sun, 7 Apr 2024 11:50:54 +0800 Subject: [PATCH 31/95] [#11878] Add Edit and Approve Account Requests functionality (#12975) * add edit and approve functionality * remove rejection code * fix snap * integrate endpoint * disable approve button for approved requests * use comments instead of comment * use searchString instead of searchQuery * fix snap --- .../account-request-table-model.ts | 4 +- .../account-request-table.component.html | 8 +++- .../account-request-table.component.ts | 48 ++++++++++++++++++- .../account-request-table.module.ts | 4 ++ .../admin-edit-request-modal-model.ts | 9 ++++ .../admin-edit-request-modal.component.html | 34 +++++++++++++ .../admin-edit-request-modal.component.scss | 0 .../admin-edit-request-modal.component.ts | 40 ++++++++++++++++ .../admin-search-page.component.spec.ts.snap | 16 +++++++ .../admin-search-page.component.html | 2 +- .../admin-search-page.component.spec.ts | 6 +-- src/web/services/account.service.ts | 40 +++++++++++++++- src/web/services/search.service.ts | 5 +- 13 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal-model.ts create mode 100644 src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal.component.html create mode 100644 src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal.component.scss create mode 100644 src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal.component.ts diff --git a/src/web/app/components/account-requests-table/account-request-table-model.ts b/src/web/app/components/account-requests-table/account-request-table-model.ts index b2a088e8b40..1dc11a3b43c 100644 --- a/src/web/app/components/account-requests-table/account-request-table-model.ts +++ b/src/web/app/components/account-requests-table/account-request-table-model.ts @@ -1,3 +1,5 @@ +import { AccountRequestStatus } from 'src/web/types/api-output'; + /** * Model for the row entries in the account requests table. */ @@ -5,7 +7,7 @@ export interface AccountRequestTableRowModel { id: string; name: string; email: string; - status: string; + status: AccountRequestStatus; instituteAndCountry: string; createdAtText: string; registeredAtText: string; diff --git a/src/web/app/components/account-requests-table/account-request-table.component.html b/src/web/app/components/account-requests-table/account-request-table.component.html index 2959ee3b480..7da84774a38 100644 --- a/src/web/app/components/account-requests-table/account-request-table.component.html +++ b/src/web/app/components/account-requests-table/account-request-table.component.html @@ -51,6 +51,11 @@ diff --git a/src/web/app/components/account-requests-table/account-request-table.component.ts b/src/web/app/components/account-requests-table/account-request-table.component.ts index dc3ef132795..86613fae493 100755 --- a/src/web/app/components/account-requests-table/account-request-table.component.ts +++ b/src/web/app/components/account-requests-table/account-request-table.component.ts @@ -1,10 +1,11 @@ import { Component, Input } from '@angular/core'; -import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModalRef, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { AccountRequestTableRowModel } from './account-request-table-model'; +import { EditRequestModalComponent } from './admin-edit-request-modal/admin-edit-request-modal.component'; import { AccountService } from '../../../services/account.service'; import { SimpleModalService } from '../../../services/simple-modal.service'; import { StatusMessageService } from '../../../services/status-message.service'; -import { MessageOutput } from '../../../types/api-output'; +import { AccountRequest, AccountRequestStatus, MessageOutput } from '../../../types/api-output'; import { ErrorMessageOutput } from '../../error-message-output'; import { SimpleModalType } from '../simple-modal/simple-modal-type'; import { collapseAnim } from '../teammates-common/collapse-anim'; @@ -31,6 +32,7 @@ export class AccountRequestTableComponent { private statusMessageService: StatusMessageService, private simpleModalService: SimpleModalService, private accountService: AccountService, + private ngbModal: NgbModal, ) {} /** @@ -51,6 +53,48 @@ export class AccountRequestTableComponent { } } + editAccountRequest(accountRequest: AccountRequestTableRowModel): void { + const modalRef: NgbModalRef = this.ngbModal.open(EditRequestModalComponent); + modalRef.componentInstance.accountRequestName = accountRequest.name; + modalRef.componentInstance.accountRequestEmail = accountRequest.email; + modalRef.componentInstance.accountRequestInstitution = accountRequest.instituteAndCountry; + modalRef.componentInstance.accountRequestComments = accountRequest.comments; + + modalRef.result.then(() => { + this.accountService.editAccountRequest( + accountRequest.id, + modalRef.componentInstance.accountRequestName, + modalRef.componentInstance.accountRequestEmail, + modalRef.componentInstance.accountRequestInstitution, + accountRequest.status, + modalRef.componentInstance.accountRequestComments) + .subscribe({ + next: (resp: AccountRequest) => { + accountRequest.comments = resp.comments ?? ''; + accountRequest.name = resp.name; + accountRequest.email = resp.email; + accountRequest.instituteAndCountry = resp.institute; + }, + error: (resp: ErrorMessageOutput) => { + this.statusMessageService.showErrorToast(resp.error.message); + }, + }); + }); + } + + approveAccountRequest(accountRequest: AccountRequestTableRowModel): void { + this.accountService.approveAccountRequest(accountRequest.id, accountRequest.name, + accountRequest.email, accountRequest.instituteAndCountry) + .subscribe({ + next: () => { + accountRequest.status = AccountRequestStatus.APPROVED; + }, + error: (resp: ErrorMessageOutput) => { + this.statusMessageService.showErrorToast(resp.error.message); + }, + }); + } + resetAccountRequest(accountRequest: AccountRequestTableRowModel): void { const modalContent = `Are you sure you want to reset the account request for ${accountRequest.name} with email ${accountRequest.email} from diff --git a/src/web/app/components/account-requests-table/account-request-table.module.ts b/src/web/app/components/account-requests-table/account-request-table.module.ts index 1a05086cc4b..f0177f5fdc2 100644 --- a/src/web/app/components/account-requests-table/account-request-table.module.ts +++ b/src/web/app/components/account-requests-table/account-request-table.module.ts @@ -3,7 +3,9 @@ import { NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { NgbTooltipModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { AccountRequestTableComponent } from './account-request-table.component'; +import { EditRequestModalComponent } from './admin-edit-request-modal/admin-edit-request-modal.component'; import { Pipes } from '../../pipes/pipes.module'; +import { RichTextEditorModule } from '../rich-text-editor/rich-text-editor.module'; /** * Module for account requests table. @@ -11,6 +13,7 @@ import { Pipes } from '../../pipes/pipes.module'; @NgModule({ declarations: [ AccountRequestTableComponent, + EditRequestModalComponent, ], exports: [ AccountRequestTableComponent, @@ -21,6 +24,7 @@ import { Pipes } from '../../pipes/pipes.module'; NgbTooltipModule, NgbDropdownModule, Pipes, + RichTextEditorModule, ], }) export class AccountRequestTableModule { } diff --git a/src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal-model.ts b/src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal-model.ts new file mode 100644 index 00000000000..9d5a2ecea2c --- /dev/null +++ b/src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal-model.ts @@ -0,0 +1,9 @@ +/** + * Result of {@link EditRequestModalComponent} + */ +export interface EditRequestModalComponentResult { + accountRequestName: string; + accountRequestEmail: string; + accountRequestInstitution: string; + accountRequestComment: string; +} diff --git a/src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal.component.html b/src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal.component.html new file mode 100644 index 00000000000..a2f65852529 --- /dev/null +++ b/src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal.component.html @@ -0,0 +1,34 @@ + + - + diff --git a/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts b/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts index 9d0be53a7fd..678d0d13976 100644 --- a/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts +++ b/src/web/app/pages-admin/admin-search-page/admin-search-page.component.spec.ts @@ -495,7 +495,7 @@ describe('AdminSearchPageComponent', () => { it('should show account request links when expand all button clicked', () => { const accountRequestResult: AccountRequestTableRowModel = DEFAULT_ACCOUNT_REQUEST_SEARCH_RESULT; component.accountRequests = [accountRequestResult]; - component.searchQuery = 'test'; // To show the account request table + component.searchString = 'test'; // To show the account request table fixture.detectChanges(); const button: any = fixture.debugElement.nativeElement.querySelector('#show-account-request-links'); @@ -961,7 +961,7 @@ describe('AdminSearchPageComponent', () => { it('should show error message when resetting account request is unsuccessful', () => { component.accountRequests = [DEFAULT_ACCOUNT_REQUEST_SEARCH_RESULT]; component.accountRequests[0].registeredAtText = 'Wed, 09 Feb 2022, 10:23 AM +00:00'; - component.searchQuery = 'test'; + component.searchString = 'test'; fixture.detectChanges(); jest.spyOn(ngbModal, 'open').mockImplementation(() => { @@ -988,7 +988,7 @@ describe('AdminSearchPageComponent', () => { it('should show success message when resetting account request is successful', () => { component.accountRequests = [DEFAULT_ACCOUNT_REQUEST_SEARCH_RESULT]; component.accountRequests[0].registeredAtText = 'Wed, 09 Feb 2022, 10:23 AM +00:00'; - component.searchQuery = 'test'; + component.searchString = 'test'; fixture.detectChanges(); jest.spyOn(ngbModal, 'open').mockImplementation(() => { diff --git a/src/web/services/account.service.ts b/src/web/services/account.service.ts index 8877da6ec29..e6227ab7148 100644 --- a/src/web/services/account.service.ts +++ b/src/web/services/account.service.ts @@ -11,7 +11,7 @@ import { MessageOutput, AccountRequestStatus, } from '../types/api-output'; -import { AccountCreateRequest } from '../types/api-request'; +import { AccountCreateRequest, AccountRequestUpdateRequest } from '../types/api-request'; /** * Handles account related logic provision @@ -95,6 +95,44 @@ export class AccountService { return this.httpRequestService.put(ResourceEndpoints.ACCOUNT_RESET, paramMap); } + /** + * Approves account request by calling API + */ + approveAccountRequest(id: string, name: string, email: string, institute: string) + : Observable { + const paramMap: Record = { + id, + }; + const accountReqUpdateRequest : AccountRequestUpdateRequest = { + name, + email, + institute, + status: AccountRequestStatus.APPROVED, + }; + + return this.httpRequestService.put(ResourceEndpoints.ACCOUNT_REQUEST, paramMap, accountReqUpdateRequest); + } + + /** + * Edits an account request by calling API. + */ + editAccountRequest(id: string, name: string, email: string, institute: string, + status: AccountRequestStatus, comments: string) + : Observable { + const paramMap: Record = { + id, + }; + const accountReqUpdateRequest : AccountRequestUpdateRequest = { + name, + email, + institute, + status, + comments, + }; + + return this.httpRequestService.put(ResourceEndpoints.ACCOUNT_REQUEST, paramMap, accountReqUpdateRequest); + } + /** * Gets an account by calling API. */ diff --git a/src/web/services/search.service.ts b/src/web/services/search.service.ts index fbd95617779..be19f3cd277 100644 --- a/src/web/services/search.service.ts +++ b/src/web/services/search.service.ts @@ -11,6 +11,7 @@ import { ResourceEndpoints } from '../types/api-const'; import { AccountRequest, AccountRequests, + AccountRequestStatus, Course, FeedbackSession, FeedbackSessions, Instructor, @@ -307,7 +308,7 @@ export class SearchService { registeredAtText: '', registrationLink: '', showLinks: false, - status: '', + status: AccountRequestStatus.PENDING, comments: '', }; @@ -474,7 +475,7 @@ export interface AccountRequestSearchResult { id: string; name: string; email: string; - status: string; + status: AccountRequestStatus; institute: string; createdAtText: string; registeredAtText: string | null; From 96e5abdc1419af634eb9ef3c42f52220c287f0ae Mon Sep 17 00:00:00 2001 From: EuniceSim142 <77243938+EuniceSim142@users.noreply.github.com> Date: Mon, 8 Apr 2024 12:50:56 +0800 Subject: [PATCH 32/95] [#11878] Add AccountRequest Rejection email generator. (#12987) * add rejection-email template and email generator method * add javadoc for email generator method * add test * fix test method names * fix test method name 2 * fix lint * add bcc for rejection email --- .../java/teammates/common/util/EmailType.java | 1 + .../sqllogic/api/SqlEmailGenerator.java | 13 +++++++++ .../sqllogic/api/SqlEmailGeneratorTest.java | 27 +++++++++++++++++++ ...nstructorAccountRequestRejectionEmail.html | 10 +++++++ 4 files changed, 51 insertions(+) create mode 100644 src/test/resources/emails/instructorAccountRequestRejectionEmail.html diff --git a/src/main/java/teammates/common/util/EmailType.java b/src/main/java/teammates/common/util/EmailType.java index 6ee9abcbab9..a42280ba7f0 100644 --- a/src/main/java/teammates/common/util/EmailType.java +++ b/src/main/java/teammates/common/util/EmailType.java @@ -25,6 +25,7 @@ public enum EmailType { STUDENT_COURSE_REJOIN_AFTER_GOOGLE_ID_RESET("TEAMMATES: Your account has been reset for course [%s][Course ID: %s]"), NEW_ACCOUNT_REQUEST_ADMIN_ALERT("TEAMMATES (Action Needed): New Account Request Received"), NEW_ACCOUNT_REQUEST_ACKNOWLEDGEMENT("TEAMMATES: Acknowledgement of Instructor Account Request"), + ACCOUNT_REQUEST_REJECTION("TEAMMATES: %s"), INSTRUCTOR_COURSE_JOIN("TEAMMATES: Invitation to join course as an instructor [%s][Course ID: %s]"), INSTRUCTOR_COURSE_REJOIN_AFTER_GOOGLE_ID_RESET("TEAMMATES: Your account has been reset for course [%s][Course ID: %s]"), USER_COURSE_REGISTER("TEAMMATES: Registered for Course [%s][Course ID: %s]"), diff --git a/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java b/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java index befe9647656..2fb76991cc6 100644 --- a/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java +++ b/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java @@ -1029,6 +1029,19 @@ public EmailWrapper generateNewAccountRequestAcknowledgementEmail(AccountRequest return email; } + /** + * Generates the email to be sent to instructor when their account request has been rejected by admin. + */ + public EmailWrapper generateAccountRequestRejectionEmail(AccountRequest accountRequest, String title, String content) { + EmailWrapper email = getEmptyEmailAddressedToEmail(accountRequest.getEmail()); + email.setType(EmailType.ACCOUNT_REQUEST_REJECTION); + email.setBcc(Config.SUPPORT_EMAIL); + email.setSubjectFromType(SanitizationHelper.sanitizeTitle(title)); + email.setContent(SanitizationHelper.sanitizeForRichText(content)); + + return email; + } + /** * Generates the course registered email for the user with the given details in {@code course}. */ diff --git a/src/test/java/teammates/sqllogic/api/SqlEmailGeneratorTest.java b/src/test/java/teammates/sqllogic/api/SqlEmailGeneratorTest.java index 036ccf4b448..f984b10f46c 100644 --- a/src/test/java/teammates/sqllogic/api/SqlEmailGeneratorTest.java +++ b/src/test/java/teammates/sqllogic/api/SqlEmailGeneratorTest.java @@ -62,6 +62,33 @@ void testGenerateNewAccountRequestAcknowledgementEmail_withNoComments_generatesS "/instructorNewAccountRequestAcknowledgementEmailWithNoComments.html"); } + @Test + void testGenerateAccountRequestRejectionEmail_withDefaultReason_generatesSuccessfully() throws IOException { + AccountRequest accountRequest = new AccountRequest("maul@sith.org", "Maul", "Sith Order", + AccountRequestStatus.PENDING, null); + String title = "We are Unable to Create an Account for you"; + String content = new StringBuilder() + .append("

    Hi, Maul

    \n") + .append("

    Thanks for your interest in using TEAMMATES. ") + .append("We are unable to create a TEAMMATES instructor account for you.

    \n\n") + .append("

    \n") + .append(" Reason: The email address you provided ") + .append("is not an 'official' email address provided by your institution.
    \n") + .append(" Remedy: ") + .append("Please re-submit an account request with your 'official' institution email address.\n") + .append("

    \n\n") + .append("

    If you need further clarification or would like to appeal this decision, ") + .append("please feel free to contact us at teammates@comp.nus.edu.sg.

    \n") + .append("

    Regards,
    TEAMMATES Team.

    \n") + .toString(); + + EmailWrapper email = sqlEmailGenerator.generateAccountRequestRejectionEmail(accountRequest, title, content); + verifyEmail(email, "maul@sith.org", EmailType.ACCOUNT_REQUEST_REJECTION, + "TEAMMATES: " + title, + Config.SUPPORT_EMAIL, + "/instructorAccountRequestRejectionEmail.html"); + } + private void verifyEmail(EmailWrapper email, String expectedRecipientEmailAddress, EmailType expectedEmailType, String expectedSubject, String expectedEmailContentFilePathname) throws IOException { assertEquals(expectedRecipientEmailAddress, email.getRecipient()); diff --git a/src/test/resources/emails/instructorAccountRequestRejectionEmail.html b/src/test/resources/emails/instructorAccountRequestRejectionEmail.html new file mode 100644 index 00000000000..57ae404c7e4 --- /dev/null +++ b/src/test/resources/emails/instructorAccountRequestRejectionEmail.html @@ -0,0 +1,10 @@ +

    Hi, Maul

    +

    Thanks for your interest in using TEAMMATES. We are unable to create a TEAMMATES instructor account for you.

    + +

    + Reason: The email address you provided is not an 'official' email address provided by your institution.
    + Remedy: Please re-submit an account request with your 'official' institution email address. +

    + +

    If you need further clarification or would like to appeal this decision, please feel free to contact us at teammates@comp.nus.edu.sg.

    +

    Regards,
    TEAMMATES Team.

    From 1e9ccb091fac81793a93c460e017c076b797c66b Mon Sep 17 00:00:00 2001 From: Xenos F Date: Tue, 9 Apr 2024 02:59:23 +0800 Subject: [PATCH 33/95] [#12048] Migrate AccountRequestsLogicTest (#12780) * Migrate test cases for AccountRequestsLogic * Remove test case * Split test cases --- .../core/AccountRequestsLogicTest.java | 177 ++++++++++++++++++ .../java/teammates/test/BaseTestCase.java | 5 + 2 files changed, 182 insertions(+) create mode 100644 src/test/java/teammates/sqllogic/core/AccountRequestsLogicTest.java diff --git a/src/test/java/teammates/sqllogic/core/AccountRequestsLogicTest.java b/src/test/java/teammates/sqllogic/core/AccountRequestsLogicTest.java new file mode 100644 index 00000000000..aca37f99963 --- /dev/null +++ b/src/test/java/teammates/sqllogic/core/AccountRequestsLogicTest.java @@ -0,0 +1,177 @@ +package teammates.sqllogic.core; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.EntityDoesNotExistException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Const; +import teammates.storage.sqlapi.AccountRequestsDb; +import teammates.storage.sqlentity.AccountRequest; +import teammates.test.BaseTestCase; + +/** + * SUT: {@link AccountRequestsLogic}. + */ +public class AccountRequestsLogicTest extends BaseTestCase { + + private final AccountRequestsLogic arLogic = AccountRequestsLogic.inst(); + private AccountRequestsDb arDb; + + @BeforeMethod + public void setUpMethod() { + arDb = mock(AccountRequestsDb.class); + arLogic.initLogicDependencies(arDb); + } + + @Test + public void testCreateAccountRequest_typicalRequest_success() throws Exception { + AccountRequest accountRequest = getTypicalAccountRequest(); + when(arDb.createAccountRequest(accountRequest)).thenReturn(accountRequest); + AccountRequest createdAccountRequest = arLogic.createAccountRequest(accountRequest); + + assertEquals(accountRequest, createdAccountRequest); + verify(arDb, times(1)).createAccountRequest(accountRequest); + } + + @Test + public void testCreateAccountRequest_requestAlreadyExists_failure() throws Exception { + AccountRequest duplicateAccountRequest = getTypicalAccountRequest(); + when(arDb.createAccountRequest(duplicateAccountRequest)) + .thenThrow(new EntityAlreadyExistsException("test exception")); + + assertThrows(EntityAlreadyExistsException.class, () -> { + arLogic.createAccountRequest(duplicateAccountRequest); + }); + verify(arDb, times(1)).createAccountRequest(duplicateAccountRequest); + } + + @Test + public void testCreateAccountRequest_invalidParams_failure() throws Exception { + AccountRequest invalidEmailAccountRequest = getTypicalAccountRequest(); + invalidEmailAccountRequest.setEmail("invalid email"); + when(arDb.createAccountRequest(invalidEmailAccountRequest)) + .thenThrow(new InvalidParametersException("test exception")); + + assertThrows(InvalidParametersException.class, () -> { + arLogic.createAccountRequest(invalidEmailAccountRequest); + }); + verify(arDb, times(1)).createAccountRequest(invalidEmailAccountRequest); + } + + @Test + public void testUpdateAccountRequest_typicalRequest_success() + throws InvalidParametersException, EntityDoesNotExistException { + AccountRequest ar = getTypicalAccountRequest(); + when(arDb.updateAccountRequest(ar)).thenReturn(ar); + AccountRequest updatedAr = arLogic.updateAccountRequest(ar); + + assertEquals(ar, updatedAr); + verify(arDb, times(1)).updateAccountRequest(ar); + } + + @Test + public void testUpdateAccountRequest_requestNotFound_failure() + throws InvalidParametersException, EntityDoesNotExistException { + AccountRequest arNotFound = getTypicalAccountRequest(); + when(arDb.updateAccountRequest(arNotFound)).thenThrow(new EntityDoesNotExistException("test message")); + + assertThrows(EntityDoesNotExistException.class, + () -> arLogic.updateAccountRequest(arNotFound)); + verify(arDb, times(1)).updateAccountRequest(any(AccountRequest.class)); + } + + @Test + public void testDeleteAccountRequest_typicalRequest_success() { + AccountRequest ar = getTypicalAccountRequest(); + when(arDb.getAccountRequest(ar.getEmail(), ar.getInstitute())).thenReturn(ar); + arLogic.deleteAccountRequest(ar.getEmail(), ar.getInstitute()); + + verify(arDb, times(1)).deleteAccountRequest(any(AccountRequest.class)); + } + + @Test + public void testDeleteAccountRequest_nonexistentRequest_shouldSilentlyDelete() { + arLogic.deleteAccountRequest("not_exist", "not_exist"); + + verify(arDb, times(1)).deleteAccountRequest(nullable(AccountRequest.class)); + } + + @Test + public void testGetAccountRequestByRegistrationKey_typicalRequest_success() { + AccountRequest ar = getTypicalAccountRequest(); + String regkey = "regkey"; + ar.setRegistrationKey(regkey); + when(arDb.getAccountRequestByRegistrationKey(regkey)).thenReturn(ar); + AccountRequest actualAr = + arLogic.getAccountRequestByRegistrationKey(ar.getRegistrationKey()); + + assertEquals(ar, actualAr); + verify(arDb, times(1)).getAccountRequestByRegistrationKey(regkey); + } + + @Test + public void testGetAccountRequestByRegistrationKey_nonexistentRequest_shouldReturnNull() throws Exception { + String nonexistentRegkey = "not_exist"; + when(arDb.getAccountRequestByRegistrationKey(nonexistentRegkey)).thenReturn(null); + + assertNull(arLogic.getAccountRequestByRegistrationKey(nonexistentRegkey)); + verify(arDb, times(1)).getAccountRequestByRegistrationKey(nonexistentRegkey); + } + + @Test + public void testGetAccountRequest_typicalRequest_success() { + AccountRequest expectedAr = getTypicalAccountRequest(); + when(arDb.getAccountRequest(expectedAr.getEmail(), expectedAr.getInstitute())).thenReturn(expectedAr); + AccountRequest actualAr = + arLogic.getAccountRequest(expectedAr.getEmail(), expectedAr.getInstitute()); + + assertEquals(expectedAr, actualAr); + verify(arDb, times(1)).getAccountRequest(expectedAr.getEmail(), expectedAr.getInstitute()); + } + + @Test + public void testGetAccountRequest_nonexistentRequest_shouldReturnNull() { + String nonexistentEmail = "not-found@test.com"; + String nonexistentInstitute = "not-found"; + when(arDb.getAccountRequest(nonexistentEmail, nonexistentInstitute)).thenReturn(null); + + assertNull(arLogic.getAccountRequest(nonexistentEmail, nonexistentInstitute)); + verify(arDb, times(1)).getAccountRequest(nonexistentEmail, nonexistentInstitute); + } + + @Test + public void testResetAccountRequest_typicalRequest_success() + throws InvalidParametersException, EntityDoesNotExistException { + AccountRequest accountRequest = getTypicalAccountRequest(); + accountRequest.setRegisteredAt(Const.TIME_REPRESENTS_NOW); + when(arDb.getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute())) + .thenReturn(accountRequest); + when(arDb.updateAccountRequest(accountRequest)).thenReturn(accountRequest); + accountRequest = arLogic.resetAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute()); + + assertNull(accountRequest.getRegisteredAt()); + verify(arDb, times(1)).getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute()); + } + + @Test + public void testResetAccountRequest_nonexistentRequest_failure() + throws InvalidParametersException, EntityDoesNotExistException { + AccountRequest accountRequest = getTypicalAccountRequest(); + accountRequest.setRegisteredAt(Const.TIME_REPRESENTS_NOW); + when(arDb.getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute())) + .thenReturn(null); + assertThrows(EntityDoesNotExistException.class, + () -> arLogic.resetAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute())); + verify(arDb, times(1)).getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute()); + verify(arDb, times(0)).updateAccountRequest(nullable(AccountRequest.class)); + } +} diff --git a/src/test/java/teammates/test/BaseTestCase.java b/src/test/java/teammates/test/BaseTestCase.java index a7744061d55..ffe08b21813 100644 --- a/src/test/java/teammates/test/BaseTestCase.java +++ b/src/test/java/teammates/test/BaseTestCase.java @@ -29,6 +29,7 @@ import teammates.common.util.TimeHelperExtension; import teammates.sqllogic.core.DataBundleLogic; import teammates.storage.sqlentity.Account; +import teammates.storage.sqlentity.AccountRequest; import teammates.storage.sqlentity.Course; import teammates.storage.sqlentity.FeedbackQuestion; import teammates.storage.sqlentity.FeedbackResponse; @@ -206,6 +207,10 @@ protected FeedbackResponseComment getTypicalResponseComment(Long id) { return comment; } + protected AccountRequest getTypicalAccountRequest() { + return new AccountRequest("valid@test.com", "Test account Name", "TEAMMATES Test Institute 1"); + } + /** * Populates the feedback question and response IDs within the data bundle. * From 84ed244ade96870b71346808916568ae41b64b92 Mon Sep 17 00:00:00 2001 From: domoberzin <74132255+domoberzin@users.noreply.github.com> Date: Tue, 9 Apr 2024 03:39:55 +0800 Subject: [PATCH 34/95] [#12048] Migrate AdminSearchPageE2ETest SQL (#12811) * test e2e changes * fix: reduce e2e test json file size * fix student key * fix course key * fix instructor keys * fix filepath * fix e2e test * remove extra data from bundle * Add correct removal logic to avoid constraint violation * Fix e2e tests and lint fix reset google id test fix e2e tests fix e2e tests fix tests remove double click fix unknown symbol add toast check change toast verification message remove toast check * fix: add null check * move admin search page e2e test to sql cases * Rename AdminSearchPageE2ETest_SQLEntities.json to AdminSearchPageE2ETest_SqlEntities.json * fix failing test * fix: remove extra null check * fix: add test to e2e sql xml file * fix function call * remove unnecessary changes * create new file for sql entities * revert unnecessary changes * remove trailing whitespace * add teardown for account requests --------- Co-authored-by: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> --- .../e2e/cases/AdminSearchPageE2ETest.java | 2 +- .../e2e/cases/axe/AdminSearchPageAxeTest.java | 2 +- .../e2e/cases/sql/AdminSearchPageE2ETest.java | 185 +++++++++++++ .../e2e/cases/sql/BaseE2ETestCase.java | 14 + .../e2e/pageobjects/AdminSearchPage.java | 246 +++++++++++++++++- .../data/AdminSearchPageE2ESqlTest.json | 118 +++++++++ src/e2e/resources/testng-e2e-sql.xml | 1 + .../sqllogic/api/SqlEmailGenerator.java | 8 +- 8 files changed, 565 insertions(+), 11 deletions(-) create mode 100644 src/e2e/java/teammates/e2e/cases/sql/AdminSearchPageE2ETest.java create mode 100644 src/e2e/resources/data/AdminSearchPageE2ESqlTest.json diff --git a/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java b/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java index b5ce80693f0..b73e82c0808 100644 --- a/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java +++ b/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java @@ -31,7 +31,7 @@ protected void prepareTestData() { putDocuments(testData); sqlTestData = loadSqlDataBundle("/AdminSearchPageE2ETest_SqlEntities.json"); removeAndRestoreSqlDataBundle(sqlTestData); - doPutDocumentsSql(sqlTestData); + putSqlDocuments(sqlTestData); } @Test diff --git a/src/e2e/java/teammates/e2e/cases/axe/AdminSearchPageAxeTest.java b/src/e2e/java/teammates/e2e/cases/axe/AdminSearchPageAxeTest.java index d45e1d4e16a..732e06ceed3 100644 --- a/src/e2e/java/teammates/e2e/cases/axe/AdminSearchPageAxeTest.java +++ b/src/e2e/java/teammates/e2e/cases/axe/AdminSearchPageAxeTest.java @@ -25,7 +25,7 @@ protected void prepareTestData() { putDocuments(testData); sqlTestData = loadSqlDataBundle("/AdminSearchPageE2ETest_SqlEntities.json"); removeAndRestoreSqlDataBundle(sqlTestData); - doPutDocumentsSql(sqlTestData); + putSqlDocuments(sqlTestData); } @Test diff --git a/src/e2e/java/teammates/e2e/cases/sql/AdminSearchPageE2ETest.java b/src/e2e/java/teammates/e2e/cases/sql/AdminSearchPageE2ETest.java new file mode 100644 index 00000000000..780b0f212fd --- /dev/null +++ b/src/e2e/java/teammates/e2e/cases/sql/AdminSearchPageE2ETest.java @@ -0,0 +1,185 @@ +package teammates.e2e.cases.sql; + +import java.time.Instant; + +import org.testng.annotations.AfterClass; +import org.testng.annotations.Test; + +import teammates.common.util.AppUrl; +import teammates.common.util.Const; +import teammates.e2e.pageobjects.AdminSearchPage; +import teammates.e2e.util.TestProperties; +import teammates.storage.sqlentity.AccountRequest; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.Instructor; +import teammates.storage.sqlentity.Student; + +/** + * SUT: {@link Const.WebPageURIs#ADMIN_SEARCH_PAGE}. + */ +public class AdminSearchPageE2ETest extends BaseE2ETestCase { + + @Override + protected void prepareTestData() { + if (!TestProperties.INCLUDE_SEARCH_TESTS) { + return; + } + testData = removeAndRestoreDataBundle(loadSqlDataBundle("/AdminSearchPageE2ESqlTest.json")); + putDocuments(testData); + } + + @Test + @Override + public void testAll() { + if (!TestProperties.INCLUDE_SEARCH_TESTS) { + return; + } + + AppUrl url = createFrontendUrl(Const.WebPageURIs.ADMIN_SEARCH_PAGE); + AdminSearchPage searchPage = loginAdminToPage(url, AdminSearchPage.class); + + Course course = testData.courses.get("typicalCourse1"); + Student student = testData.students.get("student1InCourse1"); + Instructor instructor = testData.instructors.get("instructor1OfCourse1"); + AccountRequest accountRequest = testData.accountRequests.get("instructor1OfCourse1"); + + ______TS("Typical case: Search student email"); + String searchContent = student.getEmail(); + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + String studentDetails = getExpectedStudentDetails(student); + String studentManageAccountLink = getExpectedStudentManageAccountLink(student); + String studentHomePageLink = getExpectedStudentHomePageLink(student); + int numExpandedRows = getExpectedNumExpandedRows(student); + searchPage.verifyStudentRowContent(student, course, studentDetails, studentManageAccountLink, + studentHomePageLink); + searchPage.verifyStudentExpandedLinks(student, numExpandedRows); + + ______TS("Typical case: Reset student google id"); + searchPage.resetStudentGoogleId(student); + student.setGoogleId(null); + searchPage.verifyStudentRowContentAfterReset(student, course); + + ______TS("Typical case: Regenerate registration key for a course student"); + searchPage.clickExpandStudentLinks(); + String originalJoinLink = searchPage.getStudentJoinLink(student); + searchPage.regenerateStudentKey(student); + searchPage.verifyRegenerateStudentKey(student, originalJoinLink); + searchPage.waitForPageToLoad(); + + ______TS("Typical case: Search for instructor email"); + searchPage.clearSearchBox(); + searchContent = instructor.getEmail(); + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + String instructorManageAccountLink = getExpectedInstructorManageAccountLink(instructor); + String instructorHomePageLink = getExpectedInstructorHomePageLink(instructor); + searchPage.verifyInstructorRowContent(instructor, course, instructorManageAccountLink, + instructorHomePageLink); + searchPage.verifyInstructorExpandedLinks(instructor); + + ______TS("Typical case: Reset instructor google id"); + searchPage.resetInstructorGoogleId(instructor); + searchPage.verifyInstructorRowContentAfterReset(instructor, course); + + ______TS("Typical case: Regenerate registration key for an instructor"); + searchPage.clickExpandInstructorLinks(); + originalJoinLink = searchPage.getInstructorJoinLink(instructor); + searchPage.regenerateInstructorKey(instructor); + searchPage.verifyRegenerateInstructorKey(instructor, originalJoinLink); + searchPage.waitForPageToLoad(); + + ______TS("Typical case: Search for account request by email"); + searchPage.clearSearchBox(); + searchContent = accountRequest.getEmail(); + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + searchPage.verifyAccountRequestRowContent(accountRequest); + searchPage.verifyAccountRequestExpandedLinks(accountRequest); + + ______TS("Typical case: Search common search key"); + searchPage.clearSearchBox(); + searchContent = "Course1"; + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + searchPage.verifyStudentRowContentAfterReset(student, course); + searchPage.verifyInstructorRowContentAfterReset(instructor, course); + searchPage.verifyAccountRequestRowContent(accountRequest); + + ______TS("Typical case: Expand and collapse links"); + searchPage.verifyLinkExpansionButtons(student, instructor, accountRequest); + + ______TS("Typical case: Reset account request successful"); + searchContent = "ASearch.instructor1@gmail.tmt"; + searchPage.clearSearchBox(); + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + searchPage.clickResetAccountRequestButton(accountRequest); + assertNull(BACKDOOR.getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute()).getRegisteredAt()); + + ______TS("Typical case: Delete account request successful"); + accountRequest = testData.accountRequests.get("unregisteredInstructor1"); + searchContent = accountRequest.getEmail(); + searchPage.clearSearchBox(); + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + searchPage.clickDeleteAccountRequestButton(accountRequest); + assertNull(BACKDOOR.getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute())); + } + + private String getExpectedStudentDetails(Student student) { + return String.format("%s [%s] (%s)", student.getCourse().getId(), + student.getSection() == null + ? Const.DEFAULT_SECTION + : student.getSection().getName(), student.getTeam().getName()); + } + + private String getExpectedStudentHomePageLink(Student student) { + return student.isRegistered() ? createFrontendUrl(Const.WebPageURIs.STUDENT_HOME_PAGE) + .withUserId(student.getGoogleId()) + .toAbsoluteString() + : ""; + } + + private String getExpectedStudentManageAccountLink(Student student) { + return student.isRegistered() ? createFrontendUrl(Const.WebPageURIs.ADMIN_ACCOUNTS_PAGE) + .withParam(Const.ParamsNames.INSTRUCTOR_ID, student.getGoogleId()) + .toAbsoluteString() + : ""; + } + + private int getExpectedNumExpandedRows(Student student) { + int expectedNumExpandedRows = 2; + for (FeedbackSession sessions : testData.feedbackSessions.values()) { + if (sessions.getCourse().equals(student.getCourse())) { + expectedNumExpandedRows += 1; + if (sessions.getResultsVisibleFromTime().isBefore(Instant.now())) { + expectedNumExpandedRows += 1; + } + } + } + return expectedNumExpandedRows; + } + + private String getExpectedInstructorHomePageLink(Instructor instructor) { + String googleId = instructor.isRegistered() ? instructor.getGoogleId() : ""; + return createFrontendUrl(Const.WebPageURIs.INSTRUCTOR_HOME_PAGE) + .withUserId(googleId) + .toAbsoluteString(); + } + + private String getExpectedInstructorManageAccountLink(Instructor instructor) { + String googleId = instructor.isRegistered() ? instructor.getGoogleId() : ""; + return createFrontendUrl(Const.WebPageURIs.ADMIN_ACCOUNTS_PAGE) + .withParam(Const.ParamsNames.INSTRUCTOR_ID, googleId) + .toAbsoluteString(); + } + + @AfterClass + public void classTeardown() { + for (AccountRequest request : testData.accountRequests.values()) { + BACKDOOR.deleteAccountRequest(request.getEmail(), request.getInstitute()); + } + } +} diff --git a/src/e2e/java/teammates/e2e/cases/sql/BaseE2ETestCase.java b/src/e2e/java/teammates/e2e/cases/sql/BaseE2ETestCase.java index b61a4a8cf2a..fbfd60ea84b 100644 --- a/src/e2e/java/teammates/e2e/cases/sql/BaseE2ETestCase.java +++ b/src/e2e/java/teammates/e2e/cases/sql/BaseE2ETestCase.java @@ -265,4 +265,18 @@ StudentData getStudent(String courseId, String studentEmailAddress) { protected StudentData getStudent(Student student) { return getStudent(student.getCourseId(), student.getEmail()); } + + /** + * Puts the documents in the database using BACKDOOR. + * @param dataBundle the data to be put in the database + * @return the result of the operation + */ + protected String putDocuments(SqlDataBundle dataBundle) { + try { + return BACKDOOR.putSqlDocuments(dataBundle); + } catch (HttpRequestFailedException e) { + e.printStackTrace(); + return null; + } + } } diff --git a/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java b/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java index 706e9ab5a20..005b98a026b 100644 --- a/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java +++ b/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java @@ -18,6 +18,9 @@ import teammates.common.util.Const; import teammates.common.util.StringHelper; import teammates.storage.sqlentity.AccountRequest; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.Instructor; +import teammates.storage.sqlentity.Student; /** * Represents the admin home page of the website. @@ -93,6 +96,14 @@ public void clickSearchButton() { waitForPageToLoad(); } + public void regenerateStudentKey(Student student) { + WebElement studentRow = getStudentRow(student); + studentRow.findElement(By.xpath("//button[text()='Regenerate key']")).click(); + + waitForConfirmationModalAndClickOk(); + waitForPageToLoad(true); + } + public void regenerateStudentKey(StudentAttributes student) { WebElement studentRow = getStudentRow(student); studentRow.findElement(By.xpath("//button[text()='Regenerate key']")).click(); @@ -101,6 +112,30 @@ public void regenerateStudentKey(StudentAttributes student) { waitForPageToLoad(true); } + public void verifyRegenerateStudentKey(Student student, String originalJoinLink) { + verifyStatusMessage("Student's key for this course has been successfully regenerated," + + " and the email has been sent."); + + String regeneratedJoinLink = getStudentJoinLink(student); + assertNotEquals(regeneratedJoinLink, originalJoinLink); + } + + public void verifyRegenerateStudentKey(StudentAttributes student, String originalJoinLink) { + verifyStatusMessage("Student's key for this course has been successfully regenerated," + + " and the email has been sent."); + + String regeneratedJoinLink = getStudentJoinLink(student); + assertNotEquals(regeneratedJoinLink, originalJoinLink); + } + + public void regenerateInstructorKey(Instructor instructor) { + WebElement instructorRow = getInstructorRow(instructor); + instructorRow.findElement(By.xpath("//button[text()='Regenerate key']")).click(); + + waitForConfirmationModalAndClickOk(); + waitForPageToLoad(true); + } + public void regenerateInstructorKey(InstructorAttributes instructor) { WebElement instructorRow = getInstructorRow(instructor); instructorRow.findElement(By.xpath("//button[text()='Regenerate key']")).click(); @@ -143,6 +178,25 @@ public String removeSpanFromText(String text) { return text.replace("", "").replace("", ""); } + public WebElement getStudentRow(Student student) { + String details = String.format("%s [%s] (%s)", student.getCourse().getId(), + student.getSection() == null + ? Const.DEFAULT_SECTION + : student.getSection().getName(), student.getTeam().getName()); + WebElement table = browser.driver.findElement(By.id("search-table-student")); + List rows = table.findElements(By.tagName("tr")); + for (WebElement row : rows) { + List columns = row.findElements(By.tagName("td")); + if (!columns.isEmpty() && removeSpanFromText(columns.get(STUDENT_COL_DETAILS - 1) + .getAttribute("innerHTML")).contains(details) + && removeSpanFromText(columns.get(STUDENT_COL_NAME - 1) + .getAttribute("innerHTML")).contains(student.getName())) { + return row; + } + } + return null; + } + public WebElement getStudentRow(StudentAttributes student) { String details = String.format("%s [%s] (%s)", student.getCourse(), student.getSection() == null ? Const.DEFAULT_SECTION : student.getSection(), student.getTeam()); @@ -195,11 +249,25 @@ public String getStudentJoinLink(WebElement studentRow) { return getExpandedRowInputValue(studentRow, EXPANDED_ROWS_HEADER_COURSE_JOIN_LINK); } + public String getStudentJoinLink(Student student) { + WebElement studentRow = getStudentRow(student); + return getStudentJoinLink(studentRow); + } + public String getStudentJoinLink(StudentAttributes student) { WebElement studentRow = getStudentRow(student); return getStudentJoinLink(studentRow); } + public void resetStudentGoogleId(Student student) { + WebElement studentRow = getStudentRow(student); + WebElement link = studentRow.findElement(By.linkText(LINK_TEXT_RESET_GOOGLE_ID)); + link.click(); + + waitForConfirmationModalAndClickOk(); + waitForElementStaleness(link); + } + public void resetStudentGoogleId(StudentAttributes student) { WebElement studentRow = getStudentRow(student); WebElement link = studentRow.findElement(By.linkText(LINK_TEXT_RESET_GOOGLE_ID)); @@ -209,6 +277,21 @@ public void resetStudentGoogleId(StudentAttributes student) { waitForElementStaleness(link); } + public WebElement getInstructorRow(Instructor instructor) { + WebElement table = browser.driver.findElement(By.id("search-table-instructor")); + List rows = table.findElements(By.tagName("tr")); + for (WebElement row : rows) { + List columns = row.findElements(By.tagName("td")); + if (columns.size() >= 3 && (removeSpanFromText(columns.get(2) + .getAttribute("innerHTML")).contains(instructor.getGoogleId()) + || removeSpanFromText(columns.get(1) + .getAttribute("innerHTML")).contains(instructor.getName()))) { + return row; + } + } + return null; + } + public WebElement getInstructorRow(InstructorAttributes instructor) { String courseId = instructor.getCourseId(); List rows = browser.driver.findElements(By.cssSelector("#search-table-instructor tbody tr")); @@ -256,11 +339,25 @@ public String getInstructorJoinLink(WebElement instructorRow) { return getExpandedRowInputValue(instructorRow, EXPANDED_ROWS_HEADER_COURSE_JOIN_LINK); } + public String getInstructorJoinLink(Instructor instructor) { + WebElement instructorRow = getInstructorRow(instructor); + return getInstructorJoinLink(instructorRow); + } + public String getInstructorJoinLink(InstructorAttributes instructor) { WebElement instructorRow = getInstructorRow(instructor); return getInstructorJoinLink(instructorRow); } + public void resetInstructorGoogleId(Instructor instructor) { + WebElement instructorRow = getInstructorRow(instructor); + WebElement link = instructorRow.findElement(By.linkText(LINK_TEXT_RESET_GOOGLE_ID)); + link.click(); + + waitForConfirmationModalAndClickOk(); + waitForElementStaleness(link); + } + public void resetInstructorGoogleId(InstructorAttributes instructor) { WebElement instructorRow = getInstructorRow(instructor); WebElement link = instructorRow.findElement(By.linkText(LINK_TEXT_RESET_GOOGLE_ID)); @@ -386,6 +483,32 @@ private String getExpandedRowInputValue(WebElement row, String rowHeader) { } } + public void verifyStudentRowContent(Student student, Course course, + String expectedDetails, String expectedManageAccountLink, + String expectedHomePageLink) { + WebElement studentRow = getStudentRow(student); + String actualDetails = getStudentDetails(studentRow); + String actualName = getStudentName(studentRow); + String actualGoogleId = getStudentGoogleId(studentRow); + String actualHomepageLink = getStudentHomeLink(studentRow); + String actualInstitute = getStudentInstitute(studentRow); + String actualComment = getStudentComments(studentRow); + String actualManageAccountLink = getStudentManageAccountLink(studentRow); + + String expectedName = student.getName(); + String expectedGoogleId = StringHelper.convertToEmptyStringIfNull(student.getGoogleId()); + String expectedInstitute = StringHelper.convertToEmptyStringIfNull(course.getInstitute()); + String expectedComment = StringHelper.convertToEmptyStringIfNull(student.getComments()); + + assertEquals(expectedDetails, actualDetails); + assertEquals(expectedName, actualName); + assertEquals(expectedGoogleId, actualGoogleId); + assertEquals(expectedInstitute, actualInstitute); + assertEquals(expectedComment, actualComment); + assertEquals(expectedManageAccountLink, actualManageAccountLink); + assertEquals(expectedHomePageLink, actualHomepageLink); + } + public void verifyStudentRowContent(StudentAttributes student, CourseAttributes course, String expectedDetails, String expectedManageAccountLink, String expectedHomePageLink) { @@ -412,6 +535,35 @@ public void verifyStudentRowContent(StudentAttributes student, CourseAttributes assertEquals(expectedHomePageLink, actualHomepageLink); } + public void verifyStudentRowContentAfterReset(Student student, Course course) { + WebElement studentRow = getStudentRow(student); + String actualName = getStudentName(studentRow); + String actualInstitute = getStudentInstitute(studentRow); + String actualComment = getStudentComments(studentRow); + + String expectedName = student.getName(); + String expectedInstitute = StringHelper.convertToEmptyStringIfNull(course.getInstitute()); + String expectedComment = StringHelper.convertToEmptyStringIfNull(student.getComments()); + + assertEquals(expectedName, actualName); + assertEquals(expectedInstitute, actualInstitute); + assertEquals(expectedComment, actualComment); + } + + public void verifyStudentExpandedLinks(Student student, int expectedNumExpandedRows) { + clickExpandStudentLinks(); + WebElement studentRow = getStudentRow(student); + String actualEmail = getStudentEmail(studentRow); + String actualJoinLink = getStudentJoinLink(studentRow); + int actualNumExpandedRows = getNumExpandedRows(studentRow); + + String expectedEmail = student.getEmail(); + + assertEquals(expectedEmail, actualEmail); + assertNotEquals("", actualJoinLink); + assertEquals(expectedNumExpandedRows, actualNumExpandedRows); + } + public void verifyStudentExpandedLinks(StudentAttributes student, int expectedNumExpandedRows) { clickExpandStudentLinks(); WebElement studentRow = getStudentRow(student); @@ -426,6 +578,29 @@ public void verifyStudentExpandedLinks(StudentAttributes student, int expectedNu assertEquals(expectedNumExpandedRows, actualNumExpandedRows); } + public void verifyInstructorRowContent(Instructor instructor, Course course, + String expectedManageAccountLink, String expectedHomePageLink) { + WebElement instructorRow = getInstructorRow(instructor); + String actualCourseId = getInstructorCourseId(instructorRow); + String actualName = getInstructorName(instructorRow); + String actualGoogleId = getInstructorGoogleId(instructorRow); + String actualHomePageLink = getInstructorHomePageLink(instructorRow); + String actualInstitute = getInstructorInstitute(instructorRow); + String actualManageAccountLink = getInstructorManageAccountLink(instructorRow); + + String expectedCourseId = instructor.getCourseId(); + String expectedName = instructor.getName(); + String expectedGoogleId = StringHelper.convertToEmptyStringIfNull(instructor.getGoogleId()); + String expectedInstitute = StringHelper.convertToEmptyStringIfNull(course.getInstitute()); + + assertEquals(expectedCourseId, actualCourseId); + assertEquals(expectedName, actualName); + assertEquals(expectedGoogleId, actualGoogleId); + assertEquals(expectedHomePageLink, actualHomePageLink); + assertEquals(expectedInstitute, actualInstitute); + assertEquals(expectedManageAccountLink, actualManageAccountLink); + } + public void verifyInstructorRowContent(InstructorAttributes instructor, CourseAttributes course, String expectedManageAccountLink, String expectedHomePageLink) { WebElement instructorRow = getInstructorRow(instructor); @@ -449,6 +624,33 @@ public void verifyInstructorRowContent(InstructorAttributes instructor, CourseAt assertEquals(expectedManageAccountLink, actualManageAccountLink); } + public void verifyInstructorRowContentAfterReset(Instructor instructor, Course course) { + WebElement instructorRow = getInstructorRow(instructor); + String actualCourseId = getInstructorCourseId(instructorRow); + String actualName = getInstructorName(instructorRow); + String actualInstitute = getInstructorInstitute(instructorRow); + + String expectedCourseId = instructor.getCourseId(); + String expectedName = instructor.getName(); + String expectedInstitute = StringHelper.convertToEmptyStringIfNull(course.getInstitute()); + + assertEquals(expectedCourseId, actualCourseId); + assertEquals(expectedName, actualName); + assertEquals(expectedInstitute, actualInstitute); + } + + public void verifyInstructorExpandedLinks(Instructor instructor) { + clickExpandInstructorLinks(); + WebElement instructorRow = getInstructorRow(instructor); + String actualEmail = getInstructorEmail(instructorRow); + String actualJoinLink = getInstructorJoinLink(instructorRow); + + String expectedEmail = instructor.getEmail(); + + assertEquals(expectedEmail, actualEmail); + assertNotEquals("", actualJoinLink); + } + public void verifyInstructorExpandedLinks(InstructorAttributes instructor) { clickExpandInstructorLinks(); WebElement instructorRow = getInstructorRow(instructor); @@ -515,6 +717,43 @@ public void verifyAccountRequestExpandedLinks(AccountRequest accountRequest) { assertFalse(actualRegistrationLink.isBlank()); } + public void verifyLinkExpansionButtons(Student student, + Instructor instructor, AccountRequest accountRequest) { + WebElement studentRow = getStudentRow(student); + WebElement instructorRow = getInstructorRow(instructor); + WebElement accountRequestRow = getAccountRequestRow(accountRequest); + + clickExpandStudentLinks(); + clickExpandInstructorLinks(); + clickExpandAccountRequestLinks(); + int numExpandedStudentRows = getNumExpandedRows(studentRow); + int numExpandedInstructorRows = getNumExpandedRows(instructorRow); + int numExpandedAccountRequestRows = getNumExpandedRows(accountRequestRow); + assertNotEquals(numExpandedStudentRows, 0); + assertNotEquals(numExpandedInstructorRows, 0); + assertNotEquals(numExpandedAccountRequestRows, 0); + + clickCollapseInstructorLinks(); + numExpandedStudentRows = getNumExpandedRows(studentRow); + numExpandedInstructorRows = getNumExpandedRows(instructorRow); + numExpandedAccountRequestRows = getNumExpandedRows(accountRequestRow); + assertNotEquals(numExpandedStudentRows, 0); + assertEquals(numExpandedInstructorRows, 0); + assertNotEquals(numExpandedAccountRequestRows, 0); + + clickExpandInstructorLinks(); + clickCollapseStudentLinks(); + clickCollapseAccountRequestLinks(); + waitUntilAnimationFinish(); + + numExpandedStudentRows = getNumExpandedRows(studentRow); + numExpandedInstructorRows = getNumExpandedRows(instructorRow); + numExpandedAccountRequestRows = getNumExpandedRows(accountRequestRow); + assertEquals(numExpandedStudentRows, 0); + assertNotEquals(numExpandedInstructorRows, 0); + assertEquals(numExpandedAccountRequestRows, 0); + } + public void verifyLinkExpansionButtons(StudentAttributes student, InstructorAttributes instructor, AccountRequestAttributes accountRequest) { WebElement studentRow = getStudentRow(student); @@ -589,11 +828,11 @@ public void verifyLinkExpansionButtons(StudentAttributes student, assertEquals(numExpandedAccountRequestRows, 0); } - public void verifyRegenerateStudentKey(StudentAttributes student, String originalJoinLink) { - verifyStatusMessage("Student's key for this course has been successfully regenerated," + public void verifyRegenerateInstructorKey(Instructor instructor, String originalJoinLink) { + verifyStatusMessage("Instructor's key for this course has been successfully regenerated," + " and the email has been sent."); - String regeneratedJoinLink = getStudentJoinLink(student); + String regeneratedJoinLink = getInstructorJoinLink(instructor); assertNotEquals(regeneratedJoinLink, originalJoinLink); } @@ -604,5 +843,4 @@ public void verifyRegenerateInstructorKey(InstructorAttributes instructor, Strin String regeneratedJoinLink = getInstructorJoinLink(instructor); assertNotEquals(regeneratedJoinLink, originalJoinLink); } - } diff --git a/src/e2e/resources/data/AdminSearchPageE2ESqlTest.json b/src/e2e/resources/data/AdminSearchPageE2ESqlTest.json new file mode 100644 index 00000000000..94b28ae6ad2 --- /dev/null +++ b/src/e2e/resources/data/AdminSearchPageE2ESqlTest.json @@ -0,0 +1,118 @@ +{ + "accounts": { + "instructor1OfCourse1": { + "id": "00000000-0000-4000-8000-000000000001", + "googleId": "tm.e2e.ASearch.instr1", + "name": "Instructor1 of Course1", + "email": "ASearch.instructor1@gmail.tmt" + }, + "instructor2OfCourse1": { + "id": "00000000-0000-4000-8000-000000000002", + "googleId": "tm.e2e.ASearch.instr2", + "name": "Instructor2 of Course1", + "email": "ASearch.instructor2@gmail.tmt" + }, + "student1InCourse1": { + "id": "00000000-0000-4000-8000-000000000003", + "googleId": "tm.e2e.ASearch.student1", + "name": "Student1 in course1", + "email": "ASearch.student@gmail.tmt" + } + }, + "accountRequests": { + "instructor1OfCourse1": { + "name": "Instructor1 of Course1", + "email": "ASearch.instructor1@gmail.tmt", + "institute": "TEAMMATES Test Institute 1", + "createdAt": "2011-01-01T00:00:00Z", + "registeredAt": "1970-02-14T00:00:00Z" + }, + "instructor2OfCourse1": { + "name": "Instructor2 of Course1", + "email": "ASearch.instructor2@gmail.tmt", + "institute": "TEAMMATES Test Institute 1", + "createdAt": "2011-01-01T00:00:00Z", + "registeredAt": "1970-02-14T00:00:00Z" + }, + "unregisteredInstructor1": { + "name": "Typical Instructor Name", + "email": "ASearch.unregisteredinstructor1@gmail.tmt", + "institute": "TEAMMATES Test Institute 1", + "createdAt": "2011-01-01T00:00:00Z" + } + }, + "courses": { + "typicalCourse1": { + "createdAt": "2012-04-01T23:59:00Z", + "id": "00000000-0000-4000-8000-000000000303", + "name": "ASearch Course 1", + "institute": "TEAMMATES Test Institute 0", + "timeZone": "Africa/Johannesburg" + } + }, + "sections": { + "section1InCourse1": { + "id": "00000000-0000-4000-8000-000000000201", + "course": { + "id": "00000000-0000-4000-8000-000000000303" + }, + "name": "Section 1" + } + }, + "teams": { + "team1InCourse1": { + "id": "00000000-0000-4000-8000-000000000301", + "section": { + "id": "00000000-0000-4000-8000-000000000201" + }, + "name": "Team 1" + } + }, + "instructors": { + "instructor1OfCourse1": { + "id": "00000000-0000-4000-8000-000000000501", + "account": { + "id": "00000000-0000-4000-8000-000000000001" + }, + "course": { + "id": "00000000-0000-4000-8000-000000000303" + }, + "name": "Instructor1 of ASearch Course1", + "email": "ASearch.instructor@gmail.tmt", + "role": "INSTRUCTOR_PERMISSION_ROLE_COOWNER", + "isDisplayedToStudents": true, + "displayName": "Instructor", + "privileges": { + "courseLevel": { + "canModifyCourse": true, + "canModifyInstructor": true, + "canModifySession": true, + "canModifyStudent": true, + "canViewStudentInSections": true, + "canViewSessionInSections": true, + "canSubmitSessionInSections": true, + "canModifySessionCommentsInSections": true + }, + "sectionLevel": {}, + "sessionLevel": {} + } + } + }, + "students": { + "student1InCourse1": { + "id": "00000000-0000-4000-8000-000000000601", + "account": { + "id": "00000000-0000-4000-8000-000000000003" + }, + "course": { + "id": "00000000-0000-4000-8000-000000000303" + }, + "team": { + "id": "00000000-0000-4000-8000-000000000301" + }, + "email": "ASearch.student@gmail.tmt", + "name": "Student1 In ASearch Course1", + "comments": "comment for student1Course1" + } + } +} diff --git a/src/e2e/resources/testng-e2e-sql.xml b/src/e2e/resources/testng-e2e-sql.xml index 78dcc2d13a1..40e4bedfad9 100644 --- a/src/e2e/resources/testng-e2e-sql.xml +++ b/src/e2e/resources/testng-e2e-sql.xml @@ -11,6 +11,7 @@ + diff --git a/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java b/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java index 2bd8f2bfb08..50add8e08ff 100644 --- a/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java +++ b/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java @@ -256,12 +256,10 @@ public EmailWrapper generateFeedbackSessionSummaryOfCourse( Course course = coursesLogic.getCourse(courseId); boolean isInstructor = emailType == EmailType.INSTRUCTOR_COURSE_LINKS_REGENERATED; - Student student = null; + Student student = usersLogic.getStudentForEmail(courseId, userEmail); Instructor instructor = null; if (isInstructor) { instructor = usersLogic.getInstructorForEmail(courseId, userEmail); - } else { - student = usersLogic.getStudentForEmail(courseId, userEmail); } List sessions = new ArrayList<>(); @@ -868,11 +866,11 @@ private EmailWrapper generateFeedbackSessionEmailBaseForNotifiedInstructors( } private boolean isYetToJoinCourse(Student student) { - return student.getAccount().getGoogleId() == null || student.getAccount().getGoogleId().isEmpty(); + return student.getAccount() == null || student.getAccount().getGoogleId().isEmpty(); } private boolean isYetToJoinCourse(Instructor instructor) { - return instructor.getAccount().getGoogleId() == null || instructor.getAccount().getGoogleId().isEmpty(); + return instructor.getAccount() == null || instructor.getAccount().getGoogleId().isEmpty(); } /** From db0fd945b9fd03584fcac0cef53585373a02eca7 Mon Sep 17 00:00:00 2001 From: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> Date: Tue, 9 Apr 2024 04:28:02 +0800 Subject: [PATCH 35/95] [#12995] Create documentation for unit tests (#12996) * Create documentation for unit tests * Update docs/unit-testing.md Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> * Update docs/unit-testing.md Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> --------- Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> --- docs/_markbind/layouts/default.md | 1 + docs/development.md | 6 +- docs/unit-testing.md | 206 ++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 docs/unit-testing.md diff --git a/docs/_markbind/layouts/default.md b/docs/_markbind/layouts/default.md index f92791366a0..e57ee418a49 100644 --- a/docs/_markbind/layouts/default.md +++ b/docs/_markbind/layouts/default.md @@ -27,6 +27,7 @@ * [Captcha]({{ baseUrl }}/captcha.html) * [Documentation]({{ baseUrl }}/documentation.html) * [Emails]({{ baseUrl }}/emails.html) + * [Unit Testing]({{ baseUrl }}/unit-testing.html) * [End-to-End Testing]({{ baseUrl }}/e2e-testing.html) * [Performance Testing]({{ baseUrl }}/performance-testing.html) * [Accessibility Testing]({{ baseUrl }}/axe-testing.html) diff --git a/docs/development.md b/docs/development.md index 703a1ce57b0..aec8c3696ca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -291,7 +291,9 @@ There are two big categories of testing in TEAMMATES: - **Component tests**: white-box unit and integration tests, i.e. they test the application components with full knowledge of the components' internal workings. This is configured in `src/test/resources/testng-component.xml` (back-end) and `src/web/jest.config.js` (front-end). - **E2E (end-to-end) tests**: black-box tests, i.e. they test the application as a whole without knowing any internal working. This is configured in `src/e2e/resources/testng-e2e.xml`. To learn more about E2E tests, refer to this [document](e2e-testing.md). -#### Running the tests +
    + +#### Running tests ##### Frontend tests @@ -335,6 +337,8 @@ You can generate the coverage data with `jacocoReport` task after running tests, The report can be found in the `build/reports/jacoco/jacocoReport/` directory. +
    + ## Deploying to a staging server > `Staging server` is the server instance you set up on Google App Engine for hosting the app for testing purposes. diff --git a/docs/unit-testing.md b/docs/unit-testing.md new file mode 100644 index 00000000000..fe4d2786c8e --- /dev/null +++ b/docs/unit-testing.md @@ -0,0 +1,206 @@ + + title: "Unit Testing" + + +# Unit Testing + +## What is Unit Testing? + +Unit testing is a testing methodology where the objective is to test components in isolation. + +- It aims to ensure all components of the application work as expected, assuming its dependencies are working. +- This is done in TEAMMATES by using mocks to simulate a component's dependencies. + +Frontend Unit tests in TEAMMATES are located in `.spec.ts` files, while Backend Unit tests in TEAMMATES can be found in the package `teammates.test`. + + +## Writing Unit Tests + +### General guidelines + +#### Include only relevant details in tests +When writing unit tests, reduce the amount of noise in the code to make it easier for future developers to follow. + +The code below has a lot of noise in creation of the `studentModel`: + +```javascript +it('displayInviteButton: should display "Send Invite" button when a student has not joined the course', () => { + component.studentModels = [ + { + student: { + name: 'tester', + teamName: 'Team 1', + email: 'tester@tester.com', + joinState: JoinState.NOT_JOINED, + sectionName: 'Tutorial Group 1', + courseId: 'text-exa.demo', + }, + isAllowedToViewStudentInSection: true, + isAllowedToModifyStudent: true, + }, + ]; + + expect(sendInviteButton).toBeTruthy(); +}); +``` + +However, what is important is only the student joinState. We should thus reduce the noise by including only the relevant details: + +```javascript +it('displayInviteButton: should display "Send Invite" button when a student has not joined the course', () => { + component.studentModels = [ + studentModelBuilder + .joinState(JoinState.NOT_JOINED) + .build() + ]; + + expect(sendInviteButton).toBeTruthy(); +}); +``` + +Including only the relevant details in tests makes it easier for future developers to read and understand the purpose of the test. + +#### Favor readability over uniqueness +Since tests don't have tests, it should be easy for developers to manually inspect them for correctness, even at the expense of greater code duplication. + +Take the following test for example: + +```java +@BeforeMethod +public void setUp() { + users = new User[]{new User("alice"), new User("bob")}; +} + +@Test +public void test_register_canRegisterMultipleUsers() { + registerAllUsers(); + for (User user : users) { + assertTrue(forum.hasRegisteredUser(user)); + } +} + +private void registerAllUsers() { + for (User user : users) { + forum.register(user); + } +} +``` + +While the code reduces duplication, it is not as straightforward for a developer to follow. + +A more readable way to write this test would be: +```java +@Test +public void test_register_canRegisterMultipleUsers() { + User user1 = new User("alice"); + User user2 = new User("bob"); + + forum.register(user1); + forum.register(user2); + + assertTrue(forum.hasRegisteredUser(user1)); + assertTrue(forum.hasRegisteredUser(user2)); +} +``` + +By choosing readability over uniqueness in writing unit tests, there is code duplication, but the test flow is easier for a reader to follow. + + +#### Inline mocks in test code + +Inlining mock return values in the unit test itself improves readability: + +```javascript +it('getStudentCourseJoinStatus: should return true if student has joined the course' , () => { + jest.spyOn(courseService, 'getJoinCourseStatus') + .mockReturnValue(of({ hasJoined: true })); + + expect(student.getJoinCourseStatus).toBeTruthy(); +}); +``` + +By injecting the values in the test right before they are used, developers are able to more easily trace the code and understand the test. + +### Frontend + +#### Naming +Unit tests for a function should follow the format: + +`": should ... when/if ..."` + +Example: + +```javascript + it('hasSection: should return false when there are no sections in the course') +``` + +#### Creating test data +To aid with [including only relevant details in tests](#include-only-relevant-details-in-tests), use the builder in `src/web/test-helpers/generic-builder.ts` + +Usage: +```javascript +const instructorModelBuilder = createBuilder({ + email: 'instructor@gmail.com', + name: 'Instructor', + hasSubmittedSession: false, + isSelected: false, +}); + +it('isAllInstructorsSelected: should return false if at least one instructor !isSelected', () => { +component.instructorListInfoTableRowModels = [ + instructorModelBuilder.isSelected(true).build(), + instructorModelBuilder.isSelected(false).build(), + instructorModelBuilder.isSelected(true).build(), +]; + +expect(component.isAllInstructorsSelected).toBeFalsy(); +}); + +``` + +#### Testing event emission +In Angular, child components emit events. To test for event emissions, we've provided a utility function in `src/test-helpers/test-event-emitter` + +Usage: +```javascript +@Output() +deleteCommentEvent: EventEmitter = new EventEmitter(); + +triggerDeleteCommentEvent(index: number): void { + this.deleteCommentEvent.emit(index); +} + +it('triggerDeleteCommentEvent: should emit the correct index to deleteCommentEvent', () => { + let emittedIndex: number | undefined; + testEventEmission(component.deleteCommentEvent, (index) => { emittedIndex = index; }); + + component.triggerDeleteCommentEvent(5); + expect(emittedIndex).toBe(5); +}); +``` + +### Backend + +#### Naming +Unit test names should follow the format: `test__` + +Examples: +```java +public void testGetComment_commentDoesNotExist_returnsNull() +public void testCreateComment_commentDoesNotExist_success() +public void testCreateComment_commentAlreadyExists_throwsEntityAlreadyExistsException() +``` + +#### Creating test data +To aid with [including only relevant details in tests](#include-only-relevant-details-in-tests), use the `getTypicalX` functions in `BaseTestCase`, where X represents an entity. + +Example: +```java +Account account = getTypicalAccount(); +account.setEmail("newemail@teammates.com"); + +Student student = getTypicalStudent(); +student.setName("New Student Name"); +``` + + From fb0ba194ba1759bc5ce1da0f23ec467bdfed36fe Mon Sep 17 00:00:00 2001 From: Xenos F Date: Tue, 9 Apr 2024 10:50:20 +0800 Subject: [PATCH 36/95] [#11878] Create reject account request endpoint (#12985) * Create account request rejection endpoint * Add validation * Add check for already rejected request when sending email * Add integration test cases * Set request method to post * Fix lint errors * Update tests list * Update validation check * Add test for validation * Fix lint errors * Fix validation comparison * Fix error message test * Add email sending * Update test cases * Refactor reason check code for clarity --- .../webapi/RejectAccountRequestActionIT.java | 208 ++++++++++++++++++ .../java/teammates/common/util/Const.java | 3 + .../ui/constants/ResourceEndpoints.java | 1 + .../AccountRequestRejectionRequest.java | 46 ++++ .../teammates/ui/webapi/ActionFactory.java | 1 + .../ui/webapi/RejectAccountRequestAction.java | 59 +++++ .../ui/webapi/UpdateAccountRequestAction.java | 4 +- .../AccountRequestRejectionRequestTest.java | 51 +++++ .../ui/webapi/GetActionClassesActionTest.java | 1 + 9 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java create mode 100644 src/main/java/teammates/ui/request/AccountRequestRejectionRequest.java create mode 100644 src/main/java/teammates/ui/webapi/RejectAccountRequestAction.java create mode 100644 src/test/java/teammates/ui/request/AccountRequestRejectionRequestTest.java diff --git a/src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java new file mode 100644 index 00000000000..74aba31e1fb --- /dev/null +++ b/src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java @@ -0,0 +1,208 @@ +package teammates.it.ui.webapi; + +import java.util.UUID; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Config; +import teammates.common.util.Const; +import teammates.common.util.EmailType; +import teammates.common.util.EmailWrapper; +import teammates.common.util.HibernateUtil; +import teammates.common.util.SanitizationHelper; +import teammates.storage.sqlentity.AccountRequest; +import teammates.storage.sqlentity.Course; +import teammates.ui.output.AccountRequestData; +import teammates.ui.request.AccountRequestRejectionRequest; +import teammates.ui.request.InvalidHttpRequestBodyException; +import teammates.ui.webapi.EntityNotFoundException; +import teammates.ui.webapi.InvalidHttpParameterException; +import teammates.ui.webapi.InvalidOperationException; +import teammates.ui.webapi.JsonResult; +import teammates.ui.webapi.RejectAccountRequestAction; + +/** + * SUT: {@link RejectAccountRequestAction}. + */ +public class RejectAccountRequestActionIT extends BaseActionIT { + + private static final String TYPICAL_TITLE = "We are Unable to Create an Account for you"; + private static final String TYPICAL_BODY = new StringBuilder() + .append("

    Hi, Example

    \n") + .append("

    Thanks for your interest in using TEAMMATES. ") + .append("We are unable to create a TEAMMATES instructor account for you.

    \n\n") + .append("

    \n") + .append(" Reason: The email address you provided ") + .append("is not an 'official' email address provided by your institution.
    \n") + .append(" Remedy: ") + .append("Please re-submit an account request with your 'official' institution email address.\n") + .append("

    \n\n") + .append("

    If you need further clarification or would like to appeal this decision, ") + .append("please feel free to contact us at teammates@comp.nus.edu.sg.

    \n") + .append("

    Regards,
    TEAMMATES Team.

    \n") + .toString(); + + @Override + @BeforeMethod + protected void setUp() throws Exception { + super.setUp(); + persistDataBundle(typicalBundle); + HibernateUtil.flushSession(); + } + + @Override + protected String getActionUri() { + return Const.ResourceURIs.ACCOUNT_REQUEST_REJECTION; + } + + @Override + protected String getRequestMethod() { + return POST; + } + + @Override + public void testExecute() throws Exception { + // See individual test methods below + } + + @Test + protected void testExecute_withReasonTitleAndBody_shouldRejectWithEmail() + throws InvalidOperationException, InvalidHttpRequestBodyException { + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + accountRequest.setStatus(AccountRequestStatus.PENDING); + UUID id = accountRequest.getId(); + + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(TYPICAL_TITLE, TYPICAL_BODY); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + RejectAccountRequestAction action = getAction(requestBody, params); + JsonResult result = action.execute(); + + assertEquals(200, result.getStatusCode()); + + AccountRequestData data = (AccountRequestData) result.getOutput(); + assertEquals(accountRequest.getName(), data.getName()); + assertEquals(accountRequest.getEmail(), data.getEmail()); + assertEquals(accountRequest.getInstitute(), data.getInstitute()); + assertEquals(AccountRequestStatus.REJECTED, data.getStatus()); + assertEquals(accountRequest.getComments(), data.getComments()); + + verifyNumberOfEmailsSent(1); + EmailWrapper sentEmail = mockEmailSender.getEmailsSent().get(0); + assertEquals(EmailType.ACCOUNT_REQUEST_REJECTION, sentEmail.getType()); + assertEquals(Config.SUPPORT_EMAIL, sentEmail.getBcc()); + assertEquals(accountRequest.getEmail(), sentEmail.getRecipient()); + assertEquals(SanitizationHelper.sanitizeForRichText(TYPICAL_BODY), sentEmail.getContent()); + assertEquals("TEAMMATES: " + TYPICAL_TITLE, sentEmail.getSubject()); + } + + @Test + protected void testExecute_withoutReasonTitleAndBody_shouldRejectWithoutEmail() + throws InvalidOperationException, InvalidHttpRequestBodyException { + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + accountRequest.setStatus(AccountRequestStatus.PENDING); + UUID id = accountRequest.getId(); + + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(null, null); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + RejectAccountRequestAction action = getAction(requestBody, params); + JsonResult result = action.execute(); + + assertEquals(200, result.getStatusCode()); + + AccountRequestData data = (AccountRequestData) result.getOutput(); + assertEquals(accountRequest.getName(), data.getName()); + assertEquals(accountRequest.getEmail(), data.getEmail()); + assertEquals(accountRequest.getInstitute(), data.getInstitute()); + assertEquals(AccountRequestStatus.REJECTED, data.getStatus()); + assertEquals(accountRequest.getComments(), data.getComments()); + + verifyNoEmailsSent(); + } + + @Test + protected void testExecute_withReasonBodyButNoTitle_shouldThrow() { + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + UUID id = accountRequest.getId(); + + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(null, TYPICAL_BODY); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + InvalidHttpRequestBodyException ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals("Both reason body and title need to be null to reject silently", ihrbe.getMessage()); + verifyNoEmailsSent(); + } + + @Test + protected void testExecute_withReasonTitleButNoBody_shouldThrow() { + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + UUID id = accountRequest.getId(); + + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(TYPICAL_TITLE, null); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + InvalidHttpRequestBodyException ihrbe = verifyHttpRequestBodyFailure(requestBody, params); + + assertEquals("Both reason body and title need to be null to reject silently", ihrbe.getMessage()); + verifyNoEmailsSent(); + } + + @Test + protected void testExecute_alreadyRejected_shouldNotSendEmail() + throws InvalidOperationException, InvalidHttpRequestBodyException { + AccountRequest accountRequest = typicalBundle.accountRequests.get("unregisteredInstructor1"); + accountRequest.setStatus(AccountRequestStatus.REJECTED); + UUID id = accountRequest.getId(); + + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(TYPICAL_TITLE, TYPICAL_BODY); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()}; + + RejectAccountRequestAction action = getAction(requestBody, params); + JsonResult result = action.execute(); + + assertEquals(result.getStatusCode(), 200); + + AccountRequestData data = (AccountRequestData) result.getOutput(); + assertEquals(accountRequest.getName(), data.getName()); + assertEquals(accountRequest.getEmail(), data.getEmail()); + assertEquals(accountRequest.getInstitute(), data.getInstitute()); + assertEquals(accountRequest.getStatus(), data.getStatus()); + assertEquals(accountRequest.getComments(), data.getComments()); + + verifyNoEmailsSent(); + } + + @Test + protected void testExecute_invalidUuid_shouldThrow() { + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(null, null); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, "invalid"}; + + InvalidHttpParameterException ihpe = verifyHttpParameterFailure(requestBody, params); + assertEquals("Invalid UUID string: invalid", ihpe.getMessage()); + verifyNoEmailsSent(); + } + + @Test + protected void testExecute_accountRequestNotFound_shouldThrow() { + AccountRequestRejectionRequest requestBody = new AccountRequestRejectionRequest(null, null); + String uuid = UUID.randomUUID().toString(); + String[] params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, uuid}; + + EntityNotFoundException enfe = verifyEntityNotFound(requestBody, params); + assertEquals(String.format("Account request with id = %s not found", uuid), enfe.getMessage()); + verifyNoEmailsSent(); + } + + @Override + @Test + protected void testAccessControl() throws InvalidParametersException, EntityAlreadyExistsException { + Course course = typicalBundle.courses.get("course1"); + verifyOnlyAdminCanAccess(course); + } +} diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index 80369af91c4..2152278dd7e 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -45,6 +45,8 @@ public final class Const { public static final String MISSING_RESPONSE_TEXT = "No Response"; + public static final String ACCOUNT_REQUEST_NOT_FOUND = "Account request with id = %s not found"; + // These constants are used as variable values to mean that the variable is in a 'special' state. public static final int INT_UNINITIALIZED = -9999; @@ -337,6 +339,7 @@ public static class ResourceURIs { public static final String ACCOUNT_REQUEST = URI_PREFIX + "/account/request"; public static final String ACCOUNT_REQUESTS = URI_PREFIX + "/account/requests"; public static final String ACCOUNT_REQUEST_RESET = ACCOUNT_REQUEST + "/reset"; + public static final String ACCOUNT_REQUEST_REJECTION = ACCOUNT_REQUEST + "/rejection"; public static final String ACCOUNTS = URI_PREFIX + "/accounts"; public static final String RESPONSE_COMMENT = URI_PREFIX + "/responsecomment"; public static final String COURSE = URI_PREFIX + "/course"; diff --git a/src/main/java/teammates/ui/constants/ResourceEndpoints.java b/src/main/java/teammates/ui/constants/ResourceEndpoints.java index 8e288eb6264..3a7a3abe88c 100644 --- a/src/main/java/teammates/ui/constants/ResourceEndpoints.java +++ b/src/main/java/teammates/ui/constants/ResourceEndpoints.java @@ -17,6 +17,7 @@ public enum ResourceEndpoints { ACCOUNT_REQUEST(ResourceURIs.ACCOUNT_REQUEST), ACCOUNT_REQUESTS(ResourceURIs.ACCOUNT_REQUESTS), ACCOUNT_REQUEST_RESET(ResourceURIs.ACCOUNT_REQUEST_RESET), + ACCOUNT_REQUEST_REJECT(ResourceURIs.ACCOUNT_REQUEST_REJECTION), ACCOUNTS(ResourceURIs.ACCOUNTS), RESPONSE_COMMENT(ResourceURIs.RESPONSE_COMMENT), COURSE(ResourceURIs.COURSE), diff --git a/src/main/java/teammates/ui/request/AccountRequestRejectionRequest.java b/src/main/java/teammates/ui/request/AccountRequestRejectionRequest.java new file mode 100644 index 00000000000..89884e13b7d --- /dev/null +++ b/src/main/java/teammates/ui/request/AccountRequestRejectionRequest.java @@ -0,0 +1,46 @@ +package teammates.ui.request; + +import java.util.Objects; + +import javax.annotation.Nullable; + +import teammates.common.util.SanitizationHelper; + +/** + * The request reasonBody for rejecting an account request. + */ +public class AccountRequestRejectionRequest extends BasicRequest { + @Nullable + private String reasonTitle; + + @Nullable + private String reasonBody; + + public AccountRequestRejectionRequest(String reasonTitle, String reasonBody) { + this.reasonTitle = SanitizationHelper.sanitizeTitle(reasonTitle); + this.reasonBody = SanitizationHelper.sanitizeForRichText(reasonBody); + } + + @Override + public void validate() throws InvalidHttpRequestBodyException { + if (reasonBody == null || reasonTitle == null) { + assertTrue(Objects.equals(reasonBody, reasonTitle), + "Both reason body and title need to be null to reject silently"); + } + } + + public String getReasonTitle() { + return this.reasonTitle; + } + + public String getReasonBody() { + return this.reasonBody; + } + + /** + * Returns true if both reason body and title are non-null. + */ + public boolean checkHasReason() { + return this.reasonBody != null && this.reasonTitle != null; + } +} diff --git a/src/main/java/teammates/ui/webapi/ActionFactory.java b/src/main/java/teammates/ui/webapi/ActionFactory.java index 169d4ae5b07..ae834448b7a 100644 --- a/src/main/java/teammates/ui/webapi/ActionFactory.java +++ b/src/main/java/teammates/ui/webapi/ActionFactory.java @@ -53,6 +53,7 @@ public final class ActionFactory { map(ResourceURIs.ACCOUNT_REQUEST, PUT, UpdateAccountRequestAction.class); map(ResourceURIs.ACCOUNT_REQUESTS, GET, GetAccountRequestsAction.class); map(ResourceURIs.ACCOUNT_REQUEST_RESET, PUT, ResetAccountRequestAction.class); + map(ResourceURIs.ACCOUNT_REQUEST_REJECTION, POST, RejectAccountRequestAction.class); map(ResourceURIs.ACCOUNTS, GET, GetAccountsAction.class); map(ResourceURIs.COURSE, GET, GetCourseAction.class); map(ResourceURIs.COURSE, DELETE, DeleteCourseAction.class); diff --git a/src/main/java/teammates/ui/webapi/RejectAccountRequestAction.java b/src/main/java/teammates/ui/webapi/RejectAccountRequestAction.java new file mode 100644 index 00000000000..6b0b2534b44 --- /dev/null +++ b/src/main/java/teammates/ui/webapi/RejectAccountRequestAction.java @@ -0,0 +1,59 @@ +package teammates.ui.webapi; + +import java.util.UUID; + +import teammates.common.datatransfer.AccountRequestStatus; +import teammates.common.exception.EntityDoesNotExistException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Const; +import teammates.common.util.EmailWrapper; +import teammates.storage.sqlentity.AccountRequest; +import teammates.ui.output.AccountRequestData; +import teammates.ui.request.AccountRequestRejectionRequest; +import teammates.ui.request.InvalidHttpRequestBodyException; + +/** + * Rejects an account request. + */ +public class RejectAccountRequestAction extends AdminOnlyAction { + + @Override + public JsonResult execute() throws InvalidOperationException, InvalidHttpRequestBodyException { + String id = getNonNullRequestParamValue(Const.ParamsNames.ACCOUNT_REQUEST_ID); + UUID accountRequestId; + + try { + accountRequestId = UUID.fromString(id); + } catch (IllegalArgumentException e) { + throw new InvalidHttpParameterException(e.getMessage(), e); + } + + AccountRequest accountRequest = sqlLogic.getAccountRequest(accountRequestId); + + if (accountRequest == null) { + String errorMessage = String.format(Const.ACCOUNT_REQUEST_NOT_FOUND, accountRequestId.toString()); + throw new EntityNotFoundException(errorMessage); + } + + AccountRequestRejectionRequest accountRequestRejectionRequest = + getAndValidateRequestBody(AccountRequestRejectionRequest.class); + AccountRequestStatus initialStatus = accountRequest.getStatus(); + + try { + accountRequest.setStatus(AccountRequestStatus.REJECTED); + accountRequest = sqlLogic.updateAccountRequest(accountRequest); + if (accountRequestRejectionRequest.checkHasReason() + && initialStatus != AccountRequestStatus.REJECTED) { + EmailWrapper email = sqlEmailGenerator.generateAccountRequestRejectionEmail(accountRequest, + accountRequestRejectionRequest.getReasonTitle(), accountRequestRejectionRequest.getReasonBody()); + emailSender.sendEmail(email); + } + } catch (InvalidParametersException e) { + throw new InvalidHttpRequestBodyException(e); + } catch (EntityDoesNotExistException e) { + throw new EntityNotFoundException(e); + } + + return new JsonResult(new AccountRequestData(accountRequest)); + } +} diff --git a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java index 59c95ce7ef1..0f1f679be35 100644 --- a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java @@ -17,8 +17,6 @@ */ public class UpdateAccountRequestAction extends AdminOnlyAction { - static final String ACCOUNT_REQUEST_NOT_FOUND = "Account request with id = %s not found"; - @Override public boolean isTransactionNeeded() { return false; @@ -38,7 +36,7 @@ public JsonResult execute() throws InvalidOperationException, InvalidHttpRequest AccountRequest accountRequest = sqlLogic.getAccountRequestWithTransaction(accountRequestId); if (accountRequest == null) { - String errorMessage = String.format(ACCOUNT_REQUEST_NOT_FOUND, accountRequestId.toString()); + String errorMessage = String.format(Const.ACCOUNT_REQUEST_NOT_FOUND, accountRequestId.toString()); throw new EntityNotFoundException(errorMessage); } diff --git a/src/test/java/teammates/ui/request/AccountRequestRejectionRequestTest.java b/src/test/java/teammates/ui/request/AccountRequestRejectionRequestTest.java new file mode 100644 index 00000000000..412bfcf1d87 --- /dev/null +++ b/src/test/java/teammates/ui/request/AccountRequestRejectionRequestTest.java @@ -0,0 +1,51 @@ +package teammates.ui.request; + +import org.testng.annotations.Test; + +import teammates.test.BaseTestCase; + +/** + * SUT: {@link AccountRequestRejectionRequest}. + */ +public class AccountRequestRejectionRequestTest extends BaseTestCase { + + private static final String TYPICAL_TITLE = "We are Unable to Create an Account for you"; + private static final String TYPICAL_BODY = new StringBuilder() + .append("

    Hi, Example

    \n") + .append("

    Thanks for your interest in using TEAMMATES. ") + .append("We are unable to create a TEAMMATES instructor account for you.

    \n\n") + .append("

    \n") + .append(" Reason: The email address you provided ") + .append("is not an 'official' email address provided by your institution.
    \n") + .append(" Remedy: ") + .append("Please re-submit an account request with your 'official' institution email address.\n") + .append("

    \n\n") + .append("

    If you need further clarification or would like to appeal this decision, ") + .append("please feel free to contact us at teammates@comp.nus.edu.sg.

    \n") + .append("

    Regards,
    TEAMMATES Team.

    \n") + .toString(); + + @Test + public void testValidate_withNonNullBodyAndNonNullTitle_shouldPass() throws Exception { + AccountRequestRejectionRequest request = new AccountRequestRejectionRequest(TYPICAL_TITLE, TYPICAL_BODY); + request.validate(); + } + + @Test + public void testValidate_withNullBodyAndNullTitle_shouldPass() throws Exception { + AccountRequestRejectionRequest request = new AccountRequestRejectionRequest(null, null); + request.validate(); + } + + @Test + public void testValidate_withNonNullBodyAndNullTitle_shouldFail() { + AccountRequestRejectionRequest request = new AccountRequestRejectionRequest(null, TYPICAL_BODY); + assertThrows(InvalidHttpRequestBodyException.class, request::validate); + } + + @Test + public void testValidate_withNullBodyAndNonNullTitle_shouldFail() { + AccountRequestRejectionRequest request = new AccountRequestRejectionRequest(TYPICAL_TITLE, null); + assertThrows(InvalidHttpRequestBodyException.class, request::validate); + } +} diff --git a/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java b/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java index 89ace4cdf78..fbcd4a0765c 100644 --- a/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java +++ b/src/test/java/teammates/ui/webapi/GetActionClassesActionTest.java @@ -88,6 +88,7 @@ protected void testExecute() { DeleteAccountRequestAction.class, GetAccountRequestsAction.class, UpdateAccountRequestAction.class, + RejectAccountRequestAction.class, GetAccountAction.class, GetAccountsAction.class, FeedbackSessionPublishedRemindersAction.class, From 6c420faa464b9de5157191674bb39ae57babc4bd Mon Sep 17 00:00:00 2001 From: Nicolas <25302138+NicolasCwy@users.noreply.github.com> Date: Tue, 9 Apr 2024 16:02:47 +0800 Subject: [PATCH 37/95] [#12048] Remove feedbackSession attributes @fetch annotation (#12992) * Remove feedbackSession @fetch annotation --- .../java/teammates/storage/sqlentity/FeedbackSession.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/teammates/storage/sqlentity/FeedbackSession.java b/src/main/java/teammates/storage/sqlentity/FeedbackSession.java index cf25a3897b5..9f91c1b552f 100644 --- a/src/main/java/teammates/storage/sqlentity/FeedbackSession.java +++ b/src/main/java/teammates/storage/sqlentity/FeedbackSession.java @@ -8,8 +8,6 @@ import java.util.UUID; import org.apache.commons.lang.StringUtils; -import org.hibernate.annotations.Fetch; -import org.hibernate.annotations.FetchMode; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; import org.hibernate.annotations.UpdateTimestamp; @@ -92,12 +90,10 @@ public class FeedbackSession extends BaseEntity { private boolean isPublishedEmailSent; @OneToMany(mappedBy = "feedbackSession", cascade = CascadeType.REMOVE) - @Fetch(FetchMode.JOIN) @OnDelete(action = OnDeleteAction.CASCADE) private List deadlineExtensions = new ArrayList<>(); @OneToMany(mappedBy = "feedbackSession", cascade = CascadeType.REMOVE) - @Fetch(FetchMode.JOIN) @OnDelete(action = OnDeleteAction.CASCADE) private List feedbackQuestions = new ArrayList<>(); From cfff21db4a28c169de343df4f24198923e250b8e Mon Sep 17 00:00:00 2001 From: DS Date: Tue, 9 Apr 2024 16:57:20 +0800 Subject: [PATCH 38/95] Remove unused modal (#12998) --- .../admin-home-page.component.spec.ts.snap | 100 ----------- .../admin-home-page.component.html | 63 ------- .../admin-home-page.component.spec.ts | 167 ------------------ .../admin-home-page.component.ts | 133 +------------- .../admin-home-page/instructor-data.ts | 13 -- ...instructor-data-row.component.spec.ts.snap | 5 - .../new-instructor-data-row.component.html | 5 - .../new-instructor-data-row.component.spec.ts | 51 ------ .../new-instructor-data-row.component.ts | 5 - 9 files changed, 5 insertions(+), 537 deletions(-) diff --git a/src/web/app/pages-admin/admin-home-page/__snapshots__/admin-home-page.component.spec.ts.snap b/src/web/app/pages-admin/admin-home-page/__snapshots__/admin-home-page.component.spec.ts.snap index bc11da02df6..b091996522f 100644 --- a/src/web/app/pages-admin/admin-home-page/__snapshots__/admin-home-page.component.spec.ts.snap +++ b/src/web/app/pages-admin/admin-home-page/__snapshots__/admin-home-page.component.spec.ts.snap @@ -5,7 +5,6 @@ exports[`AdminHomePageComponent should snap with default view 1`] = ` accountReqs={[Function Array]} accountService={[Function AccountService]} activeRequests="0" - courseService={[Function CourseService]} currentPage={[Function Number]} formatDateDetailPipe={[Function FormatDateDetailPipe]} instructorDetails="" @@ -14,15 +13,9 @@ exports[`AdminHomePageComponent should snap with default view 1`] = ` instructorName="" instructorsConsolidated={[Function Array]} isAddingInstructors="false" - isRegisteredInstructorModalLoading="false" items$={[Function Observable]} linkService={[Function LinkService]} - ngbModal={[Function _NgbModal]} pageSize={[Function Number]} - registeredInstructorAccountData={[Function Array]} - registeredInstructorIndex="0" - registeredInstructorModal={[Function TemplateRef2]} - simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} timezoneService={[Function TimezoneService]} > @@ -140,7 +133,6 @@ exports[`AdminHomePageComponent should snap with disabled adding instructor butt accountReqs={[Function Array]} accountService={[Function AccountService]} activeRequests={[Function Number]} - courseService={[Function CourseService]} currentPage={[Function Number]} formatDateDetailPipe={[Function FormatDateDetailPipe]} instructorDetails="" @@ -149,15 +141,9 @@ exports[`AdminHomePageComponent should snap with disabled adding instructor butt instructorName="" instructorsConsolidated={[Function Array]} isAddingInstructors={[Function Boolean]} - isRegisteredInstructorModalLoading="false" items$={[Function Observable]} linkService={[Function LinkService]} - ngbModal={[Function _NgbModal]} pageSize={[Function Number]} - registeredInstructorAccountData={[Function Array]} - registeredInstructorIndex="0" - registeredInstructorModal={[Function TemplateRef2]} - simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} timezoneService={[Function TimezoneService]} > @@ -439,7 +425,6 @@ exports[`AdminHomePageComponent should snap with some instructors details 1`] = accountReqs={[Function Array]} accountService={[Function AccountService]} activeRequests="0" - courseService={[Function CourseService]} currentPage={[Function Number]} formatDateDetailPipe={[Function FormatDateDetailPipe]} instructorDetails="" @@ -448,15 +433,9 @@ exports[`AdminHomePageComponent should snap with some instructors details 1`] = instructorName="" instructorsConsolidated={[Function Array]} isAddingInstructors="false" - isRegisteredInstructorModalLoading="false" items$={[Function Observable]} linkService={[Function LinkService]} - ngbModal={[Function _NgbModal]} pageSize={[Function Number]} - registeredInstructorAccountData={[Function Array]} - registeredInstructorIndex="0" - registeredInstructorModal={[Function TemplateRef2]} - simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} timezoneService={[Function TimezoneService]} > @@ -786,85 +765,6 @@ exports[`AdminHomePageComponent should snap with some instructors details 1`] = - - - - Instructor D - - - - - instructord@example.com - - - - - Sample Institution C - - - -
    - - - -
    - - - FAIL - - - - Cannot create account request as instructor has already registered. - - - - - - - - + + +
    + + +
    +
    - diff --git a/src/web/app/components/account-requests-table/account-request-table.component.ts b/src/web/app/components/account-requests-table/account-request-table.component.ts index 86613fae493..a9177960752 100755 --- a/src/web/app/components/account-requests-table/account-request-table.component.ts +++ b/src/web/app/components/account-requests-table/account-request-table.component.ts @@ -2,6 +2,9 @@ import { Component, Input } from '@angular/core'; import { NgbModalRef, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { AccountRequestTableRowModel } from './account-request-table-model'; import { EditRequestModalComponent } from './admin-edit-request-modal/admin-edit-request-modal.component'; +import { + RejectWithReasonModalComponent, +} from './admin-reject-with-reason-modal/admin-reject-with-reason-modal.component'; import { AccountService } from '../../../services/account.service'; import { SimpleModalService } from '../../../services/simple-modal.service'; import { StatusMessageService } from '../../../services/status-message.service'; @@ -146,4 +149,39 @@ export class AccountRequestTableComponent { modalRef.result.then(() => {}, () => {}); } + + rejectAccountRequest(accountRequest: AccountRequestTableRowModel): void { + this.accountService.rejectAccountRequest(accountRequest.id) + .subscribe({ + next: (resp : AccountRequest) => { + accountRequest.status = resp.status; + }, + error: (resp: ErrorMessageOutput) => { + this.statusMessageService.showErrorToast(resp.error.message); + }, + }); + } + + rejectAccountRequestWithReason(accountRequest: AccountRequestTableRowModel): void { + const modalRef: NgbModalRef = this.ngbModal.open(RejectWithReasonModalComponent); + modalRef.componentInstance.accountRequestName = accountRequest.name; + modalRef.componentInstance.accountRequestEmail = accountRequest.email; + + modalRef.result.then(() => { + this.accountService.rejectAccountRequest(accountRequest.id, + modalRef.componentInstance.rejectionReasonTitle, modalRef.componentInstance.rejectionReasonBody) + .subscribe({ + next: (resp: AccountRequest) => { + accountRequest.status = resp.status; + }, + error: (resp: ErrorMessageOutput) => { + this.statusMessageService.showErrorToast(resp.error.message); + }, + }); + }, () => {}); + } + + trackAccountRequest(accountRequest: AccountRequestTableRowModel): string { + return accountRequest.id; + } } diff --git a/src/web/app/components/account-requests-table/account-request-table.module.ts b/src/web/app/components/account-requests-table/account-request-table.module.ts index f0177f5fdc2..2ff431b1021 100644 --- a/src/web/app/components/account-requests-table/account-request-table.module.ts +++ b/src/web/app/components/account-requests-table/account-request-table.module.ts @@ -4,6 +4,9 @@ import { FormsModule } from '@angular/forms'; import { NgbTooltipModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { AccountRequestTableComponent } from './account-request-table.component'; import { EditRequestModalComponent } from './admin-edit-request-modal/admin-edit-request-modal.component'; +import { + RejectWithReasonModalComponent, +} from './admin-reject-with-reason-modal/admin-reject-with-reason-modal.component'; import { Pipes } from '../../pipes/pipes.module'; import { RichTextEditorModule } from '../rich-text-editor/rich-text-editor.module'; @@ -14,6 +17,7 @@ import { RichTextEditorModule } from '../rich-text-editor/rich-text-editor.modul declarations: [ AccountRequestTableComponent, EditRequestModalComponent, + RejectWithReasonModalComponent, ], exports: [ AccountRequestTableComponent, diff --git a/src/web/app/components/account-requests-table/admin-reject-with-reason-modal/admin-reject-with-reason-modal-model.ts b/src/web/app/components/account-requests-table/admin-reject-with-reason-modal/admin-reject-with-reason-modal-model.ts new file mode 100644 index 00000000000..f58de29149b --- /dev/null +++ b/src/web/app/components/account-requests-table/admin-reject-with-reason-modal/admin-reject-with-reason-modal-model.ts @@ -0,0 +1,4 @@ +export interface RejectWithReasonModalComponentResult { + rejectionReasonTitle: string; + rejectionReasonBody: string; +} diff --git a/src/web/app/components/account-requests-table/admin-reject-with-reason-modal/admin-reject-with-reason-modal.component.html b/src/web/app/components/account-requests-table/admin-reject-with-reason-modal/admin-reject-with-reason-modal.component.html new file mode 100644 index 00000000000..63112771c6c --- /dev/null +++ b/src/web/app/components/account-requests-table/admin-reject-with-reason-modal/admin-reject-with-reason-modal.component.html @@ -0,0 +1,26 @@ + + + + diff --git a/src/web/app/pages-admin/admin-search-page/__snapshots__/admin-search-page.component.spec.ts.snap b/src/web/app/pages-admin/admin-search-page/__snapshots__/admin-search-page.component.spec.ts.snap index 69c6af06b03..a2bbd6ccf84 100644 --- a/src/web/app/pages-admin/admin-search-page/__snapshots__/admin-search-page.component.spec.ts.snap +++ b/src/web/app/pages-admin/admin-search-page/__snapshots__/admin-search-page.component.spec.ts.snap @@ -545,6 +545,36 @@ exports[`AdminSearchPageComponent should snap with an expanded account requests > Approve + + + + diff --git a/src/web/services/account.service.ts b/src/web/services/account.service.ts index e6227ab7148..6f7a1f8bc5f 100644 --- a/src/web/services/account.service.ts +++ b/src/web/services/account.service.ts @@ -11,7 +11,11 @@ import { MessageOutput, AccountRequestStatus, } from '../types/api-output'; -import { AccountCreateRequest, AccountRequestUpdateRequest } from '../types/api-request'; +import { + AccountCreateRequest, + AccountRequestUpdateRequest, + AccountRequestRejectionRequest, +} from '../types/api-request'; /** * Handles account related logic provision @@ -164,4 +168,24 @@ export class AccountService { return this.httpRequestService.get(ResourceEndpoints.ACCOUNT_REQUESTS, paramMap); } + /** + * Rejects an account request by calling API. + */ + rejectAccountRequest(id: string, title?: string, body?: string): Observable { + let accountReqRejectRequest: AccountRequestRejectionRequest = {}; + + if (title !== undefined && body !== undefined) { + accountReqRejectRequest = { + reasonTitle: title, + reasonBody: body, + }; + } + + const paramMap: Record = { + id, + }; + + return this.httpRequestService.post(ResourceEndpoints.ACCOUNT_REQUEST_REJECT, paramMap, accountReqRejectRequest); + } + } From 2354975511120f4292b3eff9b0113e15fd368880 Mon Sep 17 00:00:00 2001 From: Nicolas <25302138+NicolasCwy@users.noreply.github.com> Date: Thu, 11 Apr 2024 15:55:24 +0800 Subject: [PATCH 43/95] Add v9.0.0 tag to liquibase changelog (#13005) --- src/main/resources/db/changelog/db.changelog-v9.0.0.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/db/changelog/db.changelog-v9.0.0.xml b/src/main/resources/db/changelog/db.changelog-v9.0.0.xml index 205645520ff..94c02f08ed6 100644 --- a/src/main/resources/db/changelog/db.changelog-v9.0.0.xml +++ b/src/main/resources/db/changelog/db.changelog-v9.0.0.xml @@ -533,4 +533,7 @@ initiallyDeferred="false" onDelete="NO ACTION" onUpdate="NO ACTION" referencedColumnNames="id" referencedTableName="feedback_questions" validate="true" /> + + + From 6de9607e48ecd10b38c87d1b1f42aaa2379d9440 Mon Sep 17 00:00:00 2001 From: DS Date: Thu, 11 Apr 2024 19:40:57 +0800 Subject: [PATCH 44/95] [#11878] Update DeleteAccountRequest to reference by ID (#12997) * Update to delete by id * fix lint * fix lint * fix frontend lint --- .../java/teammates/e2e/cases/AdminHomePageE2ETest.java | 3 ++- .../java/teammates/e2e/cases/AdminSearchPageE2ETest.java | 2 +- .../it/sqllogic/core/AccountRequestsLogicIT.java | 4 ++-- .../it/ui/webapi/CreateAccountRequestActionIT.java | 2 +- .../it/ui/webapi/UpdateAccountRequestActionIT.java | 2 +- src/main/java/teammates/sqllogic/api/Logic.java | 6 +++--- .../teammates/sqllogic/core/AccountRequestsLogic.java | 8 ++++---- .../java/teammates/sqllogic/core/DataBundleLogic.java | 2 +- .../teammates/ui/webapi/DeleteAccountRequestAction.java | 9 +++++---- src/test/java/teammates/test/AbstractBackDoor.java | 5 ++--- .../account-request-table.component.ts | 2 +- src/web/services/account.service.spec.ts | 5 ++--- src/web/services/account.service.ts | 5 ++--- 13 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java b/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java index 56953689329..820acf5bb9b 100644 --- a/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java +++ b/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java @@ -48,7 +48,8 @@ public void testAll() { "\"invalidemail\" is not acceptable to TEAMMATES as a/an email because it is not in the correct format.")); assertNotNull(BACKDOOR.getAccountRequest(email, institute)); - BACKDOOR.deleteAccountRequest(email, institute); + // TODO: delete account request after get + // BACKDOOR.deleteAccountRequest(email, institute); } } diff --git a/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java b/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java index b5ce80693f0..09071e74770 100644 --- a/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java +++ b/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java @@ -193,7 +193,7 @@ private String getExpectedInstructorManageAccountLink(InstructorAttributes instr @AfterClass public void classTeardown() { for (AccountRequest request : sqlTestData.accountRequests.values()) { - BACKDOOR.deleteAccountRequest(request.getEmail(), request.getInstitute()); + BACKDOOR.deleteAccountRequest(request.getId()); } } diff --git a/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java b/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java index 566c967a7e7..4156e4f1ca9 100644 --- a/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java +++ b/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java @@ -67,9 +67,9 @@ public void testResetAccountRequest() ______TS("success: test delete account request"); - accountRequestsLogic.deleteAccountRequest(email, institute); + accountRequestsLogic.deleteAccountRequest(toReset.getId()); - assertNull(accountRequestsLogic.getAccountRequest(email, institute)); + assertNull(accountRequestsLogic.getAccountRequest(toReset.getId())); ______TS("failure: reset account request that does not exist"); diff --git a/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java index 85449452b5f..a4a680c4839 100644 --- a/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/CreateAccountRequestActionIT.java @@ -230,7 +230,7 @@ protected void tearDown() { HibernateUtil.beginTransaction(); List accountRequests = logic.getPendingAccountRequests(); for (AccountRequest ar : accountRequests) { - logic.deleteAccountRequest(ar.getEmail(), ar.getInstitute()); + logic.deleteAccountRequest(ar.getId()); } accountRequests = logic.getPendingAccountRequests(); HibernateUtil.commitTransaction(); diff --git a/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java index 45ad945c673..3d038daa8a3 100644 --- a/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java @@ -227,7 +227,7 @@ protected void tearDown() { HibernateUtil.beginTransaction(); List accountRequests = logic.getAllAccountRequests(); for (AccountRequest ar : accountRequests) { - logic.deleteAccountRequest(ar.getEmail(), ar.getInstitute()); + logic.deleteAccountRequest(ar.getId()); } HibernateUtil.commitTransaction(); } diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index c840ef2834c..72712057143 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -177,7 +177,7 @@ public AccountRequest resetAccountRequest(String email, String institute) } /** - * Deletes account request by email and institute. + * Deletes account request by id. * *
      *
    • Fails silently if no such account request.
    • @@ -186,8 +186,8 @@ public AccountRequest resetAccountRequest(String email, String institute) *

      Preconditions:

      * All parameters are non-null. */ - public void deleteAccountRequest(String email, String institute) { - accountRequestLogic.deleteAccountRequest(email, institute); + public void deleteAccountRequest(UUID id) { + accountRequestLogic.deleteAccountRequest(id); } /** diff --git a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java index 31956cd0101..cff14372bf1 100644 --- a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java +++ b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java @@ -159,13 +159,13 @@ public AccountRequest resetAccountRequest(String email, String institute) } /** - * Deletes account request associated with the {@code email} and {@code institute}. + * Deletes account request associated with the {@code id}. * - *

      Fails silently if no account requests with the given email and institute to delete can be found.

      + *

      Fails silently if no account requests with the given id to delete can be found.

      * */ - public void deleteAccountRequest(String email, String institute) { - AccountRequest toDelete = accountRequestDb.getAccountRequest(email, institute); + public void deleteAccountRequest(UUID id) { + AccountRequest toDelete = accountRequestDb.getAccountRequest(id); accountRequestDb.deleteAccountRequest(toDelete); } diff --git a/src/main/java/teammates/sqllogic/core/DataBundleLogic.java b/src/main/java/teammates/sqllogic/core/DataBundleLogic.java index 8ab165d31b7..0c7c9f66311 100644 --- a/src/main/java/teammates/sqllogic/core/DataBundleLogic.java +++ b/src/main/java/teammates/sqllogic/core/DataBundleLogic.java @@ -351,7 +351,7 @@ public void removeDataBundle(SqlDataBundle dataBundle) throws InvalidParametersE accountsLogic.deleteAccount(account.getGoogleId()); }); dataBundle.accountRequests.values().forEach(accountRequest -> { - accountRequestsLogic.deleteAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute()); + accountRequestsLogic.deleteAccountRequest(accountRequest.getId()); }); } diff --git a/src/main/java/teammates/ui/webapi/DeleteAccountRequestAction.java b/src/main/java/teammates/ui/webapi/DeleteAccountRequestAction.java index fa12bc67d81..ad157b1e5a3 100644 --- a/src/main/java/teammates/ui/webapi/DeleteAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/DeleteAccountRequestAction.java @@ -1,5 +1,7 @@ package teammates.ui.webapi; +import java.util.UUID; + import teammates.common.util.Const; import teammates.storage.sqlentity.AccountRequest; @@ -10,17 +12,16 @@ class DeleteAccountRequestAction extends AdminOnlyAction { @Override public JsonResult execute() throws InvalidOperationException { - String email = getNonNullRequestParamValue(Const.ParamsNames.INSTRUCTOR_EMAIL); - String institute = getNonNullRequestParamValue(Const.ParamsNames.INSTRUCTOR_INSTITUTION); + UUID id = getUuidRequestParamValue(Const.ParamsNames.ACCOUNT_REQUEST_ID); - AccountRequest toDelete = sqlLogic.getAccountRequest(email, institute); + AccountRequest toDelete = sqlLogic.getAccountRequest(id); if (toDelete != null && toDelete.getRegisteredAt() != null) { // instructor is already registered and cannot be deleted throw new InvalidOperationException("Account request of a registered instructor cannot be deleted."); } - sqlLogic.deleteAccountRequest(email, institute); + sqlLogic.deleteAccountRequest(id); return new JsonResult("Account request successfully deleted."); } diff --git a/src/test/java/teammates/test/AbstractBackDoor.java b/src/test/java/teammates/test/AbstractBackDoor.java index 16c62c5e3ed..99c1d3dc1a2 100644 --- a/src/test/java/teammates/test/AbstractBackDoor.java +++ b/src/test/java/teammates/test/AbstractBackDoor.java @@ -868,10 +868,9 @@ public String getRegKeyForAccountRequest(String email, String institute) { /** * Deletes an account request from the database. */ - public void deleteAccountRequest(String email, String institute) { + public void deleteAccountRequest(UUID id) { Map params = new HashMap<>(); - params.put(Const.ParamsNames.INSTRUCTOR_EMAIL, email); - params.put(Const.ParamsNames.INSTRUCTOR_INSTITUTION, institute); + params.put(Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()); executeDeleteRequest(Const.ResourceURIs.ACCOUNT_REQUEST, params); } diff --git a/src/web/app/components/account-requests-table/account-request-table.component.ts b/src/web/app/components/account-requests-table/account-request-table.component.ts index a9177960752..eab1fc95d7a 100755 --- a/src/web/app/components/account-requests-table/account-request-table.component.ts +++ b/src/web/app/components/account-requests-table/account-request-table.component.ts @@ -129,7 +129,7 @@ export class AccountRequestTableComponent { `Delete account request for ${accountRequest.name}?`, SimpleModalType.DANGER, modalContent); modalRef.result.then(() => { - this.accountService.deleteAccountRequest(accountRequest.email, accountRequest.instituteAndCountry) + this.accountService.deleteAccountRequest(accountRequest.id) .subscribe({ next: (resp: MessageOutput) => { this.statusMessageService.showSuccessToast(resp.message); diff --git a/src/web/services/account.service.spec.ts b/src/web/services/account.service.spec.ts index 82a2e6a5985..d463b982020 100644 --- a/src/web/services/account.service.spec.ts +++ b/src/web/services/account.service.spec.ts @@ -84,10 +84,9 @@ describe('AccountService', () => { }); it('should execute DELETE on account request endpoint', () => { - service.deleteAccountRequest('testEmail', 'testInstitution'); + service.deleteAccountRequest('testId'); const paramMap: Record = { - instructoremail: 'testEmail', - instructorinstitution: 'testInstitution', + id: 'testId', }; expect(spyHttpRequestService.delete).toHaveBeenCalledWith(ResourceEndpoints.ACCOUNT_REQUEST, paramMap); }); diff --git a/src/web/services/account.service.ts b/src/web/services/account.service.ts index 6f7a1f8bc5f..a99397a1a64 100644 --- a/src/web/services/account.service.ts +++ b/src/web/services/account.service.ts @@ -58,10 +58,9 @@ export class AccountService { /** * Deletes an account request by calling API. */ - deleteAccountRequest(email: string, institute: string): Observable { + deleteAccountRequest(id: string): Observable { const paramMap: Record = { - instructoremail: email, - instructorinstitution: institute, + id, }; return this.httpRequestService.delete(ResourceEndpoints.ACCOUNT_REQUEST, paramMap); } From e0beb08e5c1ef5e726e6f21ba87a2a7213290d26 Mon Sep 17 00:00:00 2001 From: DS Date: Thu, 11 Apr 2024 19:47:06 +0800 Subject: [PATCH 45/95] [#11878] Update ResetAccountRequest to reference by ID (#13002) * Update reset to reference by id * fix comments --- .../it/sqllogic/core/AccountRequestsLogicIT.java | 5 +++-- src/main/java/teammates/sqllogic/api/Logic.java | 8 ++++---- .../sqllogic/core/AccountRequestsLogic.java | 8 ++++---- .../ui/webapi/ResetAccountRequestAction.java | 12 ++++++------ .../account-request-table.component.ts | 2 +- src/web/services/account.service.spec.ts | 5 ++--- src/web/services/account.service.ts | 5 ++--- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java b/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java index 4156e4f1ca9..bedaec02f36 100644 --- a/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java +++ b/src/it/java/teammates/it/sqllogic/core/AccountRequestsLogicIT.java @@ -55,13 +55,14 @@ public void testResetAccountRequest() toReset.setRegisteredAt(Instant.now()); toReset = accountRequestsDb.getAccountRequest(email, institute); + UUID id = toReset.getId(); assertNotNull(toReset); assertNotNull(toReset.getRegisteredAt()); ______TS("success: reset account request that already exists"); - AccountRequest resetted = accountRequestsLogic.resetAccountRequest(email, institute); + AccountRequest resetted = accountRequestsLogic.resetAccountRequest(id); assertNull(resetted.getRegisteredAt()); @@ -74,6 +75,6 @@ public void testResetAccountRequest() ______TS("failure: reset account request that does not exist"); assertThrows(EntityDoesNotExistException.class, - () -> accountRequestsLogic.resetAccountRequest(name, institute)); + () -> accountRequestsLogic.resetAccountRequest(id)); } } diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index 72712057143..596f0566e4d 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -165,15 +165,15 @@ public AccountRequest updateAccountRequestWithTransaction(AccountRequest account } /** - * Creates/Resets the account request with the given email and institute + * Creates/Resets the account request with the given id * such that it is not registered. * * @return account request that is unregistered with the - * email and institute. + * id. */ - public AccountRequest resetAccountRequest(String email, String institute) + public AccountRequest resetAccountRequest(UUID id) throws EntityDoesNotExistException, InvalidParametersException { - return accountRequestLogic.resetAccountRequest(email, institute); + return accountRequestLogic.resetAccountRequest(id); } /** diff --git a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java index cff14372bf1..fcde8f217f0 100644 --- a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java +++ b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java @@ -143,15 +143,15 @@ public List getAllAccountRequests() { } /** - * Creates/resets the account request with the given email and institute such that it is not registered. + * Creates/resets the account request with the given id such that it is not registered. */ - public AccountRequest resetAccountRequest(String email, String institute) + public AccountRequest resetAccountRequest(UUID id) throws EntityDoesNotExistException, InvalidParametersException { - AccountRequest accountRequest = accountRequestDb.getAccountRequest(email, institute); + AccountRequest accountRequest = accountRequestDb.getAccountRequest(id); if (accountRequest == null) { throw new EntityDoesNotExistException("Failed to reset since AccountRequest with " - + "the given email and institute cannot be found."); + + "the given id cannot be found."); } accountRequest.setRegisteredAt(null); diff --git a/src/main/java/teammates/ui/webapi/ResetAccountRequestAction.java b/src/main/java/teammates/ui/webapi/ResetAccountRequestAction.java index 7fcd3a40c6b..f0ff7ff86b3 100644 --- a/src/main/java/teammates/ui/webapi/ResetAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/ResetAccountRequestAction.java @@ -1,5 +1,7 @@ package teammates.ui.webapi; +import java.util.UUID; + import org.apache.http.HttpStatus; import teammates.common.exception.EntityDoesNotExistException; @@ -19,21 +21,19 @@ class ResetAccountRequestAction extends AdminOnlyAction { @Override public JsonResult execute() throws InvalidOperationException { - String instructorEmail = getNonNullRequestParamValue(Const.ParamsNames.INSTRUCTOR_EMAIL); - String institute = getNonNullRequestParamValue(Const.ParamsNames.INSTRUCTOR_INSTITUTION); + UUID id = getUuidRequestParamValue(Const.ParamsNames.ACCOUNT_REQUEST_ID); - AccountRequest accountRequest = sqlLogic.getAccountRequest(instructorEmail, institute); + AccountRequest accountRequest = sqlLogic.getAccountRequest(id); if (accountRequest == null) { - throw new EntityNotFoundException("Account request for instructor with email: " + instructorEmail - + " and institute: " + institute + " does not exist."); + throw new EntityNotFoundException("Account request with id: " + id.toString() + " does not exist."); } if (accountRequest.getRegisteredAt() == null) { throw new InvalidOperationException("Unable to reset account request as instructor is still unregistered."); } try { - accountRequest = sqlLogic.resetAccountRequest(instructorEmail, institute); + accountRequest = sqlLogic.resetAccountRequest(id); } catch (InvalidParametersException | EntityDoesNotExistException ue) { // InvalidParametersException and EntityDoesNotExistException should not be thrown as // validity of params has been verified when fetching entity. diff --git a/src/web/app/components/account-requests-table/account-request-table.component.ts b/src/web/app/components/account-requests-table/account-request-table.component.ts index eab1fc95d7a..54a22cffeb9 100755 --- a/src/web/app/components/account-requests-table/account-request-table.component.ts +++ b/src/web/app/components/account-requests-table/account-request-table.component.ts @@ -107,7 +107,7 @@ export class AccountRequestTableComponent { `Reset account request for ${accountRequest.name}?`, SimpleModalType.WARNING, modalContent); modalRef.result.then(() => { - this.accountService.resetAccountRequest(accountRequest.email, accountRequest.instituteAndCountry) + this.accountService.resetAccountRequest(accountRequest.id) .subscribe({ next: () => { this.statusMessageService diff --git a/src/web/services/account.service.spec.ts b/src/web/services/account.service.spec.ts index d463b982020..56331550911 100644 --- a/src/web/services/account.service.spec.ts +++ b/src/web/services/account.service.spec.ts @@ -92,10 +92,9 @@ describe('AccountService', () => { }); it('should execute PUT on account request reset endpoint', () => { - service.resetAccountRequest('testEmail', 'testInstitution'); + service.resetAccountRequest('testId'); const paramMap: Record = { - instructoremail: 'testEmail', - instructorinstitution: 'testInstitution', + id: 'testId', }; expect(spyHttpRequestService.put).toHaveBeenCalledWith(ResourceEndpoints.ACCOUNT_REQUEST_RESET, paramMap); }); diff --git a/src/web/services/account.service.ts b/src/web/services/account.service.ts index a99397a1a64..6f2161935ef 100644 --- a/src/web/services/account.service.ts +++ b/src/web/services/account.service.ts @@ -68,10 +68,9 @@ export class AccountService { /** * Resets an account request by calling API. */ - resetAccountRequest(email: string, institute: string): Observable { + resetAccountRequest(id: string): Observable { const paramMap: Record = { - instructoremail: email, - instructorinstitution: institute, + id, }; return this.httpRequestService.put(ResourceEndpoints.ACCOUNT_REQUEST_RESET, paramMap); } From ce75a0a56f69d64a0c4ebcf15cffa78c9ca7b182 Mon Sep 17 00:00:00 2001 From: domoberzin <74132255+domoberzin@users.noreply.github.com> Date: Thu, 11 Apr 2024 19:54:19 +0800 Subject: [PATCH 46/95] [#11878] Add Error Message for Approving Existing Account (#13004) * add error message for duplicate account request * add tests --- .../webapi/UpdateAccountRequestActionIT.java | 14 ++++++++ .../java/teammates/sqllogic/api/Logic.java | 19 +++++++++++ .../sqllogic/core/AccountsLogic.java | 33 +++++++++++++++++++ .../ui/webapi/CreateAccountAction.java | 2 ++ .../ui/webapi/UpdateAccountRequestAction.java | 7 ++++ 5 files changed, 75 insertions(+) diff --git a/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java index 3d038daa8a3..65045f269d2 100644 --- a/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java @@ -14,12 +14,14 @@ import teammates.common.util.FieldValidator; import teammates.common.util.HibernateUtil; import teammates.common.util.StringHelperExtension; +import teammates.storage.sqlentity.Account; import teammates.storage.sqlentity.AccountRequest; import teammates.ui.output.AccountRequestData; import teammates.ui.request.AccountRequestUpdateRequest; import teammates.ui.request.InvalidHttpRequestBodyException; import teammates.ui.webapi.EntityNotFoundException; import teammates.ui.webapi.InvalidHttpParameterException; +import teammates.ui.webapi.InvalidOperationException; import teammates.ui.webapi.JsonResult; import teammates.ui.webapi.UpdateAccountRequestAction; @@ -107,6 +109,18 @@ public void testExecute() throws Exception { assertEquals(comments, data.getComments()); verifyNumberOfEmailsSent(0); + ______TS("email with existing account throws exception"); + Account account = logic.createAccountWithTransaction(getTypicalAccount()); + accountRequest = logic.createAccountRequestWithTransaction("name", account.getEmail(), + "institute", AccountRequestStatus.PENDING, "comments"); + requestBody = new AccountRequestUpdateRequest(name, email, institute, AccountRequestStatus.APPROVED, comments); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, accountRequest.getId().toString()}; + + InvalidOperationException ipe = verifyInvalidOperation(requestBody, params); + + assertEquals(String.format("An account with email %s already exists. " + + "Please reject or delete the account request instead.", account.getEmail()), ipe.getMessage()); + ______TS("non-existent but valid uuid"); requestBody = new AccountRequestUpdateRequest("name", "email", "institute", AccountRequestStatus.PENDING, "comments"); diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index 596f0566e4d..67bdc5ec5fa 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -225,6 +225,13 @@ public List getAccountsForEmail(String email) { return accountsLogic.getAccountsForEmail(email); } + /** + * Get a list of accounts associated with email provided. + */ + public List getAccountsForEmailWithTransaction(String email) { + return accountsLogic.getAccountsForEmailWithTransaction(email); + } + /** * Creates an account. * @@ -237,6 +244,18 @@ public Account createAccount(Account account) return accountsLogic.createAccount(account); } + /** + * Creates an account. + * + * @return the created account + * @throws InvalidParametersException if the account is not valid + * @throws EntityAlreadyExistsException if the account already exists in the database. + */ + public Account createAccountWithTransaction(Account account) + throws InvalidParametersException, EntityAlreadyExistsException { + return accountsLogic.createAccountWithTransaction(account); + } + /** * Deletes account by googleId. * diff --git a/src/main/java/teammates/sqllogic/core/AccountsLogic.java b/src/main/java/teammates/sqllogic/core/AccountsLogic.java index 74bc4af732b..49948080448 100644 --- a/src/main/java/teammates/sqllogic/core/AccountsLogic.java +++ b/src/main/java/teammates/sqllogic/core/AccountsLogic.java @@ -8,6 +8,7 @@ import teammates.common.exception.EntityAlreadyExistsException; import teammates.common.exception.EntityDoesNotExistException; import teammates.common.exception.InvalidParametersException; +import teammates.common.util.HibernateUtil; import teammates.storage.sqlapi.AccountsDb; import teammates.storage.sqlentity.Account; import teammates.storage.sqlentity.Course; @@ -77,6 +78,19 @@ public List getAccountsForEmail(String email) { return accountsDb.getAccountsByEmail(email); } + /** + * Gets accounts associated with email. + */ + public List getAccountsForEmailWithTransaction(String email) { + assert email != null; + + HibernateUtil.beginTransaction(); + List accounts = accountsDb.getAccountsByEmail(email); + HibernateUtil.commitTransaction(); + + return accounts; + } + /** * Creates an account. * @@ -91,6 +105,25 @@ public Account createAccount(Account account) return accountsDb.createAccount(account); } + /** + * Creates an account. + * + * @return the created account + * @throws InvalidParametersException if the account is not valid + * @throws EntityAlreadyExistsException if the account already exists in the + * database. + */ + public Account createAccountWithTransaction(Account account) + throws InvalidParametersException, EntityAlreadyExistsException { + assert account != null; + + HibernateUtil.beginTransaction(); + Account createdAccount = accountsDb.createAccount(account); + HibernateUtil.commitTransaction(); + + return createdAccount; + } + /** * Deletes account associated with the {@code googleId}. * diff --git a/src/main/java/teammates/ui/webapi/CreateAccountAction.java b/src/main/java/teammates/ui/webapi/CreateAccountAction.java index c536a09cc9c..2fe24d25dd1 100644 --- a/src/main/java/teammates/ui/webapi/CreateAccountAction.java +++ b/src/main/java/teammates/ui/webapi/CreateAccountAction.java @@ -9,6 +9,7 @@ import org.apache.http.HttpStatus; +import teammates.common.datatransfer.AccountRequestStatus; import teammates.common.datatransfer.DataBundle; import teammates.common.datatransfer.attributes.InstructorAttributes; import teammates.common.datatransfer.attributes.StudentAttributes; @@ -113,6 +114,7 @@ private AccountRequest setAccountRequestAsRegistered(AccountRequest accountReque throws InvalidParametersException, EntityDoesNotExistException { accountRequest.setEmail(instructorEmail); accountRequest.setInstitute(instructorInstitution); + accountRequest.setStatus(AccountRequestStatus.REGISTERED); accountRequest.setRegisteredAt(Instant.now()); sqlLogic.updateAccountRequest(accountRequest); diff --git a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java index c98d925d619..da579026897 100644 --- a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java @@ -40,6 +40,13 @@ public JsonResult execute() throws InvalidOperationException, InvalidHttpRequest if (accountRequestUpdateRequest.getStatus() == AccountRequestStatus.APPROVED && (accountRequest.getStatus() == AccountRequestStatus.PENDING || accountRequest.getStatus() == AccountRequestStatus.REJECTED)) { + + if (sqlLogic.getAccountsForEmailWithTransaction(accountRequest.getEmail()).size() > 0) { + throw new InvalidOperationException(String.format("An account with email %s already exists. " + + "Please reject or delete the account request instead.", + accountRequest.getEmail())); + } + try { // should not need to update other fields for an approval accountRequest.setStatus(accountRequestUpdateRequest.getStatus()); From fb7c4e874e555c7aa51949f9ae73ddeea3b733a2 Mon Sep 17 00:00:00 2001 From: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> Date: Thu, 11 Apr 2024 22:38:51 +0800 Subject: [PATCH 47/95] sort courses by id before comparison (#13003) Co-authored-by: Dominic Lim <46486515+domlimm@users.noreply.github.com> --- src/it/java/teammates/it/ui/webapi/GetCoursesActionIT.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/it/java/teammates/it/ui/webapi/GetCoursesActionIT.java b/src/it/java/teammates/it/ui/webapi/GetCoursesActionIT.java index 408821c38b7..b10149b70b0 100644 --- a/src/it/java/teammates/it/ui/webapi/GetCoursesActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/GetCoursesActionIT.java @@ -122,6 +122,7 @@ public void testGetCoursesAction_withStudentEntityType_shouldReturnCorrectCourse loginAsStudent(student.getGoogleId()); CoursesData courses = getValidCourses(params); + courses.getCourses().sort((c1, c2) -> c1.getCourseId().compareTo(c2.getCourseId())); assertEquals(3, courses.getCourses().size()); Course expectedCourse1 = typicalBundle.courses.get("typicalCourse1"); Course expectedCourse2 = typicalBundle.courses.get("typicalCourse2"); From 41524a54aa51a59a952b74bebc3792bc524e9bcc Mon Sep 17 00:00:00 2001 From: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> Date: Fri, 12 Apr 2024 20:40:03 +0800 Subject: [PATCH 48/95] [#11878] Get account request by uuid (#13007) * change GetAccountRequestAction to get by id * fix tests * remove unncessary todo --- .../java/teammates/e2e/cases/AdminHomePageE2ETest.java | 4 ---- .../teammates/e2e/cases/AdminSearchPageE2ETest.java | 4 ++-- src/e2e/java/teammates/e2e/cases/BaseE2ETestCase.java | 3 ++- .../InstructorCourseJoinConfirmationPageE2ETest.java | 2 +- .../it/ui/webapi/RejectAccountRequestActionIT.java | 2 +- .../teammates/ui/webapi/GetAccountRequestAction.java | 10 +++++----- src/test/java/teammates/test/AbstractBackDoor.java | 10 ++++------ 7 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java b/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java index 820acf5bb9b..014913e639c 100644 --- a/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java +++ b/src/e2e/java/teammates/e2e/cases/AdminHomePageE2ETest.java @@ -46,10 +46,6 @@ public void testAll() { String failureMessage = homePage.getMessageForInstructor(1); assertTrue(failureMessage.contains( "\"invalidemail\" is not acceptable to TEAMMATES as a/an email because it is not in the correct format.")); - - assertNotNull(BACKDOOR.getAccountRequest(email, institute)); - // TODO: delete account request after get - // BACKDOOR.deleteAccountRequest(email, institute); } } diff --git a/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java b/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java index 09071e74770..3836a7b1eef 100644 --- a/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java +++ b/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java @@ -132,7 +132,7 @@ public void testAll() { searchPage.inputSearchContent(searchContent); searchPage.clickSearchButton(); searchPage.clickResetAccountRequestButton(accountRequest); - assertNull(BACKDOOR.getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute()).getRegisteredAt()); + assertNull(BACKDOOR.getAccountRequest(accountRequest.getId()).getRegisteredAt()); ______TS("Typical case: Delete account request successful"); accountRequest = sqlTestData.accountRequests.get("unregisteredInstructor1"); @@ -141,7 +141,7 @@ public void testAll() { searchPage.inputSearchContent(searchContent); searchPage.clickSearchButton(); searchPage.clickDeleteAccountRequestButton(accountRequest); - assertNull(BACKDOOR.getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute())); + assertNull(BACKDOOR.getAccountRequest(accountRequest.getId())); } private String getExpectedStudentDetails(StudentAttributes student) { diff --git a/src/e2e/java/teammates/e2e/cases/BaseE2ETestCase.java b/src/e2e/java/teammates/e2e/cases/BaseE2ETestCase.java index 3ada841ac59..7b953f7a50a 100644 --- a/src/e2e/java/teammates/e2e/cases/BaseE2ETestCase.java +++ b/src/e2e/java/teammates/e2e/cases/BaseE2ETestCase.java @@ -4,6 +4,7 @@ import java.nio.file.Files; import java.nio.file.Paths; import java.util.List; +import java.util.UUID; import org.testng.ITestContext; import org.testng.annotations.AfterClass; @@ -329,7 +330,7 @@ protected String getKeyForStudent(StudentAttributes student) { @Override protected AccountRequestAttributes getAccountRequest(AccountRequestAttributes accountRequest) { - return BACKDOOR.getAccountRequest(accountRequest.getEmail(), accountRequest.getInstitute()); + return BACKDOOR.getAccountRequest(UUID.fromString(accountRequest.getId())); } NotificationAttributes getNotification(String notificationId) { diff --git a/src/e2e/java/teammates/e2e/cases/InstructorCourseJoinConfirmationPageE2ETest.java b/src/e2e/java/teammates/e2e/cases/InstructorCourseJoinConfirmationPageE2ETest.java index d3dffb01c9f..421144a2da4 100644 --- a/src/e2e/java/teammates/e2e/cases/InstructorCourseJoinConfirmationPageE2ETest.java +++ b/src/e2e/java/teammates/e2e/cases/InstructorCourseJoinConfirmationPageE2ETest.java @@ -68,7 +68,7 @@ public void testAll() { ______TS("Click join link: valid account request key"); String regKey = BACKDOOR - .getRegKeyForAccountRequest("ICJoinConf.newinstr@gmail.tmt", "TEAMMATES Test Institute 1"); + .getRegKeyForAccountRequest(sqlTestData.accountRequests.get("ICJoinConf.newinstr").getId()); joinLink = createFrontendUrl(Const.WebPageURIs.JOIN_PAGE) .withIsCreatingAccount("true") diff --git a/src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java index 469804b9921..78f25dd89e8 100644 --- a/src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/RejectAccountRequestActionIT.java @@ -222,7 +222,7 @@ protected void tearDown() { HibernateUtil.beginTransaction(); List accountRequests = logic.getAllAccountRequests(); for (AccountRequest ar : accountRequests) { - logic.deleteAccountRequest(ar.getEmail(), ar.getInstitute()); + logic.deleteAccountRequest(ar.getId()); } HibernateUtil.commitTransaction(); } diff --git a/src/main/java/teammates/ui/webapi/GetAccountRequestAction.java b/src/main/java/teammates/ui/webapi/GetAccountRequestAction.java index a3f3b5195a4..2894d06b254 100644 --- a/src/main/java/teammates/ui/webapi/GetAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/GetAccountRequestAction.java @@ -1,5 +1,7 @@ package teammates.ui.webapi; +import java.util.UUID; + import teammates.common.util.Const; import teammates.storage.sqlentity.AccountRequest; import teammates.ui.output.AccountRequestData; @@ -11,14 +13,12 @@ class GetAccountRequestAction extends AdminOnlyAction { @Override public JsonResult execute() { - String email = getNonNullRequestParamValue(Const.ParamsNames.INSTRUCTOR_EMAIL); - String institute = getNonNullRequestParamValue(Const.ParamsNames.INSTRUCTOR_INSTITUTION); + UUID id = getUuidRequestParamValue(Const.ParamsNames.ACCOUNT_REQUEST_ID); - AccountRequest accountRequest = sqlLogic.getAccountRequest(email, institute); + AccountRequest accountRequest = sqlLogic.getAccountRequest(id); if (accountRequest == null) { - throw new EntityNotFoundException("Account request for email: " - + email + " and institute: " + institute + " not found."); + throw new EntityNotFoundException("Account request with id: " + id.toString() + " does not exist."); } AccountRequestData output = new AccountRequestData(accountRequest); diff --git a/src/test/java/teammates/test/AbstractBackDoor.java b/src/test/java/teammates/test/AbstractBackDoor.java index 99c1d3dc1a2..48fb1eec380 100644 --- a/src/test/java/teammates/test/AbstractBackDoor.java +++ b/src/test/java/teammates/test/AbstractBackDoor.java @@ -832,10 +832,9 @@ public void deleteCourse(String courseId) { /** * Gets an account request from the database. */ - public AccountRequestAttributes getAccountRequest(String email, String institute) { + public AccountRequestAttributes getAccountRequest(UUID id) { Map params = new HashMap<>(); - params.put(Const.ParamsNames.INSTRUCTOR_EMAIL, email); - params.put(Const.ParamsNames.INSTRUCTOR_INSTITUTION, institute); + params.put(Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()); ResponseBodyAndCode response = executeGetRequest(Const.ResourceURIs.ACCOUNT_REQUEST, params); if (response.responseCode == HttpStatus.SC_NOT_FOUND) { @@ -852,10 +851,9 @@ public AccountRequestAttributes getAccountRequest(String email, String institute /** * Gets registration key of an account request from the database. */ - public String getRegKeyForAccountRequest(String email, String institute) { + public String getRegKeyForAccountRequest(UUID id) { Map params = new HashMap<>(); - params.put(Const.ParamsNames.INSTRUCTOR_EMAIL, email); - params.put(Const.ParamsNames.INSTRUCTOR_INSTITUTION, institute); + params.put(Const.ParamsNames.ACCOUNT_REQUEST_ID, id.toString()); ResponseBodyAndCode response = executeGetRequest(Const.ResourceURIs.ACCOUNT_REQUEST, params); if (response.responseCode == HttpStatus.SC_NOT_FOUND) { From 451a25a53c859e2d5382842450158ea641ec92f7 Mon Sep 17 00:00:00 2001 From: domoberzin <74132255+domoberzin@users.noreply.github.com> Date: Fri, 12 Apr 2024 21:34:07 +0800 Subject: [PATCH 49/95] [#11878] Handle Duplicate Approved Account Requests (#13009) --- .../it/ui/webapi/UpdateAccountRequestActionIT.java | 14 ++++++++++++++ src/main/java/teammates/sqllogic/api/Logic.java | 7 +++++++ .../sqllogic/core/AccountRequestsLogic.java | 10 ++++++++++ .../storage/sqlapi/AccountRequestsDb.java | 14 ++++++++++++++ .../ui/webapi/UpdateAccountRequestAction.java | 7 +++++++ 5 files changed, 52 insertions(+) diff --git a/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java b/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java index 65045f269d2..f5932deaf99 100644 --- a/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/UpdateAccountRequestActionIT.java @@ -227,6 +227,20 @@ public void testExecute() throws Exception { assertEquals(email, data.getEmail()); assertEquals(institute, data.getInstitute()); assertEquals(null, data.getComments()); + + ______TS("email with approved account request throws exception"); + logic.createAccountRequestWithTransaction("test", "test@email.com", + "institute", AccountRequestStatus.APPROVED, "comments"); + accountRequest = logic.createAccountRequestWithTransaction("test", "test@email.com", + "institute", AccountRequestStatus.PENDING, "comments"); + requestBody = new AccountRequestUpdateRequest(accountRequest.getName(), accountRequest.getEmail(), + accountRequest.getInstitute(), AccountRequestStatus.APPROVED, comments); + params = new String[] {Const.ParamsNames.ACCOUNT_REQUEST_ID, accountRequest.getId().toString()}; + + ipe = verifyInvalidOperation(requestBody, params); + + assertEquals(String.format("An account request with email %s has already been approved. " + + "Please reject or delete the account request instead.", accountRequest.getEmail()), ipe.getMessage()); } @Override diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java index 67bdc5ec5fa..53d90026646 100644 --- a/src/main/java/teammates/sqllogic/api/Logic.java +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -204,6 +204,13 @@ public List getAllAccountRequests() { return accountRequestLogic.getAllAccountRequests(); } + /** + * Get a list of account requests associated with email provided. + */ + public List getApprovedAccountRequestsForEmailWithTransaction(String email) { + return accountRequestLogic.getApprovedAccountRequestsForEmailWithTransaction(email); + } + /** * Gets an account. */ diff --git a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java index fcde8f217f0..2e5af513560 100644 --- a/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java +++ b/src/main/java/teammates/sqllogic/core/AccountRequestsLogic.java @@ -142,6 +142,16 @@ public List getAllAccountRequests() { return accountRequestDb.getAllAccountRequests(); } + /** + * Get a list of account requests associated with email provided. + */ + public List getApprovedAccountRequestsForEmailWithTransaction(String email) { + HibernateUtil.beginTransaction(); + List accountRequests = accountRequestDb.getApprovedAccountRequestsForEmail(email); + HibernateUtil.commitTransaction(); + return accountRequests; + } + /** * Creates/resets the account request with the given id such that it is not registered. */ diff --git a/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java b/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java index 92d61afb8eb..310b78e6239 100644 --- a/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java +++ b/src/main/java/teammates/storage/sqlapi/AccountRequestsDb.java @@ -103,6 +103,20 @@ public List getAllAccountRequests() { return query.getResultList(); } + /** + * Get all Account Requests for a given {@code email}. + */ + public List getApprovedAccountRequestsForEmail(String email) { + CriteriaBuilder cb = HibernateUtil.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(AccountRequest.class); + Root root = cr.from(AccountRequest.class); + cr.select(root).where(cb.and(cb.equal(root.get("email"), email), + cb.equal(root.get("status"), AccountRequestStatus.APPROVED))); + + TypedQuery query = HibernateUtil.createQuery(cr); + return query.getResultList(); + } + /** * Get AccountRequest by {@code registrationKey} from database. */ diff --git a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java index da579026897..7699709c8d8 100644 --- a/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java +++ b/src/main/java/teammates/ui/webapi/UpdateAccountRequestAction.java @@ -47,6 +47,13 @@ public JsonResult execute() throws InvalidOperationException, InvalidHttpRequest accountRequest.getEmail())); } + if (sqlLogic.getApprovedAccountRequestsForEmailWithTransaction(accountRequest.getEmail()).size() > 0) { + throw new InvalidOperationException(String.format( + "An account request with email %s has already been approved. " + + "Please reject or delete the account request instead.", + accountRequest.getEmail())); + } + try { // should not need to update other fields for an approval accountRequest.setStatus(accountRequestUpdateRequest.getStatus()); From 2bd06e232bc3d6f84a1ac1b085cf0551efff3f00 Mon Sep 17 00:00:00 2001 From: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> Date: Sat, 13 Apr 2024 21:11:57 +0800 Subject: [PATCH 50/95] [#11878] Merge master into feature (#13011) * Update chrome driver download link in e2e-testing.md (#12924) * [#12048] Add SQL configuration into build.properties and build-dev.properties (#12917) * Add production config * Remove forgotten host and password * Fix lint --------- Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> * [#12048] Add SQL description for postgres config (#12931) * Add production config * Remove forgotten host and password * Fix lint * Address changes, include production_user * Linting * [#12588] Improve test code coverage of core components - ToastComponent (#12916) * add test cases * add test case for isTemplate() --------- Co-authored-by: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> Co-authored-by: Dominic Lim <46486515+domlimm@users.noreply.github.com> * [#12588] Add unit tests to question edit answer form (#12935) * add unit tests to constsum-options-question-edit-answer-form * add unit tests to constsum-options-question-edit-answer-form --------- Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> * add delay to task queuer for indexing account request (#12936) Co-authored-by: Nicolas <25302138+NicolasCwy@users.noreply.github.com> * Make account req data migration script rerunnable (#12932) * [#12048] Relax read notif verification for migration verification script (#12937) * Fix account requests with wrong field during seed * Relax account attributes verification * Fix lint errors * Fix order of account request variables * [#12920] Create script to migrate noSQL test data to SQL schema format (#12922) * Add classes to migrate test json data * Add toposort script * Add function to remove foreign key data * Cleanup * WIP * Simplify keys for students and instructors * Fix lint issues * Output SQL JSON in same folder as JSON * Change output file name * Fix bug: wrong jsonkey used * Fix lint error * Make section and team name unique * Set read notification key to be unique * Delete python file * [#12588] Improve test code coverage of core components - ViewResultsPanelComponent (#12918) * add test cases to ViewResultsPanelComponent * fix lint errors --------- Co-authored-by: Dominic Lim <46486515+domlimm@users.noreply.github.com> Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> * fix resetAccountAction (#12934) Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> * [#12048] Migrate Feedback Rank Option E2E test (#12902) * Initial commit * Fix lint * Follow convention and add test * Change file path * Fix requested changes * Fixed testcases * Fix lint * Add deepcopy * Fixed e2e test --------- Co-authored-by: Wei Qing <48304907+weiquu@users.noreply.github.com> Co-authored-by: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> * [#12048] Migrate FeedbackMcqQuestionE2ETest (#12820) * Migrate MCQ E2E * Fix lint * Fix lint * Update xml --------- Co-authored-by: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> * [#12048] Remove unnecessary loading of datastore entities in InstructorNotificationsPageE2ETest (#12911) * migrate instructor notif e2e --------- Co-authored-by: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> * [#12048] Migrate InstructorCourseDetailsPageE2ETest (#12908) * Add teammates.e2e.cases.sql.InstructorCourseDetailsPageE2ETest * Remove data properly to prevent clashes * Add SQL data bundle * Verify loaded details * Use email address when getting a student row * Check student links * Verify the sending of invites * Verify the reminding of all students to join * Remove SQL data properly to prevent clashes * Verify the downloading of the student list * Implement helper methods for Student * Add BaseTestCaseWithSqlDatabaseAccess::verifyAbsentInDatabase * Add to testng-e2e-sql.xml * Verify the deleting of students * Verify the deleting of all the students * Fix lint * Remove duplicate equality check for students * [#12588] add unit tests for question submission form (#12897) Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> * Update developers.json (#12958) * Merge pull request #12960 from TEAMMATES/master (#12961) * [#12048] Fix account request indexing (#12967) * Add isTransactionNeeded method to Action * Remove delay from taskqueuer * Change CreateAccountRequest to handle own transactions * configure agroal connection pool (#12971) * [#12048] Configure connection pool using hikari (#12978) * Configure hikari * Remove spacing * Lint * [#12048] Update liquibase configuration (#12930) * Update gradle config * Update liquibase config for v9 * Turn off table generate for prod * Update of changelog file * Add configuration for generating changelog * Add schema migration docs --------- Co-authored-by: FergusMok * [#12048] Migrate AccountRequestsLogicTest (#12780) * Migrate test cases for AccountRequestsLogic * Remove test case * Split test cases * [#12048] Migrate AdminSearchPageE2ETest SQL (#12811) * test e2e changes * fix: reduce e2e test json file size * fix student key * fix course key * fix instructor keys * fix filepath * fix e2e test * remove extra data from bundle * Add correct removal logic to avoid constraint violation * Fix e2e tests and lint fix reset google id test fix e2e tests fix e2e tests fix tests remove double click fix unknown symbol add toast check change toast verification message remove toast check * fix: add null check * move admin search page e2e test to sql cases * Rename AdminSearchPageE2ETest_SQLEntities.json to AdminSearchPageE2ETest_SqlEntities.json * fix failing test * fix: remove extra null check * fix: add test to e2e sql xml file * fix function call * remove unnecessary changes * create new file for sql entities * revert unnecessary changes * remove trailing whitespace * add teardown for account requests --------- Co-authored-by: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> * [#12995] Create documentation for unit tests (#12996) * Create documentation for unit tests * Update docs/unit-testing.md Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> * Update docs/unit-testing.md Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> --------- Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> * [#12048] Remove feedbackSession attributes @fetch annotation (#12992) * Remove feedbackSession @fetch annotation * [#12048] create skeleton for sql LNP tests (#12994) * create skelton for sql LNP tests * allow lnp test to access sql storage and ensure sql lnp tests are independant of each other --------- Co-authored-by: Zhang Ziqing <69516975+ziqing26@users.noreply.github.com> * [#12048] Migrate FeedbackNumScaleQuestionE2ETest (#12940) * Migrate num scale e2e * Fix team id * Fix bugs * Add v9.0.0 tag to liquibase changelog (#13005) * sort courses by id before comparison (#13003) Co-authored-by: Dominic Lim <46486515+domlimm@users.noreply.github.com> --------- Co-authored-by: Nada Ayesh Co-authored-by: FergusMok Co-authored-by: Maureen Chang <76696006+techMedMau@users.noreply.github.com> Co-authored-by: Cedric Ong <67156011+cedricongjh@users.noreply.github.com> Co-authored-by: Dominic Lim <46486515+domlimm@users.noreply.github.com> Co-authored-by: Nicolas <25302138+NicolasCwy@users.noreply.github.com> Co-authored-by: Ching Ming Yuan Co-authored-by: Wei Qing <48304907+weiquu@users.noreply.github.com> Co-authored-by: DS Co-authored-by: Jay Aljelo Ting <65202977+jayasting98@users.noreply.github.com> Co-authored-by: Xenos F Co-authored-by: domoberzin <74132255+domoberzin@users.noreply.github.com> Co-authored-by: Marques Tye Jia Jun <97437396+marquestye@users.noreply.github.com> --- build.gradle | 25 +- docs/_markbind/layouts/default.md | 1 + docs/development.md | 6 +- docs/schema-migration.md | 34 ++ docs/unit-testing.md | 206 +++++++ .../DataStoreToSqlConverter.java | 9 +- .../e2e/cases/AdminSearchPageE2ETest.java | 2 +- .../e2e/cases/axe/AdminSearchPageAxeTest.java | 2 +- .../e2e/cases/sql/AdminSearchPageE2ETest.java | 186 ++++++ .../e2e/cases/sql/BaseE2ETestCase.java | 14 + .../sql/FeedbackNumScaleQuestionE2ETest.java | 124 ++++ .../e2e/pageobjects/AdminSearchPage.java | 246 +++++++- .../e2e/pageobjects/FeedbackSubmitPage.java | 13 + .../InstructorFeedbackEditPage.java | 10 + .../data/AdminSearchPageE2ESqlTest.json | 118 ++++ .../FeedbackNumScaleQuestionE2ESqlTest.json | 268 +++++++++ src/e2e/resources/testng-e2e-sql.xml | 2 + .../it/ui/webapi/GetCoursesActionIT.java | 1 + .../teammates/lnp/sql/BaseLNPTestCase.java | 361 ++++++++++++ .../sql/InstructorCourseUpdateLNPTest.java | 216 +++++++ .../java/teammates/lnp/sql/package-info.java | 4 + .../teammates/lnp/util/LNPSqlTestData.java | 92 +++ .../teammates/common/util/HibernateUtil.java | 15 +- .../sqllogic/api/SqlEmailGenerator.java | 8 +- .../storage/sqlentity/FeedbackSession.java | 4 - .../FeedbackRankOptionsQuestion.java | 3 +- .../db/changelog/db.changelog-root.xml | 2 +- .../db/changelog/db.changelog-v9.0.0.xml | 539 ++++++++++++++++++ .../db/changelog/db.changelog-v9.xml | 82 --- .../architecture/ArchitectureTest.java | 26 +- 30 files changed, 2505 insertions(+), 114 deletions(-) create mode 100644 docs/schema-migration.md create mode 100644 docs/unit-testing.md create mode 100644 src/e2e/java/teammates/e2e/cases/sql/AdminSearchPageE2ETest.java create mode 100644 src/e2e/java/teammates/e2e/cases/sql/FeedbackNumScaleQuestionE2ETest.java create mode 100644 src/e2e/resources/data/AdminSearchPageE2ESqlTest.json create mode 100644 src/e2e/resources/data/FeedbackNumScaleQuestionE2ESqlTest.json create mode 100644 src/lnp/java/teammates/lnp/sql/BaseLNPTestCase.java create mode 100644 src/lnp/java/teammates/lnp/sql/InstructorCourseUpdateLNPTest.java create mode 100644 src/lnp/java/teammates/lnp/sql/package-info.java create mode 100644 src/lnp/java/teammates/lnp/util/LNPSqlTestData.java create mode 100644 src/main/resources/db/changelog/db.changelog-v9.0.0.xml delete mode 100644 src/main/resources/db/changelog/db.changelog-v9.xml diff --git a/build.gradle b/build.gradle index 4fe78e4f12b..f448d003a8f 100644 --- a/build.gradle +++ b/build.gradle @@ -77,8 +77,7 @@ dependencies { implementation("org.jsoup:jsoup:1.15.2") implementation("org.hibernate.orm:hibernate-core:6.1.6.Final") implementation("org.postgresql:postgresql:42.7.2") - implementation("org.hibernate.orm:hibernate-agroal:6.1.6.Final") - implementation("io.agroal:agroal-pool:2.1") + implementation("org.hibernate:hibernate-hikaricp:6.1.6.Final") testAnnotationProcessor(testng) @@ -102,7 +101,9 @@ dependencies { exclude group: "org.apache.jmeter", module: "bom" } + liquibaseRuntime("org.liquibase:liquibase-core:4.19.0") liquibaseRuntime("info.picocli:picocli:4.7.1") + liquibaseRuntime("org.postgresql:postgresql:42.7.2") liquibaseRuntime(sourceSets.main.output) } @@ -137,6 +138,10 @@ sourceSets { } } +if (!project.hasProperty("runList")) { + project.ext.set("runList", "main") +} + liquibase { activities { main { @@ -146,7 +151,23 @@ liquibase { username project.properties['liquibaseUsername'] password project.properties['liquibasePassword'] } + snapshot { + url project.properties['liquibaseDbUrl'] + username project.properties['liquibaseUsername'] + password project.properties['liquibasePassword'] + snapshotFormat "json" + outputFile "liquibase-snapshot.json" + } + diffMain { + searchPath "${projectDir}" + changeLogFile "src/main/resources/db/changelog/db.changelog-new.xml" + referenceUrl project.properties['liquibaseDbUrl'] + referenceUsername project.properties['liquibaseUsername'] + referencePassword project.properties['liquibasePassword'] + url "offline:postgresql?snapshot=liquibase-snapshot.json" + } } + runList = project.ext.runList } tasks.withType(cz.habarta.typescript.generator.gradle.GenerateTask) { diff --git a/docs/_markbind/layouts/default.md b/docs/_markbind/layouts/default.md index f92791366a0..e57ee418a49 100644 --- a/docs/_markbind/layouts/default.md +++ b/docs/_markbind/layouts/default.md @@ -27,6 +27,7 @@ * [Captcha]({{ baseUrl }}/captcha.html) * [Documentation]({{ baseUrl }}/documentation.html) * [Emails]({{ baseUrl }}/emails.html) + * [Unit Testing]({{ baseUrl }}/unit-testing.html) * [End-to-End Testing]({{ baseUrl }}/e2e-testing.html) * [Performance Testing]({{ baseUrl }}/performance-testing.html) * [Accessibility Testing]({{ baseUrl }}/axe-testing.html) diff --git a/docs/development.md b/docs/development.md index 703a1ce57b0..aec8c3696ca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -291,7 +291,9 @@ There are two big categories of testing in TEAMMATES: - **Component tests**: white-box unit and integration tests, i.e. they test the application components with full knowledge of the components' internal workings. This is configured in `src/test/resources/testng-component.xml` (back-end) and `src/web/jest.config.js` (front-end). - **E2E (end-to-end) tests**: black-box tests, i.e. they test the application as a whole without knowing any internal working. This is configured in `src/e2e/resources/testng-e2e.xml`. To learn more about E2E tests, refer to this [document](e2e-testing.md). -#### Running the tests +
      + +#### Running tests ##### Frontend tests @@ -335,6 +337,8 @@ You can generate the coverage data with `jacocoReport` task after running tests, The report can be found in the `build/reports/jacoco/jacocoReport/` directory. +
      + ## Deploying to a staging server > `Staging server` is the server instance you set up on Google App Engine for hosting the app for testing purposes. diff --git a/docs/schema-migration.md b/docs/schema-migration.md new file mode 100644 index 00000000000..a88f49ece0f --- /dev/null +++ b/docs/schema-migration.md @@ -0,0 +1,34 @@ + + title: "Schema Migration" + + +# SQL Schema Migration + +Teammates uses _[Liquibase]_(https://docs.liquibase.com/start/home.html), a database schema change management solution that enables developers to revise and release database changes to production. The maintainers in charge of releases (Release Leader) will be in charge of generating a _Liquibase_ changelog prior to each release to keep the production databases schema in sync with the code. Therefore this section is just for documentation purposes for contributors. + +## Liquibase in Teammates +_Liquibase_ is made available using the [gradle plugin](https://github.com/liquibase/liquibase-gradle-plugin), providing _liquibase_ functions as tasks. Try `gradle tasks | grep "liquibase"` to see all the tasks available. In teammates, change logs (more in the next section) are written in _XML_. + +### Liquibase connection +Amend the `liquibaseDbUrl`, `liquibaseUsername` and `liquibasePassword` in `gradle.properties` to allow the _Liquibase_ plugin to connect your database. + +## Change logs, change sets and change types +A _change log_ is a file that contains a series of _change sets_ (analagous to a transaction) which applies _change types_ (actions). You can refer to this page on liquibase on the types of [change types](https://docs.liquibase.com/change-types/home.html) that can be used. + +## Gradle Activities for Liquibase +Activities in Gradle are a way of specifying different variables provided by gradle to the Liquibase plugin. The argument `runList` provided by `-pRunList=` e.g `./gradlew liquibaseSnapshot -PrunList=snapshot` is used to specify which activity to be used for the Liquibase command. In this case the `liquibaseSnapshot` command is run using the `snapshot` activity. + +Here is a brief description of the activities defined for Liquibase +1. Main: The default activity used by Liquibase commands and is used for running changelogs against a database. This is used by default if a `runList` is not defined +2. Snapshot: Used to specify output format and name for snapshots i.e JSON +3. diffMain: Specify the reference and the target database to generate changelog that contains operations to update reference database to the state of the target database. i.e the reference is the JSON file generated by the snapshot command, this can be replaced with a live database which is used as reference. + +## Generating/ Updating liquibase change logs +1. Ensure `diff-main` activity in `build.gradle` is pointing to the latest release changelog `src/main/resources/db/changelog/db.changelog-.xml` +2. Delete the `postgres-data` folder to clear any old database schemas +3. Run `git checkout ` and +4. Run the server using `./gradlew serverRun` to generate tables found on branch +5. Generate snapshot of database by running `./gradlew liquibaseSnapshot -PrunList=snapshot`, the snapshot will be output to `liquibase-snapshot.json` +6. Checkout your branch and repeat steps 2 and 4 to generate the tables found on your branch +7. Run `./gradlew liquibaseDiffChangeLog -PrunList=diffMain` to generate changeLog to resolve database schema differences + diff --git a/docs/unit-testing.md b/docs/unit-testing.md new file mode 100644 index 00000000000..fe4d2786c8e --- /dev/null +++ b/docs/unit-testing.md @@ -0,0 +1,206 @@ + + title: "Unit Testing" + + +# Unit Testing + +## What is Unit Testing? + +Unit testing is a testing methodology where the objective is to test components in isolation. + +- It aims to ensure all components of the application work as expected, assuming its dependencies are working. +- This is done in TEAMMATES by using mocks to simulate a component's dependencies. + +Frontend Unit tests in TEAMMATES are located in `.spec.ts` files, while Backend Unit tests in TEAMMATES can be found in the package `teammates.test`. + + +## Writing Unit Tests + +### General guidelines + +#### Include only relevant details in tests +When writing unit tests, reduce the amount of noise in the code to make it easier for future developers to follow. + +The code below has a lot of noise in creation of the `studentModel`: + +```javascript +it('displayInviteButton: should display "Send Invite" button when a student has not joined the course', () => { + component.studentModels = [ + { + student: { + name: 'tester', + teamName: 'Team 1', + email: 'tester@tester.com', + joinState: JoinState.NOT_JOINED, + sectionName: 'Tutorial Group 1', + courseId: 'text-exa.demo', + }, + isAllowedToViewStudentInSection: true, + isAllowedToModifyStudent: true, + }, + ]; + + expect(sendInviteButton).toBeTruthy(); +}); +``` + +However, what is important is only the student joinState. We should thus reduce the noise by including only the relevant details: + +```javascript +it('displayInviteButton: should display "Send Invite" button when a student has not joined the course', () => { + component.studentModels = [ + studentModelBuilder + .joinState(JoinState.NOT_JOINED) + .build() + ]; + + expect(sendInviteButton).toBeTruthy(); +}); +``` + +Including only the relevant details in tests makes it easier for future developers to read and understand the purpose of the test. + +#### Favor readability over uniqueness +Since tests don't have tests, it should be easy for developers to manually inspect them for correctness, even at the expense of greater code duplication. + +Take the following test for example: + +```java +@BeforeMethod +public void setUp() { + users = new User[]{new User("alice"), new User("bob")}; +} + +@Test +public void test_register_canRegisterMultipleUsers() { + registerAllUsers(); + for (User user : users) { + assertTrue(forum.hasRegisteredUser(user)); + } +} + +private void registerAllUsers() { + for (User user : users) { + forum.register(user); + } +} +``` + +While the code reduces duplication, it is not as straightforward for a developer to follow. + +A more readable way to write this test would be: +```java +@Test +public void test_register_canRegisterMultipleUsers() { + User user1 = new User("alice"); + User user2 = new User("bob"); + + forum.register(user1); + forum.register(user2); + + assertTrue(forum.hasRegisteredUser(user1)); + assertTrue(forum.hasRegisteredUser(user2)); +} +``` + +By choosing readability over uniqueness in writing unit tests, there is code duplication, but the test flow is easier for a reader to follow. + + +#### Inline mocks in test code + +Inlining mock return values in the unit test itself improves readability: + +```javascript +it('getStudentCourseJoinStatus: should return true if student has joined the course' , () => { + jest.spyOn(courseService, 'getJoinCourseStatus') + .mockReturnValue(of({ hasJoined: true })); + + expect(student.getJoinCourseStatus).toBeTruthy(); +}); +``` + +By injecting the values in the test right before they are used, developers are able to more easily trace the code and understand the test. + +### Frontend + +#### Naming +Unit tests for a function should follow the format: + +`": should ... when/if ..."` + +Example: + +```javascript + it('hasSection: should return false when there are no sections in the course') +``` + +#### Creating test data +To aid with [including only relevant details in tests](#include-only-relevant-details-in-tests), use the builder in `src/web/test-helpers/generic-builder.ts` + +Usage: +```javascript +const instructorModelBuilder = createBuilder({ + email: 'instructor@gmail.com', + name: 'Instructor', + hasSubmittedSession: false, + isSelected: false, +}); + +it('isAllInstructorsSelected: should return false if at least one instructor !isSelected', () => { +component.instructorListInfoTableRowModels = [ + instructorModelBuilder.isSelected(true).build(), + instructorModelBuilder.isSelected(false).build(), + instructorModelBuilder.isSelected(true).build(), +]; + +expect(component.isAllInstructorsSelected).toBeFalsy(); +}); + +``` + +#### Testing event emission +In Angular, child components emit events. To test for event emissions, we've provided a utility function in `src/test-helpers/test-event-emitter` + +Usage: +```javascript +@Output() +deleteCommentEvent: EventEmitter = new EventEmitter(); + +triggerDeleteCommentEvent(index: number): void { + this.deleteCommentEvent.emit(index); +} + +it('triggerDeleteCommentEvent: should emit the correct index to deleteCommentEvent', () => { + let emittedIndex: number | undefined; + testEventEmission(component.deleteCommentEvent, (index) => { emittedIndex = index; }); + + component.triggerDeleteCommentEvent(5); + expect(emittedIndex).toBe(5); +}); +``` + +### Backend + +#### Naming +Unit test names should follow the format: `test__` + +Examples: +```java +public void testGetComment_commentDoesNotExist_returnsNull() +public void testCreateComment_commentDoesNotExist_success() +public void testCreateComment_commentAlreadyExists_throwsEntityAlreadyExistsException() +``` + +#### Creating test data +To aid with [including only relevant details in tests](#include-only-relevant-details-in-tests), use the `getTypicalX` functions in `BaseTestCase`, where X represents an entity. + +Example: +```java +Account account = getTypicalAccount(); +account.setEmail("newemail@teammates.com"); + +Student student = getTypicalStudent(); +student.setName("New Student Name"); +``` + + diff --git a/src/client/java/teammates/client/scripts/testdataconversion/DataStoreToSqlConverter.java b/src/client/java/teammates/client/scripts/testdataconversion/DataStoreToSqlConverter.java index 716032dd0c5..8b899504833 100644 --- a/src/client/java/teammates/client/scripts/testdataconversion/DataStoreToSqlConverter.java +++ b/src/client/java/teammates/client/scripts/testdataconversion/DataStoreToSqlConverter.java @@ -57,8 +57,7 @@ public class DataStoreToSqlConverter { private UuidGenerator accounRequestUuidGenerator = new UuidGenerator(initialAccountRequestNumber, uuidPrefix); private UuidGenerator sectionUuidGenerator = new UuidGenerator(initialSectionNumber, uuidPrefix); private UuidGenerator teamUuidGenerator = new UuidGenerator(initialTeamNumber, uuidPrefix); - private UuidGenerator deadlineExtensionUuidGenerator = new UuidGenerator(initialDeadlineExtensionNumber, - uuidPrefix); + private UuidGenerator deadlineExtensionUuidGenerator = new UuidGenerator(initialDeadlineExtensionNumber, uuidPrefix); private UuidGenerator instructorUuidGenerator = new UuidGenerator(initialInstructorNumber, uuidPrefix); private UuidGenerator studentUuidGenerator = new UuidGenerator(initialStudentNumber, uuidPrefix); private UuidGenerator feedbackSessionUuidGenerator = new UuidGenerator(intitialFeedbackSessionNumber, uuidPrefix); @@ -262,11 +261,9 @@ protected Student convert(StudentAttributes student) { */ protected DeadlineExtension convert(DeadlineExtensionAttributes deadlineExtension) { FeedbackSession sqlFeedbackSession = feedbackSessions.get( - generatefeedbackSessionKey(deadlineExtension.getCourseId(), - deadlineExtension.getFeedbackSessionName())); + generatefeedbackSessionKey(deadlineExtension.getCourseId(), deadlineExtension.getFeedbackSessionName())); - // User is not included since DataBundleLogic.java does not read users from this - // attribute + // User is not included since DataBundleLogic.java does not read users from this attribute DeadlineExtension sqlDE = new DeadlineExtension(null, sqlFeedbackSession, deadlineExtension.getEndTime()); diff --git a/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java b/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java index 3836a7b1eef..22089ac7cea 100644 --- a/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java +++ b/src/e2e/java/teammates/e2e/cases/AdminSearchPageE2ETest.java @@ -31,7 +31,7 @@ protected void prepareTestData() { putDocuments(testData); sqlTestData = loadSqlDataBundle("/AdminSearchPageE2ETest_SqlEntities.json"); removeAndRestoreSqlDataBundle(sqlTestData); - doPutDocumentsSql(sqlTestData); + putSqlDocuments(sqlTestData); } @Test diff --git a/src/e2e/java/teammates/e2e/cases/axe/AdminSearchPageAxeTest.java b/src/e2e/java/teammates/e2e/cases/axe/AdminSearchPageAxeTest.java index d45e1d4e16a..732e06ceed3 100644 --- a/src/e2e/java/teammates/e2e/cases/axe/AdminSearchPageAxeTest.java +++ b/src/e2e/java/teammates/e2e/cases/axe/AdminSearchPageAxeTest.java @@ -25,7 +25,7 @@ protected void prepareTestData() { putDocuments(testData); sqlTestData = loadSqlDataBundle("/AdminSearchPageE2ETest_SqlEntities.json"); removeAndRestoreSqlDataBundle(sqlTestData); - doPutDocumentsSql(sqlTestData); + putSqlDocuments(sqlTestData); } @Test diff --git a/src/e2e/java/teammates/e2e/cases/sql/AdminSearchPageE2ETest.java b/src/e2e/java/teammates/e2e/cases/sql/AdminSearchPageE2ETest.java new file mode 100644 index 00000000000..848fee25302 --- /dev/null +++ b/src/e2e/java/teammates/e2e/cases/sql/AdminSearchPageE2ETest.java @@ -0,0 +1,186 @@ +package teammates.e2e.cases.sql; + +import java.time.Instant; + +import org.testng.annotations.AfterClass; +import org.testng.annotations.Test; + +import teammates.common.util.AppUrl; +import teammates.common.util.Const; +import teammates.e2e.pageobjects.AdminSearchPage; +import teammates.e2e.util.TestProperties; +import teammates.storage.sqlentity.AccountRequest; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.Instructor; +import teammates.storage.sqlentity.Student; + +/** + * SUT: {@link Const.WebPageURIs#ADMIN_SEARCH_PAGE}. + */ +public class AdminSearchPageE2ETest extends BaseE2ETestCase { + + @Override + protected void prepareTestData() { + if (!TestProperties.INCLUDE_SEARCH_TESTS) { + return; + } + testData = removeAndRestoreDataBundle(loadSqlDataBundle("/AdminSearchPageE2ESqlTest.json")); + putDocuments(testData); + } + + @Test + @Override + public void testAll() { + if (!TestProperties.INCLUDE_SEARCH_TESTS) { + return; + } + + AppUrl url = createFrontendUrl(Const.WebPageURIs.ADMIN_SEARCH_PAGE); + AdminSearchPage searchPage = loginAdminToPage(url, AdminSearchPage.class); + + Course course = testData.courses.get("typicalCourse1"); + Student student = testData.students.get("student1InCourse1"); + Instructor instructor = testData.instructors.get("instructor1OfCourse1"); + AccountRequest accountRequest = testData.accountRequests.get("instructor1OfCourse1"); + + ______TS("Typical case: Search student email"); + String searchContent = student.getEmail(); + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + String studentDetails = getExpectedStudentDetails(student); + String studentManageAccountLink = getExpectedStudentManageAccountLink(student); + String studentHomePageLink = getExpectedStudentHomePageLink(student); + int numExpandedRows = getExpectedNumExpandedRows(student); + searchPage.verifyStudentRowContent(student, course, studentDetails, studentManageAccountLink, + studentHomePageLink); + searchPage.verifyStudentExpandedLinks(student, numExpandedRows); + + ______TS("Typical case: Reset student google id"); + searchPage.resetStudentGoogleId(student); + student.setGoogleId(null); + searchPage.verifyStudentRowContentAfterReset(student, course); + + ______TS("Typical case: Regenerate registration key for a course student"); + searchPage.clickExpandStudentLinks(); + String originalJoinLink = searchPage.getStudentJoinLink(student); + searchPage.regenerateStudentKey(student); + searchPage.verifyRegenerateStudentKey(student, originalJoinLink); + searchPage.waitForPageToLoad(); + + ______TS("Typical case: Search for instructor email"); + searchPage.clearSearchBox(); + searchContent = instructor.getEmail(); + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + String instructorManageAccountLink = getExpectedInstructorManageAccountLink(instructor); + String instructorHomePageLink = getExpectedInstructorHomePageLink(instructor); + searchPage.verifyInstructorRowContent(instructor, course, instructorManageAccountLink, + instructorHomePageLink); + searchPage.verifyInstructorExpandedLinks(instructor); + + ______TS("Typical case: Reset instructor google id"); + searchPage.resetInstructorGoogleId(instructor); + searchPage.verifyInstructorRowContentAfterReset(instructor, course); + + ______TS("Typical case: Regenerate registration key for an instructor"); + searchPage.clickExpandInstructorLinks(); + originalJoinLink = searchPage.getInstructorJoinLink(instructor); + searchPage.regenerateInstructorKey(instructor); + searchPage.verifyRegenerateInstructorKey(instructor, originalJoinLink); + searchPage.waitForPageToLoad(); + + ______TS("Typical case: Search for account request by email"); + searchPage.clearSearchBox(); + searchContent = accountRequest.getEmail(); + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + searchPage.verifyAccountRequestRowContent(accountRequest); + searchPage.verifyAccountRequestExpandedLinks(accountRequest); + + ______TS("Typical case: Search common search key"); + searchPage.clearSearchBox(); + searchContent = "Course1"; + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + searchPage.verifyStudentRowContentAfterReset(student, course); + searchPage.verifyInstructorRowContentAfterReset(instructor, course); + searchPage.verifyAccountRequestRowContent(accountRequest); + + ______TS("Typical case: Expand and collapse links"); + searchPage.verifyLinkExpansionButtons(student, instructor, accountRequest); + + ______TS("Typical case: Reset account request successful"); + searchContent = "ASearch.instructor1@gmail.tmt"; + searchPage.clearSearchBox(); + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + searchPage.clickResetAccountRequestButton(accountRequest); + assertNull(BACKDOOR.getAccountRequest(accountRequest.getId()).getRegisteredAt()); + + ______TS("Typical case: Delete account request successful"); + accountRequest = testData.accountRequests.get("unregisteredInstructor1"); + searchContent = accountRequest.getEmail(); + searchPage.clearSearchBox(); + searchPage.inputSearchContent(searchContent); + searchPage.clickSearchButton(); + searchPage.clickDeleteAccountRequestButton(accountRequest); + assertNull(BACKDOOR.getAccountRequest(accountRequest.getId())); + } + + private String getExpectedStudentDetails(Student student) { + return String.format("%s [%s] (%s)", student.getCourse().getId(), + student.getSection() == null + ? Const.DEFAULT_SECTION + : student.getSection().getName(), + student.getTeam().getName()); + } + + private String getExpectedStudentHomePageLink(Student student) { + return student.isRegistered() ? createFrontendUrl(Const.WebPageURIs.STUDENT_HOME_PAGE) + .withUserId(student.getGoogleId()) + .toAbsoluteString() + : ""; + } + + private String getExpectedStudentManageAccountLink(Student student) { + return student.isRegistered() ? createFrontendUrl(Const.WebPageURIs.ADMIN_ACCOUNTS_PAGE) + .withParam(Const.ParamsNames.INSTRUCTOR_ID, student.getGoogleId()) + .toAbsoluteString() + : ""; + } + + private int getExpectedNumExpandedRows(Student student) { + int expectedNumExpandedRows = 2; + for (FeedbackSession sessions : testData.feedbackSessions.values()) { + if (sessions.getCourse().equals(student.getCourse())) { + expectedNumExpandedRows += 1; + if (sessions.getResultsVisibleFromTime().isBefore(Instant.now())) { + expectedNumExpandedRows += 1; + } + } + } + return expectedNumExpandedRows; + } + + private String getExpectedInstructorHomePageLink(Instructor instructor) { + String googleId = instructor.isRegistered() ? instructor.getGoogleId() : ""; + return createFrontendUrl(Const.WebPageURIs.INSTRUCTOR_HOME_PAGE) + .withUserId(googleId) + .toAbsoluteString(); + } + + private String getExpectedInstructorManageAccountLink(Instructor instructor) { + String googleId = instructor.isRegistered() ? instructor.getGoogleId() : ""; + return createFrontendUrl(Const.WebPageURIs.ADMIN_ACCOUNTS_PAGE) + .withParam(Const.ParamsNames.INSTRUCTOR_ID, googleId) + .toAbsoluteString(); + } + + @AfterClass + public void classTeardown() { + for (AccountRequest request : testData.accountRequests.values()) { + BACKDOOR.deleteAccountRequest(request.getId()); + } + } +} diff --git a/src/e2e/java/teammates/e2e/cases/sql/BaseE2ETestCase.java b/src/e2e/java/teammates/e2e/cases/sql/BaseE2ETestCase.java index b61a4a8cf2a..fbfd60ea84b 100644 --- a/src/e2e/java/teammates/e2e/cases/sql/BaseE2ETestCase.java +++ b/src/e2e/java/teammates/e2e/cases/sql/BaseE2ETestCase.java @@ -265,4 +265,18 @@ StudentData getStudent(String courseId, String studentEmailAddress) { protected StudentData getStudent(Student student) { return getStudent(student.getCourseId(), student.getEmail()); } + + /** + * Puts the documents in the database using BACKDOOR. + * @param dataBundle the data to be put in the database + * @return the result of the operation + */ + protected String putDocuments(SqlDataBundle dataBundle) { + try { + return BACKDOOR.putSqlDocuments(dataBundle); + } catch (HttpRequestFailedException e) { + e.printStackTrace(); + return null; + } + } } diff --git a/src/e2e/java/teammates/e2e/cases/sql/FeedbackNumScaleQuestionE2ETest.java b/src/e2e/java/teammates/e2e/cases/sql/FeedbackNumScaleQuestionE2ETest.java new file mode 100644 index 00000000000..61e4fc619be --- /dev/null +++ b/src/e2e/java/teammates/e2e/cases/sql/FeedbackNumScaleQuestionE2ETest.java @@ -0,0 +1,124 @@ +package teammates.e2e.cases.sql; + +import org.testng.annotations.Test; + +import teammates.common.datatransfer.questions.FeedbackNumericalScaleQuestionDetails; +import teammates.common.datatransfer.questions.FeedbackNumericalScaleResponseDetails; +import teammates.e2e.pageobjects.FeedbackSubmitPage; +import teammates.e2e.pageobjects.InstructorFeedbackEditPage; +import teammates.storage.sqlentity.FeedbackQuestion; +import teammates.storage.sqlentity.FeedbackResponse; +import teammates.storage.sqlentity.Student; + +/** + * SUT: {@link Const.WebPageURIs#INSTRUCTOR_SESSION_EDIT_PAGE}, {@link Const.WebPageURIs#SESSION_SUBMISSION_PAGE} + * specifically for NumScale questions. + */ +public class FeedbackNumScaleQuestionE2ETest extends BaseFeedbackQuestionE2ETest { + + @Override + protected void prepareTestData() { + testData = removeAndRestoreDataBundle(loadSqlDataBundle("/FeedbackNumScaleQuestionE2ESqlTest.json")); + + instructor = testData.instructors.get("instructor"); + course = testData.courses.get("course"); + feedbackSession = testData.feedbackSessions.get("openSession"); + student = testData.students.get("alice.tmms@FNumScaleQn.CS2104"); + } + + @Test + @Override + public void testAll() { + testEditPage(); + logout(); + testSubmitPage(); + } + + @Override + protected void testEditPage() { + InstructorFeedbackEditPage feedbackEditPage = loginToFeedbackEditPage(); + + ______TS("verify loaded question"); + FeedbackQuestion loadedQuestion = testData.feedbackQuestions.get("qn1ForFirstSession"); + FeedbackNumericalScaleQuestionDetails questionDetails = + (FeedbackNumericalScaleQuestionDetails) loadedQuestion.getQuestionDetailsCopy(); + feedbackEditPage.verifyNumScaleQuestionDetails(1, questionDetails); + + ______TS("add new question"); + // add new question exactly like loaded question + loadedQuestion.setQuestionNumber(2); + feedbackEditPage.addNumScaleQuestion(loadedQuestion); + feedbackEditPage.waitUntilAnimationFinish(); + + feedbackEditPage.verifyNumScaleQuestionDetails(2, questionDetails); + verifyPresentInDatabase(loadedQuestion); + + ______TS("copy question"); + FeedbackQuestion copiedQuestion = testData.feedbackQuestions.get("qn1ForSecondSession"); + questionDetails = (FeedbackNumericalScaleQuestionDetails) copiedQuestion.getQuestionDetailsCopy(); + feedbackEditPage.copyQuestion(copiedQuestion.getCourseId(), + copiedQuestion.getQuestionDetailsCopy().getQuestionText()); + copiedQuestion.setQuestionNumber(3); + copiedQuestion.setFeedbackSession(feedbackSession); + + feedbackEditPage.verifyNumScaleQuestionDetails(3, questionDetails); + verifyPresentInDatabase(copiedQuestion); + + ______TS("edit question"); + questionDetails = (FeedbackNumericalScaleQuestionDetails) loadedQuestion.getQuestionDetailsCopy(); + FeedbackNumericalScaleQuestionDetails newQuestionDetails = + (FeedbackNumericalScaleQuestionDetails) questionDetails.getDeepCopy(); + newQuestionDetails.setMinScale(0); + newQuestionDetails.setStep(1); + newQuestionDetails.setMaxScale(100); + loadedQuestion.setQuestionDetails(newQuestionDetails); + feedbackEditPage.editNumScaleQuestion(2, newQuestionDetails); + feedbackEditPage.waitForPageToLoad(); + + feedbackEditPage.verifyNumScaleQuestionDetails(2, newQuestionDetails); + verifyPresentInDatabase(loadedQuestion); + + // reset question details to original + loadedQuestion.setQuestionDetails(questionDetails); + } + + @Override + protected void testSubmitPage() { + FeedbackSubmitPage feedbackSubmitPage = loginToFeedbackSubmitPage(); + + ______TS("verify loaded question"); + FeedbackQuestion question = testData.feedbackQuestions.get("qn1ForFirstSession"); + Student receiver = testData.students.get("benny.tmms@FNumScaleQn.CS2104"); + feedbackSubmitPage.verifyNumScaleQuestion(1, receiver.getTeamName(), + (FeedbackNumericalScaleQuestionDetails) question.getQuestionDetailsCopy()); + + ______TS("submit response"); + FeedbackResponse response = getResponse(question, receiver, 5.4); + feedbackSubmitPage.fillNumScaleResponse(1, receiver.getTeamName(), response); + feedbackSubmitPage.clickSubmitQuestionButton(1); + + // TODO: uncomment when SubmitFeedbackResponse is working + // verifyPresentInDatabase(response); + + // ______TS("check previous response"); + // feedbackSubmitPage = getFeedbackSubmitPage(); + // feedbackSubmitPage.verifyNumScaleResponse(1, receiver.getTeamName(), response); + + // ______TS("edit response"); + // response = getResponse(question, receiver, 10.0); + // feedbackSubmitPage.fillNumScaleResponse(1, receiver.getTeamName(), response); + // feedbackSubmitPage.clickSubmitQuestionButton(1); + + // feedbackSubmitPage = getFeedbackSubmitPage(); + // feedbackSubmitPage.verifyNumScaleResponse(1, receiver.getTeamName(), response); + // verifyPresentInDatabase(response); + } + + private FeedbackResponse getResponse(FeedbackQuestion feedbackQuestion, Student receiver, Double answer) { + FeedbackNumericalScaleResponseDetails details = new FeedbackNumericalScaleResponseDetails(); + details.setAnswer(answer); + return FeedbackResponse.makeResponse( + feedbackQuestion, student.getEmail(), null, receiver.getTeamName(), null, details); + } + +} diff --git a/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java b/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java index b0e9049f481..15253ca5171 100644 --- a/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java +++ b/src/e2e/java/teammates/e2e/pageobjects/AdminSearchPage.java @@ -18,6 +18,9 @@ import teammates.common.util.Const; import teammates.common.util.StringHelper; import teammates.storage.sqlentity.AccountRequest; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.Instructor; +import teammates.storage.sqlentity.Student; /** * Represents the admin home page of the website. @@ -93,6 +96,14 @@ public void clickSearchButton() { waitForPageToLoad(); } + public void regenerateStudentKey(Student student) { + WebElement studentRow = getStudentRow(student); + studentRow.findElement(By.xpath("//button[text()='Regenerate key']")).click(); + + waitForConfirmationModalAndClickOk(); + waitForPageToLoad(true); + } + public void regenerateStudentKey(StudentAttributes student) { WebElement studentRow = getStudentRow(student); studentRow.findElement(By.xpath("//button[text()='Regenerate key']")).click(); @@ -101,6 +112,30 @@ public void regenerateStudentKey(StudentAttributes student) { waitForPageToLoad(true); } + public void verifyRegenerateStudentKey(Student student, String originalJoinLink) { + verifyStatusMessage("Student's key for this course has been successfully regenerated," + + " and the email has been sent."); + + String regeneratedJoinLink = getStudentJoinLink(student); + assertNotEquals(regeneratedJoinLink, originalJoinLink); + } + + public void verifyRegenerateStudentKey(StudentAttributes student, String originalJoinLink) { + verifyStatusMessage("Student's key for this course has been successfully regenerated," + + " and the email has been sent."); + + String regeneratedJoinLink = getStudentJoinLink(student); + assertNotEquals(regeneratedJoinLink, originalJoinLink); + } + + public void regenerateInstructorKey(Instructor instructor) { + WebElement instructorRow = getInstructorRow(instructor); + instructorRow.findElement(By.xpath("//button[text()='Regenerate key']")).click(); + + waitForConfirmationModalAndClickOk(); + waitForPageToLoad(true); + } + public void regenerateInstructorKey(InstructorAttributes instructor) { WebElement instructorRow = getInstructorRow(instructor); instructorRow.findElement(By.xpath("//button[text()='Regenerate key']")).click(); @@ -143,6 +178,25 @@ public String removeSpanFromText(String text) { return text.replace("", "").replace("", ""); } + public WebElement getStudentRow(Student student) { + String details = String.format("%s [%s] (%s)", student.getCourse().getId(), + student.getSection() == null + ? Const.DEFAULT_SECTION + : student.getSection().getName(), student.getTeam().getName()); + WebElement table = browser.driver.findElement(By.id("search-table-student")); + List rows = table.findElements(By.tagName("tr")); + for (WebElement row : rows) { + List columns = row.findElements(By.tagName("td")); + if (!columns.isEmpty() && removeSpanFromText(columns.get(STUDENT_COL_DETAILS - 1) + .getAttribute("innerHTML")).contains(details) + && removeSpanFromText(columns.get(STUDENT_COL_NAME - 1) + .getAttribute("innerHTML")).contains(student.getName())) { + return row; + } + } + return null; + } + public WebElement getStudentRow(StudentAttributes student) { String details = String.format("%s [%s] (%s)", student.getCourse(), student.getSection() == null ? Const.DEFAULT_SECTION : student.getSection(), student.getTeam()); @@ -195,11 +249,25 @@ public String getStudentJoinLink(WebElement studentRow) { return getExpandedRowInputValue(studentRow, EXPANDED_ROWS_HEADER_COURSE_JOIN_LINK); } + public String getStudentJoinLink(Student student) { + WebElement studentRow = getStudentRow(student); + return getStudentJoinLink(studentRow); + } + public String getStudentJoinLink(StudentAttributes student) { WebElement studentRow = getStudentRow(student); return getStudentJoinLink(studentRow); } + public void resetStudentGoogleId(Student student) { + WebElement studentRow = getStudentRow(student); + WebElement link = studentRow.findElement(By.linkText(LINK_TEXT_RESET_GOOGLE_ID)); + link.click(); + + waitForConfirmationModalAndClickOk(); + waitForElementStaleness(link); + } + public void resetStudentGoogleId(StudentAttributes student) { WebElement studentRow = getStudentRow(student); WebElement link = studentRow.findElement(By.linkText(LINK_TEXT_RESET_GOOGLE_ID)); @@ -209,6 +277,21 @@ public void resetStudentGoogleId(StudentAttributes student) { waitForElementStaleness(link); } + public WebElement getInstructorRow(Instructor instructor) { + WebElement table = browser.driver.findElement(By.id("search-table-instructor")); + List rows = table.findElements(By.tagName("tr")); + for (WebElement row : rows) { + List columns = row.findElements(By.tagName("td")); + if (columns.size() >= 3 && (removeSpanFromText(columns.get(2) + .getAttribute("innerHTML")).contains(instructor.getGoogleId()) + || removeSpanFromText(columns.get(1) + .getAttribute("innerHTML")).contains(instructor.getName()))) { + return row; + } + } + return null; + } + public WebElement getInstructorRow(InstructorAttributes instructor) { String courseId = instructor.getCourseId(); List rows = browser.driver.findElements(By.cssSelector("#search-table-instructor tbody tr")); @@ -256,11 +339,25 @@ public String getInstructorJoinLink(WebElement instructorRow) { return getExpandedRowInputValue(instructorRow, EXPANDED_ROWS_HEADER_COURSE_JOIN_LINK); } + public String getInstructorJoinLink(Instructor instructor) { + WebElement instructorRow = getInstructorRow(instructor); + return getInstructorJoinLink(instructorRow); + } + public String getInstructorJoinLink(InstructorAttributes instructor) { WebElement instructorRow = getInstructorRow(instructor); return getInstructorJoinLink(instructorRow); } + public void resetInstructorGoogleId(Instructor instructor) { + WebElement instructorRow = getInstructorRow(instructor); + WebElement link = instructorRow.findElement(By.linkText(LINK_TEXT_RESET_GOOGLE_ID)); + link.click(); + + waitForConfirmationModalAndClickOk(); + waitForElementStaleness(link); + } + public void resetInstructorGoogleId(InstructorAttributes instructor) { WebElement instructorRow = getInstructorRow(instructor); WebElement link = instructorRow.findElement(By.linkText(LINK_TEXT_RESET_GOOGLE_ID)); @@ -386,6 +483,32 @@ private String getExpandedRowInputValue(WebElement row, String rowHeader) { } } + public void verifyStudentRowContent(Student student, Course course, + String expectedDetails, String expectedManageAccountLink, + String expectedHomePageLink) { + WebElement studentRow = getStudentRow(student); + String actualDetails = getStudentDetails(studentRow); + String actualName = getStudentName(studentRow); + String actualGoogleId = getStudentGoogleId(studentRow); + String actualHomepageLink = getStudentHomeLink(studentRow); + String actualInstitute = getStudentInstitute(studentRow); + String actualComment = getStudentComments(studentRow); + String actualManageAccountLink = getStudentManageAccountLink(studentRow); + + String expectedName = student.getName(); + String expectedGoogleId = StringHelper.convertToEmptyStringIfNull(student.getGoogleId()); + String expectedInstitute = StringHelper.convertToEmptyStringIfNull(course.getInstitute()); + String expectedComment = StringHelper.convertToEmptyStringIfNull(student.getComments()); + + assertEquals(expectedDetails, actualDetails); + assertEquals(expectedName, actualName); + assertEquals(expectedGoogleId, actualGoogleId); + assertEquals(expectedInstitute, actualInstitute); + assertEquals(expectedComment, actualComment); + assertEquals(expectedManageAccountLink, actualManageAccountLink); + assertEquals(expectedHomePageLink, actualHomepageLink); + } + public void verifyStudentRowContent(StudentAttributes student, CourseAttributes course, String expectedDetails, String expectedManageAccountLink, String expectedHomePageLink) { @@ -412,6 +535,35 @@ public void verifyStudentRowContent(StudentAttributes student, CourseAttributes assertEquals(expectedHomePageLink, actualHomepageLink); } + public void verifyStudentRowContentAfterReset(Student student, Course course) { + WebElement studentRow = getStudentRow(student); + String actualName = getStudentName(studentRow); + String actualInstitute = getStudentInstitute(studentRow); + String actualComment = getStudentComments(studentRow); + + String expectedName = student.getName(); + String expectedInstitute = StringHelper.convertToEmptyStringIfNull(course.getInstitute()); + String expectedComment = StringHelper.convertToEmptyStringIfNull(student.getComments()); + + assertEquals(expectedName, actualName); + assertEquals(expectedInstitute, actualInstitute); + assertEquals(expectedComment, actualComment); + } + + public void verifyStudentExpandedLinks(Student student, int expectedNumExpandedRows) { + clickExpandStudentLinks(); + WebElement studentRow = getStudentRow(student); + String actualEmail = getStudentEmail(studentRow); + String actualJoinLink = getStudentJoinLink(studentRow); + int actualNumExpandedRows = getNumExpandedRows(studentRow); + + String expectedEmail = student.getEmail(); + + assertEquals(expectedEmail, actualEmail); + assertNotEquals("", actualJoinLink); + assertEquals(expectedNumExpandedRows, actualNumExpandedRows); + } + public void verifyStudentExpandedLinks(StudentAttributes student, int expectedNumExpandedRows) { clickExpandStudentLinks(); WebElement studentRow = getStudentRow(student); @@ -426,6 +578,29 @@ public void verifyStudentExpandedLinks(StudentAttributes student, int expectedNu assertEquals(expectedNumExpandedRows, actualNumExpandedRows); } + public void verifyInstructorRowContent(Instructor instructor, Course course, + String expectedManageAccountLink, String expectedHomePageLink) { + WebElement instructorRow = getInstructorRow(instructor); + String actualCourseId = getInstructorCourseId(instructorRow); + String actualName = getInstructorName(instructorRow); + String actualGoogleId = getInstructorGoogleId(instructorRow); + String actualHomePageLink = getInstructorHomePageLink(instructorRow); + String actualInstitute = getInstructorInstitute(instructorRow); + String actualManageAccountLink = getInstructorManageAccountLink(instructorRow); + + String expectedCourseId = instructor.getCourseId(); + String expectedName = instructor.getName(); + String expectedGoogleId = StringHelper.convertToEmptyStringIfNull(instructor.getGoogleId()); + String expectedInstitute = StringHelper.convertToEmptyStringIfNull(course.getInstitute()); + + assertEquals(expectedCourseId, actualCourseId); + assertEquals(expectedName, actualName); + assertEquals(expectedGoogleId, actualGoogleId); + assertEquals(expectedHomePageLink, actualHomePageLink); + assertEquals(expectedInstitute, actualInstitute); + assertEquals(expectedManageAccountLink, actualManageAccountLink); + } + public void verifyInstructorRowContent(InstructorAttributes instructor, CourseAttributes course, String expectedManageAccountLink, String expectedHomePageLink) { WebElement instructorRow = getInstructorRow(instructor); @@ -449,6 +624,33 @@ public void verifyInstructorRowContent(InstructorAttributes instructor, CourseAt assertEquals(expectedManageAccountLink, actualManageAccountLink); } + public void verifyInstructorRowContentAfterReset(Instructor instructor, Course course) { + WebElement instructorRow = getInstructorRow(instructor); + String actualCourseId = getInstructorCourseId(instructorRow); + String actualName = getInstructorName(instructorRow); + String actualInstitute = getInstructorInstitute(instructorRow); + + String expectedCourseId = instructor.getCourseId(); + String expectedName = instructor.getName(); + String expectedInstitute = StringHelper.convertToEmptyStringIfNull(course.getInstitute()); + + assertEquals(expectedCourseId, actualCourseId); + assertEquals(expectedName, actualName); + assertEquals(expectedInstitute, actualInstitute); + } + + public void verifyInstructorExpandedLinks(Instructor instructor) { + clickExpandInstructorLinks(); + WebElement instructorRow = getInstructorRow(instructor); + String actualEmail = getInstructorEmail(instructorRow); + String actualJoinLink = getInstructorJoinLink(instructorRow); + + String expectedEmail = instructor.getEmail(); + + assertEquals(expectedEmail, actualEmail); + assertNotEquals("", actualJoinLink); + } + public void verifyInstructorExpandedLinks(InstructorAttributes instructor) { clickExpandInstructorLinks(); WebElement instructorRow = getInstructorRow(instructor); @@ -515,6 +717,43 @@ public void verifyAccountRequestExpandedLinks(AccountRequest accountRequest) { assertFalse(actualRegistrationLink.isBlank()); } + public void verifyLinkExpansionButtons(Student student, + Instructor instructor, AccountRequest accountRequest) { + WebElement studentRow = getStudentRow(student); + WebElement instructorRow = getInstructorRow(instructor); + WebElement accountRequestRow = getAccountRequestRow(accountRequest); + + clickExpandStudentLinks(); + clickExpandInstructorLinks(); + clickExpandAccountRequestLinks(); + int numExpandedStudentRows = getNumExpandedRows(studentRow); + int numExpandedInstructorRows = getNumExpandedRows(instructorRow); + int numExpandedAccountRequestRows = getNumExpandedRows(accountRequestRow); + assertNotEquals(numExpandedStudentRows, 0); + assertNotEquals(numExpandedInstructorRows, 0); + assertNotEquals(numExpandedAccountRequestRows, 0); + + clickCollapseInstructorLinks(); + numExpandedStudentRows = getNumExpandedRows(studentRow); + numExpandedInstructorRows = getNumExpandedRows(instructorRow); + numExpandedAccountRequestRows = getNumExpandedRows(accountRequestRow); + assertNotEquals(numExpandedStudentRows, 0); + assertEquals(numExpandedInstructorRows, 0); + assertNotEquals(numExpandedAccountRequestRows, 0); + + clickExpandInstructorLinks(); + clickCollapseStudentLinks(); + clickCollapseAccountRequestLinks(); + waitUntilAnimationFinish(); + + numExpandedStudentRows = getNumExpandedRows(studentRow); + numExpandedInstructorRows = getNumExpandedRows(instructorRow); + numExpandedAccountRequestRows = getNumExpandedRows(accountRequestRow); + assertEquals(numExpandedStudentRows, 0); + assertNotEquals(numExpandedInstructorRows, 0); + assertEquals(numExpandedAccountRequestRows, 0); + } + public void verifyLinkExpansionButtons(StudentAttributes student, InstructorAttributes instructor, AccountRequestAttributes accountRequest) { WebElement studentRow = getStudentRow(student); @@ -589,11 +828,11 @@ public void verifyLinkExpansionButtons(StudentAttributes student, assertEquals(numExpandedAccountRequestRows, 0); } - public void verifyRegenerateStudentKey(StudentAttributes student, String originalJoinLink) { - verifyStatusMessage("Student's key for this course has been successfully regenerated," + public void verifyRegenerateInstructorKey(Instructor instructor, String originalJoinLink) { + verifyStatusMessage("Instructor's key for this course has been successfully regenerated," + " and the email has been sent."); - String regeneratedJoinLink = getStudentJoinLink(student); + String regeneratedJoinLink = getInstructorJoinLink(instructor); assertNotEquals(regeneratedJoinLink, originalJoinLink); } @@ -604,5 +843,4 @@ public void verifyRegenerateInstructorKey(InstructorAttributes instructor, Strin String regeneratedJoinLink = getInstructorJoinLink(instructor); assertNotEquals(regeneratedJoinLink, originalJoinLink); } - } diff --git a/src/e2e/java/teammates/e2e/pageobjects/FeedbackSubmitPage.java b/src/e2e/java/teammates/e2e/pageobjects/FeedbackSubmitPage.java index e32bbcaaa96..11911490a04 100644 --- a/src/e2e/java/teammates/e2e/pageobjects/FeedbackSubmitPage.java +++ b/src/e2e/java/teammates/e2e/pageobjects/FeedbackSubmitPage.java @@ -392,6 +392,12 @@ public void fillNumScaleResponse(int qnNumber, String recipient, FeedbackRespons fillTextBox(getNumScaleInput(qnNumber, recipient), Double.toString(responseDetails.getAnswer())); } + public void fillNumScaleResponse(int qnNumber, String recipient, FeedbackResponse response) { + FeedbackNumericalScaleResponseDetails responseDetails = + (FeedbackNumericalScaleResponseDetails) response.getFeedbackResponseDetailsCopy(); + fillTextBox(getNumScaleInput(qnNumber, recipient), Double.toString(responseDetails.getAnswer())); + } + public void verifyNumScaleResponse(int qnNumber, String recipient, FeedbackResponseAttributes response) { FeedbackNumericalScaleResponseDetails responseDetails = (FeedbackNumericalScaleResponseDetails) response.getResponseDetailsCopy(); @@ -399,6 +405,13 @@ public void verifyNumScaleResponse(int qnNumber, String recipient, FeedbackRespo getDoubleString(responseDetails.getAnswer())); } + public void verifyNumScaleResponse(int qnNumber, String recipient, FeedbackResponse response) { + FeedbackNumericalScaleResponseDetails responseDetails = + (FeedbackNumericalScaleResponseDetails) response.getFeedbackResponseDetailsCopy(); + assertEquals(getNumScaleInput(qnNumber, recipient).getAttribute("value"), + getDoubleString(responseDetails.getAnswer())); + } + public void verifyConstSumQuestion(int qnNumber, String recipient, FeedbackConstantSumQuestionDetails questionDetails) { if (!questionDetails.isDistributeToRecipients()) { diff --git a/src/e2e/java/teammates/e2e/pageobjects/InstructorFeedbackEditPage.java b/src/e2e/java/teammates/e2e/pageobjects/InstructorFeedbackEditPage.java index f104afcc7ba..8c824ccc1d7 100644 --- a/src/e2e/java/teammates/e2e/pageobjects/InstructorFeedbackEditPage.java +++ b/src/e2e/java/teammates/e2e/pageobjects/InstructorFeedbackEditPage.java @@ -674,6 +674,16 @@ public void addNumScaleQuestion(FeedbackQuestionAttributes feedbackQuestion) { clickSaveNewQuestionButton(); } + public void addNumScaleQuestion(FeedbackQuestion feedbackQuestion) { + addNewQuestion(5); + int questionNum = getNumQuestions(); + inputQuestionDetails(questionNum, feedbackQuestion); + FeedbackNumericalScaleQuestionDetails questionDetails = + (FeedbackNumericalScaleQuestionDetails) feedbackQuestion.getQuestionDetailsCopy(); + inputNumScaleDetails(questionNum, questionDetails); + clickSaveNewQuestionButton(); + } + public void editNumScaleQuestion(int questionNum, FeedbackNumericalScaleQuestionDetails questionDetails) { clickEditQuestionButton(questionNum); inputNumScaleDetails(questionNum, questionDetails); diff --git a/src/e2e/resources/data/AdminSearchPageE2ESqlTest.json b/src/e2e/resources/data/AdminSearchPageE2ESqlTest.json new file mode 100644 index 00000000000..94b28ae6ad2 --- /dev/null +++ b/src/e2e/resources/data/AdminSearchPageE2ESqlTest.json @@ -0,0 +1,118 @@ +{ + "accounts": { + "instructor1OfCourse1": { + "id": "00000000-0000-4000-8000-000000000001", + "googleId": "tm.e2e.ASearch.instr1", + "name": "Instructor1 of Course1", + "email": "ASearch.instructor1@gmail.tmt" + }, + "instructor2OfCourse1": { + "id": "00000000-0000-4000-8000-000000000002", + "googleId": "tm.e2e.ASearch.instr2", + "name": "Instructor2 of Course1", + "email": "ASearch.instructor2@gmail.tmt" + }, + "student1InCourse1": { + "id": "00000000-0000-4000-8000-000000000003", + "googleId": "tm.e2e.ASearch.student1", + "name": "Student1 in course1", + "email": "ASearch.student@gmail.tmt" + } + }, + "accountRequests": { + "instructor1OfCourse1": { + "name": "Instructor1 of Course1", + "email": "ASearch.instructor1@gmail.tmt", + "institute": "TEAMMATES Test Institute 1", + "createdAt": "2011-01-01T00:00:00Z", + "registeredAt": "1970-02-14T00:00:00Z" + }, + "instructor2OfCourse1": { + "name": "Instructor2 of Course1", + "email": "ASearch.instructor2@gmail.tmt", + "institute": "TEAMMATES Test Institute 1", + "createdAt": "2011-01-01T00:00:00Z", + "registeredAt": "1970-02-14T00:00:00Z" + }, + "unregisteredInstructor1": { + "name": "Typical Instructor Name", + "email": "ASearch.unregisteredinstructor1@gmail.tmt", + "institute": "TEAMMATES Test Institute 1", + "createdAt": "2011-01-01T00:00:00Z" + } + }, + "courses": { + "typicalCourse1": { + "createdAt": "2012-04-01T23:59:00Z", + "id": "00000000-0000-4000-8000-000000000303", + "name": "ASearch Course 1", + "institute": "TEAMMATES Test Institute 0", + "timeZone": "Africa/Johannesburg" + } + }, + "sections": { + "section1InCourse1": { + "id": "00000000-0000-4000-8000-000000000201", + "course": { + "id": "00000000-0000-4000-8000-000000000303" + }, + "name": "Section 1" + } + }, + "teams": { + "team1InCourse1": { + "id": "00000000-0000-4000-8000-000000000301", + "section": { + "id": "00000000-0000-4000-8000-000000000201" + }, + "name": "Team 1" + } + }, + "instructors": { + "instructor1OfCourse1": { + "id": "00000000-0000-4000-8000-000000000501", + "account": { + "id": "00000000-0000-4000-8000-000000000001" + }, + "course": { + "id": "00000000-0000-4000-8000-000000000303" + }, + "name": "Instructor1 of ASearch Course1", + "email": "ASearch.instructor@gmail.tmt", + "role": "INSTRUCTOR_PERMISSION_ROLE_COOWNER", + "isDisplayedToStudents": true, + "displayName": "Instructor", + "privileges": { + "courseLevel": { + "canModifyCourse": true, + "canModifyInstructor": true, + "canModifySession": true, + "canModifyStudent": true, + "canViewStudentInSections": true, + "canViewSessionInSections": true, + "canSubmitSessionInSections": true, + "canModifySessionCommentsInSections": true + }, + "sectionLevel": {}, + "sessionLevel": {} + } + } + }, + "students": { + "student1InCourse1": { + "id": "00000000-0000-4000-8000-000000000601", + "account": { + "id": "00000000-0000-4000-8000-000000000003" + }, + "course": { + "id": "00000000-0000-4000-8000-000000000303" + }, + "team": { + "id": "00000000-0000-4000-8000-000000000301" + }, + "email": "ASearch.student@gmail.tmt", + "name": "Student1 In ASearch Course1", + "comments": "comment for student1Course1" + } + } +} diff --git a/src/e2e/resources/data/FeedbackNumScaleQuestionE2ESqlTest.json b/src/e2e/resources/data/FeedbackNumScaleQuestionE2ESqlTest.json new file mode 100644 index 00000000000..908ecff638c --- /dev/null +++ b/src/e2e/resources/data/FeedbackNumScaleQuestionE2ESqlTest.json @@ -0,0 +1,268 @@ +{ + "accounts": { + "instructorWithSessions": { + "googleId": "tm.e2e.FNumScaleQn.instructor", + "name": "Teammates Test", + "email": "tmms.test@gmail.tmt", + "id": "00000000-0000-4000-8000-000000000001" + }, + "tm.e2e.FNumScaleQn.alice.tmms": { + "googleId": "tm.e2e.FNumScaleQn.alice.tmms", + "name": "Alice Betsy", + "email": "alice.b.tmms@gmail.tmt", + "id": "00000000-0000-4000-8000-000000000002" + } + }, + "accountRequests": {}, + "courses": { + "course": { + "id": "tm.e2e.FNumScaleQn.CS2104", + "name": "Programming Language Concepts", + "institute": "TEAMMATES Test Institute 1", + "timeZone": "Africa/Johannesburg" + }, + "course2": { + "id": "tm.e2e.FNumScaleQn.CS1101", + "name": "Programming Methodology", + "institute": "TEAMMATES Test Institute 1", + "timeZone": "Africa/Johannesburg" + } + }, + "instructors": { + "instructor": { + "account": { + "id": "00000000-0000-4000-8000-000000000001" + }, + "name": "Teammates Test", + "email": "tmms.test@gmail.tmt", + "role": "INSTRUCTOR_PERMISSION_ROLE_COOWNER", + "isDisplayedToStudents": true, + "privileges": { + "courseLevel": { + "canViewStudentInSections": true, + "canSubmitSessionInSections": true, + "canModifySessionCommentsInSections": true, + "canModifyCourse": true, + "canViewSessionInSections": true, + "canModifySession": true, + "canModifyStudent": true, + "canModifyInstructor": true + }, + "sectionLevel": {}, + "sessionLevel": {} + }, + "id": "00000000-0000-4000-8000-000000000501", + "course": { + "id": "tm.e2e.FNumScaleQn.CS2104" + }, + "displayName": "Co-owner" + }, + "instructor2": { + "account": { + "id": "00000000-0000-4000-8000-000000000001" + }, + "name": "Teammates Test", + "email": "tmms.test@gmail.tmt", + "role": "INSTRUCTOR_PERMISSION_ROLE_COOWNER", + "isDisplayedToStudents": true, + "privileges": { + "courseLevel": { + "canViewStudentInSections": true, + "canSubmitSessionInSections": true, + "canModifySessionCommentsInSections": true, + "canModifyCourse": true, + "canViewSessionInSections": true, + "canModifySession": true, + "canModifyStudent": true, + "canModifyInstructor": true + }, + "sectionLevel": {}, + "sessionLevel": {} + }, + "id": "00000000-0000-4000-8000-000000000502", + "course": { + "id": "tm.e2e.FNumScaleQn.CS1101" + }, + "displayName": "Co-owner" + } + }, + "sections": { + "ProgrammingLanguageConceptsNone": { + "id": "00000000-0000-4000-8000-000000000101", + "course": { + "id": "tm.e2e.FNumScaleQn.CS2104" + }, + "name": "None" + }, + "ProgrammingMethodologyNone": { + "id": "00000000-0000-4000-8000-000000000102", + "course": { + "id": "tm.e2e.FNumScaleQn.CS1101" + }, + "name": "None" + } + }, + "teams": { + "ProgrammingLanguageConceptsTeam1": { + "id": "00000000-0000-4000-8000-000000000201", + "section": { + "id": "00000000-0000-4000-8000-000000000101" + }, + "name": "Team 1" + }, + "ProgrammingLanguageConceptsTeam2": { + "id": "00000000-0000-4000-8000-000000000202", + "section": { + "id": "00000000-0000-4000-8000-000000000101" + }, + "name": "Team 2" + }, + "ProgrammingMethodologyTeam1": { + "id": "00000000-0000-4000-8000-000000000203", + "section": { + "id": "00000000-0000-4000-8000-000000000102" + }, + "name": "Team 1" + } + }, + "feedbackSessions": { + "openSession": { + "creatorEmail": "tmms.test@gmail.tmt", + "instructions": "

      Instructions for first session

      ", + "createdTime": "2012-04-01T23:59:00Z", + "startTime": "2012-04-01T22:00:00Z", + "endTime": "2026-04-30T22:00:00Z", + "sessionVisibleFromTime": "2012-04-01T22:00:00Z", + "resultsVisibleFromTime": "2026-05-01T22:00:00Z", + "timeZone": "Africa/Johannesburg", + "gracePeriod": 10, + "sentOpenEmail": false, + "sentClosingEmail": false, + "sentClosedEmail": false, + "sentPublishedEmail": false, + "isOpeningEmailEnabled": true, + "isClosingEmailEnabled": true, + "isPublishedEmailEnabled": true, + "studentDeadlines": {}, + "instructorDeadlines": {}, + "id": "00000000-0000-4000-8000-000000000701", + "course": { + "id": "tm.e2e.FNumScaleQn.CS2104" + }, + "name": "First Session" + }, + "openSession2": { + "creatorEmail": "tmms.test@gmail.tmt", + "instructions": "

      Instructions for second session

      ", + "createdTime": "2012-04-01T23:59:00Z", + "startTime": "2012-04-01T22:00:00Z", + "endTime": "2026-04-30T22:00:00Z", + "sessionVisibleFromTime": "2012-04-01T22:00:00Z", + "resultsVisibleFromTime": "2026-05-01T22:00:00Z", + "timeZone": "Africa/Johannesburg", + "gracePeriod": 10, + "sentOpenEmail": false, + "sentClosingEmail": false, + "sentClosedEmail": false, + "sentPublishedEmail": false, + "isOpeningEmailEnabled": true, + "isClosingEmailEnabled": true, + "isPublishedEmailEnabled": true, + "studentDeadlines": {}, + "instructorDeadlines": {}, + "id": "00000000-0000-4000-8000-000000000702", + "course": { + "id": "tm.e2e.FNumScaleQn.CS1101" + }, + "name": "Second Session" + } + }, + "feedbackQuestions": { + "qn1ForFirstSession": { + "id": "00000000-0000-4000-8000-000000000801", + "feedbackSession": { + "id": "00000000-0000-4000-8000-000000000701" + }, + "questionDetails": { + "questionType": "NUMSCALE", + "questionText": "Rate this team's product", + "minScale": 0, + "maxScale": 10, + "step": 0.2 + }, + "description": "

      Testing description for first session

      ", + "questionNumber": 1, + "giverType": "STUDENTS", + "recipientType": "TEAMS_EXCLUDING_SELF", + "numOfEntitiesToGiveFeedbackTo": 1, + "showResponsesTo": ["INSTRUCTORS", "RECEIVER"], + "showGiverNameTo": ["INSTRUCTORS"], + "showRecipientNameTo": ["INSTRUCTORS", "RECEIVER"] + }, + "qn1ForSecondSession": { + "id": "00000000-0000-4000-8000-000000000802", + "feedbackSession": { + "id": "00000000-0000-4000-8000-000000000702" + }, + "questionDetails": { + "questionType": "NUMSCALE", + "questionText": "Rate this team's teamwork", + "minScale": 1, + "maxScale": 10, + "step": 0.005 + }, + "description": "

      Testing description for second session

      ", + "questionNumber": 1, + "giverType": "STUDENTS", + "recipientType": "TEAMS_EXCLUDING_SELF", + "numOfEntitiesToGiveFeedbackTo": 1, + "showResponsesTo": ["INSTRUCTORS", "RECEIVER"], + "showGiverNameTo": ["INSTRUCTORS"], + "showRecipientNameTo": ["INSTRUCTORS", "RECEIVER"] + } + }, + "notifications": {}, + "readNotifications": {}, + "feedbackResponseComments": {}, + "students": { + "alice.tmms@FNumScaleQn.CS2104": { + "id": "00000000-0000-4000-8000-000000000601", + "account": { + "id": "00000000-0000-4000-8000-000000000002" + }, + "course": { + "id": "tm.e2e.FNumScaleQn.CS2104" + }, + "team": { + "id": "00000000-0000-4000-8000-000000000201" + }, + "email": "alice.b.tmms@gmail.tmt", + "name": "Alice Betsy", + "comments": "This student's name is Alice Betsy" + }, + "benny.tmms@FNumScaleQn.CS2104": { + "id": "00000000-0000-4000-8000-000000000602", + "course": { + "id": "tm.e2e.FNumScaleQn.CS2104" + }, + "team": { + "id": "00000000-0000-4000-8000-000000000202" + }, + "email": "benny.tmms@gmail.tmt", + "name": "Benny Charles", + "comments": "This student's name is Benny Charles" + }, + "charlie.tmms@FNumScaleQn.CS1101": { + "id": "00000000-0000-4000-8000-000000000603", + "course": { + "id": "tm.e2e.FNumScaleQn.CS1101" + }, + "team": { + "id": "00000000-0000-4000-8000-000000000203" + }, + "email": "charlie.tmms@gmail.tmt", + "name": "Charlie Davis", + "comments": "This student's name is Charlie Davis" + } + } +} diff --git a/src/e2e/resources/testng-e2e-sql.xml b/src/e2e/resources/testng-e2e-sql.xml index 78dcc2d13a1..61cc506ff93 100644 --- a/src/e2e/resources/testng-e2e-sql.xml +++ b/src/e2e/resources/testng-e2e-sql.xml @@ -9,8 +9,10 @@ + + diff --git a/src/it/java/teammates/it/ui/webapi/GetCoursesActionIT.java b/src/it/java/teammates/it/ui/webapi/GetCoursesActionIT.java index 408821c38b7..b10149b70b0 100644 --- a/src/it/java/teammates/it/ui/webapi/GetCoursesActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/GetCoursesActionIT.java @@ -122,6 +122,7 @@ public void testGetCoursesAction_withStudentEntityType_shouldReturnCorrectCourse loginAsStudent(student.getGoogleId()); CoursesData courses = getValidCourses(params); + courses.getCourses().sort((c1, c2) -> c1.getCourseId().compareTo(c2.getCourseId())); assertEquals(3, courses.getCourses().size()); Course expectedCourse1 = typicalBundle.courses.get("typicalCourse1"); Course expectedCourse2 = typicalBundle.courses.get("typicalCourse2"); diff --git a/src/lnp/java/teammates/lnp/sql/BaseLNPTestCase.java b/src/lnp/java/teammates/lnp/sql/BaseLNPTestCase.java new file mode 100644 index 00000000000..94cb0eb698b --- /dev/null +++ b/src/lnp/java/teammates/lnp/sql/BaseLNPTestCase.java @@ -0,0 +1,361 @@ +package teammates.lnp.sql; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.StringJoiner; + +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.jmeter.engine.StandardJMeterEngine; +import org.apache.jmeter.report.config.ConfigurationException; +import org.apache.jmeter.report.dashboard.GenerationException; +import org.apache.jmeter.report.dashboard.ReportGenerator; +import org.apache.jmeter.reporters.ResultCollector; +import org.apache.jmeter.reporters.Summariser; +import org.apache.jmeter.save.SaveService; +import org.apache.jmeter.util.JMeterUtils; +import org.apache.jorphan.collections.HashTree; +import org.apache.jorphan.collections.ListedHashTree; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.stream.JsonReader; + +import teammates.common.datatransfer.SqlDataBundle; +import teammates.common.exception.HttpRequestFailedException; +import teammates.common.util.JsonUtils; +import teammates.common.util.Logger; +import teammates.lnp.util.BackDoor; +import teammates.lnp.util.LNPResultsStatistics; +import teammates.lnp.util.LNPSpecification; +import teammates.lnp.util.LNPSqlTestData; +import teammates.lnp.util.TestProperties; +import teammates.test.BaseTestCase; +import teammates.test.FileHelper; + +/** + * Base class for all L&P test cases. + */ +public abstract class BaseLNPTestCase extends BaseTestCase { + + static final String GET = HttpGet.METHOD_NAME; + static final String POST = HttpPost.METHOD_NAME; + static final String PUT = HttpPut.METHOD_NAME; + static final String DELETE = HttpDelete.METHOD_NAME; + + private static final Logger log = Logger.getLogger(); + + private static final int RESULT_COUNT = 3; + + final BackDoor backdoor = BackDoor.getInstance(); + String timeStamp; + LNPSpecification specification; + + /** + * Returns the test data used for the current test. + */ + protected abstract LNPSqlTestData getTestData(); + + /** + * Returns the JMeter test plan for the L&P test case. + * @return A nested tree structure that consists of the various elements that are used in the JMeter test. + */ + protected abstract ListedHashTree getLnpTestPlan(); + + /** + * Sets up the specification for this L&P test case. + */ + protected abstract void setupSpecification(); + + /** + * Returns the path to the generated JSON data bundle file. + */ + protected String getJsonDataPath() { + return "/" + getClass().getSimpleName() + timeStamp + ".json"; + } + + /** + * Returns the path to the generated JMeter CSV config file. + */ + protected String getCsvConfigPath() { + return "/" + getClass().getSimpleName() + "Config" + timeStamp + ".csv"; + } + + /** + * Returns the path to the generated JTL test results file. + */ + protected String getJtlResultsPath() { + return "/" + getClass().getSimpleName() + timeStamp + ".jtl"; + } + + @Override + protected String getTestDataFolder() { + return TestProperties.LNP_TEST_DATA_FOLDER; + } + + /** + * Returns the path to the data file, relative to the project root directory. + */ + protected String getPathToTestDataFile(String fileName) { + return getTestDataFolder() + fileName; + } + + /** + * Returns the path to the JSON test results statistics file, relative to the project root directory. + */ + private String getPathToTestStatisticsResultsFile() { + return String.format("%s/%sStatistics%s.json", TestProperties.LNP_TEST_RESULTS_FOLDER, + this.getClass().getSimpleName(), this.timeStamp); + } + + String createFileAndDirectory(String directory, String fileName) throws IOException { + File dir = new File(directory); + if (!dir.exists()) { + dir.mkdir(); + } + + String pathToFile = directory + fileName; + File file = new File(pathToFile); + + // Write data to the file; overwrite if it already exists + if (file.exists()) { + file.delete(); + } + file.createNewFile(); + return pathToFile; + } + + /** + * Creates the JSON data and writes it to the file specified by {@link #getJsonDataPath()}. + */ + void createJsonDataFile(LNPSqlTestData testData) throws IOException { + SqlDataBundle jsonData = testData.generateJsonData(); + + String pathToResultFile = createFileAndDirectory(TestProperties.LNP_TEST_DATA_FOLDER, getJsonDataPath()); + try (BufferedWriter bw = Files.newBufferedWriter(Paths.get(pathToResultFile))) { + bw.write(JsonUtils.toJson(jsonData, SqlDataBundle.class)); + bw.flush(); + } + } + + /** + * Creates the CSV data and writes it to the file specified by {@link #getCsvConfigPath()}. + */ + private void createCsvConfigDataFile(LNPSqlTestData testData) throws IOException { + List headers = testData.generateCsvHeaders(); + List> valuesList = testData.generateCsvData(); + + String pathToCsvFile = createFileAndDirectory(TestProperties.LNP_TEST_DATA_FOLDER, getCsvConfigPath()); + try (BufferedWriter bw = Files.newBufferedWriter(Paths.get(pathToCsvFile))) { + // Write headers and data to the CSV file + bw.write(convertToCsv(headers)); + + for (List values : valuesList) { + bw.write(convertToCsv(values)); + } + + bw.flush(); + } + } + + /** + * Converts the list of {@code values} to a CSV row. + * @return A single string containing {@code values} separated by pipelines and ending with newline. + */ + String convertToCsv(List values) { + StringJoiner csvRow = new StringJoiner("|", "", "\n"); + for (String value : values) { + csvRow.add(value); + } + return csvRow.toString(); + } + + /** + * Returns the L&P test results statistics. + * @return The initialized result statistics from the L&P test results. + * @throws IOException if there is an error when loading the result file. + */ + private LNPResultsStatistics getResultsStatistics() throws IOException { + Gson gson = new Gson(); + JsonReader reader = new JsonReader(Files.newBufferedReader(Paths.get(getPathToTestStatisticsResultsFile()))); + JsonObject jsonObject = gson.fromJson(reader, JsonObject.class); + + JsonObject endpointStats = jsonObject.getAsJsonObject("HTTP Request Sampler"); + return gson.fromJson(endpointStats, LNPResultsStatistics.class); + } + + /** + * Renames the default results statistics file to the name of the test. + */ + private void renameStatisticsFile() { + File defaultFile = new File(TestProperties.LNP_TEST_RESULTS_FOLDER + "/statistics.json"); + File lnpStatisticsFile = new File(getPathToTestStatisticsResultsFile()); + + if (lnpStatisticsFile.exists()) { + lnpStatisticsFile.delete(); + } + if (!defaultFile.renameTo(lnpStatisticsFile)) { + log.warning("Failed to rename generated statistics.json file."); + } + } + + /** + * Setup and load the JMeter configuration and property files to run the Jmeter test. + * @throws IOException if the save service properties file cannot be loaded. + */ + private void setJmeterProperties() throws IOException { + JMeterUtils.loadJMeterProperties(TestProperties.JMETER_PROPERTIES_PATH); + JMeterUtils.setJMeterHome(TestProperties.JMETER_HOME); + JMeterUtils.initLocale(); + SaveService.loadProperties(); + } + + /** + * Creates the JSON test data and CSV config data files for the performance test from {@code testData}. + */ + protected void createTestData() throws IOException, HttpRequestFailedException { + LNPSqlTestData testData = getTestData(); + createJsonDataFile(testData); + persistTestData(); + createCsvConfigDataFile(testData); + } + + /** + * Creates the entities in the database from the JSON data file. + */ + protected void persistTestData() throws IOException, HttpRequestFailedException { + SqlDataBundle dataBundle = loadSqlDataBundle(getJsonDataPath()); + SqlDataBundle responseBody = backdoor.removeAndRestoreSqlDataBundle(dataBundle); + + String pathToResultFile = createFileAndDirectory(TestProperties.LNP_TEST_DATA_FOLDER, getJsonDataPath()); + String jsonValue = JsonUtils.toJson(responseBody, SqlDataBundle.class); + FileHelper.saveFile(pathToResultFile, jsonValue); + } + + /** + * Display the L&P results on the console. + */ + protected void displayLnpResults() throws IOException { + LNPResultsStatistics resultsStats = getResultsStatistics(); + + resultsStats.displayLnpResultsStatistics(); + specification.verifyLnpTestSuccess(resultsStats); + } + + /** + * Runs the JMeter test. + * @param shouldCreateJmxFile true if the generated test plan should be saved to a `.jmx` file which + * can be opened in the JMeter GUI, and false otherwise. + */ + protected void runJmeter(boolean shouldCreateJmxFile) throws IOException { + StandardJMeterEngine jmeter = new StandardJMeterEngine(); + setJmeterProperties(); + + HashTree testPlan = getLnpTestPlan(); + + if (shouldCreateJmxFile) { + String pathToConfigFile = createFileAndDirectory( + TestProperties.LNP_TEST_CONFIG_FOLDER, "/" + getClass().getSimpleName() + ".jmx"); + SaveService.saveTree(testPlan, Files.newOutputStream(Paths.get(pathToConfigFile))); + } + + // Add result collector to the test plan for generating results file + Summariser summariser = null; + String summariserName = JMeterUtils.getPropDefault("summariser.name", "summary"); + if (summariserName.length() > 0) { + summariser = new Summariser(summariserName); + } + + String resultsFile = createFileAndDirectory(TestProperties.LNP_TEST_RESULTS_FOLDER, getJtlResultsPath()); + ResultCollector resultCollector = new ResultCollector(summariser); + resultCollector.setFilename(resultsFile); + testPlan.add(testPlan.getArray()[0], resultCollector); + + // Run Jmeter Test + jmeter.configure(testPlan); + jmeter.run(); + + try { + ReportGenerator reportGenerator = new ReportGenerator(resultsFile, null); + reportGenerator.generate(); + } catch (ConfigurationException | GenerationException e) { + log.warning(e.getMessage()); + } + + renameStatisticsFile(); + } + + /** + * Deletes the data that was created in the database from the JSON data file. + */ + protected void deleteTestData() { + SqlDataBundle dataBundle = loadSqlDataBundle(getJsonDataPath()); + backdoor.removeSqlDataBundle(dataBundle); + } + + /** + * Deletes the JSON and CSV data files that were created. + */ + protected void deleteDataFiles() throws IOException { + String pathToJsonFile = getPathToTestDataFile(getJsonDataPath()); + String pathToCsvFile = getPathToTestDataFile(getCsvConfigPath()); + + Files.delete(Paths.get(pathToJsonFile)); + Files.delete(Paths.get(pathToCsvFile)); + } + + /** + * Deletes the oldest excess result .jtl file and the statistics file, if there are more than RESULT_COUNT. + */ + protected void cleanupResults() throws IOException { + File[] fileList = new File(TestProperties.LNP_TEST_RESULTS_FOLDER) + .listFiles((d, s) -> { + return s.contains(this.getClass().getSimpleName()); + }); + if (fileList == null) { + fileList = new File[] {}; + } + Arrays.sort(fileList, (a, b) -> { + return b.getName().compareTo(a.getName()); + }); + + int jtlCounter = 0; + int statisticsCounter = 0; + for (File file : fileList) { + if (file.getName().contains("Statistics")) { + statisticsCounter++; + if (statisticsCounter > RESULT_COUNT) { + Files.delete(file.toPath()); + } + } else { + jtlCounter++; + if (jtlCounter > RESULT_COUNT) { + Files.delete(file.toPath()); + } + } + } + } + + /** + * Sanitize the string to be CSV-safe string. + */ + protected String sanitizeForCsv(String originalString) { + return String.format("\"%s\"", originalString.replace(System.lineSeparator(), "").replace("\"", "\"\"")); + } + + /** + * Generates timestamp for generated statistics/CSV files in order to prevent concurrency issues. + */ + protected void generateTimeStamp() { + this.timeStamp = ZonedDateTime.now().format(DateTimeFormatter.ofPattern("_uuuuMMddHHmmss")); + } +} diff --git a/src/lnp/java/teammates/lnp/sql/InstructorCourseUpdateLNPTest.java b/src/lnp/java/teammates/lnp/sql/InstructorCourseUpdateLNPTest.java new file mode 100644 index 00000000000..6196fd1f916 --- /dev/null +++ b/src/lnp/java/teammates/lnp/sql/InstructorCourseUpdateLNPTest.java @@ -0,0 +1,216 @@ +package teammates.lnp.sql; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.jmeter.protocol.http.control.HeaderManager; +import org.apache.jorphan.collections.HashTree; +import org.apache.jorphan.collections.ListedHashTree; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import teammates.common.datatransfer.InstructorPermissionRole; +import teammates.common.datatransfer.InstructorPrivileges; +import teammates.common.datatransfer.SqlDataBundle; +import teammates.common.exception.HttpRequestFailedException; +import teammates.common.util.Const; +import teammates.common.util.JsonUtils; +import teammates.lnp.util.JMeterElements; +import teammates.lnp.util.LNPSpecification; +import teammates.lnp.util.LNPSqlTestData; +import teammates.storage.sqlentity.Account; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.Instructor; +import teammates.ui.request.CourseUpdateRequest; + +/** + * L&P Test Case for course update cascade API. + */ +public class InstructorCourseUpdateLNPTest extends BaseLNPTestCase { + private static final int NUM_INSTRUCTORS = 1; + private static final int RAMP_UP_PERIOD = NUM_INSTRUCTORS * 2; + + private static final int NUM_FEEDBACK_SESSIONS = 500; + + private static final String COURSE_ID = "TestData.CS101"; + private static final String COURSE_NAME = "LnPCourse"; + private static final String COURSE_TIME_ZONE = "UTC"; + private static final String COURSE_INSTITUTE = "LnpInstitute"; + + private static final String ACCOUNT_NAME = "LnpAccount"; + + private static final String UPDATE_COURSE_NAME = "updatedCourse"; + private static final String UPDATE_COURSE_TIME_ZONE = "GMT"; + + private static final String INSTRUCTOR_ID = "LnPInstructor_id"; + private static final String INSTRUCTOR_NAME = "LnPInstructor"; + private static final String INSTRUCTOR_EMAIL = "tmms.test@gmail.tmt"; + + private static final String FEEDBACK_SESSION_NAME = "Test Feedback Session"; + + private static final double ERROR_RATE_LIMIT = 0.01; + private static final double MEAN_RESP_TIME_LIMIT = 10; + + @Override + protected LNPSqlTestData getTestData() { + Account instructorAccount = new Account(INSTRUCTOR_ID, ACCOUNT_NAME, INSTRUCTOR_EMAIL); + Course instructorCourse = new Course(COURSE_ID, COURSE_NAME, COURSE_TIME_ZONE, COURSE_INSTITUTE); + return new LNPSqlTestData() { + @Override + protected Map generateCourses() { + Map courses = new HashMap<>(); + + courses.put(COURSE_NAME, instructorCourse); + + return courses; + } + + @Override + protected Map generateAccounts() { + Map accounts = new HashMap<>(); + + accounts.put(ACCOUNT_NAME, instructorAccount); + + return accounts; + } + + @Override + protected Map generateInstructors() { + Map instructors = new HashMap<>(); + + Instructor instructor = new Instructor( + instructorCourse, INSTRUCTOR_NAME, INSTRUCTOR_EMAIL, + true, "Co-owner", InstructorPermissionRole.INSTRUCTOR_PERMISSION_ROLE_COOWNER, + new InstructorPrivileges(Const.InstructorPermissionRoleNames.INSTRUCTOR_PERMISSION_ROLE_COOWNER)); + + instructor.setAccount(instructorAccount); + instructors.put(INSTRUCTOR_NAME, instructor); + + return instructors; + } + + @Override + protected Map generateFeedbackSessions() { + Map feedbackSessions = new LinkedHashMap<>(); + + for (int i = 1; i <= NUM_FEEDBACK_SESSIONS; i++) { + Instant now = Instant.now(); + FeedbackSession session = new FeedbackSession(FEEDBACK_SESSION_NAME + " " + i, + instructorCourse, INSTRUCTOR_EMAIL, "", + now.plus(Duration.ofMinutes(1)), now.plus(Duration.ofDays(1)), + now, now.plus(Duration.ofDays(2)), null, false, false, false); + + feedbackSessions.put(FEEDBACK_SESSION_NAME + " " + i, session); + } + + return feedbackSessions; + } + + @Override + public List generateCsvHeaders() { + List headers = new ArrayList<>(); + + headers.add("loginId"); + headers.add("courseId"); + headers.add("updateData"); + + return headers; + } + + @Override + public List> generateCsvData() { + SqlDataBundle dataBundle = loadSqlDataBundle(getJsonDataPath()); + List> csvData = new ArrayList<>(); + + dataBundle.instructors.forEach((key, instructor) -> { + List csvRow = new ArrayList<>(); + + csvRow.add(INSTRUCTOR_ID); + csvRow.add(COURSE_ID); + + CourseUpdateRequest courseUpdateRequest = new CourseUpdateRequest(); + courseUpdateRequest.setCourseName(UPDATE_COURSE_NAME); + courseUpdateRequest.setTimeZone(UPDATE_COURSE_TIME_ZONE); + + String updateData = sanitizeForCsv(JsonUtils.toJson(courseUpdateRequest)); + csvRow.add(updateData); + + csvData.add(csvRow); + }); + + return csvData; + } + }; + } + + private Map getRequestHeaders() { + Map headers = new HashMap<>(); + + headers.put(Const.HeaderNames.CSRF_TOKEN, "${csrfToken}"); + headers.put("Content-Type", "application/json"); + + return headers; + } + + private String getTestEndpoint() { + return Const.ResourceURIs.COURSE + "?courseid=${courseId}"; + } + + @Override + protected ListedHashTree getLnpTestPlan() { + ListedHashTree testPlan = new ListedHashTree(JMeterElements.testPlan()); + HashTree threadGroup = testPlan.add( + JMeterElements.threadGroup(NUM_INSTRUCTORS, RAMP_UP_PERIOD, 1)); + + threadGroup.add(JMeterElements.csvDataSet(getPathToTestDataFile(getCsvConfigPath()))); + threadGroup.add(JMeterElements.cookieManager()); + threadGroup.add(JMeterElements.defaultSampler()); + + threadGroup.add(JMeterElements.onceOnlyController()) + .add(JMeterElements.loginSampler()) + .add(JMeterElements.csrfExtractor("csrfToken")); + + // Add HTTP sampler for test endpoint + HeaderManager headerManager = JMeterElements.headerManager(getRequestHeaders()); + threadGroup.add(JMeterElements.httpSampler(getTestEndpoint(), PUT, "${updateData}")) + .add(headerManager); + + return testPlan; + } + + @Override + protected void setupSpecification() { + this.specification = LNPSpecification.builder() + .withErrorRateLimit(ERROR_RATE_LIMIT) + .withMeanRespTimeLimit(MEAN_RESP_TIME_LIMIT) + .build(); + } + + @BeforeClass + public void classSetup() throws IOException, HttpRequestFailedException { + generateTimeStamp(); + createTestData(); + setupSpecification(); + } + + @Test + public void runLnpTest() throws IOException { + runJmeter(false); + displayLnpResults(); + } + + @AfterClass + public void classTearDown() throws IOException { + deleteTestData(); + deleteDataFiles(); + cleanupResults(); + } +} diff --git a/src/lnp/java/teammates/lnp/sql/package-info.java b/src/lnp/java/teammates/lnp/sql/package-info.java new file mode 100644 index 00000000000..bf87f3a9fe8 --- /dev/null +++ b/src/lnp/java/teammates/lnp/sql/package-info.java @@ -0,0 +1,4 @@ +/** + * Contains L&P test cases. + */ +package teammates.lnp.sql; diff --git a/src/lnp/java/teammates/lnp/util/LNPSqlTestData.java b/src/lnp/java/teammates/lnp/util/LNPSqlTestData.java new file mode 100644 index 00000000000..48aebf22f2d --- /dev/null +++ b/src/lnp/java/teammates/lnp/util/LNPSqlTestData.java @@ -0,0 +1,92 @@ +package teammates.lnp.util; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import teammates.common.datatransfer.SqlDataBundle; +import teammates.storage.sqlentity.Account; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackQuestion; +import teammates.storage.sqlentity.FeedbackResponse; +import teammates.storage.sqlentity.FeedbackResponseComment; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.Instructor; +import teammates.storage.sqlentity.Student; + +/** + * L&P test data generator. + */ +public abstract class LNPSqlTestData { + + // CHECKSTYLE.OFF:MissingJavadocMethod generator for different entities are self-explained by the method name + + protected Map generateAccounts() { + return new HashMap<>(); + } + + protected Map generateCourses() { + return new HashMap<>(); + } + + protected Map generateInstructors() { + return new HashMap<>(); + } + + protected Map generateStudents() { + return new HashMap<>(); + } + + protected Map generateFeedbackSessions() { + return new HashMap<>(); + } + + protected Map generateFeedbackQuestions() { + return new HashMap<>(); + } + + protected Map generateFeedbackResponses() { + return new HashMap<>(); + } + + protected Map generateFeedbackResponseComments() { + return new HashMap<>(); + } + + // CHECKSTYLE.ON:MissingJavadocMethod + + /** + * Returns a JSON data bundle containing the data relevant for the performance test. + */ + public SqlDataBundle generateJsonData() { + SqlDataBundle dataBundle = new SqlDataBundle(); + + dataBundle.accounts = generateAccounts(); + dataBundle.courses = generateCourses(); + dataBundle.instructors = generateInstructors(); + dataBundle.students = generateStudents(); + dataBundle.feedbackSessions = generateFeedbackSessions(); + dataBundle.feedbackQuestions = generateFeedbackQuestions(); + dataBundle.feedbackResponses = generateFeedbackResponses(); + dataBundle.feedbackResponseComments = generateFeedbackResponseComments(); + + return dataBundle; + } + + /** + * Returns list of header fields for the data in the CSV file to be generated. + * + *

      Note that these header names should correspond to the variables used in the JMeter L&P test.

      + */ + public abstract List generateCsvHeaders(); + + /** + * Returns the data for the entries in the CSV file to be generated. + * The order of the field values for each entry should correspond to the order of headers specified + * in {@link #generateCsvHeaders()}. + * + * @return List of entries, which are made up of a list of field values. + */ + public abstract List> generateCsvData(); + +} diff --git a/src/main/java/teammates/common/util/HibernateUtil.java b/src/main/java/teammates/common/util/HibernateUtil.java index 5fb180758fc..8edcca6c807 100644 --- a/src/main/java/teammates/common/util/HibernateUtil.java +++ b/src/main/java/teammates/common/util/HibernateUtil.java @@ -111,15 +111,18 @@ public static void buildSessionFactory(String dbUrl, String username, String pas Configuration config = new Configuration() .setProperty("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect") .setProperty("hibernate.connection.driver_class", "org.postgresql.Driver") + .setProperty("hibernate.connection.provider_class", + "org.hibernate.hikaricp.internal.HikariCPConnectionProvider") .setProperty("hibernate.connection.username", username) .setProperty("hibernate.connection.password", password) .setProperty("hibernate.connection.url", dbUrl) - .setProperty("hibernate.hbm2ddl.auto", "update") + .setProperty("hibernate.hbm2ddl.auto", "validate") .setProperty("show_sql", "true") .setProperty("hibernate.current_session_context_class", "thread") - .setProperty("hibernate.agroal.minSize", "5") - .setProperty("hibernate.agroal.maxSize", "50") - .setProperty("hibernate.agroal.reapTimeout", "PT1M") + .setProperty("hibernate.hikari.minimumIdle", "10") + .setProperty("hibernate.hikari.maximumPoolSize", "30") + .setProperty("hibernate.hikari.idleTimeout", "300000") + .setProperty("hibernate.hikari.connectionTimeout", "30000") // Uncomment only during migration for optimized batch-insertion, batch-update, and batch-fetch. // .setProperty("hibernate.jdbc.batch_size", "50") // .setProperty("hibernate.order_updates", "true") @@ -127,6 +130,10 @@ public static void buildSessionFactory(String dbUrl, String username, String pas // .setProperty("hibernate.jdbc.fetch_size", "50") .addPackage("teammates.storage.sqlentity"); + if (Config.IS_DEV_SERVER) { + config.setProperty("hibernate.hbm2ddl.auto", "update"); + } + for (Class cls : ANNOTATED_CLASSES) { config = config.addAnnotatedClass(cls); } diff --git a/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java b/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java index 2fb76991cc6..cc5fc4507e7 100644 --- a/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java +++ b/src/main/java/teammates/sqllogic/api/SqlEmailGenerator.java @@ -257,12 +257,10 @@ public EmailWrapper generateFeedbackSessionSummaryOfCourse( Course course = coursesLogic.getCourse(courseId); boolean isInstructor = emailType == EmailType.INSTRUCTOR_COURSE_LINKS_REGENERATED; - Student student = null; + Student student = usersLogic.getStudentForEmail(courseId, userEmail); Instructor instructor = null; if (isInstructor) { instructor = usersLogic.getInstructorForEmail(courseId, userEmail); - } else { - student = usersLogic.getStudentForEmail(courseId, userEmail); } List sessions = new ArrayList<>(); @@ -869,11 +867,11 @@ private EmailWrapper generateFeedbackSessionEmailBaseForNotifiedInstructors( } private boolean isYetToJoinCourse(Student student) { - return student.getAccount().getGoogleId() == null || student.getAccount().getGoogleId().isEmpty(); + return student.getAccount() == null || student.getAccount().getGoogleId().isEmpty(); } private boolean isYetToJoinCourse(Instructor instructor) { - return instructor.getAccount().getGoogleId() == null || instructor.getAccount().getGoogleId().isEmpty(); + return instructor.getAccount() == null || instructor.getAccount().getGoogleId().isEmpty(); } /** diff --git a/src/main/java/teammates/storage/sqlentity/FeedbackSession.java b/src/main/java/teammates/storage/sqlentity/FeedbackSession.java index cf25a3897b5..9f91c1b552f 100644 --- a/src/main/java/teammates/storage/sqlentity/FeedbackSession.java +++ b/src/main/java/teammates/storage/sqlentity/FeedbackSession.java @@ -8,8 +8,6 @@ import java.util.UUID; import org.apache.commons.lang.StringUtils; -import org.hibernate.annotations.Fetch; -import org.hibernate.annotations.FetchMode; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; import org.hibernate.annotations.UpdateTimestamp; @@ -92,12 +90,10 @@ public class FeedbackSession extends BaseEntity { private boolean isPublishedEmailSent; @OneToMany(mappedBy = "feedbackSession", cascade = CascadeType.REMOVE) - @Fetch(FetchMode.JOIN) @OnDelete(action = OnDeleteAction.CASCADE) private List deadlineExtensions = new ArrayList<>(); @OneToMany(mappedBy = "feedbackSession", cascade = CascadeType.REMOVE) - @Fetch(FetchMode.JOIN) @OnDelete(action = OnDeleteAction.CASCADE) private List feedbackQuestions = new ArrayList<>(); diff --git a/src/main/java/teammates/storage/sqlentity/questions/FeedbackRankOptionsQuestion.java b/src/main/java/teammates/storage/sqlentity/questions/FeedbackRankOptionsQuestion.java index 9693901d041..ba135c7cae8 100644 --- a/src/main/java/teammates/storage/sqlentity/questions/FeedbackRankOptionsQuestion.java +++ b/src/main/java/teammates/storage/sqlentity/questions/FeedbackRankOptionsQuestion.java @@ -33,8 +33,7 @@ public FeedbackRankOptionsQuestion( String description, FeedbackParticipantType giverType, FeedbackParticipantType recipientType, Integer numOfEntitiesToGiveFeedbackTo, List showResponsesTo, List showGiverNameTo, List showRecipientNameTo, - FeedbackQuestionDetails feedbackQuestionDetails - ) { + FeedbackQuestionDetails feedbackQuestionDetails) { super(feedbackSession, questionNumber, description, giverType, recipientType, numOfEntitiesToGiveFeedbackTo, showResponsesTo, showGiverNameTo, showRecipientNameTo); setFeedBackQuestionDetails((FeedbackRankOptionsQuestionDetails) feedbackQuestionDetails); diff --git a/src/main/resources/db/changelog/db.changelog-root.xml b/src/main/resources/db/changelog/db.changelog-root.xml index 66d4b7d7c88..af5c5e34d7e 100644 --- a/src/main/resources/db/changelog/db.changelog-root.xml +++ b/src/main/resources/db/changelog/db.changelog-root.xml @@ -4,5 +4,5 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> - + diff --git a/src/main/resources/db/changelog/db.changelog-v9.0.0.xml b/src/main/resources/db/changelog/db.changelog-v9.0.0.xml new file mode 100644 index 00000000000..94c02f08ed6 --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-v9.0.0.xml @@ -0,0 +1,539 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/db.changelog-v9.xml b/src/main/resources/db/changelog/db.changelog-v9.xml deleted file mode 100644 index 57b6e7f9587..00000000000 --- a/src/main/resources/db/changelog/db.changelog-v9.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/test/java/teammates/architecture/ArchitectureTest.java b/src/test/java/teammates/architecture/ArchitectureTest.java index 1113db61372..83d1221a6e2 100644 --- a/src/test/java/teammates/architecture/ArchitectureTest.java +++ b/src/test/java/teammates/architecture/ArchitectureTest.java @@ -49,6 +49,7 @@ public class ArchitectureTest { private static final String LNP_PACKAGE = "teammates.lnp"; private static final String LNP_CASES_PACKAGE = LNP_PACKAGE + ".cases"; + private static final String LNP_SQL_PACKAGE = LNP_PACKAGE + ".sql"; private static final String LNP_UTIL_PACKAGE = LNP_PACKAGE + ".util"; private static final String CLIENT_PACKAGE = "teammates.client"; @@ -413,7 +414,13 @@ public void testArchitecture_lnp_lnpShouldBeSelfContained() { @Test public void testArchitecture_lnp_lnpShouldNotTouchProductionCodeExceptCommonAndRequests() { noClasses().that().resideInAPackage(includeSubpackages(LNP_PACKAGE)) - .should().accessClassesThat().resideInAPackage(includeSubpackages(STORAGE_PACKAGE)) + .should().accessClassesThat(new DescribedPredicate<>("") { + @Override + public boolean apply(JavaClass input) { + return input.getPackageName().startsWith(STORAGE_PACKAGE) + && !input.getPackageName().startsWith(STORAGE_SQL_ENTITY_PACKAGE); + } + }) .orShould().accessClassesThat().resideInAPackage(includeSubpackages(LOGIC_PACKAGE)) .orShould().accessClassesThat(new DescribedPredicate<>("") { @Override @@ -442,6 +449,23 @@ public boolean apply(JavaClass input) { }).check(forClasses(LNP_CASES_PACKAGE)); } + @Test + public void testArchitecture_lnp_lnpSqlTestCasesShouldBeIndependentOfEachOther() { + noClasses().that(new DescribedPredicate<>("") { + @Override + public boolean apply(JavaClass input) { + return input.getPackageName().startsWith(LNP_SQL_PACKAGE) && !input.isInnerClass(); + } + }).should().accessClassesThat(new DescribedPredicate<>("") { + @Override + public boolean apply(JavaClass input) { + return input.getPackageName().startsWith(LNP_SQL_PACKAGE) + && !input.getSimpleName().startsWith("Base") + && !input.isInnerClass(); + } + }).check(forClasses(LNP_SQL_PACKAGE)); + } + @Test public void testArchitecture_lnp_lnpShouldNotHaveAnyDependency() { noClasses().that().resideInAPackage(includeSubpackages(LNP_UTIL_PACKAGE)) From 8d506a13d6a11feb0d7129b1c2563e82b7b5527d Mon Sep 17 00:00:00 2001 From: DS Date: Sat, 13 Apr 2024 23:26:40 +0800 Subject: [PATCH 51/95] [#11843] Update front end for session activity logs (#12973) * Create FeedbackSessionLog entity * fix lint * Create UpdateFeedbackSessionLogsAction * Sort query results from logging service * Update type of feedbackSessionLogType * Fix naming * Fix enum in entity * Update filter to differentiate by session * Add Uri Info * Add tests * Update test case * Update to getOrderedFeedbackSessionLogs * Create skeleton * Implement logic and db layer * fix lint * Update entity * Fix tests * Update action to use fslDb * Fix tests * Update DbIT to use databundle * Fix bugs and optimize action * Prevent courseId from being null * Update GCP logs to store ids * Fix tests * Update action to use reference * Add some error handling * Fix tests * Add ids to api output * Fix lint * Update front end * Update cron.yaml * Update front end * Fix result display * Tidy up code * Update actions to use getUuid * Fix formatting * Fix bug * Add buttons to access page * Shift logging * fiox bug * fix fe tests * Fix bug * Fix tests * Add IT * remove email and fsname * fix ts lint * Fix status message * Remove front end error messages * Add assertion fortests * Fix migrated check * Change to use id * Update javadoc * Change cron job to 15 mins intervals * fix tests * fix fe bug * Add delay note * Update to use const * Add const * fix fe tests --- .../CreateFeedbackSessionLogActionIT.java | 160 ++++++++++++++++++ .../GetFeedbackSessionLogsActionIT.java | 17 +- .../UpdateFeedbackSessionLogsActionIT.java | 121 +++++-------- src/main/appengine/cron.yaml | 2 +- .../datatransfer/FeedbackSessionLogEntry.java | 8 +- .../java/teammates/common/util/Const.java | 5 + .../teammates/common/util/TimeHelper.java | 12 ++ .../teammates/logic/api/LogsProcessor.java | 5 +- .../external/GoogleCloudLoggingService.java | 17 +- .../logic/external/LocalLoggingService.java | 5 +- .../teammates/logic/external/LogService.java | 5 +- .../java/teammates/ui/constants/ApiConst.java | 4 +- .../java/teammates/ui/output/CourseData.java | 8 + .../CreateFeedbackSessionLogAction.java | 28 +-- .../webapi/GetFeedbackSessionLogsAction.java | 30 +++- .../UpdateFeedbackSessionLogsAction.java | 16 +- .../teammates/common/util/TimeHelperTest.java | 64 +++++++ .../logic/api/MockLogsProcessor.java | 11 +- .../GetFeedbackSessionLogsActionTest.java | 24 +-- .../UpdateFeedbackSessionLogsActionTest.java | 96 +++++------ .../CreateFeedbackSessionLogActionTest.java | 29 ++-- .../instructor-courses-page.component.html | 7 +- .../instructor-home-page.component.html | 5 + ...udent-activity-logs.component.spec.ts.snap | 42 +++-- ...uctor-student-activity-logs.component.html | 12 +- ...or-student-activity-logs.component.spec.ts | 10 +- ...tructor-student-activity-logs.component.ts | 120 +++++++++---- ...session-result-page.component.spec.ts.snap | 16 ++ .../session-result-page.component.spec.ts | 2 +- .../session-result-page.component.ts | 48 ++++-- ...ion-submission-page.component.spec.ts.snap | 16 ++ .../session-submission-page.component.ts | 53 +++--- src/web/services/log.service.ts | 20 +++ 33 files changed, 698 insertions(+), 320 deletions(-) create mode 100644 src/it/java/teammates/it/ui/webapi/CreateFeedbackSessionLogActionIT.java diff --git a/src/it/java/teammates/it/ui/webapi/CreateFeedbackSessionLogActionIT.java b/src/it/java/teammates/it/ui/webapi/CreateFeedbackSessionLogActionIT.java new file mode 100644 index 00000000000..c15c45752cc --- /dev/null +++ b/src/it/java/teammates/it/ui/webapi/CreateFeedbackSessionLogActionIT.java @@ -0,0 +1,160 @@ +package teammates.it.ui.webapi; + +import java.util.UUID; + +import org.testng.annotations.Test; + +import teammates.common.datatransfer.logs.FeedbackSessionLogType; +import teammates.common.util.Const; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackSession; +import teammates.storage.sqlentity.Student; +import teammates.ui.output.MessageOutput; +import teammates.ui.webapi.CreateFeedbackSessionLogAction; +import teammates.ui.webapi.JsonResult; + +/** + * SUT: {@link CreateFeedbackSessionLogAction}. + */ +public class CreateFeedbackSessionLogActionIT extends BaseActionIT { + + @Override + protected String getActionUri() { + return Const.ResourceURIs.SESSION_LOGS; + } + + @Override + protected String getRequestMethod() { + return POST; + } + + @Test + @Override + protected void testExecute() throws Exception { + Course course1 = typicalBundle.courses.get("course1"); + String courseId1 = course1.getId(); + FeedbackSession fs1 = typicalBundle.feedbackSessions.get("session1InCourse1"); + FeedbackSession fs2 = typicalBundle.feedbackSessions.get("session2InTypicalCourse"); + Student student1 = typicalBundle.students.get("student1InCourse1"); + Student student2 = typicalBundle.students.get("student2InCourse1"); + Student student3 = typicalBundle.students.get("student1InCourse3"); + + ______TS("Failure case: not enough parameters"); + verifyHttpParameterFailure(Const.ParamsNames.COURSE_ID, courseId1); + verifyHttpParameterFailure( + Const.ParamsNames.COURSE_ID, courseId1, + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName() + ); + verifyHttpParameterFailure( + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), + Const.ParamsNames.STUDENT_EMAIL, student1.getEmail() + ); + verifyHttpParameterFailure( + Const.ParamsNames.COURSE_ID, courseId1, + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.ACCESS.getLabel(), + Const.ParamsNames.STUDENT_EMAIL, student1.getEmail() + ); + verifyHttpParameterFailure( + Const.ParamsNames.COURSE_ID, courseId1, + Const.ParamsNames.FEEDBACK_SESSION_ID, fs2.getId().toString(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), + Const.ParamsNames.STUDENT_SQL_ID, student2.getId().toString() + ); + + ______TS("Failure case: invalid log type"); + String[] paramsInvalid = { + Const.ParamsNames.COURSE_ID, courseId1, + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, "invalid log type", + Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), + Const.ParamsNames.FEEDBACK_SESSION_ID, fs1.getId().toString(), + Const.ParamsNames.STUDENT_SQL_ID, student1.getId().toString(), + }; + verifyHttpParameterFailure(paramsInvalid); + + ______TS("Success case: typical access"); + String[] paramsSuccessfulAccess = { + Const.ParamsNames.COURSE_ID, courseId1, + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.ACCESS.getLabel(), + Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), + Const.ParamsNames.FEEDBACK_SESSION_ID, fs1.getId().toString(), + Const.ParamsNames.STUDENT_SQL_ID, student1.getId().toString(), + }; + JsonResult response = getJsonResult(getAction(paramsSuccessfulAccess)); + MessageOutput output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); + + ______TS("Success case: typical submission"); + String[] paramsSuccessfulSubmission = { + Const.ParamsNames.COURSE_ID, courseId1, + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs2.getName(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), + Const.ParamsNames.STUDENT_EMAIL, student2.getEmail(), + Const.ParamsNames.FEEDBACK_SESSION_ID, fs2.getId().toString(), + Const.ParamsNames.STUDENT_SQL_ID, student2.getId().toString(), + }; + response = getJsonResult(getAction(paramsSuccessfulSubmission)); + output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); + + ______TS("Success case: should create even for invalid parameters"); + String[] paramsNonExistentCourseId = { + Const.ParamsNames.COURSE_ID, "non-existent-course-id", + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), + Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), + Const.ParamsNames.FEEDBACK_SESSION_ID, fs1.getId().toString(), + Const.ParamsNames.STUDENT_SQL_ID, student1.getId().toString(), + }; + response = getJsonResult(getAction(paramsNonExistentCourseId)); + output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); + + ______TS("Success case: should create even for invalid parameters"); + String[] paramsNonExistentFsName = { + Const.ParamsNames.COURSE_ID, courseId1, + Const.ParamsNames.FEEDBACK_SESSION_NAME, "non-existent-feedback-session-name", + Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), + Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), + Const.ParamsNames.FEEDBACK_SESSION_ID, UUID.randomUUID().toString(), + Const.ParamsNames.STUDENT_SQL_ID, student1.getId().toString(), + }; + response = getJsonResult(getAction(paramsNonExistentFsName)); + output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); + + String[] paramsNonExistentStudentEmail = { + Const.ParamsNames.COURSE_ID, courseId1, + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), + Const.ParamsNames.STUDENT_EMAIL, "non-existent-student@email.com", + Const.ParamsNames.FEEDBACK_SESSION_ID, fs1.getId().toString(), + Const.ParamsNames.STUDENT_SQL_ID, UUID.randomUUID().toString(), + }; + response = getJsonResult(getAction(paramsNonExistentStudentEmail)); + output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); + + ______TS("Success case: should create even when student cannot access feedback session in course"); + String[] paramsWithoutAccess = { + Const.ParamsNames.COURSE_ID, courseId1, + Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), + Const.ParamsNames.STUDENT_EMAIL, student3.getEmail(), + Const.ParamsNames.FEEDBACK_SESSION_ID, fs1.getId().toString(), + Const.ParamsNames.STUDENT_SQL_ID, student3.getId().toString(), + }; + response = getJsonResult(getAction(paramsWithoutAccess)); + output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); + } + + @Test + @Override + protected void testAccessControl() throws Exception { + verifyAnyUserCanAccess(); + } +} diff --git a/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java b/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java index 9413be9d691..238e9175465 100644 --- a/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/GetFeedbackSessionLogsActionIT.java @@ -41,7 +41,7 @@ protected String getRequestMethod() { return GET; } - @Test(enabled = false) + @Test @Override protected void testExecute() { JsonResult actionOutput; @@ -49,7 +49,6 @@ protected void testExecute() { Course course = typicalBundle.courses.get("course1"); String courseId = course.getId(); FeedbackSession fsa1 = typicalBundle.feedbackSessions.get("session1InCourse1"); - String fsa1Name = fsa1.getName(); Student student1 = typicalBundle.students.get("student1InCourse1"); Student student2 = typicalBundle.students.get("student2InCourse1"); String student1Email = student1.getEmail(); @@ -73,16 +72,16 @@ protected void testExecute() { ______TS("Failure case: invalid course id"); String[] paramsInvalid1 = { Const.ParamsNames.COURSE_ID, "fake-course-id", - Const.ParamsNames.STUDENT_EMAIL, student1Email, + Const.ParamsNames.STUDENT_SQL_ID, student1.getId().toString(), Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), }; verifyEntityNotFound(paramsInvalid1); - ______TS("Failure case: invalid student email"); + ______TS("Failure case: invalid student id"); String[] paramsInvalid2 = { Const.ParamsNames.COURSE_ID, courseId, - Const.ParamsNames.STUDENT_EMAIL, "fake-student-email@gmail.com", + Const.ParamsNames.STUDENT_SQL_ID, "00000000-0000-0000-0000-000000000000", Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), }; @@ -140,10 +139,10 @@ protected void testExecute() { assertEquals(fsLogEntries2.get(1).getStudentData().getEmail(), student1Email); assertEquals(fsLogEntries2.get(1).getFeedbackSessionLogType(), FeedbackSessionLogType.SUBMISSION); - ______TS("Success case: should accept optional email"); + ______TS("Success case: should accept optional student Id"); String[] paramsSuccessful2 = { Const.ParamsNames.COURSE_ID, courseId, - Const.ParamsNames.STUDENT_EMAIL, student1Email, + Const.ParamsNames.STUDENT_SQL_ID, student1.getId().toString(), Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), }; @@ -170,10 +169,10 @@ protected void testExecute() { assertEquals(fsLogEntries2.get(1).getStudentData().getEmail(), student1Email); assertEquals(fsLogEntries2.get(1).getFeedbackSessionLogType(), FeedbackSessionLogType.SUBMISSION); - ______TS("Success case: should accept feedback session"); + ______TS("Success case: should accept optional feedback session"); String[] paramsSuccessful3 = { Const.ParamsNames.COURSE_ID, courseId, - Const.ParamsNames.FEEDBACK_SESSION_NAME, fsa1Name, + Const.ParamsNames.FEEDBACK_SESSION_ID, fsa1.getId().toString(), Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), }; diff --git a/src/it/java/teammates/it/ui/webapi/UpdateFeedbackSessionLogsActionIT.java b/src/it/java/teammates/it/ui/webapi/UpdateFeedbackSessionLogsActionIT.java index ce30c978426..b6f2a8b1f47 100644 --- a/src/it/java/teammates/it/ui/webapi/UpdateFeedbackSessionLogsActionIT.java +++ b/src/it/java/teammates/it/ui/webapi/UpdateFeedbackSessionLogsActionIT.java @@ -26,8 +26,8 @@ */ public class UpdateFeedbackSessionLogsActionIT extends BaseActionIT { - static final int COLLECTION_TIME_PERIOD = 60; // represents one hour - static final long SPAM_FILTER = 2000L; // in ms + static final long COLLECTION_TIME_PERIOD = Const.STUDENT_ACTIVITY_LOGS_UPDATE_INTERVAL.toMinutes(); + static final long SPAM_FILTER = Const.STUDENT_ACTIVITY_LOGS_FILTER_WINDOW.toMillis(); Student student1InCourse1; Student student2InCourse1; @@ -51,7 +51,7 @@ protected void setUp() throws Exception { HibernateUtil.flushSession(); SearchManagerFactory.getStudentSearchManager().resetCollections(); - endTime = TimeHelper.getInstantNearestHourBefore(Instant.now()); + endTime = TimeHelper.getInstantNearestQuarterHourBefore(Instant.now()); startTime = endTime.minus(COLLECTION_TIME_PERIOD, ChronoUnit.MINUTES); course1 = typicalBundle.courses.get("course1"); @@ -65,7 +65,7 @@ protected void setUp() throws Exception { session2InCourse1 = typicalBundle.feedbackSessions.get("session2InTypicalCourse"); session1InCourse3 = typicalBundle.feedbackSessions.get("ongoingSession1InCourse3"); - mockLogsProcessor.getOrderedFeedbackSessionLogs("", "GET", 0, 0, "DELETE").clear(); + mockLogsProcessor.getOrderedFeedbackSessionLogs("", "", 0, 0, "").clear(); } @Override @@ -84,56 +84,44 @@ protected void testExecute() { ______TS("No spam all logs added"); // Different Types mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(300).toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(100).toEpochMilli()); mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.SUBMISSION.getLabel(), - startTime.plusSeconds(300).toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.SUBMISSION.getLabel(), + startTime.plusSeconds(100).toEpochMilli()); mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.VIEW_RESULT.getLabel(), - startTime.plusSeconds(300).toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.VIEW_RESULT.getLabel(), + startTime.plusSeconds(100).toEpochMilli()); // Different feedback sessions mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(600).toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(200).toEpochMilli()); mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session2InCourse1.getId(), session2InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(600).toEpochMilli()); + session2InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(200).toEpochMilli()); // Different Student mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(900).toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(300).toEpochMilli()); mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student2InCourse1.getId(), - student2InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(900).toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(300).toEpochMilli()); // Different course mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(1200).toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(400).toEpochMilli()); mockLogsProcessor.insertFeedbackSessionLog(course3.getId(), student1InCourse3.getId(), - student1InCourse3.getEmail(), - session1InCourse3.getId(), session1InCourse3.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(1200).toEpochMilli()); + session1InCourse3.getId(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(400).toEpochMilli()); // Gap is larger than spam filter mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli()); mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); UpdateFeedbackSessionLogsAction action = getAction(); @@ -153,44 +141,34 @@ protected void testExecute() { protected void testExecute_recentLogsWithSpam_someLogsCreated() { // Gap is smaller than spam filter mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli()); mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER - 2).toEpochMilli()); // Filters multiple logs within one spam window mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER - 1).toEpochMilli()); // Correctly adds new log after filtering mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); // Filters out spam in the new window mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 2).toEpochMilli()); UpdateFeedbackSessionLogsAction action = getAction(); action.execute(); List expected = new ArrayList<>(); - expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.toEpochMilli())); - expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli())); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli())); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli())); List actual = logic.getOrderedFeedbackSessionLogs(course1.getId(), null, null, startTime, endTime); @@ -201,37 +179,28 @@ protected void testExecute_recentLogsWithSpam_someLogsCreated() { protected void testExecute_badLogs_otherLogsCreated() { UUID badUuid = UUID.fromString("00000000-0000-0000-0000-000000000000"); mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(300).toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(100).toEpochMilli()); mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(900).toEpochMilli()); + session1InCourse1.getId(), FeedbackSessionLogType.ACCESS.getLabel(), + startTime.plusSeconds(300).toEpochMilli()); // bad student id - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), badUuid, student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(600).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), badUuid, session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(200).toEpochMilli()); // bad session id - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - badUuid, session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(600).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1InCourse1.getId(), badUuid, + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(200).toEpochMilli()); UpdateFeedbackSessionLogsAction action = getAction(); action.execute(); List expected = new ArrayList<>(); - expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(300).toEpochMilli())); - expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), - student1InCourse1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(900).toEpochMilli())); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(100).toEpochMilli())); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1InCourse1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(300).toEpochMilli())); List actual = logic.getOrderedFeedbackSessionLogs(course1.getId(), null, null, startTime, endTime); diff --git a/src/main/appengine/cron.yaml b/src/main/appengine/cron.yaml index 996ff3d8401..f6426a84e1e 100644 --- a/src/main/appengine/cron.yaml +++ b/src/main/appengine/cron.yaml @@ -34,6 +34,6 @@ cron: timezone: 'Asia/Singapore' description: 'Compile severe logs and sends out email notifications.' - url: '/auto/updateFeedbackSessionLogs' - schedule: 'every 60 minutes from 00:15 to 23:59' + schedule: 'every 15 minutes from 00:01 to 23:59' timezone: 'Asia/Singapore' description: 'Process feedback session activity logs from logging service and store in the database.' diff --git a/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java b/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java index d0aef5ce481..10608f179fb 100644 --- a/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java +++ b/src/main/java/teammates/common/datatransfer/FeedbackSessionLogEntry.java @@ -25,13 +25,13 @@ public FeedbackSessionLogEntry(String courseId, String studentEmail, this.timestamp = timestamp; } - public FeedbackSessionLogEntry(String courseId, UUID studentId, String studentEmail, UUID feedbackSessionId, - String feedbackSessionName, String feedbackSessionLogType, long timestamp) { + public FeedbackSessionLogEntry(String courseId, UUID studentId, UUID feedbackSessionId, + String feedbackSessionLogType, long timestamp) { this.courseId = courseId; this.studentId = studentId; - this.studentEmail = studentEmail; + this.studentEmail = null; this.feedbackSessionId = feedbackSessionId; - this.feedbackSessionName = feedbackSessionName; + this.feedbackSessionName = null; this.feedbackSessionLogType = feedbackSessionLogType; this.timestamp = timestamp; } diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index 4aad059a211..c48fa64e0ed 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -45,6 +45,9 @@ public final class Const { public static final String MISSING_RESPONSE_TEXT = "No Response"; + public static final Duration STUDENT_ACTIVITY_LOGS_UPDATE_INTERVAL = Duration.ofMinutes(15); + public static final Duration STUDENT_ACTIVITY_LOGS_FILTER_WINDOW = Duration.ofSeconds(2); + // These constants are used as variable values to mean that the variable is in a 'special' state. public static final int INT_UNINITIALIZED = -9999; @@ -123,6 +126,7 @@ public static class ParamsNames { public static final String IS_CREATING_ACCOUNT = "iscreatingaccount"; public static final String IS_INSTRUCTOR = "isinstructor"; + public static final String FEEDBACK_SESSION_ID = "fsid"; public static final String FEEDBACK_SESSION_NAME = "fsname"; public static final String FEEDBACK_SESSION_STARTTIME = "starttime"; public static final String FEEDBACK_SESSION_ENDTIME = "endtime"; @@ -144,6 +148,7 @@ public static class ParamsNames { public static final String PREVIEWAS = "previewas"; + public static final String STUDENT_SQL_ID = "studentid"; public static final String STUDENT_ID = "googleid"; public static final String INVITER_ID = "invitergoogleid"; diff --git a/src/main/java/teammates/common/util/TimeHelper.java b/src/main/java/teammates/common/util/TimeHelper.java index 8122ff6f39a..de7d58f796a 100644 --- a/src/main/java/teammates/common/util/TimeHelper.java +++ b/src/main/java/teammates/common/util/TimeHelper.java @@ -29,6 +29,18 @@ public static Instant getInstantNearestHourBefore(Instant instant) { return parseInstant(nearestHourString); } + /** + * Returns an Instant that represents the nearest quarter hour before the given object. + * + *

      The time zone used is assumed to be the default timezone, namely UTC. + */ + public static Instant getInstantNearestQuarterHourBefore(Instant instant) { + ZonedDateTime zdt = instant.atZone(ZoneId.of(Const.DEFAULT_TIME_ZONE)); + int minutesPastQuarter = zdt.getMinute() % 15; + ZonedDateTime nearestQuarterZdt = zdt.minusMinutes(minutesPastQuarter).withSecond(0).withNano(0); + return nearestQuarterZdt.toInstant(); + } + /** * Returns an Instant that is offset by a number of days from now. * diff --git a/src/main/java/teammates/logic/api/LogsProcessor.java b/src/main/java/teammates/logic/api/LogsProcessor.java index 1aca48e7147..57d889be488 100644 --- a/src/main/java/teammates/logic/api/LogsProcessor.java +++ b/src/main/java/teammates/logic/api/LogsProcessor.java @@ -54,9 +54,8 @@ public void createFeedbackSessionLog(String courseId, String email, String fsNam /** * Creates a feedback session log. */ - public void createFeedbackSessionLog(String courseId, UUID studentId, String email, UUID fsId, String fsName, - String fslType) { - service.createFeedbackSessionLog(courseId, studentId, email, fsId, fsName, fslType); + public void createFeedbackSessionLog(String courseId, UUID studentId, UUID fsId, String fslType) { + service.createFeedbackSessionLog(courseId, studentId, fsId, fslType); } /** diff --git a/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java b/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java index 6d49eb3f26a..be758f8809c 100644 --- a/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java +++ b/src/main/java/teammates/logic/external/GoogleCloudLoggingService.java @@ -116,8 +116,7 @@ public void createFeedbackSessionLog(String courseId, String email, String fsNam } @Override - public void createFeedbackSessionLog(String courseId, UUID studentId, String email, UUID fsId, String fsName, - String fslType) { + public void createFeedbackSessionLog(String courseId, UUID studentId, UUID fsId, String fslType) { // This method is not necessary for production usage because a feedback session log // is already separately created through the standardized logging infrastructure. // However, this method is not removed as it is necessary to assist in local testing. @@ -163,10 +162,16 @@ public List getOrderedFeedbackSessionLogs(String course continue; } - FeedbackSessionLogEntry fslEntry = new FeedbackSessionLogEntry(details.getCourseId(), - UUID.fromString(details.getStudentId()), details.getStudentEmail(), - UUID.fromString(details.getFeedbackSessionId()), details.getFeedbackSessionName(), - details.getAccessType(), timestamp); + UUID studentId = details.getStudentId() != null ? UUID.fromString(details.getStudentId()) : null; + UUID fsId = details.getFeedbackSessionId() != null ? UUID.fromString(details.getFeedbackSessionId()) : null; + FeedbackSessionLogEntry fslEntry; + if (fsId != null && studentId != null) { + fslEntry = new FeedbackSessionLogEntry(details.getCourseId(), studentId, fsId, details.getAccessType(), + timestamp); + } else { + fslEntry = new FeedbackSessionLogEntry(details.getCourseId(), details.getStudentEmail(), + details.getFeedbackSessionName(), details.getAccessType(), timestamp); + } fsLogEntries.add(fslEntry); } diff --git a/src/main/java/teammates/logic/external/LocalLoggingService.java b/src/main/java/teammates/logic/external/LocalLoggingService.java index ac6c4c10fc2..9750206fa37 100644 --- a/src/main/java/teammates/logic/external/LocalLoggingService.java +++ b/src/main/java/teammates/logic/external/LocalLoggingService.java @@ -210,9 +210,8 @@ public void createFeedbackSessionLog(String courseId, String email, String fsNam } @Override - public void createFeedbackSessionLog(String courseId, UUID studentId, String email, UUID fsId, String fsName, - String fslType) { - FeedbackSessionLogEntry logEntry = new FeedbackSessionLogEntry(courseId, studentId, email, fsId, fsName, + public void createFeedbackSessionLog(String courseId, UUID studentId, UUID fsId, String fslType) { + FeedbackSessionLogEntry logEntry = new FeedbackSessionLogEntry(courseId, studentId, fsId, fslType, Instant.now().toEpochMilli()); FEEDBACK_SESSION_LOG_ENTRIES.computeIfAbsent(courseId, k -> new ArrayList<>()).add(logEntry); } diff --git a/src/main/java/teammates/logic/external/LogService.java b/src/main/java/teammates/logic/external/LogService.java index ab0b705fef4..08f3653d2d3 100644 --- a/src/main/java/teammates/logic/external/LogService.java +++ b/src/main/java/teammates/logic/external/LogService.java @@ -23,10 +23,9 @@ public interface LogService { void createFeedbackSessionLog(String courseId, String email, String fsName, String fslType); /** - * Creates a feedback session log. + * Creates a feedback session log for migrated courses. */ - void createFeedbackSessionLog(String courseId, UUID studentId, String email, UUID fsId, String fsName, - String fslType); + void createFeedbackSessionLog(String courseId, UUID studentId, UUID fsId, String fslType); /** * Gets the feedback session logs as filtered by the given parameters ordered by ascending timestamp. diff --git a/src/main/java/teammates/ui/constants/ApiConst.java b/src/main/java/teammates/ui/constants/ApiConst.java index ce946c8f3e6..2a411e7b1bd 100644 --- a/src/main/java/teammates/ui/constants/ApiConst.java +++ b/src/main/java/teammates/ui/constants/ApiConst.java @@ -28,7 +28,9 @@ public enum ApiConst { RANK_RECIPIENTS_ANSWER_NOT_SUBMITTED(Const.POINTS_NOT_SUBMITTED), NO_VALUE(Const.POINTS_NO_VALUE), LOGS_RETENTION_PERIOD(Const.LOGS_RETENTION_PERIOD.toDays()), - SEARCH_QUERY_SIZE_LIMIT(Const.SEARCH_QUERY_SIZE_LIMIT); + SEARCH_QUERY_SIZE_LIMIT(Const.SEARCH_QUERY_SIZE_LIMIT), + STUDENT_ACTIVITY_LOGS_UPDATE_INTERVAL(Const.STUDENT_ACTIVITY_LOGS_UPDATE_INTERVAL.toMinutes()); + // CHECKSTYLE.ON:JavadocVariable private final Object value; diff --git a/src/main/java/teammates/ui/output/CourseData.java b/src/main/java/teammates/ui/output/CourseData.java index b2ce6334483..89b751de3fd 100644 --- a/src/main/java/teammates/ui/output/CourseData.java +++ b/src/main/java/teammates/ui/output/CourseData.java @@ -15,6 +15,8 @@ public class CourseData extends ApiOutput { private final String courseName; private final String timeZone; private final String institute; + @Nullable + private final Boolean isMigrated; private long creationTimestamp; private long deletionTimestamp; @Nullable @@ -29,6 +31,7 @@ public CourseData(CourseAttributes courseAttributes) { if (courseAttributes.getDeletedAt() != null) { this.deletionTimestamp = courseAttributes.getDeletedAt().toEpochMilli(); } + this.isMigrated = false; } public CourseData(Course course) { @@ -40,6 +43,7 @@ public CourseData(Course course) { if (course.getDeletedAt() != null) { this.deletionTimestamp = course.getDeletedAt().toEpochMilli(); } + this.isMigrated = true; } public String getCourseId() { @@ -66,6 +70,10 @@ public long getDeletionTimestamp() { return deletionTimestamp; } + public Boolean getIsMigrated() { + return isMigrated; + } + public InstructorPermissionSet getPrivileges() { return privileges; } diff --git a/src/main/java/teammates/ui/webapi/CreateFeedbackSessionLogAction.java b/src/main/java/teammates/ui/webapi/CreateFeedbackSessionLogAction.java index 69b5e43d0e6..5ea725da87b 100644 --- a/src/main/java/teammates/ui/webapi/CreateFeedbackSessionLogAction.java +++ b/src/main/java/teammates/ui/webapi/CreateFeedbackSessionLogAction.java @@ -6,13 +6,11 @@ import teammates.common.datatransfer.logs.FeedbackSessionLogType; import teammates.common.util.Const; import teammates.common.util.Logger; -import teammates.storage.sqlentity.FeedbackSession; -import teammates.storage.sqlentity.Student; /** * Action: creates a feedback session log for the purposes of tracking and auditing. */ -class CreateFeedbackSessionLogAction extends Action { +public class CreateFeedbackSessionLogAction extends Action { private static final Logger log = Logger.getLogger(); @@ -37,7 +35,8 @@ public JsonResult execute() { String courseId = getNonNullRequestParamValue(Const.ParamsNames.COURSE_ID); String fsName = getNonNullRequestParamValue(Const.ParamsNames.FEEDBACK_SESSION_NAME); String studentEmail = getNonNullRequestParamValue(Const.ParamsNames.STUDENT_EMAIL); - // Skip rigorous validations to avoid incurring extra db reads and to keep the endpoint light + // Skip rigorous validations to avoid incurring extra db reads and to keep the endpoint + // light FeedbackSessionAuditLogDetails details = new FeedbackSessionAuditLogDetails(); details.setCourseId(courseId); @@ -46,26 +45,17 @@ public JsonResult execute() { details.setAccessType(fslType); if (isCourseMigrated(courseId)) { - // TODO: remove unnecessary db reads after updating the front end - Student student = sqlLogic.getStudentForEmail(courseId, studentEmail); - FeedbackSession feedbackSession = sqlLogic.getFeedbackSession(fsName, courseId); - UUID studentId = null; - UUID fsId = null; + UUID studentId = getUuidRequestParamValue(Const.ParamsNames.STUDENT_SQL_ID); + UUID fsId = getUuidRequestParamValue(Const.ParamsNames.FEEDBACK_SESSION_ID); - if (student != null) { - studentId = student.getId(); - details.setStudentId(studentId.toString()); - } + details.setStudentId(studentId.toString()); + details.setFeedbackSessionId(fsId.toString()); - if (feedbackSession != null) { - fsId = feedbackSession.getId(); - details.setFeedbackSessionId(fsId.toString()); - } // Necessary to assist local testing. For production usage, this will be a no-op. - logsProcessor.createFeedbackSessionLog(courseId, studentId, studentEmail, fsId, fsName, fslType); + logsProcessor.createFeedbackSessionLog(courseId, studentId, fsId, fslType); } else { // Necessary to assist local testing. For production usage, this will be a no-op. - logsProcessor.createFeedbackSessionLog(courseId, null, studentEmail, null, fsName, fslType); + logsProcessor.createFeedbackSessionLog(courseId, studentEmail, fsName, fslType); } log.event("Feedback session audit event: " + fslType, details); diff --git a/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java b/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java index 8a3a5514514..720ec6e726b 100644 --- a/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java +++ b/src/main/java/teammates/ui/webapi/GetFeedbackSessionLogsAction.java @@ -6,6 +6,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; import teammates.common.datatransfer.FeedbackSessionLogEntry; @@ -109,25 +110,34 @@ public JsonResult execute() { } } - String email = getRequestParamValue(Const.ParamsNames.STUDENT_EMAIL); - String feedbackSessionName = getRequestParamValue(Const.ParamsNames.FEEDBACK_SESSION_NAME); - if (isCourseMigrated(courseId)) { - // TODO: replace null ids with value from request after FE changes and enable test + UUID studentId = null; + UUID feedbackSessionId = null; + String studentIdString = getRequestParamValue(Const.ParamsNames.STUDENT_SQL_ID); + String feedbackSessionIdString = getRequestParamValue(Const.ParamsNames.FEEDBACK_SESSION_ID); + + if (studentIdString != null) { + studentId = getUuidFromString(Const.ParamsNames.STUDENT_SQL_ID, studentIdString); + } + + if (feedbackSessionIdString != null) { + feedbackSessionId = getUuidFromString(Const.ParamsNames.FEEDBACK_SESSION_ID, feedbackSessionIdString); + } + if (sqlLogic.getCourse(courseId) == null) { throw new EntityNotFoundException("Course not found"); } - if (email != null && sqlLogic.getStudentForEmail(courseId, email) == null) { + if (studentId != null && sqlLogic.getStudent(studentId) == null) { throw new EntityNotFoundException("Student not found"); } - if (feedbackSessionName != null && sqlLogic.getFeedbackSession(feedbackSessionName, courseId) == null) { + if (feedbackSessionId != null && sqlLogic.getFeedbackSession(feedbackSessionId) == null) { throw new EntityNotFoundException("Feedback session not found"); } - List fsLogEntries = sqlLogic.getOrderedFeedbackSessionLogs(courseId, null, - null, Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime)); + List fsLogEntries = sqlLogic.getOrderedFeedbackSessionLogs(courseId, studentId, + feedbackSessionId, Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime)); Map studentsMap = new HashMap<>(); Map sessionsMap = new HashMap<>(); List feedbackSessions = sqlLogic.getFeedbackSessionsForCourse(courseId); @@ -142,7 +152,7 @@ public JsonResult execute() { } if (!studentsMap.containsKey(logEntry.getStudent().getEmail())) { - Student student = sqlLogic.getStudentForEmail(courseId, logEntry.getStudent().getEmail()); + Student student = sqlLogic.getStudent(logEntry.getStudent().getId()); if (student == null) { // If the student email retrieved from the log is invalid, ignore the log return false; @@ -163,10 +173,12 @@ public JsonResult execute() { throw new EntityNotFoundException("Course not found"); } + String email = getRequestParamValue(Const.ParamsNames.STUDENT_EMAIL); if (email != null && logic.getStudentForEmail(courseId, email) == null) { throw new EntityNotFoundException("Student not found"); } + String feedbackSessionName = getRequestParamValue(Const.ParamsNames.FEEDBACK_SESSION_NAME); if (feedbackSessionName != null && logic.getFeedbackSession(feedbackSessionName, courseId) == null) { throw new EntityNotFoundException("Feedback session not found"); } diff --git a/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java b/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java index 164db9fa344..9324d4615df 100644 --- a/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java +++ b/src/main/java/teammates/ui/webapi/UpdateFeedbackSessionLogsAction.java @@ -9,7 +9,9 @@ import java.util.UUID; import teammates.common.datatransfer.FeedbackSessionLogEntry; +import teammates.common.datatransfer.attributes.CourseAttributes; import teammates.common.datatransfer.logs.FeedbackSessionLogType; +import teammates.common.util.Const; import teammates.common.util.TimeHelper; import teammates.storage.sqlentity.FeedbackSession; import teammates.storage.sqlentity.FeedbackSessionLog; @@ -21,23 +23,29 @@ */ public class UpdateFeedbackSessionLogsAction extends AdminOnlyAction { - static final int COLLECTION_TIME_PERIOD = 60; // represents one hour - static final long SPAM_FILTER = 2000L; // in ms + static final long COLLECTION_TIME_PERIOD = Const.STUDENT_ACTIVITY_LOGS_UPDATE_INTERVAL.toMinutes(); + static final long SPAM_FILTER = Const.STUDENT_ACTIVITY_LOGS_FILTER_WINDOW.toMillis(); @Override public JsonResult execute() { List filteredLogs = new ArrayList<>(); - Instant endTime = TimeHelper.getInstantNearestHourBefore(Instant.now()); + Instant endTime = TimeHelper.getInstantNearestQuarterHourBefore(Instant.now()); Instant startTime = endTime.minus(COLLECTION_TIME_PERIOD, ChronoUnit.MINUTES); List logEntries = logsProcessor.getOrderedFeedbackSessionLogs(null, null, startTime.toEpochMilli(), endTime.toEpochMilli(), null); Map>>> lastSavedTimestamps = new HashMap<>(); + Map isCourseMigratedMap = new HashMap<>(); for (FeedbackSessionLogEntry logEntry : logEntries) { - if (!isCourseMigrated(logEntry.getCourseId())) { + isCourseMigratedMap.computeIfAbsent(logEntry.getCourseId(), k -> { + CourseAttributes course = logic.getCourse(logEntry.getCourseId()); + return course == null || course.isMigrated(); + }); + + if (!isCourseMigratedMap.get(logEntry.getCourseId())) { continue; } diff --git a/src/test/java/teammates/common/util/TimeHelperTest.java b/src/test/java/teammates/common/util/TimeHelperTest.java index 3c805252f97..9f9ca7035ab 100644 --- a/src/test/java/teammates/common/util/TimeHelperTest.java +++ b/src/test/java/teammates/common/util/TimeHelperTest.java @@ -146,4 +146,68 @@ public void testGetInstantMonthsOffsetFromNow() { assertEquals(expected, actual); } + @Test + public void getInstantNearestQuarterHourBefore() { + Instant expectedQ1 = Instant.parse("2020-12-31T16:00:00Z"); + Instant actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:00:00Z")); + + assertEquals(expectedQ1, actual); + + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:09:30Z")); + + assertEquals(expectedQ1, actual); + + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:14:59Z")); + + assertEquals(expectedQ1, actual); + + actual = TimeHelper + .getInstantNearestQuarterHourBefore(OffsetDateTime.parse("2021-01-01T00:10:00+08:00").toInstant()); + + assertEquals(expectedQ1, actual); + + actual = TimeHelper + .getInstantNearestQuarterHourBefore(OffsetDateTime.parse("2020-12-31T12:09:00-04:00").toInstant()); + + assertEquals(expectedQ1, actual); + + Instant expectedQ2 = Instant.parse("2020-12-31T16:15:00Z"); + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:15:00Z")); + + assertEquals(expectedQ2, actual); + + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:19:30Z")); + + assertEquals(expectedQ2, actual); + + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:29:59Z")); + + assertEquals(expectedQ2, actual); + + Instant expectedQ3 = Instant.parse("2020-12-31T16:30:00Z"); + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:30:00Z")); + + assertEquals(expectedQ3, actual); + + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:39:30Z")); + + assertEquals(expectedQ3, actual); + + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:44:59Z")); + + assertEquals(expectedQ3, actual); + + Instant expectedQ4 = Instant.parse("2020-12-31T16:45:00Z"); + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:45:00Z")); + + assertEquals(expectedQ4, actual); + + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:49:30Z")); + + assertEquals(expectedQ4, actual); + + actual = TimeHelper.getInstantNearestQuarterHourBefore(Instant.parse("2020-12-31T16:59:59Z")); + + assertEquals(expectedQ4, actual); + } } diff --git a/src/test/java/teammates/logic/api/MockLogsProcessor.java b/src/test/java/teammates/logic/api/MockLogsProcessor.java index fbff7904f4d..60299b538be 100644 --- a/src/test/java/teammates/logic/api/MockLogsProcessor.java +++ b/src/test/java/teammates/logic/api/MockLogsProcessor.java @@ -33,10 +33,10 @@ public void insertFeedbackSessionLog(String courseId, String studentEmail, Strin /** * Simulates insertion of feedback session logs. */ - public void insertFeedbackSessionLog(String courseId, UUID studentId, String studentEmail, - UUID feedbackSessionId, String feedbackSessionName, String fslType, long timestamp) { - feedbackSessionLogs.add(new FeedbackSessionLogEntry(courseId, studentId, studentEmail, feedbackSessionId, - feedbackSessionName, fslType, timestamp)); + public void insertFeedbackSessionLog(String courseId, UUID studentId, UUID feedbackSessionId, + String fslType, long timestamp) { + feedbackSessionLogs + .add(new FeedbackSessionLogEntry(courseId, studentId, feedbackSessionId, fslType, timestamp)); } /** @@ -108,8 +108,7 @@ public QueryLogsResults queryLogs(QueryLogsParams queryLogsParams) { } @Override - public void createFeedbackSessionLog(String courseId, UUID studentId, String email, UUID fsId, String fsName, - String fslType) { + public void createFeedbackSessionLog(String courseId, UUID studentId, UUID fsId, String fslType) { // No-op } diff --git a/src/test/java/teammates/sqlui/webapi/GetFeedbackSessionLogsActionTest.java b/src/test/java/teammates/sqlui/webapi/GetFeedbackSessionLogsActionTest.java index 5c50ecf1638..73af8c67f3d 100644 --- a/src/test/java/teammates/sqlui/webapi/GetFeedbackSessionLogsActionTest.java +++ b/src/test/java/teammates/sqlui/webapi/GetFeedbackSessionLogsActionTest.java @@ -75,9 +75,9 @@ void setUp() { fs2.setCreatedAt(Instant.now()); when(mockLogic.getCourse(course.getId())).thenReturn(course); - when(mockLogic.getFeedbackSession(fs1.getName(), course.getId())).thenReturn(fs1); - when(mockLogic.getStudentForEmail(course.getId(), student1.getEmail())).thenReturn(student1); - when(mockLogic.getStudentForEmail(course.getId(), student2.getEmail())).thenReturn(student2); + when(mockLogic.getFeedbackSession(fs1.getId())).thenReturn(fs1); + when(mockLogic.getStudent(student1.getId())).thenReturn(student1); + when(mockLogic.getStudent(student2.getId())).thenReturn(student2); List feedbackSessions = new ArrayList<>(); feedbackSessions.add(fs1); @@ -124,7 +124,7 @@ void setUp() { Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime))).thenReturn(student1Fs1Logs); } - @Test(enabled = false) + @Test protected void testExecute() { JsonResult actionOutput; @@ -142,16 +142,16 @@ protected void testExecute() { ______TS("Failure case: invalid course id"); String[] paramsInvalid1 = { Const.ParamsNames.COURSE_ID, "fake-course-id", - Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), + Const.ParamsNames.STUDENT_SQL_ID, student1.getId().toString(), Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), }; verifyEntityNotFound(paramsInvalid1); - ______TS("Failure case: invalid student email"); + ______TS("Failure case: invalid student id"); String[] paramsInvalid2 = { Const.ParamsNames.COURSE_ID, course.getId(), - Const.ParamsNames.STUDENT_EMAIL, "fake-student-email@gmail.com", + Const.ParamsNames.STUDENT_SQL_ID, "00000000-0000-0000-0000-000000000000", Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), }; @@ -204,10 +204,10 @@ protected void testExecute() { assertEquals(fsLogEntries2.get(1).getStudentData().getEmail(), student1.getEmail()); assertEquals(fsLogEntries2.get(1).getFeedbackSessionLogType(), FeedbackSessionLogType.SUBMISSION); - ______TS("Success case: should accept optional email"); + ______TS("Success case: should accept optional student id"); String[] paramsSuccessful2 = { Const.ParamsNames.COURSE_ID, course.getId(), - Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), + Const.ParamsNames.STUDENT_SQL_ID, student1.getId().toString(), Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), }; @@ -233,7 +233,7 @@ protected void testExecute() { ______TS("Success case: should accept optional feedback session"); String[] paramsSuccessful3 = { Const.ParamsNames.COURSE_ID, course.getId(), - Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.FEEDBACK_SESSION_ID, fs1.getId().toString(), Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), }; @@ -257,8 +257,8 @@ protected void testExecute() { ______TS("Success case: should accept all optional params"); String[] paramsSuccessful4 = { Const.ParamsNames.COURSE_ID, course.getId(), - Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), - Const.ParamsNames.FEEDBACK_SESSION_NAME, fs1.getName(), + Const.ParamsNames.STUDENT_SQL_ID, student1.getId().toString(), + Const.ParamsNames.FEEDBACK_SESSION_ID, fs1.getId().toString(), Const.ParamsNames.FEEDBACK_SESSION_LOG_STARTTIME, String.valueOf(startTime), Const.ParamsNames.FEEDBACK_SESSION_LOG_ENDTIME, String.valueOf(endTime), }; diff --git a/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java b/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java index 72a8ec36802..6fcaf98daff 100644 --- a/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java +++ b/src/test/java/teammates/sqlui/webapi/UpdateFeedbackSessionLogsActionTest.java @@ -32,8 +32,8 @@ public class UpdateFeedbackSessionLogsActionTest extends BaseActionTest { - static final int COLLECTION_TIME_PERIOD = 60; // represents one hour - static final long SPAM_FILTER = 2000L; // in ms + static final long COLLECTION_TIME_PERIOD = Const.STUDENT_ACTIVITY_LOGS_UPDATE_INTERVAL.toMinutes(); + static final long SPAM_FILTER = Const.STUDENT_ACTIVITY_LOGS_FILTER_WINDOW.toMillis(); Student student1; Student student2; @@ -60,7 +60,7 @@ String getRequestMethod() { @BeforeMethod void setUp() { - endTime = TimeHelper.getInstantNearestHourBefore(Instant.now()); + endTime = TimeHelper.getInstantNearestQuarterHourBefore(Instant.now()); startTime = endTime.minus(COLLECTION_TIME_PERIOD, ChronoUnit.MINUTES); course1 = getTypicalCourse(); @@ -114,47 +114,36 @@ public void testExecute_noRecentLogs_noLogsCreated() public void testExecute_recentLogsNoSpam_allLogsCreated() throws EntityAlreadyExistsException, InvalidParametersException { // Different Types - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(300).toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.SUBMISSION.getLabel(), - startTime.plusSeconds(300).toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.VIEW_RESULT.getLabel(), - startTime.plusSeconds(300).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(100).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.SUBMISSION.getLabel(), startTime.plusSeconds(100).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.VIEW_RESULT.getLabel(), startTime.plusSeconds(100).toEpochMilli()); // Different feedback sessions - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(600).toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session2InCourse1.getId(), session2InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(600).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(200).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session2InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(200).toEpochMilli()); // Different Student - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(900).toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student2.getId(), student2.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(900).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(300).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student2.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(300).toEpochMilli()); // Different course - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(1200).toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(course2.getId(), student1.getId(), student1.getEmail(), - session1InCourse2.getId(), session1InCourse2.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusSeconds(1200).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(400).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course2.getId(), student1.getId(), session1InCourse2.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusSeconds(400).toEpochMilli()); // Gap is larger than spam filter - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); UpdateFeedbackSessionLogsAction action = getAction(); action.execute(); @@ -169,38 +158,31 @@ public void testExecute_recentLogsNoSpam_allLogsCreated() public void testExecute_recentLogsWithSpam_someLogsCreated() throws EntityAlreadyExistsException, InvalidParametersException { // Gap is smaller than spam filter - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.toEpochMilli()); - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusMillis(SPAM_FILTER - 2).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER - 2).toEpochMilli()); // Filters multiple logs within one spam window - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusMillis(SPAM_FILTER - 1).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER - 1).toEpochMilli()); // Correctly adds new log after filtering - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli()); // Filters out spam in the new window - mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusMillis(SPAM_FILTER + 2).toEpochMilli()); + mockLogsProcessor.insertFeedbackSessionLog(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 2).toEpochMilli()); UpdateFeedbackSessionLogsAction action = getAction(); action.execute(); List expected = new ArrayList<>(); - expected.add(new FeedbackSessionLogEntry(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.toEpochMilli())); - expected.add(new FeedbackSessionLogEntry(course1.getId(), student1.getId(), student1.getEmail(), - session1InCourse1.getId(), session1InCourse1.getName(), FeedbackSessionLogType.ACCESS.getLabel(), - startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli())); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.toEpochMilli())); + expected.add(new FeedbackSessionLogEntry(course1.getId(), student1.getId(), session1InCourse1.getId(), + FeedbackSessionLogType.ACCESS.getLabel(), startTime.plusMillis(SPAM_FILTER + 1).toEpochMilli())); verify(mockLogic).createFeedbackSessionLogs(argThat(filteredLogs -> isEqual(expected, filteredLogs))); } diff --git a/src/test/java/teammates/ui/webapi/CreateFeedbackSessionLogActionTest.java b/src/test/java/teammates/ui/webapi/CreateFeedbackSessionLogActionTest.java index dd5974c4243..dad8b7ad1a4 100644 --- a/src/test/java/teammates/ui/webapi/CreateFeedbackSessionLogActionTest.java +++ b/src/test/java/teammates/ui/webapi/CreateFeedbackSessionLogActionTest.java @@ -7,6 +7,7 @@ import teammates.common.datatransfer.attributes.StudentAttributes; import teammates.common.datatransfer.logs.FeedbackSessionLogType; import teammates.common.util.Const; +import teammates.ui.output.MessageOutput; /** * SUT: {@link CreateFeedbackSessionLogAction}. @@ -61,7 +62,9 @@ protected void testExecute() { Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.ACCESS.getLabel(), Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), }; - getJsonResult(getAction(paramsSuccessfulAccess)); + JsonResult response = getJsonResult(getAction(paramsSuccessfulAccess)); + MessageOutput output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); ______TS("Success case: typical submission"); String[] paramsSuccessfulSubmission = { @@ -70,24 +73,20 @@ protected void testExecute() { Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), Const.ParamsNames.STUDENT_EMAIL, student2.getEmail(), }; - getJsonResult(getAction(paramsSuccessfulSubmission)); + response = getJsonResult(getAction(paramsSuccessfulSubmission)); + output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); ______TS("Success case: should create even for invalid parameters"); - String[] paramsNonExistentCourseId = { - Const.ParamsNames.COURSE_ID, "non-existent-course-id", - Const.ParamsNames.FEEDBACK_SESSION_NAME, fsa1.getFeedbackSessionName(), - Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), - Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), - }; - getJsonResult(getAction(paramsNonExistentCourseId)); - String[] paramsNonExistentFsName = { Const.ParamsNames.COURSE_ID, courseId1, Const.ParamsNames.FEEDBACK_SESSION_NAME, "non-existent-feedback-session-name", Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), Const.ParamsNames.STUDENT_EMAIL, student1.getEmail(), }; - getJsonResult(getAction(paramsNonExistentFsName)); + response = getJsonResult(getAction(paramsNonExistentFsName)); + output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); String[] paramsNonExistentStudentEmail = { Const.ParamsNames.COURSE_ID, courseId1, @@ -95,7 +94,9 @@ protected void testExecute() { Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), Const.ParamsNames.STUDENT_EMAIL, "non-existent-student@email.com", }; - getJsonResult(getAction(paramsNonExistentStudentEmail)); + response = getJsonResult(getAction(paramsNonExistentStudentEmail)); + output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); ______TS("Success case: should create even when student cannot access feedback session in course"); String[] paramsWithoutAccess = { @@ -104,7 +105,9 @@ protected void testExecute() { Const.ParamsNames.FEEDBACK_SESSION_LOG_TYPE, FeedbackSessionLogType.SUBMISSION.getLabel(), Const.ParamsNames.STUDENT_EMAIL, student3.getEmail(), }; - getJsonResult(getAction(paramsWithoutAccess)); + response = getJsonResult(getAction(paramsWithoutAccess)); + output = (MessageOutput) response.getOutput(); + assertEquals("Successful", output.getMessage()); } @Test diff --git a/src/web/app/pages-instructor/instructor-courses-page/instructor-courses-page.component.html b/src/web/app/pages-instructor/instructor-courses-page/instructor-courses-page.component.html index b8d08573d05..5926e1db649 100644 --- a/src/web/app/pages-instructor/instructor-courses-page/instructor-courses-page.component.html +++ b/src/web/app/pages-instructor/instructor-courses-page/instructor-courses-page.component.html @@ -166,9 +166,10 @@

      Active courses

      placement="left" container="body"> Archive - - - + + View Logs +

    tmRouterLink="./"> Copy + + View Logs + + @@ -31,7 +32,11 @@ exports[`InstructorStudentActivityLogsComponent should snap when page is still l owner or manager privileges - for the course. The earliest date you can search for is + for the course. Please note that recent logs may be shown as the information is updated every + + 15 minutes + + . The earliest date you can search for is 30 days @@ -64,6 +69,7 @@ exports[`InstructorStudentActivityLogsComponent should snap when searching for d LOGS_DATE_TIME_FORMAT={[Function String]} LOGS_RETENTION_PERIOD={[Function Number]} LOG_TYPES={[Function Array]} + STUDENT_ACTIVITY_LOGS_UPDATE_INTERVAL={[Function Number]} SortBy={[Function Object]} course={[Function Object]} courseService={[Function CourseService]} @@ -78,8 +84,8 @@ exports[`InstructorStudentActivityLogsComponent should snap when searching for d route={[Function ActivatedRoute]} searchResults={[Function Array]} statusMessageService={[Function StatusMessageService]} + studentLogsMap={[Function Map]} studentService={[Function StudentService]} - studentToLog={[Function Object]} students={[Function Array]} timezoneService={[Function TimezoneService]} > @@ -90,7 +96,11 @@ exports[`InstructorStudentActivityLogsComponent should snap when searching for d owner or manager privileges - for the course. The earliest date you can search for is + for the course. Please note that recent logs may be shown as the information is updated every + + 15 minutes + + . The earliest date you can search for is 30 days @@ -351,7 +361,7 @@ exports[`InstructorStudentActivityLogsComponent should snap when searching for d id="student-name-dropdown" > @@ -567,7 +577,7 @@ exports[`InstructorStudentActivityLogsComponent should snap when searching for d id="session-dropdown" >
    - + + diff --git a/src/web/app/pages-instructor/instructor-student-activity-logs/instructor-student-activity-logs.component.spec.ts b/src/web/app/pages-instructor/instructor-student-activity-logs/instructor-student-activity-logs.component.spec.ts index b9ec1db2edf..612245422de 100644 --- a/src/web/app/pages-instructor/instructor-student-activity-logs/instructor-student-activity-logs.component.spec.ts +++ b/src/web/app/pages-instructor/instructor-student-activity-logs/instructor-student-activity-logs.component.spec.ts @@ -145,9 +145,9 @@ describe('InstructorStudentActivityLogsComponent', () => { logsTimeFrom: { hour: 23, minute: 59 }, logsDateTo: { year: 1998, month: 9, day: 11 }, logsTimeTo: { hour: 15, minute: 0 }, - studentEmail: 'doejohn@email.com', + selectedStudent: { studentEmail: 'doejohn@email.com', studentId: undefined }, logType: 'session access', - feedbackSessionName: '', + selectedSession: { feedbackSessionName: undefined, sessionId: undefined }, showActions: false, showInactions: false, }; @@ -232,9 +232,9 @@ describe('InstructorStudentActivityLogsComponent', () => { logsTimeFrom: { hour: 23, minute: 59 }, logsDateTo: { year: 2020, month: 12, day: 31 }, logsTimeTo: { hour: 23, minute: 59 }, - studentEmail: testStudent.email, + selectedStudent: { studentEmail: testStudent.email, studentId: '' }, logType: 'submission', - feedbackSessionName: '', + selectedSession: { feedbackSessionName: '', sessionId: '' }, showActions: true, showInactions: false, }; @@ -261,6 +261,8 @@ describe('InstructorStudentActivityLogsComponent', () => { studentEmail: testStudent.email, sessionName: '', logType: 'submission', + studentId: '', + sessionId: '', }); expect(component.searchResults.length).toEqual(2); diff --git a/src/web/app/pages-instructor/instructor-student-activity-logs/instructor-student-activity-logs.component.ts b/src/web/app/pages-instructor/instructor-student-activity-logs/instructor-student-activity-logs.component.ts index 211e3fd0a53..e7301844772 100644 --- a/src/web/app/pages-instructor/instructor-student-activity-logs/instructor-student-activity-logs.component.ts +++ b/src/web/app/pages-instructor/instructor-student-activity-logs/instructor-student-activity-logs.component.ts @@ -37,8 +37,8 @@ interface SearchLogsFormModel { logsTimeFrom: TimeFormat; logsTimeTo: TimeFormat; logType: string; - feedbackSessionName: string; - studentEmail: string; + selectedSession: SelectedSession; + selectedStudent: SelectedStudent; showActions: boolean; showInactions: boolean; } @@ -48,6 +48,16 @@ interface LogType { value: string; } +interface SelectedStudent { + studentEmail?: string; + studentId?: string; +} + +interface SelectedSession { + feedbackSessionName?: string; + sessionId?: string; +} + /** * Model for displaying of feedback session logs */ @@ -70,6 +80,7 @@ interface FeedbackSessionLogModel { export class InstructorStudentActivityLogsComponent implements OnInit { LOGS_DATE_TIME_FORMAT: string = 'ddd, DD MMM YYYY hh:mm:ss A'; LOGS_RETENTION_PERIOD: number = ApiConst.LOGS_RETENTION_PERIOD; + STUDENT_ACTIVITY_LOGS_UPDATE_INTERVAL: number = ApiConst.STUDENT_ACTIVITY_LOGS_UPDATE_INTERVAL; LOG_TYPES: LogType[] = [ { label: 'session access', value: 'access' }, { label: 'session submission', value: 'submission' }, @@ -86,8 +97,8 @@ export class InstructorStudentActivityLogsComponent implements OnInit { logsDateTo: getDefaultDateFormat(), logsTimeTo: getDefaultTimeFormat(), logType: '', - studentEmail: '', - feedbackSessionName: '', + selectedStudent: { studentEmail: '', studentId: '' }, + selectedSession: { feedbackSessionName: '', sessionId: '' }, showActions: false, showInactions: false, }; @@ -101,7 +112,7 @@ export class InstructorStudentActivityLogsComponent implements OnInit { }; dateToday: DateFormat = getDefaultDateFormat(); earliestSearchDate: DateFormat = getDefaultDateFormat(); - studentToLog: Record = {}; + studentLogsMap: Map = new Map(); students: Student[] = []; feedbackSessions: Map = new Map(); searchResults: FeedbackSessionLogModel[] = []; @@ -158,7 +169,12 @@ export class InstructorStudentActivityLogsComponent implements OnInit { * Search for logs of student activity */ search(): void { - this.studentToLog = {}; + if (this.formModel.logType === '') { + this.statusMessageService.showErrorToast('Please select an activity type'); + return; + } + + this.studentLogsMap = new Map(); this.searchResults = []; this.isSearching = true; @@ -172,29 +188,44 @@ export class InstructorStudentActivityLogsComponent implements OnInit { courseId: this.course.courseId, searchFrom: searchFrom.toString(), searchUntil: searchUntil.toString(), - studentEmail: this.formModel.studentEmail, + studentEmail: this.formModel.selectedStudent.studentEmail, logType: this.formModel.logType, - sessionName: this.formModel.feedbackSessionName, + sessionName: this.formModel.selectedSession.feedbackSessionName, + studentId: this.formModel.selectedStudent.studentId, + sessionId: this.formModel.selectedSession.sessionId, }).pipe( finalize(() => { this.isSearching = false; }), ).subscribe({ next: (logs: FeedbackSessionLogs) => { - if (this.formModel.feedbackSessionName === '') { + if (this.formModel.selectedSession.feedbackSessionName === '') { logs.feedbackSessionLogs.forEach((log: FeedbackSessionLog) => { log.feedbackSessionLogEntries.forEach((entry: FeedbackSessionLogEntry) => { - this.studentToLog[this.getStudentKey(log, entry.studentData.email)] = entry; + const arr: FeedbackSessionLogEntry[] | undefined = + this.studentLogsMap.get(this.getStudentKey(log, entry.studentData.email)); + if (arr) { + arr.push(entry); + } else { + this.studentLogsMap.set(this.getStudentKey(log, entry.studentData.email), [entry]); + } }); this.searchResults.push(this.toFeedbackSessionLogModel(log)); }); } else { const targetFeedbackSessionLog = logs.feedbackSessionLogs.find((log: FeedbackSessionLog) => - log.feedbackSessionData.feedbackSessionName === this.formModel.feedbackSessionName); + log.feedbackSessionData.feedbackSessionName === this.formModel.selectedSession.feedbackSessionName); if (targetFeedbackSessionLog) { targetFeedbackSessionLog.feedbackSessionLogEntries.forEach((entry: FeedbackSessionLogEntry) => { - this.studentToLog[this.getStudentKey(targetFeedbackSessionLog, entry.studentData.email)] = entry; + const arr: FeedbackSessionLogEntry[] | undefined = + this.studentLogsMap.get(this.getStudentKey(targetFeedbackSessionLog, entry.studentData.email)); + if (arr) { + arr.push(entry); + } else { + this.studentLogsMap.set( + this.getStudentKey(targetFeedbackSessionLog, entry.studentData.email), [entry]); + } }); this.searchResults.push(this.toFeedbackSessionLogModel(targetFeedbackSessionLog)); } @@ -283,7 +314,10 @@ export class InstructorStudentActivityLogsComponent implements OnInit { return false; } - if (this.formModel.studentEmail !== '' && student.email !== this.formModel.studentEmail) { + if ( + this.formModel.selectedStudent.studentEmail !== '' + && student.email !== this.formModel.selectedStudent.studentEmail + ) { return false; } @@ -293,7 +327,7 @@ export class InstructorStudentActivityLogsComponent implements OnInit { const studentKey = this.getStudentKey(log, student.email); - if (studentKey in this.studentToLog) { + if (this.studentLogsMap.has(studentKey)) { if (this.formModel.showInactions) { return false; } @@ -303,33 +337,45 @@ export class InstructorStudentActivityLogsComponent implements OnInit { return true; }) - .map((student: Student) => { + .flatMap((student: Student) => { let status: string; let dataStyle: string = 'font-family:monospace; white-space:pre;'; - const statusPrefix = this.logTypeToActivityDisplay(this.formModel.logType); const studentKey = this.getStudentKey(log, student.email); - if (studentKey in this.studentToLog) { - const entry: FeedbackSessionLogEntry = this.studentToLog[studentKey]; - const timestamp: string = this.timezoneService.formatToString( + const entries: FeedbackSessionLogEntry[] | undefined = this.studentLogsMap.get(studentKey); + const rows: any[] = []; + if (entries) { + entries.forEach((entry: FeedbackSessionLogEntry) => { + const timestamp: string = this.timezoneService.formatToString( entry.timestamp, log.feedbackSessionData.timeZone, this.LOGS_DATE_TIME_FORMAT); - status = `${statusPrefix} at ${timestamp}`; + status = `${this.logTypeToActivityDisplay(entry.feedbackSessionLogType)} at ${timestamp}`; + status = status.charAt(0).toUpperCase() + status.slice(1); + rows.push([{ + value: status, + style: dataStyle, + }, + { value: student.name }, + { value: student.email }, + { value: student.sectionName }, + { value: student.teamName }]); + }); } else { const timestamp: string = this.timezoneService.formatToString( - notViewedSince, log.feedbackSessionData.timeZone, this.LOGS_DATE_TIME_FORMAT); - status = `Not ${statusPrefix.toLowerCase()} since ${timestamp}`; + notViewedSince, log.feedbackSessionData.timeZone, this.LOGS_DATE_TIME_FORMAT); + status = `Not ${this.logTypeToActivityDisplay(this.formModel.logType)} since ${timestamp}`; dataStyle += 'color:red;'; + rows.push([ + { + value: status, + style: dataStyle, + }, + { value: student.name }, + { value: student.email }, + { value: student.sectionName }, + { value: student.teamName }, + ]); } - return [ - { - value: status, - style: dataStyle, - }, - { value: student.name }, - { value: student.email }, - { value: student.sectionName }, - { value: student.teamName }, - ]; + return rows; }), isTabExpanded: (log.feedbackSessionLogEntries.length !== 0 && this.formModel.showActions) || (log.feedbackSessionLogEntries.length === 0 && this.formModel.showInactions), @@ -339,15 +385,15 @@ export class InstructorStudentActivityLogsComponent implements OnInit { private logTypeToActivityDisplay(logType: string): string { switch (logType.toUpperCase()) { case 'ACCESS': - return 'Viewed the submission page'; + return 'viewed the submission page'; case 'SUBMISSION': - return 'Submitted responses'; + return 'submitted responses'; case 'VIEW RESULT': - return 'Viewed the session results'; + return 'viewed the session results'; case 'ACCESS,SUBMISSION': - return 'Viewed the submission page or submitted responses'; + return 'viewed the submission page or submitted responses'; default: - return 'Unknown activity'; + return 'unknown activity'; } } diff --git a/src/web/app/pages-session/session-result-page/__snapshots__/session-result-page.component.spec.ts.snap b/src/web/app/pages-session/session-result-page/__snapshots__/session-result-page.component.spec.ts.snap index 1b96764aa89..1fb615bcc24 100644 --- a/src/web/app/pages-session/session-result-page/__snapshots__/session-result-page.component.spec.ts.snap +++ b/src/web/app/pages-session/session-result-page/__snapshots__/session-result-page.component.spec.ts.snap @@ -11,6 +11,7 @@ exports[`SessionResultPageComponent should snap when previewing results 1`] = ` courseService={[Function CourseService]} entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} + feedbackSessionId="" feedbackSessionName={[Function String]} feedbackSessionsService={[Function FeedbackSessionsService]} formattedSessionClosingTime="" @@ -35,6 +36,7 @@ exports[`SessionResultPageComponent should snap when previewing results 1`] = ` route={[Function Object]} session={[Function Object]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} visibilityRecipient={[Function String]} @@ -157,6 +159,7 @@ exports[`SessionResultPageComponent should snap when session results failed to l courseService={[Function CourseService]} entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} + feedbackSessionId="" feedbackSessionName={[Function String]} feedbackSessionsService={[Function FeedbackSessionsService]} formattedSessionClosingTime="" @@ -181,6 +184,7 @@ exports[`SessionResultPageComponent should snap when session results failed to l route={[Function Object]} session={[Function Object]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} visibilityRecipient={[Function String]} @@ -307,6 +311,7 @@ exports[`SessionResultPageComponent should snap with an open feedback session wi courseService={[Function CourseService]} entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} + feedbackSessionId="" feedbackSessionName={[Function String]} feedbackSessionsService={[Function FeedbackSessionsService]} formattedSessionClosingTime="" @@ -331,6 +336,7 @@ exports[`SessionResultPageComponent should snap with an open feedback session wi route={[Function Object]} session={[Function Object]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} visibilityRecipient={[Function String]} @@ -454,6 +460,7 @@ exports[`SessionResultPageComponent should snap with default fields 1`] = ` courseService={[Function CourseService]} entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} + feedbackSessionId="" feedbackSessionName={[Function String]} feedbackSessionsService={[Function FeedbackSessionsService]} formattedSessionClosingTime="" @@ -478,6 +485,7 @@ exports[`SessionResultPageComponent should snap with default fields 1`] = ` route={[Function Object]} session={[Function Object]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} visibilityRecipient={[Function String]} @@ -601,6 +609,7 @@ exports[`SessionResultPageComponent should snap with session details and results courseService={[Function CourseService]} entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} + feedbackSessionId="" feedbackSessionName={[Function String]} feedbackSessionsService={[Function FeedbackSessionsService]} formattedSessionClosingTime="" @@ -625,6 +634,7 @@ exports[`SessionResultPageComponent should snap with session details and results route={[Function Object]} session={[Function Object]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} visibilityRecipient={[Function String]} @@ -697,6 +707,7 @@ exports[`SessionResultPageComponent should snap with session details loaded and courseService={[Function CourseService]} entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} + feedbackSessionId="" feedbackSessionName={[Function String]} feedbackSessionsService={[Function FeedbackSessionsService]} formattedSessionClosingTime="" @@ -721,6 +732,7 @@ exports[`SessionResultPageComponent should snap with session details loaded and route={[Function Object]} session={[Function Object]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} visibilityRecipient={[Function String]} @@ -844,6 +856,7 @@ exports[`SessionResultPageComponent should snap with user that is logged in and courseService={[Function CourseService]} entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} + feedbackSessionId="" feedbackSessionName={[Function String]} feedbackSessionsService={[Function FeedbackSessionsService]} formattedSessionClosingTime="" @@ -868,6 +881,7 @@ exports[`SessionResultPageComponent should snap with user that is logged in and route={[Function Object]} session={[Function Object]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} visibilityRecipient={[Function String]} @@ -992,6 +1006,7 @@ exports[`SessionResultPageComponent should snap with user that is not logged in courseService={[Function CourseService]} entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} + feedbackSessionId="" feedbackSessionName={[Function String]} feedbackSessionsService={[Function FeedbackSessionsService]} formattedSessionClosingTime="" @@ -1016,6 +1031,7 @@ exports[`SessionResultPageComponent should snap with user that is not logged in route={[Function Object]} session={[Function Object]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} visibilityRecipient={[Function String]} diff --git a/src/web/app/pages-session/session-result-page/session-result-page.component.spec.ts b/src/web/app/pages-session/session-result-page/session-result-page.component.spec.ts index dfda80f2254..9c13a60836e 100644 --- a/src/web/app/pages-session/session-result-page/session-result-page.component.spec.ts +++ b/src/web/app/pages-session/session-result-page/session-result-page.component.spec.ts @@ -291,7 +291,7 @@ describe('SessionResultPageComponent', () => { jest.spyOn(authService, 'getAuthRegkeyValidity').mockReturnValue(of(testValidity)); jest.spyOn(studentService, 'getStudent').mockReturnValue(of({ name: 'student-name', - email: '', + email: 'student@tmt.tmt', courseId: '', sectionName: '', teamName: '', diff --git a/src/web/app/pages-session/session-result-page/session-result-page.component.ts b/src/web/app/pages-session/session-result-page/session-result-page.component.ts index 9dcbeb6563a..5769cbda252 100644 --- a/src/web/app/pages-session/session-result-page/session-result-page.component.ts +++ b/src/web/app/pages-session/session-result-page/session-result-page.component.ts @@ -103,6 +103,9 @@ export class SessionResultPageComponent implements OnInit { hasFeedbackSessionResultsLoadingFailed: boolean = false; retryAttempts: number = DEFAULT_NUMBER_OF_RETRY_ATTEMPTS; + feedbackSessionId: string | undefined = ''; + studentId: string | undefined = ''; + private backendUrl: string = environment.backendUrl; constructor(private feedbackQuestionsService: FeedbackQuestionsService, @@ -252,20 +255,10 @@ export class SessionResultPageComponent implements OnInit { this.previewAsPerson, this.regKey, ).subscribe((student: Student) => { + this.studentId = student.studentId; this.personName = student.name; this.personEmail = student.email; - - this.logService.createFeedbackSessionLog({ - courseId: this.courseId, - feedbackSessionName: this.feedbackSessionName, - studentEmail: this.personEmail, - logType: FeedbackSessionLogType.VIEW_RESULT, - }).subscribe({ - next: () => { - // No action needed if log is successfully created. - }, - error: () => this.statusMessageService.showWarningToast('Failed to log feedback session view'), - }); + this.logStudentView(); }); break; case Intent.INSTRUCTOR_RESULT: @@ -294,15 +287,21 @@ export class SessionResultPageComponent implements OnInit { key: this.regKey, previewAs: this.previewAsPerson, }) - .pipe(finalize(() => { this.isFeedbackSessionDetailsLoading = false; })) + .pipe(finalize(() => { + this.isFeedbackSessionDetailsLoading = false; + })) .subscribe({ next: (feedbackSession: FeedbackSession) => { const TIME_FORMAT: string = 'ddd, DD MMM, YYYY, hh:mm A zz'; this.session = feedbackSession; + this.feedbackSessionId = feedbackSession.feedbackSessionId; this.formattedSessionOpeningTime = this.timezoneService .formatToString(this.session.submissionStartTimestamp, this.session.timeZone, TIME_FORMAT); this.formattedSessionClosingTime = this.timezoneService .formatToString(this.session.submissionEndTimestamp, this.session.timeZone, TIME_FORMAT); + + this.logStudentView(); + this.feedbackQuestionsService.getFeedbackQuestions({ courseId: this.courseId, feedbackSessionName: this.feedbackSessionName, @@ -378,4 +377,27 @@ export class SessionResultPageComponent implements OnInit { this.statusMessageService.showErrorToast(resp.error.message); } } + + /** + * Logs student activity after student/session details have been fetched. + */ + logStudentView(): void { + if (this.intent !== Intent.STUDENT_RESULT) { + return; + } + + // dummy vars to check that both student and session has been loaded + if (!this.personEmail || !this.session.courseId) { + return; + } + + this.logService.createFeedbackSessionLog({ + courseId: this.courseId, + feedbackSessionName: this.feedbackSessionName, + studentEmail: this.personEmail, + logType: FeedbackSessionLogType.VIEW_RESULT, + feedbackSessionId: this.feedbackSessionId, + studentId: this.studentId, + }).subscribe(); + } } diff --git a/src/web/app/pages-session/session-submission-page/__snapshots__/session-submission-page.component.spec.ts.snap b/src/web/app/pages-session/session-submission-page/__snapshots__/session-submission-page.component.spec.ts.snap index 1edb9658bb7..5110ed14b92 100644 --- a/src/web/app/pages-session/session-submission-page/__snapshots__/session-submission-page.component.spec.ts.snap +++ b/src/web/app/pages-session/session-submission-page/__snapshots__/session-submission-page.component.spec.ts.snap @@ -18,6 +18,7 @@ exports[`SessionSubmissionPageComponent should snap when feedback session questi entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} feedbackResponsesService={[Function FeedbackResponsesService]} + feedbackSessionId="" feedbackSessionInstructions="" feedbackSessionName={[Function String]} feedbackSessionSubmissionStatus={[Function String]} @@ -54,6 +55,7 @@ exports[`SessionSubmissionPageComponent should snap when feedback session questi route={[Function Object]} simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} ungroupableQuestions={[Function Set]} @@ -174,6 +176,7 @@ exports[`SessionSubmissionPageComponent should snap when saving responses 1`] = entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} feedbackResponsesService={[Function FeedbackResponsesService]} + feedbackSessionId="" feedbackSessionInstructions="" feedbackSessionName={[Function String]} feedbackSessionSubmissionStatus={[Function String]} @@ -210,6 +213,7 @@ exports[`SessionSubmissionPageComponent should snap when saving responses 1`] = route={[Function Object]} simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} ungroupableQuestions={[Function Set]} @@ -327,6 +331,7 @@ exports[`SessionSubmissionPageComponent should snap with default fields 1`] = ` entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} feedbackResponsesService={[Function FeedbackResponsesService]} + feedbackSessionId="" feedbackSessionInstructions="" feedbackSessionName={[Function String]} feedbackSessionSubmissionStatus={[Function String]} @@ -363,6 +368,7 @@ exports[`SessionSubmissionPageComponent should snap with default fields 1`] = ` route={[Function Object]} simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} ungroupableQuestions={[Function Set]} @@ -480,6 +486,7 @@ exports[`SessionSubmissionPageComponent should snap with feedback session and us entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} feedbackResponsesService={[Function FeedbackResponsesService]} + feedbackSessionId="" feedbackSessionInstructions={[Function String]} feedbackSessionName={[Function String]} feedbackSessionSubmissionStatus={[Function String]} @@ -516,6 +523,7 @@ exports[`SessionSubmissionPageComponent should snap with feedback session and us route={[Function Object]} simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} ungroupableQuestions={[Function Set]} @@ -766,6 +774,7 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} feedbackResponsesService={[Function FeedbackResponsesService]} + feedbackSessionId="" feedbackSessionInstructions="" feedbackSessionName={[Function String]} feedbackSessionSubmissionStatus={[Function String]} @@ -802,6 +811,7 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi route={[Function Object]} simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} ungroupableQuestions={[Function Set]} @@ -3776,6 +3786,7 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} feedbackResponsesService={[Function FeedbackResponsesService]} + feedbackSessionId="" feedbackSessionInstructions="" feedbackSessionName={[Function String]} feedbackSessionSubmissionStatus={[Function String]} @@ -3812,6 +3823,7 @@ exports[`SessionSubmissionPageComponent should snap with feedback session questi route={[Function Object]} simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} ungroupableQuestions={[Function Set]} @@ -6816,6 +6828,7 @@ exports[`SessionSubmissionPageComponent should snap with user that is logged in entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} feedbackResponsesService={[Function FeedbackResponsesService]} + feedbackSessionId="" feedbackSessionInstructions="" feedbackSessionName={[Function String]} feedbackSessionSubmissionStatus={[Function String]} @@ -6852,6 +6865,7 @@ exports[`SessionSubmissionPageComponent should snap with user that is logged in route={[Function Object]} simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} ungroupableQuestions={[Function Set]} @@ -6970,6 +6984,7 @@ exports[`SessionSubmissionPageComponent should snap with user that is not logged entityType={[Function String]} feedbackQuestionsService={[Function FeedbackQuestionsService]} feedbackResponsesService={[Function FeedbackResponsesService]} + feedbackSessionId="" feedbackSessionInstructions="" feedbackSessionName={[Function String]} feedbackSessionSubmissionStatus={[Function String]} @@ -7006,6 +7021,7 @@ exports[`SessionSubmissionPageComponent should snap with user that is not logged route={[Function Object]} simpleModalService={[Function SimpleModalService]} statusMessageService={[Function StatusMessageService]} + studentId="" studentService={[Function StudentService]} timezoneService={[Function TimezoneService]} ungroupableQuestions={[Function Set]} diff --git a/src/web/app/pages-session/session-submission-page/session-submission-page.component.ts b/src/web/app/pages-session/session-submission-page/session-submission-page.component.ts index 696f6fa2012..3761289f162 100644 --- a/src/web/app/pages-session/session-submission-page/session-submission-page.component.ts +++ b/src/web/app/pages-session/session-submission-page/session-submission-page.component.ts @@ -127,6 +127,9 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { ungroupableQuestions: Set = new Set(); ungroupableQuestionsSorted: number[] = []; + feedbackSessionId: string | undefined = ''; + studentId: string | undefined = ''; + private backendUrl: string = environment.backendUrl; constructor(private route: ActivatedRoute, @@ -322,21 +325,10 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { this.moderatedPerson || this.previewAsPerson, this.regKey, ).subscribe((student: Student) => { + this.studentId = student.studentId; this.personName = student.name; this.personEmail = student.email; - - this.logService.createFeedbackSessionLog({ - courseId: this.courseId, - feedbackSessionName: this.feedbackSessionName, - studentEmail: this.personEmail, - logType: FeedbackSessionLogType.ACCESS, - }).subscribe({ - next: () => {}, - error: () => { - this.statusMessageService.showWarningToast('Failed to log feedback session access'); - }, - }); - + this.logStudentAccess(); }); break; case Intent.INSTRUCTOR_SUBMISSION: @@ -381,6 +373,7 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { })) .subscribe({ next: (feedbackSession: FeedbackSession) => { + this.feedbackSessionId = feedbackSession.feedbackSessionId; this.feedbackSessionInstructions = feedbackSession.instructions; this.formattedSessionOpeningTime = this.timezoneService .formatToString(feedbackSession.submissionStartTimestamp, feedbackSession.timeZone, TIME_FORMAT); @@ -390,6 +383,8 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { this.feedbackSessionSubmissionStatus = feedbackSession.submissionStatus; this.feedbackSessionTimezone = feedbackSession.timeZone; + this.logStudentAccess(); + // don't show alert modal in moderation if (!this.moderatedPerson) { let modalContent: string; @@ -761,12 +756,9 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { feedbackSessionName: this.feedbackSessionName, studentEmail: this.personEmail, logType: FeedbackSessionLogType.SUBMISSION, - }).subscribe({ - next: () => {}, - error: () => { - this.statusMessageService.showWarningToast('Failed to log feedback session submission'); - }, - }); + feedbackSessionId: this.feedbackSessionId, + studentId: this.studentId, + }).subscribe(); questionSubmissionForms.forEach((questionSubmissionFormModel: QuestionSubmissionFormModel) => { let isQuestionFullyAnswered: boolean = true; @@ -1138,4 +1130,27 @@ export class SessionSubmissionPageComponent implements OnInit, AfterViewInit { return recipient ? recipient.recipientName : 'Unknown'; } + + /** + * Logs student activity after student/session details have been fetched. + */ + logStudentAccess(): void { + if (this.intent !== Intent.STUDENT_SUBMISSION) { + return; + } + + // dummy vars to check that both student and session has been loaded + if (!this.personEmail || !this.feedbackSessionTimezone) { + return; + } + + this.logService.createFeedbackSessionLog({ + courseId: this.courseId, + feedbackSessionName: this.feedbackSessionName, + studentEmail: this.personEmail, + logType: FeedbackSessionLogType.ACCESS, + feedbackSessionId: this.feedbackSessionId, + studentId: this.studentId, + }).subscribe(); + } } diff --git a/src/web/services/log.service.ts b/src/web/services/log.service.ts index c61f88ce0c1..3be61aa6d9f 100644 --- a/src/web/services/log.service.ts +++ b/src/web/services/log.service.ts @@ -28,6 +28,8 @@ export class LogService { feedbackSessionName: string, studentEmail: string, logType: FeedbackSessionLogType, + feedbackSessionId?: string, + studentId?: string, }): Observable { const paramMap: Record = { courseid: queryParams.courseId, @@ -36,6 +38,14 @@ export class LogService { fsltype: queryParams.logType.toString(), }; + if (queryParams.feedbackSessionId) { + paramMap['fsid'] = queryParams.feedbackSessionId; + } + + if (queryParams.studentId) { + paramMap['studentid'] = queryParams.studentId; + } + return this.httpRequestService.post(ResourceEndpoints.SESSION_LOGS, paramMap); } @@ -49,6 +59,8 @@ export class LogService { studentEmail?: string, sessionName?: string, logType?: string, + studentId?: string, + sessionId?: string, }): Observable { const paramMap: Record = { courseid: queryParams.courseId, @@ -68,6 +80,14 @@ export class LogService { paramMap['fsltype'] = queryParams.logType; } + if (queryParams.studentId) { + paramMap['studentid'] = queryParams.studentId; + } + + if (queryParams.sessionId) { + paramMap['fsid'] = queryParams.sessionId; + } + return this.httpRequestService.get(ResourceEndpoints.SESSION_LOGS, paramMap); } From 4682dffb71e4566ed72a9e8a7b2b814e8d16f869 Mon Sep 17 00:00:00 2001 From: domoberzin <74132255+domoberzin@users.noreply.github.com> Date: Sun, 14 Apr 2024 19:22:50 +0800 Subject: [PATCH 52/95] [#11878] Add tests for Account Request Table (#12977) * add component tests for account request table * modify tests * remove old tests * remove comment * remove unnecessary code * add tests * update disabled criteria * remove extra builders and update snaps --- ...count-request-table.component.spec.ts.snap | 758 ++++++++++++++++++ .../account-request-table.component.html | 8 +- .../account-request-table.component.spec.ts | 497 ++++++++++++ .../account-request-table.component.ts | 6 +- ...-edit-request-modal.component.spec.ts.snap | 215 +++++ .../admin-edit-request-modal.component.html | 2 +- ...admin-edit-request-modal.component.spec.ts | 60 ++ ...t-with-reason-modal.component.spec.ts.snap | 84 ++ ...reject-with-reason-modal.component.spec.ts | 80 ++ .../admin-search-page.component.spec.ts.snap | 202 ----- .../admin-search-page.component.spec.ts | 193 ----- src/web/services/account.service.ts | 2 +- 12 files changed, 1703 insertions(+), 404 deletions(-) create mode 100644 src/web/app/components/account-requests-table/__snapshots__/account-request-table.component.spec.ts.snap create mode 100644 src/web/app/components/account-requests-table/account-request-table.component.spec.ts create mode 100644 src/web/app/components/account-requests-table/admin-edit-request-modal/__snapshots__/admin-edit-request-modal.component.spec.ts.snap create mode 100644 src/web/app/components/account-requests-table/admin-edit-request-modal/admin-edit-request-modal.component.spec.ts create mode 100644 src/web/app/components/account-requests-table/admin-reject-with-reason-modal/__snapshots__/admin-reject-with-reason-modal.component.spec.ts.snap create mode 100644 src/web/app/components/account-requests-table/admin-reject-with-reason-modal/admin-reject-with-reason-modal.component.spec.ts diff --git a/src/web/app/components/account-requests-table/__snapshots__/account-request-table.component.spec.ts.snap b/src/web/app/components/account-requests-table/__snapshots__/account-request-table.component.spec.ts.snap new file mode 100644 index 00000000000..51ad25e8f44 --- /dev/null +++ b/src/web/app/components/account-requests-table/__snapshots__/account-request-table.component.spec.ts.snap @@ -0,0 +1,758 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountRequestTableComponent should display account requests with no reset or expand links button 1`] = ` + +
    +
    + + Pending Account Requests + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + + Email + + Status + + Institute, Country + + Created At + + Comments + + Options +
    + name + + email + + PENDING + + institute + + Tue, 08 Feb 2022, 08:23 AM +00:00 + +
    + comment +
    +
    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    + + + + + +
    +
    + name + + email + + PENDING + + institute + + Tue, 08 Feb 2022, 08:23 AM +00:00 + +
    + comment +
    +
    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    + + + + + +
    +
    +
    +
    +
    +`; + +exports[`AccountRequestTableComponent should display account requests with reset button and expandable links buttons 1`] = ` + +
    +
    +
    + + Account Requests Found + +
    +
    + + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + + Email + + Status + + Institute, Country + + Created At + + Registered At + + Comments + + Options +
    + name + + email + + APPROVED + + institute + + Tue, 08 Feb 2022, 08:23 AM +00:00 + + Not Registered Yet + +
    + comment +
    +
    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    + + + + + +
    + +
    +
    +
    + name + + email + + REGISTERED + + institute + + Tue, 08 Feb 2022, 08:23 AM +00:00 + + Not Registered Yet + +
    + comment +
    +
    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    + + + + + +
    + +
    +
    +
    +
    +
    +
    +`; + +exports[`AccountRequestTableComponent should snap with an expanded account requests table 1`] = ` + +
    +
    + + Pending Account Requests + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + + Email + + Status + + Institute, Country + + Created At + + Comments + + Options +
    + name + + email + + PENDING + + institute + + Tue, 08 Feb 2022, 08:23 AM +00:00 + +
    + comment +
    +
    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    + + + + + +
    +
    +
    +
    +
    +`; diff --git a/src/web/app/components/account-requests-table/account-request-table.component.html b/src/web/app/components/account-requests-table/account-request-table.component.html index 5095fac7a61..7bf55527f90 100644 --- a/src/web/app/components/account-requests-table/account-request-table.component.html +++ b/src/web/app/components/account-requests-table/account-request-table.component.html @@ -66,12 +66,12 @@
    - + - +
    - - + +
    diff --git a/src/web/app/components/account-requests-table/account-request-table.component.spec.ts b/src/web/app/components/account-requests-table/account-request-table.component.spec.ts new file mode 100644 index 00000000000..6de7c277b22 --- /dev/null +++ b/src/web/app/components/account-requests-table/account-request-table.component.spec.ts @@ -0,0 +1,497 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { of, throwError } from 'rxjs'; +import { AccountRequestTableRowModel } from './account-request-table-model'; +import { AccountRequestTableComponent } from './account-request-table.component'; +import { AccountRequestTableModule } from './account-request-table.module'; +import { EditRequestModalComponent } from './admin-edit-request-modal/admin-edit-request-modal.component'; +import { + RejectWithReasonModalComponent, +} from './admin-reject-with-reason-modal/admin-reject-with-reason-modal.component'; +import { AccountService } from '../../../services/account.service'; +import { SimpleModalService } from '../../../services/simple-modal.service'; +import { StatusMessageService } from '../../../services/status-message.service'; +import { createBuilder } from '../../../test-helpers/generic-builder'; +import { createMockNgbModalRef } from '../../../test-helpers/mock-ngb-modal-ref'; +import { AccountRequest, AccountRequestStatus } from '../../../types/api-output'; +import { SimpleModalType } from '../simple-modal/simple-modal-type'; + +describe('AccountRequestTableComponent', () => { + let component: AccountRequestTableComponent; + let fixture: ComponentFixture; + let accountService: AccountService; + let statusMessageService: StatusMessageService; + let simpleModalService: SimpleModalService; + let ngbModal: NgbModal; + + const accountRequestDetailsBuilder = createBuilder({ + id: '', + email: '', + name: '', + instituteAndCountry: '', + registrationLink: '', + status: AccountRequestStatus.PENDING, + comments: '', + registeredAtText: '', + createdAtText: '', + showLinks: false, + }); + + const DEFAULT_ACCOUNT_REQUEST = accountRequestDetailsBuilder + .email('email') + .name('name') + .status(AccountRequestStatus.PENDING) + .instituteAndCountry('institute') + .createdAtText('Tue, 08 Feb 2022, 08:23 AM +00:00') + .comments('comment'); + + const resetModalContent = `Are you sure you want to reset the account request for + name with email email from + institute? + An email with the account registration link will also be sent to the instructor.`; + const resetModalTitle = 'Reset account request for name?'; + const deleteModalContent = `Are you sure you want to delete the account request for + name with email email from + institute?`; + const deleteModalTitle = 'Delete account request for name?'; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [AccountRequestTableComponent], + imports: [ + AccountRequestTableModule, + BrowserAnimationsModule, + HttpClientTestingModule, + ], + providers: [ + AccountService, SimpleModalService, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountRequestTableComponent); + component = fixture.componentInstance; + accountService = TestBed.inject(AccountService); + statusMessageService = TestBed.inject(StatusMessageService); + simpleModalService = TestBed.inject(SimpleModalService); + ngbModal = TestBed.inject(NgbModal); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should snap with an expanded account requests table', () => { + const accountRequestResult: AccountRequestTableRowModel = DEFAULT_ACCOUNT_REQUEST.build(); + component.accountRequests = [ + accountRequestResult, + ]; + + fixture.detectChanges(); + expect(fixture).toMatchSnapshot(); + }); + + it('should show account request links when expand all button clicked', () => { + const accountRequestResult: AccountRequestTableRowModel = DEFAULT_ACCOUNT_REQUEST.build(); + accountRequestResult.status = AccountRequestStatus.APPROVED; + accountRequestResult.registrationLink = 'registrationLink'; + component.accountRequests = [ + accountRequestResult, + ]; + component.searchString = 'test'; + fixture.detectChanges(); + + const button: any = fixture.debugElement.nativeElement.querySelector('#show-account-request-links'); + button.click(); + expect(component.accountRequests[0].showLinks).toEqual(true); + }); + + it('should display account requests with no reset or expand links button', () => { + const accountRequestResults: AccountRequestTableRowModel[] = [ + DEFAULT_ACCOUNT_REQUEST.build(), + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + component.accountRequests = accountRequestResults; + fixture.detectChanges(); + expect(fixture).toMatchSnapshot(); + }); + + it('should display account requests with reset button and expandable links buttons', + () => { + const approvedAccountRequestResult: AccountRequestTableRowModel = DEFAULT_ACCOUNT_REQUEST.build(); + approvedAccountRequestResult.status = AccountRequestStatus.APPROVED; + approvedAccountRequestResult.registrationLink = 'registrationLink'; + + const registeredAccountRequestResult: AccountRequestTableRowModel = DEFAULT_ACCOUNT_REQUEST.build(); + registeredAccountRequestResult.status = AccountRequestStatus.REGISTERED; + registeredAccountRequestResult.registrationLink = 'registrationLink'; + + const accountRequestResults: AccountRequestTableRowModel[] = [ + approvedAccountRequestResult, + registeredAccountRequestResult, + ]; + + component.accountRequests = accountRequestResults; + component.searchString = 'test'; + fixture.detectChanges(); + expect(fixture).toMatchSnapshot(); + }); + + it('should show success message when deleting account request is successful', () => { + component.accountRequests = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + fixture.detectChanges(); + + const modalSpy = jest.spyOn(simpleModalService, 'openConfirmationModal').mockImplementation(() => { + return createMockNgbModalRef({}); + }); + + jest.spyOn(accountService, 'deleteAccountRequest').mockReturnValue(of({ + message: 'Account request successfully deleted.', + })); + + const spyStatusMessageService: any = jest.spyOn(statusMessageService, 'showSuccessToast') + .mockImplementation((args: string) => { + expect(args).toEqual('Account request successfully deleted.'); + }); + + const deleteButton: any = fixture.debugElement.nativeElement.querySelector('#delete-account-request-0'); + deleteButton.click(); + + expect(spyStatusMessageService).toHaveBeenCalled(); + expect(modalSpy).toHaveBeenCalledTimes(1); + expect(modalSpy).toHaveBeenCalledWith(deleteModalTitle, SimpleModalType.DANGER, deleteModalContent); + }); + + it('should show error message when deleting account request is unsuccessful', () => { + component.accountRequests = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + fixture.detectChanges(); + + const modalSpy = jest.spyOn(simpleModalService, 'openConfirmationModal').mockImplementation(() => { + return createMockNgbModalRef({}); + }); + + jest.spyOn(accountService, 'deleteAccountRequest').mockReturnValue(throwError(() => ({ + error: { + message: 'This is the error message.', + }, + }))); + + const spyStatusMessageService: any = jest.spyOn(statusMessageService, 'showErrorToast') + .mockImplementation((args: string) => { + expect(args).toEqual('This is the error message.'); + }); + + const deleteButton: any = fixture.debugElement.nativeElement.querySelector('#delete-account-request-0'); + deleteButton.click(); + + expect(spyStatusMessageService).toHaveBeenCalled(); + expect(modalSpy).toHaveBeenCalledTimes(1); + expect(modalSpy).toHaveBeenCalledWith(deleteModalTitle, SimpleModalType.DANGER, deleteModalContent); + }); + + it('should show success message when resetting account request is successful', () => { + const registeredAccountRequestResult: AccountRequestTableRowModel = DEFAULT_ACCOUNT_REQUEST.build(); + registeredAccountRequestResult.status = AccountRequestStatus.REGISTERED; + registeredAccountRequestResult.registrationLink = 'registrationLink'; + registeredAccountRequestResult.registeredAtText = 'registeredTime'; + component.accountRequests = [ + registeredAccountRequestResult, + ]; + + component.searchString = 'test'; + fixture.detectChanges(); + + const modalSpy = jest.spyOn(simpleModalService, 'openConfirmationModal').mockImplementation(() => { + return createMockNgbModalRef({}); + }); + + jest.spyOn(accountService, 'resetAccountRequest').mockReturnValue(of({ + joinLink: 'joinlink', + })); + + const spyStatusMessageService = jest.spyOn(statusMessageService, 'showSuccessToast') + .mockImplementation((args: string) => { + expect(args) + .toEqual('Reset successful. An email has been sent to email.'); + }); + + const resetButton = fixture.debugElement.nativeElement.querySelector('#reset-account-request-0'); + resetButton.click(); + + expect(spyStatusMessageService).toHaveBeenCalled(); + expect(modalSpy).toHaveBeenCalledTimes(1); + expect(modalSpy).toHaveBeenCalledWith(resetModalTitle, SimpleModalType.WARNING, resetModalContent); + }); + + it('should show error message when resetting account request is unsuccessful', () => { + const registeredAccountRequestResult: AccountRequestTableRowModel = DEFAULT_ACCOUNT_REQUEST.build(); + registeredAccountRequestResult.status = AccountRequestStatus.REGISTERED; + registeredAccountRequestResult.registrationLink = 'registrationLink'; + registeredAccountRequestResult.registeredAtText = 'registeredTime'; + component.accountRequests = [ + registeredAccountRequestResult, + ]; + + component.searchString = 'test'; + fixture.detectChanges(); + + const modalSpy = jest.spyOn(simpleModalService, 'openConfirmationModal').mockImplementation(() => { + return createMockNgbModalRef({}); + }); + + jest.spyOn(accountService, 'resetAccountRequest').mockReturnValue(throwError(() => ({ + error: { + message: 'This is the error message.', + }, + }))); + + const spyStatusMessageService = jest.spyOn(statusMessageService, 'showErrorToast') + .mockImplementation((args: string) => { + expect(args).toEqual('This is the error message.'); + }); + + const resetButton = fixture.debugElement.nativeElement.querySelector('#reset-account-request-0'); + resetButton.click(); + + expect(spyStatusMessageService).toHaveBeenCalled(); + expect(modalSpy).toHaveBeenCalledTimes(1); + expect(modalSpy).toHaveBeenCalledWith(resetModalTitle, SimpleModalType.WARNING, resetModalContent); + }); + + it('should display comment modal', () => { + const accountRequestResults: AccountRequestTableRowModel[] = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + component.accountRequests = accountRequestResults; + fixture.detectChanges(); + + const modalSpy = jest.spyOn(simpleModalService, 'openInformationModal') + .mockReturnValue(createMockNgbModalRef()); + + const viewCommentButton: any = fixture.debugElement.nativeElement.querySelector('#view-account-request-0'); + viewCommentButton.click(); + expect(modalSpy).toHaveBeenCalledTimes(1); + expect(modalSpy).toHaveBeenCalledWith('Comments for name Request', + SimpleModalType.INFO, 'Comment: comment'); + }); + + it('should display edit modal when edit button is clicked', () => { + const accountRequestResults: AccountRequestTableRowModel[] = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + component.accountRequests = accountRequestResults; + fixture.detectChanges(); + + const modalSpy = jest.spyOn(ngbModal, 'open').mockImplementation(() => { + return createMockNgbModalRef({}); + }); + + const editButton: any = fixture.debugElement.nativeElement.querySelector('#edit-account-request-0'); + editButton.click(); + expect(modalSpy).toHaveBeenCalledTimes(1); + expect(modalSpy).toHaveBeenCalledWith(EditRequestModalComponent); + }); + + it('should display reject modal when reject button is clicked', () => { + const accountRequestResults: AccountRequestTableRowModel[] = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + component.accountRequests = accountRequestResults; + fixture.detectChanges(); + + const modalSpy = jest.spyOn(ngbModal, 'open').mockImplementation(() => { + return createMockNgbModalRef({}); + }); + + const rejectButton: any = fixture.debugElement.nativeElement.querySelector('#reject-request-with-reason-0'); + rejectButton.click(); + fixture.detectChanges(); + expect(modalSpy).toHaveBeenCalledTimes(1); + expect(modalSpy).toHaveBeenCalledWith(RejectWithReasonModalComponent); + }); + + it('should display error message when rejection was unsuccessful', () => { + const accountRequestResults: AccountRequestTableRowModel[] = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + component.accountRequests = accountRequestResults; + fixture.detectChanges(); + + jest.spyOn(accountService, 'rejectAccountRequest').mockReturnValue(throwError(() => ({ + error: { + message: 'This is the error message.', + }, + }))); + + const spyStatusMessageService = jest.spyOn(statusMessageService, 'showErrorToast') + .mockImplementation((args: string) => { + expect(args).toEqual('This is the error message.'); + }); + + const rejectButton = fixture.debugElement.nativeElement.querySelector('#reject-request-0'); + rejectButton.click(); + + expect(spyStatusMessageService).toHaveBeenCalled(); + }); + + it('should display error message when approval was unsuccessful', () => { + const accountRequestResults: AccountRequestTableRowModel[] = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + component.accountRequests = accountRequestResults; + fixture.detectChanges(); + + jest.spyOn(accountService, 'approveAccountRequest').mockReturnValue(throwError(() => ({ + error: { + message: 'This is the error message.', + }, + }))); + + const spyStatusMessageService: any = jest.spyOn(statusMessageService, 'showErrorToast') + .mockImplementation((args: string) => { + expect(args).toEqual('This is the error message.'); + }); + + const approveButton: any = fixture.debugElement.nativeElement.querySelector('#approve-account-request-0'); + approveButton.click(); + + expect(spyStatusMessageService).toHaveBeenCalled(); + }); + + it('should display error message when edit was unsuccessful', () => { + const accountRequestResults: AccountRequestTableRowModel[] = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + component.accountRequests = accountRequestResults; + fixture.detectChanges(); + + jest.spyOn(ngbModal, 'open').mockImplementation(() => { + return createMockNgbModalRef({}); + }); + + jest.spyOn(accountService, 'editAccountRequest').mockReturnValue(throwError(() => ({ + error: { + message: 'This is the error message.', + }, + }))); + + const spyStatusMessageService = jest.spyOn(statusMessageService, 'showErrorToast') + .mockImplementation((args: string) => { + expect(args).toEqual('This is the error message.'); + }); + + const editButton = fixture.debugElement.nativeElement.querySelector('#edit-account-request-0'); + editButton.click(); + + expect(spyStatusMessageService).toHaveBeenCalled(); + }); + + it('should update request when edit is succcessful', () => { + const accountRequestResults: AccountRequestTableRowModel[] = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + component.accountRequests = accountRequestResults; + fixture.detectChanges(); + + const modalSpy = jest.spyOn(ngbModal, 'open').mockImplementation(() => { + return createMockNgbModalRef({}); + }); + + const editedAccountRequest : AccountRequest = { + id: 'id', + comments: 'new comment', + email: 'new email', + institute: 'new institute', + registrationKey: 'registration key', + name: 'new name', + createdAt: 1, + status: AccountRequestStatus.PENDING, + }; + + jest.spyOn(accountService, 'editAccountRequest').mockReturnValue(of(editedAccountRequest)); + + const editButton: any = fixture.debugElement.nativeElement.querySelector('#edit-account-request-0'); + editButton.click(); + expect(modalSpy).toHaveBeenCalledTimes(1); + expect(modalSpy).toHaveBeenCalledWith(EditRequestModalComponent); + + fixture.detectChanges(); + expect(component.accountRequests[0].comments).toEqual('new comment'); + expect(component.accountRequests[0].email).toEqual('new email'); + expect(component.accountRequests[0].instituteAndCountry).toEqual('new institute'); + expect(component.accountRequests[0].name).toEqual('new name'); + }); + + it('should update status when approval is succcessful', () => { + const accountRequestResults: AccountRequestTableRowModel[] = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + component.accountRequests = accountRequestResults; + fixture.detectChanges(); + + const approvedRequest : AccountRequest = { + id: component.accountRequests[0].id, + comments: component.accountRequests[0].comments, + email: component.accountRequests[0].email, + institute: component.accountRequests[0].instituteAndCountry, + registrationKey: 'registration key', + name: component.accountRequests[0].name, + createdAt: 1, + status: AccountRequestStatus.APPROVED, + }; + + jest.spyOn(accountService, 'approveAccountRequest').mockReturnValue(of(approvedRequest)); + + const approveButton: any = fixture.debugElement.nativeElement.querySelector('#approve-account-request-0'); + approveButton.click(); + + fixture.detectChanges(); + expect(component.accountRequests[0].status).toEqual(AccountRequestStatus.APPROVED); + }); + + it('should update status when rejection is succcessful', () => { + const accountRequestResults: AccountRequestTableRowModel[] = [ + DEFAULT_ACCOUNT_REQUEST.build(), + ]; + + component.accountRequests = accountRequestResults; + fixture.detectChanges(); + + const rejectedRequest : AccountRequest = { + id: component.accountRequests[0].id, + comments: component.accountRequests[0].comments, + email: component.accountRequests[0].email, + institute: component.accountRequests[0].instituteAndCountry, + registrationKey: 'registration key', + name: component.accountRequests[0].name, + createdAt: 1, + status: AccountRequestStatus.REJECTED, + }; + + jest.spyOn(accountService, 'rejectAccountRequest').mockReturnValue(of(rejectedRequest)); + + const rejectButton: any = fixture.debugElement.nativeElement.querySelector('#reject-request-0'); + rejectButton.click(); + + fixture.detectChanges(); + expect(component.accountRequests[0].status).toEqual(AccountRequestStatus.REJECTED); + }); +}); diff --git a/src/web/app/components/account-requests-table/account-request-table.component.ts b/src/web/app/components/account-requests-table/account-request-table.component.ts index 54a22cffeb9..2af6f4b9aad 100755 --- a/src/web/app/components/account-requests-table/account-request-table.component.ts +++ b/src/web/app/components/account-requests-table/account-request-table.component.ts @@ -8,7 +8,7 @@ import { import { AccountService } from '../../../services/account.service'; import { SimpleModalService } from '../../../services/simple-modal.service'; import { StatusMessageService } from '../../../services/status-message.service'; -import { AccountRequest, AccountRequestStatus, MessageOutput } from '../../../types/api-output'; +import { AccountRequest, MessageOutput } from '../../../types/api-output'; import { ErrorMessageOutput } from '../../error-message-output'; import { SimpleModalType } from '../simple-modal/simple-modal-type'; import { collapseAnim } from '../teammates-common/collapse-anim'; @@ -89,8 +89,8 @@ export class AccountRequestTableComponent { this.accountService.approveAccountRequest(accountRequest.id, accountRequest.name, accountRequest.email, accountRequest.instituteAndCountry) .subscribe({ - next: () => { - accountRequest.status = AccountRequestStatus.APPROVED; + next: (resp : AccountRequest) => { + accountRequest.status = resp.status; }, error: (resp: ErrorMessageOutput) => { this.statusMessageService.showErrorToast(resp.error.message); diff --git a/src/web/app/components/account-requests-table/admin-edit-request-modal/__snapshots__/admin-edit-request-modal.component.spec.ts.snap b/src/web/app/components/account-requests-table/admin-edit-request-modal/__snapshots__/admin-edit-request-modal.component.spec.ts.snap new file mode 100644 index 00000000000..17770c36eb2 --- /dev/null +++ b/src/web/app/components/account-requests-table/admin-edit-request-modal/__snapshots__/admin-edit-request-modal.component.spec.ts.snap @@ -0,0 +1,215 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RejectWithReasonModal should show empty fields 1`] = ` + +