Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User can report answer in peer review, teacher can view reports in manual review #1364

Merged
merged 12 commits into from
Jan 30, 2025
1 change: 0 additions & 1 deletion .stylelintrc
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
"function-no-unknown": null,
"media-query-no-invalid": null,
"declaration-property-value-no-unknown": null,
"at-rule-descriptor-no-unknown": null,
"selector-type-no-unknown": [
true,
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { css, cx } from "@emotion/css"
import { useQuery } from "@tanstack/react-query"
import { ExclamationMessage } from "@vectopus/atlas-icons-react"
import React, { useContext, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"

Expand All @@ -8,15 +9,21 @@ import ContentRenderer from "../../.."
import {
Block,
fetchPeerOrSelfReviewDataByExerciseId,
postFlagAnswerInPeerReview,
postPeerOrSelfReviewSubmission,
} from "../../../../../services/backend"
import ExerciseTaskIframe from "../ExerciseTaskIframe"

import PeerOrSelfReviewQuestion from "./PeerOrSelfReviewQuestion"
import MarkAsSpamDialog from "./PeerRevireMarkingSpam/MarkAsSpamDialog"

import { getPeerReviewBeginningScrollingId, PeerOrSelfReviewViewProps } from "."

import { CourseMaterialPeerOrSelfReviewQuestionAnswer } from "@/shared-module/common/bindings"
import {
CourseMaterialPeerOrSelfReviewQuestionAnswer,
ReportReason,
} from "@/shared-module/common/bindings"
import Button from "@/shared-module/common/components/Button"
import ErrorBanner from "@/shared-module/common/components/ErrorBanner"
import PeerReviewProgress from "@/shared-module/common/components/PeerReview/PeerReviewProgress"
import Spinner from "@/shared-module/common/components/Spinner"
Expand All @@ -37,6 +44,7 @@ const PeerOrSelfReviewViewImpl: React.FC<React.PropsWithChildren<PeerOrSelfRevie
const [answers, setAnswers] = useState<Map<string, CourseMaterialPeerOrSelfReviewQuestionAnswer>>(
new Map(),
)
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false)
Maijjay marked this conversation as resolved.
Show resolved Hide resolved

const query = useQuery({
queryKey: [`exercise-${exerciseId}-peer-or-self-review`],
Expand Down Expand Up @@ -126,6 +134,39 @@ const PeerOrSelfReviewViewImpl: React.FC<React.PropsWithChildren<PeerOrSelfRevie
},
)

const reportMutation = useToastMutation(
async ({ reason, description }: { reason: ReportReason; description: string }) => {
if (!peerOrSelfReviewData || !peerOrSelfReviewData.answer_to_review) {
return
}
const token = query.data?.token
if (!peerOrSelfReviewData || !peerOrSelfReviewData.answer_to_review || !token) {
return
}
return await postFlagAnswerInPeerReview(exerciseId, {
submission_id: peerOrSelfReviewData.answer_to_review.exercise_slide_submission_id,
reason,
description,
flagged_user: null,
flagged_by: null,
peer_or_self_review_config_id: peerOrSelfReviewData.peer_or_self_review_config.id,
token: token,
})
},
{ notify: true, method: "POST" },
{
onSuccess: () => {
setIsReportDialogOpen(false)
setAnswers(new Map())
query.refetch()
},
},
)

const handleReportSubmit = (reason: ReportReason, description: string) => {
reportMutation.mutate({ reason, description })
}

if (query.isError) {
return <ErrorBanner variant={"readOnly"} error={query.error} />
}
Expand Down Expand Up @@ -266,7 +307,7 @@ const PeerOrSelfReviewViewImpl: React.FC<React.PropsWithChildren<PeerOrSelfRevie
margin-top: 3rem;
margin-bottom: 2rem;
background-color: #e0e0e0;
height: 6px;
height: 5px;
border: none;
`}
/>
Expand Down Expand Up @@ -299,13 +340,38 @@ const PeerOrSelfReviewViewImpl: React.FC<React.PropsWithChildren<PeerOrSelfRevie
}}
/>
))}

<button
className={cx(exerciseButtonStyles)}
className={cx(
css`
margin-top: 2.5rem !important;
`,
exerciseButtonStyles,
)}
disabled={!isValid || !peerOrSelfReviewData || submitPeerOrSelfReviewMutation.isPending}
onClick={() => submitPeerOrSelfReviewMutation.mutate()}
>
{t("submit-button")}
</button>
<Button
className={css`
display: flex !important;
align-items: center;
gap: 6px;
`}
variant={"icon"}
transform="capitalize"
size={"small"}
onClick={() => setIsReportDialogOpen(true)}
>
<ExclamationMessage /> {t("button-text-report")}
</Button>
Maijjay marked this conversation as resolved.
Show resolved Hide resolved

<MarkAsSpamDialog
isOpen={isReportDialogOpen}
onClose={() => setIsReportDialogOpen(false)}
onSubmit={handleReportSubmit}
/>
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { css } from "@emotion/css"
import styled from "@emotion/styled"
import React, { useState } from "react"
import { useTranslation } from "react-i18next"

import RadioButton from "@/shared-module/common/components/InputFields/RadioButton"
import StandardDialog from "@/shared-module/common/components/StandardDialog"

const FieldContainer = styled.div`
margin-bottom: 1rem;
`
export const ReportReasonValues = {
// eslint-disable-next-line i18next/no-literal-string
Spam: "Spam",
// eslint-disable-next-line i18next/no-literal-string
HarmfulContent: "HarmfulContent",
// eslint-disable-next-line i18next/no-literal-string
AiGenerated: "AiGenerated",
} as const

// Ensure the type aligns with the backend type
export type ReportReason = (typeof ReportReasonValues)[keyof typeof ReportReasonValues]

const MarkAsSpamDialog: React.FC<{
isOpen: boolean
onClose: () => void
onSubmit: (reason: ReportReason, description: string) => void
}> = ({ isOpen, onClose, onSubmit }) => {
const { t } = useTranslation()
const [selectedReason, setSelectedReason] = useState<ReportReason | null>(null)
const [description, setDescription] = useState<string>("")

const handleSubmit = () => {
if (selectedReason) {
onSubmit(selectedReason, description)
setSelectedReason(null)
setDescription("")
onClose()
}
}

return (
<StandardDialog
open={isOpen}
onClose={onClose}
aria-labelledby="report-dialog-title"
title={t("title-report-dialog")}
buttons={[
{
// eslint-disable-next-line i18next/no-literal-string
variant: "primary",
onClick: () => handleSubmit(),
disabled: !selectedReason,
children: t("submit-button"),
},
]}
>
<div
className={css`
margin-bottom: 1rem;
`}
>
{t("select-reason")}
<FieldContainer>
<RadioButton
label={t("flagging-reason-spam")}
value={ReportReasonValues.Spam}
// eslint-disable-next-line i18next/no-literal-string
name="reason"
onChange={() => setSelectedReason(ReportReasonValues.Spam)}
/>
</FieldContainer>
<FieldContainer>
<RadioButton
label={t("flagging-reason-harmful-content")}
value={ReportReasonValues.HarmfulContent}
// eslint-disable-next-line i18next/no-literal-string
name="reason"
onChange={() => setSelectedReason(ReportReasonValues.HarmfulContent)}
/>
</FieldContainer>
<FieldContainer>
<RadioButton
label={t("flagging-reason-ai-generated")}
value={ReportReasonValues.AiGenerated}
// eslint-disable-next-line i18next/no-literal-string
name="reason"
onChange={() => setSelectedReason(ReportReasonValues.AiGenerated)}
/>
</FieldContainer>
</div>

<textarea
placeholder={t("optional-description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
className={css`
width: 100%;
height: 5rem;
margin-bottom: 1rem;
padding: 10px 12px;
`}
/>
</StandardDialog>
)
}

export default MarkAsSpamDialog
17 changes: 17 additions & 0 deletions services/course-material/src/services/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import {
ExamData,
ExamEnrollment,
ExerciseSlideSubmissionAndUserExerciseStateList,
FlaggedAnswer,
IsChapterFrontPage,
MaterialReference,
NewFeedback,
NewFlaggedAnswerWithToken,
NewMaterialReference,
NewProposedPageEdits,
NewResearchFormQuestionAnswer,
Expand Down Expand Up @@ -71,6 +73,7 @@ import {
isCustomViewExerciseSubmissions,
isExamData,
isExerciseSlideSubmissionAndUserExerciseStateList,
isFlaggedAnswer,
isIsChapterFrontPage,
isMaterialReference,
isOEmbedResponse,
Expand Down Expand Up @@ -412,6 +415,20 @@ export const postPeerOrSelfReviewSubmission = async (
)
}

export const postFlagAnswerInPeerReview = async (
exerciseId: string,
newFlaggedAnswer: NewFlaggedAnswerWithToken,
): Promise<FlaggedAnswer> => {
const response = await courseMaterialClient.post(
`/exercises/${exerciseId}/flag-peer-review-answer`,
newFlaggedAnswer,
{
responseType: "json",
},
)
return validateResponse(response, isFlaggedAnswer)
}

export const postStartPeerOrSelfReview = async (exerciseId: string): Promise<void> => {
await courseMaterialClient.post(`/exercises/${exerciseId}/peer-or-self-reviews/start`)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
DROP TABLE IF EXISTS flagged_answers;

DROP TYPE IF EXISTS report_reason;

ALTER TABLE exercise_slide_submissions DROP COLUMN flag_count;

ALTER TABLE courses DROP COLUMN flagged_answers_threshold;
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
CREATE TYPE report_reason AS ENUM (
'flagging-reason-spam',
'flagging-reason-harmful-content',
'flagging-reason-ai-generated'
);

CREATE TABLE flagged_answers (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
submission_id UUID NOT NULL REFERENCES exercise_slide_submissions(id),
flagged_user UUID NOT NULL REFERENCES users(id),
flagged_by UUID NOT NULL REFERENCES users(id),
reason report_reason NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
UNIQUE NULLS NOT DISTINCT (submission_id, flagged_by, deleted_at)
);

CREATE INDEX idx_flagged_answers_submission_id ON flagged_answers(submission_id);

CREATE TRIGGER set_timestamp BEFORE
UPDATE ON flagged_answers FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp();

COMMENT ON TABLE flagged_answers IS 'Used to keep track of answers that has been flagged in peer review';
COMMENT ON COLUMN flagged_answers.submission_id IS 'The id of the exercise task submission being flagged.';
COMMENT ON COLUMN flagged_answers.flagged_user IS 'The id of the user whose answer was flagged.';
COMMENT ON COLUMN flagged_answers.flagged_by IS 'The id of the user who flagged the answer.';
COMMENT ON COLUMN flagged_answers.reason IS 'The reason for flagging the answer.';
COMMENT ON COLUMN flagged_answers.description IS 'Optional additional explanation provided by the user.';
COMMENT ON COLUMN flagged_answers.created_at IS 'Timestamp when the flag was created.';
COMMENT ON COLUMN flagged_answers.updated_at IS 'Timestamp when the flag was last updated. The field is updated automatically by the set_timestamp trigger.';
COMMENT ON COLUMN flagged_answers.deleted_at IS 'Timestamp when the flag was deleted. If null, the record is not deleted.';

ALTER TABLE exercise_slide_submissions
ADD COLUMN flag_count INTEGER NOT NULL DEFAULT 0;
Comment on lines +35 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add CHECK constraint for flag_count.

Prevent negative values in the flag_count column by adding a CHECK constraint.

 ALTER TABLE exercise_slide_submissions
-ADD COLUMN flag_count INTEGER NOT NULL DEFAULT 0;
+ADD COLUMN flag_count INTEGER NOT NULL DEFAULT 0 CHECK (flag_count >= 0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ALTER TABLE exercise_slide_submissions
ADD COLUMN flag_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE exercise_slide_submissions
ADD COLUMN flag_count INTEGER NOT NULL DEFAULT 0 CHECK (flag_count >= 0);


COMMENT ON COLUMN exercise_slide_submissions.flag_count IS 'The number of times the submission has been flagged.';

ALTER TABLE courses
ADD COLUMN flagged_answers_threshold INTEGER NOT NULL DEFAULT 3 CHECK (flagged_answers_threshold > 0);

COMMENT ON COLUMN courses.flagged_answers_threshold IS 'The amount of flags required to trigger a teacher review for an answer.';

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading