From 89b96bbbb5d4fa52ad5d3d632d62fa9218206853 Mon Sep 17 00:00:00 2001 From: nikhen-s Date: Tue, 1 Jul 2025 11:03:10 +0100 Subject: [PATCH 01/10] Added testimonials tab and basic table --- backend/backend/settings.py | 1 + backend/backend/urls.py | 1 + backend/testimonials/__init__.py | 0 backend/testimonials/admin.py | 3 + backend/testimonials/apps.py | 6 + backend/testimonials/migrations/__init__.py | 0 backend/testimonials/models.py | 35 ++ backend/testimonials/tests.py | 3 + backend/testimonials/urls.py | 6 + backend/testimonials/views.py | 41 ++ frontend/package.json | 11 +- frontend/src/api/hooks.ts | 8 + frontend/src/app.tsx | 7 + frontend/src/pages/testimonials-page.tsx | 433 ++++++++++++++++++++ frontend/yarn.lock | 243 +++++------ 15 files changed, 675 insertions(+), 123 deletions(-) create mode 100644 backend/testimonials/__init__.py create mode 100644 backend/testimonials/admin.py create mode 100644 backend/testimonials/apps.py create mode 100644 backend/testimonials/migrations/__init__.py create mode 100644 backend/testimonials/models.py create mode 100644 backend/testimonials/tests.py create mode 100644 backend/testimonials/urls.py create mode 100644 backend/testimonials/views.py create mode 100644 frontend/src/pages/testimonials-page.tsx diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 308e7a65e..c49be3a1c 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -231,6 +231,7 @@ "testing.apps.TestingConfig", "django_probes", "dissertations.apps.DissertationsConfig", + "testimonials.apps.TestimonialsConfig" ] + (["django_gsuite_email"] if "django_gsuite_email" in EMAIL_BACKEND else []) MIDDLEWARE = [ diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 5556a57a0..b151998c6 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -49,6 +49,7 @@ def wrapper(request): path("api/document/", include("documents.urls")), path("api/stats/", include("stats.urls")), path("api/dissertations/", include("dissertations.urls")), + path("api/testimonials/", include("testimonials.urls")), re_path( r"^static/(?P.*)$", views.cached_serve, diff --git a/backend/testimonials/__init__.py b/backend/testimonials/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/testimonials/admin.py b/backend/testimonials/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/backend/testimonials/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/testimonials/apps.py b/backend/testimonials/apps.py new file mode 100644 index 000000000..64fc74b83 --- /dev/null +++ b/backend/testimonials/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TestimonialsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "testimonials" diff --git a/backend/testimonials/migrations/__init__.py b/backend/testimonials/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/testimonials/models.py b/backend/testimonials/models.py new file mode 100644 index 000000000..25f83ed0e --- /dev/null +++ b/backend/testimonials/models.py @@ -0,0 +1,35 @@ +from django.db import models + +class Course(models.Model): + name = models.TextField() + code = models.CharField(max_length=256, primary_key = True) #Course Code is the Primary Key + level = models.IntegerField() #Difficulty Level of Course + credits = models.IntegerField() + delivery = models.TextField() #SEM1, SEM2, YEAR + work_exam_ratio = models.TextField() + dpmt_link = models.TextField() + + def __str__(self): + return self.code # Use only a field of the model + #add a testimonial data type to courses + +class Testimonial(models.Model): + #Link this to models.py ediauth username + id = models.AutoField(primary_key=True) + author = models.ForeignKey("auth.User", on_delete=models.CASCADE, default="") + course = models.ForeignKey( # Link Testimonial to a Course + "testimonials.Course", + on_delete=models.CASCADE, # Delete testimonials if course is deleted + ) #Course_id, author_id, id (testimonial_id) + testimonial = models.TextField() + year_taken = models.IntegerField() + recommendability_rating = models.IntegerField() #min=1 max=5 + workload_rating = models.IntegerField() #min=1 max=5 + difficulty_rating = models.IntegerField() #min=1 max=5 + + class Meta: + # Enforce uniqueness across `student_number` and `course` + unique_together = ("author", "course") + + + diff --git a/backend/testimonials/tests.py b/backend/testimonials/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/backend/testimonials/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/testimonials/urls.py b/backend/testimonials/urls.py new file mode 100644 index 000000000..19b42f5f3 --- /dev/null +++ b/backend/testimonials/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from testimonials import views +urlpatterns = [ + path("listcourses/", views.course_metadata, name="course_list"), + path("listtestimonials/", views.testimonial_metadata, name="testimonial_list"), +] \ No newline at end of file diff --git a/backend/testimonials/views.py b/backend/testimonials/views.py new file mode 100644 index 000000000..abcdbb597 --- /dev/null +++ b/backend/testimonials/views.py @@ -0,0 +1,41 @@ +from util import response +from ediauth import auth_check +from testimonials.models import Course, Testimonial +from django.shortcuts import get_object_or_404 +from datetime import timedelta + +@response.request_get() +@auth_check.require_login +def course_metadata(request): + courses = Course.objects.all() + res = [ + { + "course_code": course.code, + "course_name": course.name, + "course_delivery": course.delivery, + "course_credits": course.credits, + "course_work_exam_ratio": course.work_exam_ratio, + "course_level": course.level, + "course_dpmt_link": course.dpmt_link + } + for course in courses + ] + return response.success(value=res) + +@response.request_get() +@auth_check.require_login +def testimonial_metadata(request): + testimonials = Testimonial.objects.all() + res = [ + { + "author": testimonial.author.username, + "course": testimonial.course.code, + "testimonial": testimonial.testimonial, + "year_taken": testimonial.year_taken, + "recommendability_rating": testimonial.recommendability_rating, + "workload_rating": testimonial.workload_rating, + "difficulty_rating": testimonial.difficulty_rating, + } + for testimonial in testimonials + ] + return response.success(value=res) diff --git a/frontend/package.json b/frontend/package.json index b1aa44d9b..48d383f96 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,18 +6,21 @@ "dependencies": { "@mantine/charts": "^7.0.0", "@mantine/colors-generator": "^7.7.0", - "@mantine/core": "^7.0.0", + "@mantine/core": "^8.1.2", + "@mantine/dates": "^8.1.2", "@mantine/form": "^8.1.2", - "@mantine/hooks": "^7.0.0", - "@tabler/icons-react": "^3.0.0", + "@mantine/hooks": "^8.1.2", + "@tabler/icons-react": "^3.34.0", "@umijs/hooks": "1.9.3", "chroma-js": "^2.4.2", - "clsx": "^2.1.0", + "clsx": "^2.1.1", "date-fns": "^3.3.0", + "dayjs": "^1.11.13", "jszip": "^3.10.0", "jwt-decode": "^4.0.0", "katex": "^0.16.9", "lodash-es": "^4.17.21", + "mantine-datatable": "^8.1.2", "moment": "^2.29.1", "pdfjs-dist": "^4.0.0", "query-string": "^7.1.0", diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 3a3b78e3d..964b4f684 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -590,3 +590,11 @@ export const useRegenerateDocumentAPIKey = ( documentSlug: string, onSuccess?: (res: Document) => void, ) => useMutation(() => regenerateDocumentAPIKey(documentSlug), onSuccess); + +export const loadCourses = async () => { + return (await fetchGet("/api/testimonials/listcourses")) +}; + +export const loadTestimonials = async () => { + return (await fetchGet("/api/testimonials/listtestimonials")) +}; \ No newline at end of file diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 0891dea92..e19872130 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -49,6 +49,7 @@ import { defaultConfigOptions, } from "./components/Navbar/constants"; import { useDisclosure } from "@mantine/hooks"; +import TestimonialsPage from "./pages/testimonials-page"; function calculateShades(primaryColor: string): MantineColorsTuple { var baseHSLcolor = tinycolor(primaryColor).toHsl(); @@ -187,6 +188,7 @@ const App: React.FC<{}> = () => { const bottomHeaderNav = [ { title: "Home", href: "/" }, { title: "Search", href: "/search" }, + { title: "Testimonials", href:"/testimonials"}, { title: "Dissertations", href: "/dissertations" }, { title: "More", @@ -270,6 +272,11 @@ const App: React.FC<{}> = () => { path="/dissertations" component={DissertationListPage} /> + (1); + + if (courses && testimonials){ + console.log("Courses loaded in effect:", courses); + console.log("Testimonials loaded in effect:", testimonials); + tableData = new Array(courses['value'].length); + for (let i = 0; i < courses['value'].length; i++){ + let course = courses['value'][i]; + let currentCourseTestimonials : CourseTestimonial[] = testimonials['value'].filter( + (testimonial: CourseTestimonial) => (testimonial.course == course.course_code + )); + + let average_course_difficulty = 0.0; + let average_course_workload = 0.0; + let average_course_recommendability = 0.0; + + if (currentCourseTestimonials.length == 0){ + average_course_difficulty = -1; + average_course_workload = -1; + average_course_recommendability = -1; + } else { + for (let j = 0; j < currentCourseTestimonials.length; j++){ + average_course_difficulty+= currentCourseTestimonials[j].difficulty_rating; + average_course_recommendability+= currentCourseTestimonials[j].recommendability_rating; + average_course_workload+= currentCourseTestimonials[j].workload_rating; + } + average_course_difficulty /= currentCourseTestimonials.length + average_course_recommendability /= currentCourseTestimonials.length + average_course_workload /= currentCourseTestimonials.length + } + + //average of testimonials and etc! + tableData[i] = { + course_code: course.course_code, + course_name: course.course_name, + course_delivery: course.course_delivery, + course_credits: course.course_credits, + course_work_exam_ratio: course.course_work_exam_ratio, + course_level: course.course_level, + course_dpmt_link: course.course_dpmt_link, + course_overall_rating: (average_course_recommendability + average_course_difficulty + average_course_workload)/3, + course_recommendability_rating: average_course_recommendability, + course_difficulty_rating: average_course_difficulty, + course_workload_rating: average_course_workload, + testimonials: currentCourseTestimonials + } + } + console.log("Table data:", tableData); + } + return tableData; +} + +const TestimonialsPage: React.FC<{}> = () => { + useTitle("Testimonials"); + const [uwu, _] = useLocalStorageState("uwu", false); + const { data : courses, loading: loading_courses, error: error_courses} = useRequest( + () => loadCourses() + ); + const { data : testimonials, loading: loading_testimonials, error: error_testimonials } = useRequest( + () => loadTestimonials() + ); + return ( + <> + + {uwu ? ( + + + + ) : ( + <> + Better­Informatics + Course Testimonials + + )} + + BetterInformatics Course Testimonials is a platform for students to share + their experiences and study tips in courses that they have taken to help future students make course choices. + + + + + {(courses && testimonials && )|| + ((loading_courses || error_courses || loading_testimonials || error_testimonials) && ) + } + + + + ); +} + +function getRatingBox(rating: any) : JSX.Element{ + + let ratingsBoxColor = "#A0AEC0" + if (rating==-1){ + ratingsBoxColor = "#A0AEC0" + } else if (rating >= 4){ + ratingsBoxColor = "green" + } else if (rating >= 3){ + ratingsBoxColor = "#D69E2E" + } else if (rating >= 2){ + ratingsBoxColor = "#DD6B20" //orange + } else { + ratingsBoxColor = "'#E53E3E" //red + } + + return ( +
+ {rating == -1? "N/A" : rating} +
); +} + +const ReviewsTable: React.FC = ({data}) => { + const [opened, { open, close }] = useDisclosure(false); + const [yearValue, setYearValue] = useState(2025); + // const columns = useMemo[]>( + // () => [ + // { + // accessorKey: 'course_code', + // header: 'Course Code', + // }, + // { + // accessorKey: 'course_name', + // header: 'Course Name', + // minSize:100, + // maxSize:450, + // size:350 + // }, + // { + // accessorKey: 'course_delivery', + // header: 'Delivery', + // }, + // { + // accessorKey: 'course_credits', + // header: 'Credits', + // }, + // { + // accessorKey: 'course_work_exam_ratio', + // header: 'Work%/Exam%', + // }, + // { + // accessorKey: 'course_level', + // header: 'Level', + // }, + // { + // accessorKey: 'course_overall_rating', + // header: 'Overall', + // Cell: ({ renderedCellValue, row }) => ( + // getRatingBox(renderedCellValue) + // ), + // size:50 + // }, + // { + // accessorKey: 'course_recommendability_rating', + // header: 'Recommendability', + // size:50 + // }, + // { + // accessorKey: 'course_workload_rating', + // header: 'Workload', + // size:50 + // }, + // { + // accessorKey: 'course_difficulty_rating', + // header: 'Difficulty', + // size:50 + // } + // ], + // [], + // ); + const computedColorScheme = useComputedColorScheme("light"); + + // const table = useMantineReactTable({ + // columns, + // data, + // initialState: { + // columnVisibility: { + // course_delivery: false, + // course_credits: false, + // course_work_exam_ratio: false, + // course_level: false + // }, + // }, + // renderDetailPanel: ({ row }) => + // row.original.testimonials?.length === 0 ? ( + // + // No testimonials yet :( + // open()} + // leftSection={} + // color={computedColorScheme === "dark" ? "compsocMain" : "dark"} + // variant="outline" + // > + // Add first testimonial + // + // + // ) : ( + // <> + // {row.original.testimonials.map((testimonial: CourseTestimonial) => + // <> + // + // + // Overall Recommendation: {String(testimonial.difficulty_rating)}/5 + // open()} + // leftSection={} + // color={computedColorScheme === "dark" ? "compsocMain" : "dark"} + // variant="outline" + // > + // Add new testimonial + // + // + // + // + // + // )} + // + // ) + // }); + return <> + + + } + value={yearValue} + onChange={setYearValue} + label="When did you take this course?" + placeholder="Year" + /> + On a scale of 1-5, how likely are you to recommend this course? + + On a scale of 1-5, how balanced was the workload of the course? + + On a scale of 1-5, how manageable did you find the course? + + Testimonial (Experience, Advice, Study Tips) + + + ( + // getRatingBox(renderedCellValue) + // ), + // size:50 + }, + { + accessor: 'course_recommendability_rating', + title: 'Recommendability', + //width:50 + }, + { + accessor: 'course_workload_rating', + title: 'Workload', + //width:50 + }, + { + accessor: 'course_difficulty_rating', + title: 'Difficulty', + //width:50 + } + ]} + records ={data} + /> + + {/* ( + + +
Postal address:
+
+ {record.streetAddress}, {record.city}, {record.state} +
+
+ +
Mission statement:
+ “{record.missionStatement}” +
+
+ ), + }} + /> */} + ; +}; + +interface ratingProps{ + ratingType: String, ratingLevel: String +} + +const RatingBox: React.FC = ({ratingType, ratingLevel}) => { + return ( + ({ borderRadius: theme.radius.md, textAlign: 'center', fontSize: 'medium'})}> {ratingType} {ratingLevel}/5 + ) +} + +interface reviewProps{ + typeOfStudent: String, yearTaken: String, recommendabilityRating:String, workloadRating:String, difficultyRating:String, testimonial:String +} + +const ReviewCard: React.FC = ({yearTaken, recommendabilityRating, workloadRating, difficultyRating, testimonial}) => { + return( + + + + + s2236467 + + + + + + + + Year Course Taken: {yearTaken} + + + "{testimonial}" + + + ) +} + + +export default TestimonialsPage; \ No newline at end of file diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d41ac607d..20bd4d608 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1101,11 +1101,9 @@ regenerator-runtime "^0.14.0" "@babel/runtime@^7.20.13": - version "7.24.4" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.4.tgz#de795accd698007a66ba44add6cc86542aff1edd" - integrity sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA== - dependencies: - regenerator-runtime "^0.14.0" + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" + integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== "@babel/template@^7.22.15", "@babel/template@^7.23.9": version "7.23.9" @@ -1288,41 +1286,41 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b" integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A== -"@floating-ui/core@^1.0.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" - integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g== +"@floating-ui/core@^1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.2.tgz#3d1c35263950b314b6d5a72c8bfb9e3c1551aefd" + integrity sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw== dependencies: - "@floating-ui/utils" "^0.2.1" + "@floating-ui/utils" "^0.2.10" -"@floating-ui/dom@^1.6.1": - version "1.6.3" - resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.3.tgz#954e46c1dd3ad48e49db9ada7218b0985cee75ef" - integrity sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw== +"@floating-ui/dom@^1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.2.tgz#3540b051cf5ce0d4f4db5fb2507a76e8ea5b4a45" + integrity sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA== dependencies: - "@floating-ui/core" "^1.0.0" - "@floating-ui/utils" "^0.2.0" + "@floating-ui/core" "^1.7.2" + "@floating-ui/utils" "^0.2.10" -"@floating-ui/react-dom@^2.0.0": - version "2.0.8" - resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d" - integrity sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw== +"@floating-ui/react-dom@^2.1.2": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.4.tgz#a0689be8978352fff2be2dfdd718cf668c488ec3" + integrity sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw== dependencies: - "@floating-ui/dom" "^1.6.1" + "@floating-ui/dom" "^1.7.2" -"@floating-ui/react@^0.26.9": - version "0.26.12" - resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.12.tgz#6908f774d8e3167d89b37fd83be975c7e5d8be99" - integrity sha512-D09o62HrWdIkstF2kGekIKAC0/N/Dl6wo3CQsnLcOmO3LkW6Ik8uIb3kw8JYkwxNCcg+uJ2bpWUiIijTBep05w== +"@floating-ui/react@^0.26.28": + version "0.26.28" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.28.tgz#93f44ebaeb02409312e9df9507e83aab4a8c0dc7" + integrity sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw== dependencies: - "@floating-ui/react-dom" "^2.0.0" - "@floating-ui/utils" "^0.2.0" + "@floating-ui/react-dom" "^2.1.2" + "@floating-ui/utils" "^0.2.8" tabbable "^6.0.0" -"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" - integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== +"@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.8": + version "0.2.10" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" + integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== "@humanwhocodes/config-array@^0.11.13": version "0.11.14" @@ -1385,17 +1383,24 @@ resolved "https://registry.yarnpkg.com/@mantine/colors-generator/-/colors-generator-7.7.2.tgz#fbac1e60da7d3925df5a79af5d1194627093a0fd" integrity sha512-ZVLYmZmITiqlOXF2zw9NGDth1P4hNDOisQJOjmIL68uPGaDqaW+0HcZzKWtl4xQWXjfnbuaaFW9m7ip1UvJYCw== -"@mantine/core@^7.0.0": - version "7.7.2" - resolved "https://registry.yarnpkg.com/@mantine/core/-/core-7.7.2.tgz#1e70069e7888fd35d28817075d2cb88d74ddb17b" - integrity sha512-5kpAB6GWxywAiiVNBrSOYD68IRmr5Sj8nbXiotvPhypc2cvDlhJ3EI6JQhN+DS9xZHBzgAY4zjvXpk8CDKBCsA== +"@mantine/core@^8.1.2": + version "8.1.2" + resolved "https://registry.yarnpkg.com/@mantine/core/-/core-8.1.2.tgz#ae4460b4a954981417f83644a917d140794343f4" + integrity sha512-+maX0a1+fxh6Lvnzi0qb0AZsCnnHlIiTE/hFC+dd3eRfUW2PEKJ5/wTpmrX8IGyxa+NS+fXjZD/cU4Yt9xNjdg== + dependencies: + "@floating-ui/react" "^0.26.28" + clsx "^2.1.1" + react-number-format "^5.4.3" + react-remove-scroll "^2.6.2" + react-textarea-autosize "8.5.9" + type-fest "^4.27.0" + +"@mantine/dates@^8.1.2": + version "8.1.2" + resolved "https://registry.yarnpkg.com/@mantine/dates/-/dates-8.1.2.tgz#f78cb75d8b155b648bc013d7d2a717c0b0c01620" + integrity sha512-cq2Sp8g8KIYWIg9Yh4yCuHBEMQRCAAe0LhzPfQnNAJ2DtgW0qlszgWUu62WiPs8T5TU1bBgC4NHXGUM0w6Ef4A== dependencies: - "@floating-ui/react" "^0.26.9" - clsx "2.1.0" - react-number-format "^5.3.1" - react-remove-scroll "^2.5.7" - react-textarea-autosize "8.5.3" - type-fest "^4.12.0" + clsx "^2.1.1" "@mantine/form@^8.1.2": version "8.1.2" @@ -1405,10 +1410,10 @@ fast-deep-equal "^3.1.3" klona "^2.0.6" -"@mantine/hooks@^7.0.0": - version "7.7.2" - resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-7.7.2.tgz#8cf3bdf5c94117b9ddf60c58a5031a8781f3edde" - integrity sha512-fupbyZvH/hK7YPVlrRnx3uV5eOaMzFFF2Ll3RKMTRQnMhk4uvddzt9uUgGLIIsnZKfhPUZSIr54LZkHQTbPXhg== +"@mantine/hooks@^8.1.2": + version "8.1.2" + resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-8.1.2.tgz#1cafbf99c751e9407c11622ed3dca1e8c7058d69" + integrity sha512-BrriTsiazqZ2fLuL7UDasNTQJSaoJ7mN2qYVkdsiYI158lxJdUaFWHhd6BDyzK+W6thvBx2D+R/hh1rsWWefdQ== "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" @@ -1635,17 +1640,17 @@ "@svgr/hast-util-to-babel-ast" "8.0.0" svg-parser "^2.0.4" -"@tabler/icons-react@^3.0.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@tabler/icons-react/-/icons-react-3.1.0.tgz#5202b5cc7c6e57dde75c41981b618dcfebd69b50" - integrity sha512-k/WTlax2vbj/LpxvaJ+BmaLAAhVUgyLj4Ftgaczz66tUSNzqrAZXCFdOU7cRMYPNVBqyqE2IdQd2rzzhDEJvkw== +"@tabler/icons-react@^3.34.0": + version "3.34.0" + resolved "https://registry.yarnpkg.com/@tabler/icons-react/-/icons-react-3.34.0.tgz#8503c25ba9e50ff02a49a1f9c62ceecfd27fa3ab" + integrity sha512-OpEIR2iZsIXECtAIMbn1zfKfQ3zKJjXyIZlkgOGUL9UkMCFycEiF2Y8AVfEQsyre/3FnBdlWJvGr0NU47n2TbQ== dependencies: - "@tabler/icons" "3.1.0" + "@tabler/icons" "3.34.0" -"@tabler/icons@3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.1.0.tgz#d69d184eae572db6adb452b511562442133cc26d" - integrity sha512-CpZGyS1IVJKFcv88yZ2sYZIpWWhQ6oy76BQKQ5SF0fGgOqgyqKdBGG/YGyyMW632on37MX7VqQIMTzN/uQqmFg== +"@tabler/icons@3.34.0": + version "3.34.0" + resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.34.0.tgz#b11734dc76686abe969b68c2f33e565b116b70fb" + integrity sha512-jtVqv0JC1WU2TTEBN32D9+R6mc1iEBuPwLnBsWaR02SIEciu9aq5806AWkCHuObhQ4ERhhXErLEK7Fs+tEZxiA== "@types/babel__core@^7.20.5": version "7.20.5" @@ -2425,12 +2430,7 @@ chroma-js@^2.4.2: resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-2.4.2.tgz#dffc214ed0c11fa8eefca2c36651d8e57cbfb2b0" integrity sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A== -clsx@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.0.tgz#e851283bcb5c80ee7608db18487433f7b23f77cb" - integrity sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg== - -clsx@^2.0.0, clsx@^2.1.0: +clsx@^2.0.0, clsx@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== @@ -2622,6 +2622,11 @@ date-fns@^3.3.0: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.3.1.tgz#7581daca0892d139736697717a168afbb908cfed" integrity sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw== +dayjs@^1.11.13: + version "1.11.13" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" + integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -3729,13 +3734,6 @@ intersection-observer@^0.7.0: resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.7.0.tgz#ee16bee978db53516ead2f0a8154b09b400bbdc9" integrity sha512-Id0Fij0HsB/vKWGeBe9PxeY45ttRiBmhFyyt/geBdDHBYNctMRTE3dC1U3ujzz3lap+hVXlEcVaB56kZP/eEUg== -invariant@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - is-alphabetical@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" @@ -4171,7 +4169,7 @@ longest-streak@^3.0.0: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -4207,6 +4205,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +mantine-datatable@^8.1.2: + version "8.1.2" + resolved "https://registry.yarnpkg.com/mantine-datatable/-/mantine-datatable-8.1.2.tgz#6b77b0ca3c469279518e61cf7ecaebc14d1717a7" + integrity sha512-3Q7aGQJTSck5yqtjOsZqCqD0fu8D+CVgUSB9I1fGjwueWRYo7KSIBQI4/OBxkdzwOk65MPHtRX9jDo1JsfD58A== + markdown-table@^3.0.0: version "3.0.4" resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a" @@ -5137,7 +5140,7 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.6.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -5232,36 +5235,34 @@ react-markdown@^9.0.0: unist-util-visit "^5.0.0" vfile "^6.0.0" -react-number-format@^5.3.1: - version "5.3.4" - resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.3.4.tgz#4780522ba1fdaff20aaa0732716490c6758b8557" - integrity sha512-2hHN5mbLuCDUx19bv0Q8wet67QqYK6xmtLQeY5xx+h7UXiMmRtaCwqko4mMPoKXLc6xAzwRrutg8XbTRlsfjRg== - dependencies: - prop-types "^15.7.2" +react-number-format@^5.4.3: + version "5.4.4" + resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.4.4.tgz#d31f0e260609431500c8d3f81bbd3ae1fb7cacad" + integrity sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA== react-refresh@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-remove-scroll-bar@^2.3.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c" - integrity sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g== +react-remove-scroll-bar@^2.3.7: + version "2.3.8" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223" + integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q== dependencies: - react-style-singleton "^2.2.1" + react-style-singleton "^2.2.2" tslib "^2.0.0" -react-remove-scroll@^2.5.7: - version "2.5.9" - resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.9.tgz#6a38e7d46043abc2c6b0fb39db650b9f2e38be3e" - integrity sha512-bvHCLBrFfM2OgcrpPY2YW84sPdS2o2HKWJUf1xGyGLnSoEnOTOBpahIarjRuYtN0ryahCeP242yf+5TrBX/pZA== +react-remove-scroll@^2.6.2: + version "2.7.1" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz#d2101d414f6d81d7d3bf033f3c1cb4785789f753" + integrity sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA== dependencies: - react-remove-scroll-bar "^2.3.6" - react-style-singleton "^2.2.1" + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" tslib "^2.1.0" - use-callback-ref "^1.3.0" - use-sidecar "^1.1.2" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" react-router-dom@5.3.4: version "5.3.4" @@ -5300,13 +5301,12 @@ react-smooth@^4.0.0: prop-types "^15.8.1" react-transition-group "^4.4.5" -react-style-singleton@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" - integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g== +react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" + integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ== dependencies: get-nonce "^1.0.0" - invariant "^2.2.4" tslib "^2.0.0" react-syntax-highlighter@^15.5.0: @@ -5320,10 +5320,10 @@ react-syntax-highlighter@^15.5.0: prismjs "^1.27.0" refractor "^3.6.0" -react-textarea-autosize@8.5.3: - version "8.5.3" - resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz#d1e9fe760178413891484847d3378706052dd409" - integrity sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ== +react-textarea-autosize@8.5.9: + version "8.5.9" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz#ab8627b09aa04d8a2f45d5b5cd94c84d1d4a8893" + integrity sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A== dependencies: "@babel/runtime" "^7.20.13" use-composed-ref "^1.3.0" @@ -6002,7 +6002,12 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0: +tslib@^2.0.0, tslib@^2.1.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +tslib@^2.0.3: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== @@ -6033,10 +6038,10 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== -type-fest@^4.12.0: - version "4.15.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.15.0.tgz#21da206b89c15774cc718c4f2d693e13a1a14a43" - integrity sha512-tB9lu0pQpX5KJq54g+oHOLumOx+pMep4RaM6liXh2PKmVRFF+/vAtUP0ZaJ0kOySfVNjF6doBWPHhBhISKdlIA== +type-fest@^4.27.0: + version "4.41.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" + integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== typed-array-buffer@^1.0.0: version "1.0.1" @@ -6210,27 +6215,27 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -use-callback-ref@^1.3.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.2.tgz#6134c7f6ff76e2be0b56c809b17a650c942b1693" - integrity sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA== +use-callback-ref@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf" + integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg== dependencies: tslib "^2.0.0" use-composed-ref@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda" - integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ== + version "1.4.0" + resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.4.0.tgz#09e023bf798d005286ad85cd20674bdf5770653b" + integrity sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w== use-isomorphic-layout-effect@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" - integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== + version "1.2.1" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz#2f11a525628f56424521c748feabc2ffcc962fce" + integrity sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA== use-latest@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2" - integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw== + version "1.3.0" + resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.3.0.tgz#549b9b0d4c1761862072f0899c6f096eb379137a" + integrity sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ== dependencies: use-isomorphic-layout-effect "^1.1.1" @@ -6241,10 +6246,10 @@ use-query-params@2.2.1: dependencies: serialize-query-params "^2.0.2" -use-sidecar@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2" - integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw== +use-sidecar@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb" + integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ== dependencies: detect-node-es "^1.1.0" tslib "^2.0.0" From 4d8516de246ead2dc1c1f96ef94b845d9cfb1699 Mon Sep 17 00:00:00 2001 From: nikhen-s Date: Wed, 2 Jul 2025 13:29:00 +0100 Subject: [PATCH 02/10] Changed react table library --- frontend/package.json | 1 + frontend/src/pages/testimonials-page.tsx | 180 +++++++++++++---------- frontend/yarn.lock | 5 + 3 files changed, 112 insertions(+), 74 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 48d383f96..501acc87b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,7 @@ "type": "module", "private": true, "dependencies": { + "@formkit/auto-animate": "^0.8.2", "@mantine/charts": "^7.0.0", "@mantine/colors-generator": "^7.7.0", "@mantine/core": "^8.1.2", diff --git a/frontend/src/pages/testimonials-page.tsx b/frontend/src/pages/testimonials-page.tsx index 6642cb396..d524db468 100644 --- a/frontend/src/pages/testimonials-page.tsx +++ b/frontend/src/pages/testimonials-page.tsx @@ -1,7 +1,9 @@ import '@mantine/core/styles.css'; import '@mantine/dates/styles.css'; // import 'mantine-react-table/styles.css'; +import { useAutoAnimate } from '@formkit/auto-animate/react'; import { useRequest } from "@umijs/hooks"; +import dayjs from 'dayjs'; import { useMemo, useEffect, useState } from 'react'; import { DataTable } from 'mantine-datatable'; //import mantine-datatable clsx @@ -68,8 +70,8 @@ function getTableData(courses: any, testimonials: any) : CourseWithTestimonial[] let tableData = new Array(1); if (courses && testimonials){ - console.log("Courses loaded in effect:", courses); - console.log("Testimonials loaded in effect:", testimonials); + // console.log("Courses loaded in effect:", courses); + // console.log("Testimonials loaded in effect:", testimonials); tableData = new Array(courses['value'].length); for (let i = 0; i < courses['value'].length; i++){ let course = courses['value'][i]; @@ -242,6 +244,9 @@ const ReviewsTable: React.FC = ({data}) => { // [], // ); const computedColorScheme = useComputedColorScheme("light"); + const [parent] = useAutoAnimate(); + const [expandedRecordIds, setExpandedRecordIds] = useState([]); + //const [bodyRef] = useAutoAnimate(); // const table = useMantineReactTable({ // columns, @@ -310,85 +315,112 @@ const ReviewsTable: React.FC = ({data}) => { ( - // getRatingBox(renderedCellValue) - // ), - // size:50 - }, - { - accessor: 'course_recommendability_rating', - title: 'Recommendability', - //width:50 - }, - { - accessor: 'course_workload_rating', - title: 'Workload', - //width:50 - }, - { - accessor: 'course_difficulty_rating', - title: 'Difficulty', - //width:50 - } - ]} - records ={data} - /> - - {/* : <> } + noRecordsText={data?.length === 0 ? "No records to show" : ""} + scrollAreaProps={{ type: 'never' }} + columns = {[ + { + accessor: 'course_code', + title: 'Course Code', + }, + { + accessor: 'course_name', + title: 'Course Name', + // minSize:100, + // maxSize:450, + width:350 + }, + // { + // accessor: 'course_delivery', + // title: 'Delivery', + // }, + // { + // accessor: 'course_credits', + // title: 'Credits', + // }, + // { + // accessor: 'course_work_exam_ratio', + // title: 'Work%/Exam%', + // }, + // { + // accessor: 'course_level', + // title: 'Level', + // }, + { + accessor: 'course_overall_rating', + title: 'Overall', + // Cell: ({ renderedCellValue, row }) => ( + // getRatingBox(renderedCellValue) + // ), + // size:50 + }, + { + accessor: 'course_recommendability_rating', + title: 'Recommendability', + //width:50 + }, + { + accessor: 'course_workload_rating', + title: 'Workload', + //width:50 + }, + { + accessor: 'course_difficulty_rating', + title: 'Difficulty', + //width:50 + } + ]} + records ={data} + idAccessor="course_code" + rowStyle={() => ({ + cursor: 'pointer', + '&:hover': { + backgroundColor: computedColorScheme === 'dark' ? 'gray' : 'gray', + }, + })} rowExpansion={{ + // expanded: { + // recordIds: expandedRecordIds, + // onRecordIdsChange: setExpandedRecordIds, + // }, + collapseProps: { + transitionDuration: 150, + animateOpacity: false, + transitionTimingFunction: 'ease-out', + }, + allowMultiple: true, content: ({ record }) => ( - - -
Postal address:
-
- {record.streetAddress}, {record.city}, {record.state} -
-
- -
Mission statement:
- “{record.missionStatement}” -
+ + + Overall Recommendation: {String(record.course_overall_rating)}/5 + open()} + leftSection={} + color={computedColorScheme === "dark" ? "compsocMain" : "dark"} + variant="outline" + > + Add new testimonial + + + { + record.testimonials.map((testimonial) => //add a key to the testimonial + + ) + } ), }} - /> */} + //bodyRef={bodyRef} + // fetching={data.length == 0} + // loaderType={"oval"} + // loaderSize={"lg"} + // loaderColor={"blue"} + // loaderBackgroundBlur={1} + /> ; }; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 20bd4d608..fb1d79b19 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1322,6 +1322,11 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c" integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== +"@formkit/auto-animate@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@formkit/auto-animate/-/auto-animate-0.8.2.tgz#24a56e816159fd5585405dcee6bb992f81c27585" + integrity sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ== + "@humanwhocodes/config-array@^0.11.13": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" From e4588003608fd9e6d6dfc2c602d45d61e761b9a8 Mon Sep 17 00:00:00 2001 From: nikhen-s Date: Mon, 28 Jul 2025 10:04:58 +0700 Subject: [PATCH 03/10] Fixed DataTable UI --- frontend/package.json | 8 +-- frontend/src/app.tsx | 7 ++- frontend/src/layout.css | 3 + frontend/src/pages/testimonials-page.tsx | 70 ++++++++++++++++++------ frontend/yarn.lock | 32 +++++------ 5 files changed, 82 insertions(+), 38 deletions(-) create mode 100644 frontend/src/layout.css diff --git a/frontend/package.json b/frontend/package.json index 501acc87b..66cdbcebc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,10 +5,10 @@ "private": true, "dependencies": { "@formkit/auto-animate": "^0.8.2", - "@mantine/charts": "^7.0.0", + "@mantine/charts": "^8.2.1", "@mantine/colors-generator": "^7.7.0", - "@mantine/core": "^8.1.2", - "@mantine/dates": "^8.1.2", + "@mantine/core": "^8.2.1", + "@mantine/dates": "^8.2.1", "@mantine/form": "^8.1.2", "@mantine/hooks": "^8.1.2", "@tabler/icons-react": "^3.34.0", @@ -21,7 +21,7 @@ "jwt-decode": "^4.0.0", "katex": "^0.16.9", "lodash-es": "^4.17.21", - "mantine-datatable": "^8.1.2", + "mantine-datatable": "^8.2.0", "moment": "^2.29.1", "pdfjs-dist": "^4.0.0", "query-string": "^7.1.0", diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index e19872130..b42bb6066 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -10,7 +10,10 @@ import { CSSVariablesResolver, SegmentedControl, } from "@mantine/core"; -import "@mantine/core/styles.css"; +import "@mantine/hooks"; +import '@mantine/core/styles.layer.css'; +import 'mantine-datatable/styles.layer.css'; +import './layout.css'; import "@mantine/charts/styles.css"; import React, { useEffect, useState } from "react"; import { Redirect, Route, Switch } from "react-router-dom"; @@ -233,7 +236,7 @@ const App: React.FC<{}> = () => { }); return ( - + diff --git a/frontend/src/layout.css b/frontend/src/layout.css new file mode 100644 index 000000000..a031b77a6 --- /dev/null +++ b/frontend/src/layout.css @@ -0,0 +1,3 @@ +/* layout.css */ +/* 👇 Apply Mantine core styles first, DataTable styles second */ +@layer mantine, mantine-datatable; \ No newline at end of file diff --git a/frontend/src/pages/testimonials-page.tsx b/frontend/src/pages/testimonials-page.tsx index d524db468..c93338a3f 100644 --- a/frontend/src/pages/testimonials-page.tsx +++ b/frontend/src/pages/testimonials-page.tsx @@ -1,12 +1,13 @@ -import '@mantine/core/styles.css'; -import '@mantine/dates/styles.css'; -// import 'mantine-react-table/styles.css'; +// import '@mantine/core/styles.layer.css'; +// import 'mantine-datatable/styles.layer.css'; +// import { Table } from '@mantine/core'; import { useAutoAnimate } from '@formkit/auto-animate/react'; import { useRequest } from "@umijs/hooks"; import dayjs from 'dayjs'; import { useMemo, useEffect, useState } from 'react'; import { DataTable } from 'mantine-datatable'; //import mantine-datatable clsx +//why does it work for mantine core but not for anything else? import useTitle from '../hooks/useTitle'; import { useDisclosure } from '@mantine/hooks'; import { useLocalStorageState } from '@umijs/hooks'; @@ -158,7 +159,6 @@ const TestimonialsPage: React.FC<{}> = () => { } function getRatingBox(rating: any) : JSX.Element{ - let ratingsBoxColor = "#A0AEC0" if (rating==-1){ ratingsBoxColor = "#A0AEC0" @@ -174,7 +174,6 @@ function getRatingBox(rating: any) : JSX.Element{ return (
= ({data}) => { // // ) // }); + // return ( + // ( + // + // {party.slice(0, 3).toUpperCase()} + // + // ), + // }, + // { accessor: 'bornIn' }, + // ]} + // // 👇 execute this callback when a row is clicked + // /> + // ); return <> @@ -317,22 +352,23 @@ const ReviewsTable: React.FC = ({data}) => { : <> } noRecordsText={data?.length === 0 ? "No records to show" : ""} - scrollAreaProps={{ type: 'never' }} + //scrollAreaProps={{ type: 'never' }} columns = {[ { accessor: 'course_code', title: 'Course Code', + width: "12%", }, { accessor: 'course_name', title: 'Course Name', // minSize:100, // maxSize:450, - width:350 }, // { // accessor: 'course_delivery', @@ -353,6 +389,8 @@ const ReviewsTable: React.FC = ({data}) => { { accessor: 'course_overall_rating', title: 'Overall', + render: ({course_overall_rating}) => (getRatingBox(course_overall_rating)), + width: "12%", // Cell: ({ renderedCellValue, row }) => ( // getRatingBox(renderedCellValue) // ), @@ -361,27 +399,27 @@ const ReviewsTable: React.FC = ({data}) => { { accessor: 'course_recommendability_rating', title: 'Recommendability', + width: "12%", + render: ({course_recommendability_rating}) => (getRatingBox(course_recommendability_rating)) //width:50 }, { accessor: 'course_workload_rating', title: 'Workload', + render: ({course_workload_rating}) => (getRatingBox(course_workload_rating)), + width: "12%" //width:50 }, { accessor: 'course_difficulty_rating', title: 'Difficulty', + render: ({course_difficulty_rating}) => (getRatingBox(course_difficulty_rating)), + width: "12%" //width:50 } ]} records ={data} idAccessor="course_code" - rowStyle={() => ({ - cursor: 'pointer', - '&:hover': { - backgroundColor: computedColorScheme === 'dark' ? 'gray' : 'gray', - }, - })} rowExpansion={{ // expanded: { // recordIds: expandedRecordIds, @@ -395,8 +433,8 @@ const ReviewsTable: React.FC = ({data}) => { allowMultiple: true, content: ({ record }) => ( - - Overall Recommendation: {String(record.course_overall_rating)}/5 + + Overall Recommendation: {record.course_overall_rating == -1? "N/A" : String(record.course_overall_rating)+"/5"} open()} leftSection={} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index fb1d79b19..01ec16077 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1378,20 +1378,20 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@mantine/charts@^7.0.0": - version "7.8.1" - resolved "https://registry.yarnpkg.com/@mantine/charts/-/charts-7.8.1.tgz#fef57f32201dba04f91c481ad240ebd292309ba7" - integrity sha512-ZMUQPin8ILv3ruRelOycXvex3PW87+QrPWHKEUUTUqp8RANLuz7dYnmeGaemG91ymsd2kHxzFlGYX96Yc/tCdQ== +"@mantine/charts@^8.2.1": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@mantine/charts/-/charts-8.2.1.tgz#df136fd9341644d1300b3d10cd6c7234944bbeb3" + integrity sha512-ZtVilizgZkX1PyT4lxZqbjI7hf26IYQ1vqTxIGiBZhg7JyI5b5f0/4cA3XgakAdqNY0MOuZL6BKlcDvYG0PRIQ== "@mantine/colors-generator@^7.7.0": version "7.7.2" resolved "https://registry.yarnpkg.com/@mantine/colors-generator/-/colors-generator-7.7.2.tgz#fbac1e60da7d3925df5a79af5d1194627093a0fd" integrity sha512-ZVLYmZmITiqlOXF2zw9NGDth1P4hNDOisQJOjmIL68uPGaDqaW+0HcZzKWtl4xQWXjfnbuaaFW9m7ip1UvJYCw== -"@mantine/core@^8.1.2": - version "8.1.2" - resolved "https://registry.yarnpkg.com/@mantine/core/-/core-8.1.2.tgz#ae4460b4a954981417f83644a917d140794343f4" - integrity sha512-+maX0a1+fxh6Lvnzi0qb0AZsCnnHlIiTE/hFC+dd3eRfUW2PEKJ5/wTpmrX8IGyxa+NS+fXjZD/cU4Yt9xNjdg== +"@mantine/core@^8.2.1": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@mantine/core/-/core-8.2.1.tgz#149b3f09c6b67833cb4b6d3eca1c5e7a966b7ca2" + integrity sha512-KxvydotyFRdrRbqULUX2G35/GddPFju9XQUv/vdDWu1ytIWZViTguc+WSj1aBd0DtfRrSaofU5ezZISEXVrPBA== dependencies: "@floating-ui/react" "^0.26.28" clsx "^2.1.1" @@ -1400,10 +1400,10 @@ react-textarea-autosize "8.5.9" type-fest "^4.27.0" -"@mantine/dates@^8.1.2": - version "8.1.2" - resolved "https://registry.yarnpkg.com/@mantine/dates/-/dates-8.1.2.tgz#f78cb75d8b155b648bc013d7d2a717c0b0c01620" - integrity sha512-cq2Sp8g8KIYWIg9Yh4yCuHBEMQRCAAe0LhzPfQnNAJ2DtgW0qlszgWUu62WiPs8T5TU1bBgC4NHXGUM0w6Ef4A== +"@mantine/dates@^8.2.1": + version "8.2.1" + resolved "https://registry.yarnpkg.com/@mantine/dates/-/dates-8.2.1.tgz#b50febeb0c7248673b57826374514104901b628d" + integrity sha512-xNeI+Jw7p9UYEsLbg+QKny4NZ1O3bL6rlrtJKGqOm3HQoATpbRTrdunmY2sIOYXcPEasSCe+y2Ye0fORUcMUEA== dependencies: clsx "^2.1.1" @@ -4210,10 +4210,10 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -mantine-datatable@^8.1.2: - version "8.1.2" - resolved "https://registry.yarnpkg.com/mantine-datatable/-/mantine-datatable-8.1.2.tgz#6b77b0ca3c469279518e61cf7ecaebc14d1717a7" - integrity sha512-3Q7aGQJTSck5yqtjOsZqCqD0fu8D+CVgUSB9I1fGjwueWRYo7KSIBQI4/OBxkdzwOk65MPHtRX9jDo1JsfD58A== +mantine-datatable@^8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/mantine-datatable/-/mantine-datatable-8.2.0.tgz#05ee4f8ed59a0138bff2b9aee8ecc36fbb34f448" + integrity sha512-dkdOnDw1DF9t5kxl4VEPM0baubNW2Tc+lvbc0bkVU1bNaDpXphSeoTGU6lai0sQ21hrKSMbfiK0ACczdrSOKyg== markdown-table@^3.0.0: version "3.0.4" From 068cf42f0854ac2dbef485289d301bac1d5cd545 Mon Sep 17 00:00:00 2001 From: nikhen-s Date: Mon, 28 Jul 2025 15:25:35 +0700 Subject: [PATCH 04/10] Fix Layout of Add Testimonial Modal --- frontend/src/pages/testimonials-page.tsx | 247 ++++++++--------------- 1 file changed, 82 insertions(+), 165 deletions(-) diff --git a/frontend/src/pages/testimonials-page.tsx b/frontend/src/pages/testimonials-page.tsx index c93338a3f..12b874488 100644 --- a/frontend/src/pages/testimonials-page.tsx +++ b/frontend/src/pages/testimonials-page.tsx @@ -1,17 +1,12 @@ -// import '@mantine/core/styles.layer.css'; -// import 'mantine-datatable/styles.layer.css'; -// import { Table } from '@mantine/core'; import { useAutoAnimate } from '@formkit/auto-animate/react'; import { useRequest } from "@umijs/hooks"; import dayjs from 'dayjs'; import { useMemo, useEffect, useState } from 'react'; import { DataTable } from 'mantine-datatable'; -//import mantine-datatable clsx -//why does it work for mantine core but not for anything else? import useTitle from '../hooks/useTitle'; import { useDisclosure } from '@mantine/hooks'; import { useLocalStorageState } from '@umijs/hooks'; -import { Container, Text, Title, Modal, NumberInput, Button, Rating, Input, Flex, Center, Box, Card, Stack, useComputedColorScheme} from '@mantine/core'; +import { Container, Text, Title, Textarea, Modal, Group, NumberInput, Button, Rating, TextInput, Input, Flex, Center, Box, Card, Stack, useComputedColorScheme} from '@mantine/core'; import KawaiiBetterInformatics from "../assets/kawaii-betterinformatics.svg?react"; import ShimmerButton from '../components/shimmer-button'; import { @@ -184,170 +179,92 @@ function getRatingBox(rating: any) : JSX.Element{
); } +function addTestimonial(testimonial: CourseTestimonial) { + console.log(testimonial) +} + const ReviewsTable: React.FC = ({data}) => { const [opened, { open, close }] = useDisclosure(false); - const [yearValue, setYearValue] = useState(2025); - // const columns = useMemo[]>( - // () => [ - // { - // accessorKey: 'course_code', - // header: 'Course Code', - // }, - // { - // accessorKey: 'course_name', - // header: 'Course Name', - // minSize:100, - // maxSize:450, - // size:350 - // }, - // { - // accessorKey: 'course_delivery', - // header: 'Delivery', - // }, - // { - // accessorKey: 'course_credits', - // header: 'Credits', - // }, - // { - // accessorKey: 'course_work_exam_ratio', - // header: 'Work%/Exam%', - // }, - // { - // accessorKey: 'course_level', - // header: 'Level', - // }, - // { - // accessorKey: 'course_overall_rating', - // header: 'Overall', - // Cell: ({ renderedCellValue, row }) => ( - // getRatingBox(renderedCellValue) - // ), - // size:50 - // }, - // { - // accessorKey: 'course_recommendability_rating', - // header: 'Recommendability', - // size:50 - // }, - // { - // accessorKey: 'course_workload_rating', - // header: 'Workload', - // size:50 - // }, - // { - // accessorKey: 'course_difficulty_rating', - // header: 'Difficulty', - // size:50 - // } - // ], - // [], - // ); + + //Inputs + const [courseName, setCourseName] = useState(""); + const [yearTakenValue, setYearTakenValue] = useState(2025); //convert to this year + const [difficultyRating, setDifficultyRating] = useState(0); + const [workloadRating, setWorkloadRating] = useState(0); + const [recommendabilityRating, setRecommendabilityRating] = useState(0); + const [testimonialString, setTestimonialString] = useState(""); + const computedColorScheme = useComputedColorScheme("light"); const [parent] = useAutoAnimate(); - const [expandedRecordIds, setExpandedRecordIds] = useState([]); - //const [bodyRef] = useAutoAnimate(); - - // const table = useMantineReactTable({ - // columns, - // data, - // initialState: { - // columnVisibility: { - // course_delivery: false, - // course_credits: false, - // course_work_exam_ratio: false, - // course_level: false - // }, - // }, - // renderDetailPanel: ({ row }) => - // row.original.testimonials?.length === 0 ? ( - // - // No testimonials yet :( - // open()} - // leftSection={} - // color={computedColorScheme === "dark" ? "compsocMain" : "dark"} - // variant="outline" - // > - // Add first testimonial - // - // - // ) : ( - // <> - // {row.original.testimonials.map((testimonial: CourseTestimonial) => - // <> - // - // - // Overall Recommendation: {String(testimonial.difficulty_rating)}/5 - // open()} - // leftSection={} - // color={computedColorScheme === "dark" ? "compsocMain" : "dark"} - // variant="outline" - // > - // Add new testimonial - // - // - // - // - // - // )} - // - // ) - // }); - // return ( - // ( - // - // {party.slice(0, 3).toUpperCase()} - // - // ), - // }, - // { accessor: 'bornIn' }, - // ]} - // // 👇 execute this callback when a row is clicked - // /> - // ); + + // type CourseTestimonial = { + // author: string, + // course: string, + // difficulty_rating: number, + // workload_rating: number, + // recommendability_rating: number, + // testimonial: string, + // year_taken: number + // } return <> - - - } - value={yearValue} - onChange={setYearValue} - label="When did you take this course?" - placeholder="Year" - /> - On a scale of 1-5, how likely are you to recommend this course? - - On a scale of 1-5, how balanced was the workload of the course? - - On a scale of 1-5, how manageable did you find the course? - - Testimonial (Experience, Advice, Study Tips) - + + + (setCourseName(event.target.value))} required withAsterisk> + } + value={yearTakenValue} + onChange={setYearTakenValue} + label="When did you take this course?" + placeholder="Year" + required withAsterisk + /> + Ratings + + + + Recommendability * + {/* On a scale of 1-5, how likely are you to recommend this course? */} + + + + + + Workload * + {/* On a scale of 1-5, how much workload was involved in the course? */} + + + + + + Difficulty * + {/* On a scale of 1-5, how difficult did you find the course? */} + + + + + + + + Testimonial (Experience, Advice, Study Tips) + + + {/* text area */} + + Date: Wed, 30 Jul 2025 15:28:20 +0700 Subject: [PATCH 05/10] Add Testimonial Backend and Frontend working --- backend/testimonials/models.py | 6 +- backend/testimonials/urls.py | 1 + backend/testimonials/views.py | 49 ++++++ frontend/src/api/hooks.ts | 1 + frontend/src/app.tsx | 6 + frontend/src/pages/add-testimonials-page.tsx | 153 +++++++++++++++++++ frontend/src/pages/testimonials-page.tsx | 121 +++++---------- 7 files changed, 251 insertions(+), 86 deletions(-) create mode 100644 frontend/src/pages/add-testimonials-page.tsx diff --git a/backend/testimonials/models.py b/backend/testimonials/models.py index 25f83ed0e..2ef2b0781 100644 --- a/backend/testimonials/models.py +++ b/backend/testimonials/models.py @@ -23,9 +23,9 @@ class Testimonial(models.Model): ) #Course_id, author_id, id (testimonial_id) testimonial = models.TextField() year_taken = models.IntegerField() - recommendability_rating = models.IntegerField() #min=1 max=5 - workload_rating = models.IntegerField() #min=1 max=5 - difficulty_rating = models.IntegerField() #min=1 max=5 + recommendability_rating = models.FloatField() #min=1 max=5 + workload_rating = models.FloatField() #min=1 max=5 + difficulty_rating = models.FloatField() #min=1 max=5 class Meta: # Enforce uniqueness across `student_number` and `course` diff --git a/backend/testimonials/urls.py b/backend/testimonials/urls.py index 19b42f5f3..38a96ace2 100644 --- a/backend/testimonials/urls.py +++ b/backend/testimonials/urls.py @@ -3,4 +3,5 @@ urlpatterns = [ path("listcourses/", views.course_metadata, name="course_list"), path("listtestimonials/", views.testimonial_metadata, name="testimonial_list"), + path('addtestimonial/', views.add_testimonial, name='add_testimonial'), ] \ No newline at end of file diff --git a/backend/testimonials/views.py b/backend/testimonials/views.py index abcdbb597..49a7532bc 100644 --- a/backend/testimonials/views.py +++ b/backend/testimonials/views.py @@ -3,6 +3,7 @@ from testimonials.models import Course, Testimonial from django.shortcuts import get_object_or_404 from datetime import timedelta +from django.http import JsonResponse @response.request_get() @auth_check.require_login @@ -39,3 +40,51 @@ def testimonial_metadata(request): for testimonial in testimonials ] return response.success(value=res) + +@response.request_post("course", "year_taken", optional=True) +@auth_check.require_login +def add_testimonial(request): + author = request.user + + course_code = request.POST.get('course') #course code instead of course name + course = Course.objects.get(code=course_code) + year_taken = request.POST.get('year_taken') + recommendability_rating = float(request.POST.get('recommendability_rating')) + workload_rating = request.POST.get('workload_rating') # notes is optional + difficulty_rating = request.POST.get('difficulty_rating') + testimonial = request.POST.get('testimonial') + #grade_band = request.POST.get('grade_band', None) # Added grade_band, optional + if not author: + return response.not_possible("Missing argument: author") + if not course: + return response.not_possible("Missing argument: course") + if not year_taken: + return response.not_possible("Missing argument: year_taken") + if not recommendability_rating: + return response.not_possible("Missing argument: recommendability_rating") + if not workload_rating: + return response.not_possible("Missing argument: workload_rating") + if not difficulty_rating: + return response.not_possible("Missing argument: difficulty_rating") + if not testimonial: + return response.not_possible("Missing argument: testimonial") + + # Create Dissertation entry in DB + + testimonials = Testimonial.objects.all() + + for t in testimonials: + if t.author == author and t.course.code == course_code: + return response.not_possible("You can only add 1 testimonial for each course.") + + testimonial = Testimonial.objects.create( + author=author, + course=course, + year_taken=year_taken, + recommendability_rating=recommendability_rating, + workload_rating=workload_rating, + difficulty_rating=difficulty_rating, + testimonial=testimonial, + ) + + return response.success(value=testimonial.id) diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 964b4f684..7d35b59fd 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -47,6 +47,7 @@ export const useUserInfo = (username: string) => { ); return [error, loading, data, run] as const; }; + const setUserDisplayUsername = async (displayUsername: string) => { await fetchPost("/api/auth/update_name/", { display_username: displayUsername, diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index b42bb6066..6f6380bd2 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -53,6 +53,7 @@ import { } from "./components/Navbar/constants"; import { useDisclosure } from "@mantine/hooks"; import TestimonialsPage from "./pages/testimonials-page"; +import AddTestimonialsPage from "./pages/add-testimonials-page"; function calculateShades(primaryColor: string): MantineColorsTuple { var baseHSLcolor = tinycolor(primaryColor).toHsl(); @@ -280,6 +281,11 @@ const App: React.FC<{}> = () => { path="/testimonials" component={TestimonialsPage} /> + = ({data}) => { + const history = useHistory(); + const [uploadSuccess, setUploadSuccess] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const { data : courses, loading: loading_courses, error: error_courses} = useRequest( + () => loadCourses() + ); + const form = useForm({ + initialValues: { + courseName: '', + yearTakenValue: 2025, + difficultyRating: 0, + workloadRating: 0, + recommendabilityRating: 0, + testimonialString: '', + }, + + validate: { + courseName: (value: string) => (value ? null : 'Course Name is required'), + yearTakenValue: (value: number) => (value ? null : 'Year Taken is required'), + difficultyRating: (value: number) => (value ? null : 'Supervisors are required'), + workloadRating: (value: number) => (value ? null : 'PDF file is required'), + recommendabilityRating: (value: number) => (value ? null : 'Study level is required'), + testimonialString: (value: string) => (value ? null : "Testimonial is required") + }, + }); + + const handleSubmit = async (values: typeof form.values) => { + setUploadSuccess(null); + setErrorMessage(null); + + // fetchPost expects a plain object, and it will construct FormData internally + console.log(values.courseName.split(" - ")[0]) + const dataToSend = { + course: values.courseName.split(" - ")[0], + year_taken: values.yearTakenValue, + difficulty_rating: values.difficultyRating, + workload_rating: values.workloadRating, + recommendability_rating: values.recommendabilityRating, + testimonial: values.testimonialString, + }; + + try { + const response = await fetchPost('/api/testimonials/addtestimonial/', dataToSend); + console.log("Response Add Testimonial") + console.log(response.value) + if (response.value) { + setUploadSuccess(true); + form.reset(); + //history.push('/testimonials'); // Redirect to testimonials page on success + //reload the table + } else { + setUploadSuccess(false); + setErrorMessage(response.error || 'Unknown error during upload.'); + } + } catch (error: any) { + setUploadSuccess(false); + console.log(error) + setErrorMessage(error || 'Network error during upload.'); + } + }; + + return ( + + Add a Course Testimonial +
+ + + course.course_code + " - " + course.course_name)} size={"md"} {...form.getInputProps('courseName')} label="Course Name" styles={{ label: {fontSize:"medium"} }} placeholder = "Course Name" required withAsterisk /> + } + {...form.getInputProps('yearTakenValue')} + label="When did you take this course?" + placeholder="Year" + required withAsterisk + /> + Ratings + + + + Recommendability * + {/* On a scale of 1-5, how likely are you to recommend this course? */} + + + + + + Workload * + {/* On a scale of 1-5, how much workload was involved in the course? */} + + + + + + Difficulty * + {/* On a scale of 1-5, how difficult did you find the course? */} + + + + + + + + Testimonial (Experience, Advice, Study Tips) + + + {/* text area */} + {uploadSuccess === true && ( + setUploadSuccess(null)} icon={}> + Thanks for submitting your review, your knowledge will be much appreciated by future UoE students. + + )} + + {uploadSuccess === false && errorMessage && ( + setUploadSuccess(null)} icon={}> + {errorMessage} + + )} + + + +
+
+ ); +}; + +export default AddTestimonialsPage; \ No newline at end of file diff --git a/frontend/src/pages/testimonials-page.tsx b/frontend/src/pages/testimonials-page.tsx index 12b874488..32c5b8e87 100644 --- a/frontend/src/pages/testimonials-page.tsx +++ b/frontend/src/pages/testimonials-page.tsx @@ -1,12 +1,14 @@ import { useAutoAnimate } from '@formkit/auto-animate/react'; import { useRequest } from "@umijs/hooks"; +import {useHistory} from 'react-router-dom' +import { fetchPost, fetchGet } from '../api/fetch-utils'; import dayjs from 'dayjs'; -import { useMemo, useEffect, useState } from 'react'; +import {useEffect, useState } from 'react'; import { DataTable } from 'mantine-datatable'; import useTitle from '../hooks/useTitle'; import { useDisclosure } from '@mantine/hooks'; import { useLocalStorageState } from '@umijs/hooks'; -import { Container, Text, Title, Textarea, Modal, Group, NumberInput, Button, Rating, TextInput, Input, Flex, Center, Box, Card, Stack, useComputedColorScheme} from '@mantine/core'; +import { Container, Text, Title, Autocomplete, Textarea, Notification, Modal, Group, NumberInput, Button, Rating, TextInput, Input, Flex, Center, Box, Card, Stack, useComputedColorScheme} from '@mantine/core'; import KawaiiBetterInformatics from "../assets/kawaii-betterinformatics.svg?react"; import ShimmerButton from '../components/shimmer-button'; import { @@ -58,7 +60,7 @@ type CourseWithTestimonial = { //there is repetition here } -interface ReviewTableProps{ +export interface ReviewTableProps{ data: CourseWithTestimonial[] } @@ -103,7 +105,7 @@ function getTableData(courses: any, testimonials: any) : CourseWithTestimonial[] course_work_exam_ratio: course.course_work_exam_ratio, course_level: course.course_level, course_dpmt_link: course.course_dpmt_link, - course_overall_rating: (average_course_recommendability + average_course_difficulty + average_course_workload)/3, + course_overall_rating: parseFloat(((average_course_recommendability + average_course_difficulty + average_course_workload)/3).toFixed(2)), course_recommendability_rating: average_course_recommendability, course_difficulty_rating: average_course_difficulty, course_workload_rating: average_course_workload, @@ -181,99 +183,52 @@ function getRatingBox(rating: any) : JSX.Element{ function addTestimonial(testimonial: CourseTestimonial) { console.log(testimonial) + } const ReviewsTable: React.FC = ({data}) => { + + const [allData, setAllData] = useState(); + useEffect(() => { + setAllData(data); + }, [data]); const [opened, { open, close }] = useDisclosure(false); //Inputs - const [courseName, setCourseName] = useState(""); - const [yearTakenValue, setYearTakenValue] = useState(2025); //convert to this year - const [difficultyRating, setDifficultyRating] = useState(0); - const [workloadRating, setWorkloadRating] = useState(0); - const [recommendabilityRating, setRecommendabilityRating] = useState(0); - const [testimonialString, setTestimonialString] = useState(""); + // const [courseName, setCourseName] = useState(""); + // const [yearTakenValue, setYearTakenValue] = useState(2025); //convert to this year + // const [difficultyRating, setDifficultyRating] = useState(0); + // const [workloadRating, setWorkloadRating] = useState(0); + // const [recommendabilityRating, setRecommendabilityRating] = useState(0); + // const [testimonialString, setTestimonialString] = useState(""); + + const { + data: courses, + loading: loadingCourses, + error: errorCourses, + refresh: refetchCourses, + } = useRequest(() => loadCourses()); + + const { + data: testimonials, + loading: loadingTestimonials, + error: errorTestimonials, + refresh: refetchTestimonials, + } = useRequest(() => loadTestimonials()); const computedColorScheme = useComputedColorScheme("light"); const [parent] = useAutoAnimate(); - // type CourseTestimonial = { - // author: string, - // course: string, - // difficulty_rating: number, - // workload_rating: number, - // recommendability_rating: number, - // testimonial: string, - // year_taken: number - // } + const history = useHistory(); return <> - - - (setCourseName(event.target.value))} required withAsterisk> - } - value={yearTakenValue} - onChange={setYearTakenValue} - label="When did you take this course?" - placeholder="Year" - required withAsterisk - /> - Ratings - - - - Recommendability * - {/* On a scale of 1-5, how likely are you to recommend this course? */} - - - - - - Workload * - {/* On a scale of 1-5, how much workload was involved in the course? */} - - - - - - Difficulty * - {/* On a scale of 1-5, how difficult did you find the course? */} - - - - - - - - Testimonial (Experience, Advice, Study Tips) - - - {/* text area */} - - - : <> } - noRecordsText={data?.length === 0 ? "No records to show" : ""} + noRecordsIcon={allData?.length === 0 ? <> : <> } + noRecordsText={allData?.length === 0 ? "No records to show" : ""} //scrollAreaProps={{ type: 'never' }} columns = {[ { @@ -335,7 +290,7 @@ const ReviewsTable: React.FC = ({data}) => { //width:50 } ]} - records ={data} + records ={allData} idAccessor="course_code" rowExpansion={{ // expanded: { @@ -353,7 +308,7 @@ const ReviewsTable: React.FC = ({data}) => { Overall Recommendation: {record.course_overall_rating == -1? "N/A" : String(record.course_overall_rating)+"/5"} open()} + onClick={() => history.push('/addtestimonials')} leftSection={} color={computedColorScheme === "dark" ? "compsocMain" : "dark"} variant="outline" @@ -370,7 +325,7 @@ const ReviewsTable: React.FC = ({data}) => { ), }} //bodyRef={bodyRef} - // fetching={data.length == 0} + fetching={allData? allData.length == 0 : false} // loaderType={"oval"} // loaderSize={"lg"} // loaderColor={"blue"} From 6f517150e6dfa2257ba1f6f3d001926ef5751e68 Mon Sep 17 00:00:00 2001 From: nikhen-s Date: Wed, 6 Aug 2025 14:53:15 +0700 Subject: [PATCH 06/10] feat: AI toxicity Testimonial Check + Admin View to Approve Testimonial + Notifications to users depending on testimonial result --- Dockerfile | 1 + backend/ediauth/auth_backend.py | 1 + backend/notifications/models.py | 1 + backend/notifications/notification_util.py | 37 +- backend/requirements.txt | 1 + backend/testimonials/models.py | 5 +- backend/testimonials/urls.py | 4 +- backend/testimonials/views.py | 129 +++++- frontend/package.json | 1 + frontend/src/api/hooks.ts | 10 +- frontend/src/api/testimonials.ts | 15 + frontend/src/app.tsx | 2 +- .../components/user-notification-settings.tsx | 4 +- frontend/src/components/user-testimonials.tsx | 294 ++++++++++++++ frontend/src/pages/add-testimonials-page.tsx | 75 ++-- frontend/src/pages/testimonials-page.tsx | 368 ++++++++++-------- frontend/src/pages/userinfo-page.tsx | 7 +- frontend/yarn.lock | 307 ++++++++++++++- 18 files changed, 1010 insertions(+), 252 deletions(-) create mode 100644 frontend/src/api/testimonials.ts create mode 100644 frontend/src/components/user-testimonials.tsx diff --git a/Dockerfile b/Dockerfile index 95ed034e6..c1a302a62 100755 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,7 @@ RUN apt-get install -y --no-install-recommends \ smbclient poppler-utils \ pgbouncer RUN pip3 install -r requirements.txt +RUN python3 -m spacy download en_core_web_sm RUN rm -rf /var/lib/apt/lists/* COPY ./backend/ ./ diff --git a/backend/ediauth/auth_backend.py b/backend/ediauth/auth_backend.py index 2255160bb..cfe1445f3 100644 --- a/backend/ediauth/auth_backend.py +++ b/backend/ediauth/auth_backend.py @@ -71,6 +71,7 @@ def add_auth_to_request(request: HttpRequest): NotificationType.NEW_COMMENT_TO_COMMENT, NotificationType.NEW_ANSWER_TO_ANSWER, NotificationType.NEW_COMMENT_TO_DOCUMENT, + NotificationType.UPDATE_TO_TESTIMONIAL_APPROVAL_STATUS ]: setting = NotificationSetting(user=user, type=type_.value) setting.save() diff --git a/backend/notifications/models.py b/backend/notifications/models.py index b650bcf5c..7c4453826 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -8,6 +8,7 @@ class NotificationType(enum.Enum): NEW_COMMENT_TO_COMMENT = 2 NEW_ANSWER_TO_ANSWER = 3 NEW_COMMENT_TO_DOCUMENT = 4 + UPDATE_TO_TESTIMONIAL_APPROVAL_STATUS = 5 class Notification(models.Model): diff --git a/backend/notifications/notification_util.py b/backend/notifications/notification_util.py index 721ac9878..3ddeddd1f 100644 --- a/backend/notifications/notification_util.py +++ b/backend/notifications/notification_util.py @@ -33,6 +33,18 @@ def send_notification( associated_data: Answer, ) -> None: ... +@overload +def send_notification( + sender: User, + receiver: User, + type_: Literal[ + NotificationType.UPDATE_TO_TESTIMONIAL_APPROVAL_STATUS + ], + title: str, + message: str, + associated_data: str, #Update to Testimonial +) -> None: ... + @overload def send_notification( @@ -51,10 +63,11 @@ def send_notification( type_: NotificationType, title: str, message: str, - associated_data: Union[Answer, Document], + associated_data: Union[Answer, Document, str], ): if sender == receiver: return + if is_notification_enabled(receiver, type_): send_inapp_notification( sender, receiver, type_, title, message, associated_data @@ -97,7 +110,7 @@ def send_email_notification( type_: NotificationType, title: str, message: str, - data: Union[Document, Answer], + data: Union[Document, Answer, str], ): """If the user has email notifications enabled, send an email notification. @@ -114,9 +127,17 @@ def send_email_notification( ) ): return + + dataOnMail = None + if isinstance(data, Document): + dataOnMail = data.display_name + elif isinstance(data, str): + dataOnMail = data + else: + dataOnMail = data.answer_section.exam.displayname send_mail( - f"BetterInformatics: {title} / {data.display_name if isinstance(data, Document) else data.answer_section.exam.displayname}", + f"BetterInformatics: {title} / {dataOnMail}", ( f"Hello {receiver.profile.display_username}!\n" f"{message}\n\n" @@ -200,3 +221,13 @@ def new_comment_to_document(document: Document, new_comment: DocumentComment): "A new comment was added to your document.\n\n{}".format(new_comment.text), document, ) + +def update_to_testimonial_status(sender, receiver, title, message): + send_notification( + sender, #Admin + receiver, + NotificationType.UPDATE_TO_TESTIMONIAL_APPROVAL_STATUS, + title, + message, + "" + ) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index ef8443d96..245656bdc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ requests==2.25.1 email-validator==2.0.0.post2 djau-gsuite-email==0.1.4 python-ipware==1.0.5 +spacy==3.7.0 \ No newline at end of file diff --git a/backend/testimonials/models.py b/backend/testimonials/models.py index 2ef2b0781..d5c3809e8 100644 --- a/backend/testimonials/models.py +++ b/backend/testimonials/models.py @@ -13,6 +13,7 @@ def __str__(self): return self.code # Use only a field of the model #add a testimonial data type to courses + class Testimonial(models.Model): #Link this to models.py ediauth username id = models.AutoField(primary_key=True) @@ -23,9 +24,7 @@ class Testimonial(models.Model): ) #Course_id, author_id, id (testimonial_id) testimonial = models.TextField() year_taken = models.IntegerField() - recommendability_rating = models.FloatField() #min=1 max=5 - workload_rating = models.FloatField() #min=1 max=5 - difficulty_rating = models.FloatField() #min=1 max=5 + approved = models.BooleanField(default=False) class Meta: # Enforce uniqueness across `student_number` and `course` diff --git a/backend/testimonials/urls.py b/backend/testimonials/urls.py index 38a96ace2..0cbfff4ae 100644 --- a/backend/testimonials/urls.py +++ b/backend/testimonials/urls.py @@ -4,4 +4,6 @@ path("listcourses/", views.course_metadata, name="course_list"), path("listtestimonials/", views.testimonial_metadata, name="testimonial_list"), path('addtestimonial/', views.add_testimonial, name='add_testimonial'), -] \ No newline at end of file + path('removetestimonial/', views.remove_testimonial, name='remove_testimonial'), + path('updatetestimonialapproval/', views.update_testimonial_approval_status, name="update_testimonial_approval_status") +] \ No newline at end of file diff --git a/backend/testimonials/views.py b/backend/testimonials/views.py index 49a7532bc..c0828576d 100644 --- a/backend/testimonials/views.py +++ b/backend/testimonials/views.py @@ -1,9 +1,16 @@ from util import response from ediauth import auth_check from testimonials.models import Course, Testimonial +from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 from datetime import timedelta from django.http import JsonResponse +import requests +from django.views.decorators.csrf import csrf_exempt +from notifications.notification_util import update_to_testimonial_status +import spacy +import json +import os @response.request_get() @auth_check.require_login @@ -29,13 +36,13 @@ def testimonial_metadata(request): testimonials = Testimonial.objects.all() res = [ { - "author": testimonial.author.username, - "course": testimonial.course.code, + "authorId": testimonial.author.username, + "authorDisplayName": testimonial.author.profile.display_username, + "course_code": testimonial.course.code, + "course_name": testimonial.course.name, "testimonial": testimonial.testimonial, "year_taken": testimonial.year_taken, - "recommendability_rating": testimonial.recommendability_rating, - "workload_rating": testimonial.workload_rating, - "difficulty_rating": testimonial.difficulty_rating, + "approved": testimonial.approved, } for testimonial in testimonials ] @@ -49,23 +56,15 @@ def add_testimonial(request): course_code = request.POST.get('course') #course code instead of course name course = Course.objects.get(code=course_code) year_taken = request.POST.get('year_taken') - recommendability_rating = float(request.POST.get('recommendability_rating')) - workload_rating = request.POST.get('workload_rating') # notes is optional - difficulty_rating = request.POST.get('difficulty_rating') testimonial = request.POST.get('testimonial') #grade_band = request.POST.get('grade_band', None) # Added grade_band, optional + if not author: return response.not_possible("Missing argument: author") if not course: return response.not_possible("Missing argument: course") if not year_taken: return response.not_possible("Missing argument: year_taken") - if not recommendability_rating: - return response.not_possible("Missing argument: recommendability_rating") - if not workload_rating: - return response.not_possible("Missing argument: workload_rating") - if not difficulty_rating: - return response.not_possible("Missing argument: difficulty_rating") if not testimonial: return response.not_possible("Missing argument: testimonial") @@ -76,15 +75,107 @@ def add_testimonial(request): for t in testimonials: if t.author == author and t.course.code == course_code: return response.not_possible("You can only add 1 testimonial for each course.") - + + api_key = os.environ.get('PERSPECTIVE_API_KEY') + url = f"https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze?key={api_key}" + + headers = { + "Content-Type": "application/json" + } + + requested_atributtes = ["TOXICITY", "IDENTITY_ATTACK", "INSULT", "PROFANITY"] + + data = { + "comment": { + "text": testimonial + }, + "languages": ["en"], + "requestedAttributes": {attr: {} for attr in requested_atributtes} + } + + toxicity_response = requests.post(url, headers=headers, json=data) + toxicity_response_json = toxicity_response.json() + + + scores = [toxicity_response_json["attributeScores"][attr]["summaryScore"]["value"] for attr in requested_atributtes] + + nlp = spacy.load("en_core_web_sm") + doc = nlp(testimonial) + names = [ent.text for ent in doc.ents if ent.label_ == "PERSON"] + + + approved_flag = True + if names == []: + for score in scores: + if score > 0.3: #if score is below 0.3, add it as a review + approved_flag = False + break + else: + approved_flag = False + + scores_dict = {requested_atributtes[i]: scores[i] for i in range(len(requested_atributtes))} + testimonial = Testimonial.objects.create( author=author, course=course, year_taken=year_taken, - recommendability_rating=recommendability_rating, - workload_rating=workload_rating, - difficulty_rating=difficulty_rating, + approved= approved_flag, testimonial=testimonial, ) - return response.success(value=testimonial.id) + return response.success(value={"testimonial_id" : testimonial.id, "approved" : approved_flag, "scores": scores_dict, "names_found": names}) + +@response.request_post("username", "course_code", optional=True) +@auth_check.require_login +def remove_testimonial(request): + username = request.POST.get('username') + course_code = request.POST.get('course_code') + + testimonials = Testimonial.objects.all() + + testimonial = None + + for t in testimonials: + if t.author.username == username and t.course.code == course_code: + testimonial = t + + if not testimonial: + return response.not_possible("Testimonial not found for author: " + username + " and course: " + course_code) + + if not (testimonial.author == request.user or auth_check.has_admin_rights(request)): + return response.not_possible("No permission to delete this.") + + testimonial.delete() + return response.success(value="Deleted Testimonial " + str(testimonial)) + +@response.request_post("title", "message", optional=True) +@auth_check.require_login +def update_testimonial_approval_status(request): + + sender = request.user + testimonial_author = request.POST.get('author') + receiver = get_object_or_404(User, username=testimonial_author) + course_code = request.POST.get('course_code') + title = request.POST.get('title') + message = request.POST.get('message') + approval_status = request.POST.get('approval_status') + approve_status = True if approval_status == "true" else False + course = get_object_or_404(Course, code=course_code) + + testimonial = Testimonial.objects.filter(author=receiver, course=course) + + final_message = "" + + if approval_status == True: + testimonial.update(approved=approve_status) + final_message = "Your Testimonial has been Accepted, it is now available to see in the Testimonials tab." + update_to_testimonial_status(sender, receiver, title, final_message) #notification + return response.success(value="Testimonial Accepted and the notification has been sent to " + str(receiver) + ".") + else: + final_message = f'Your Testimonial to {course_code} - {course.name}: \n"{testimonial.testimonial}." has not been accepted due to: {message}' + testimonial.delete() + update_to_testimonial_status(sender, receiver, title, final_message) #notification + return response.success(value="Testimonial Not Accepted " + "and the notification has been sent to " + str(receiver) + ".") + + + diff --git a/frontend/package.json b/frontend/package.json index 66cdbcebc..c723e4b55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "clsx": "^2.1.1", "date-fns": "^3.3.0", "dayjs": "^1.11.13", + "googleapis": "^154.1.0", "jszip": "^3.10.0", "jwt-decode": "^4.0.0", "katex": "^0.16.9", diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 7d35b59fd..afaf4621f 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -590,12 +590,4 @@ export const regenerateDocumentAPIKey = async (documentSlug: string) => { export const useRegenerateDocumentAPIKey = ( documentSlug: string, onSuccess?: (res: Document) => void, -) => useMutation(() => regenerateDocumentAPIKey(documentSlug), onSuccess); - -export const loadCourses = async () => { - return (await fetchGet("/api/testimonials/listcourses")) -}; - -export const loadTestimonials = async () => { - return (await fetchGet("/api/testimonials/listtestimonials")) -}; \ No newline at end of file +) => useMutation(() => regenerateDocumentAPIKey(documentSlug), onSuccess); \ No newline at end of file diff --git a/frontend/src/api/testimonials.ts b/frontend/src/api/testimonials.ts new file mode 100644 index 000000000..39e8806d5 --- /dev/null +++ b/frontend/src/api/testimonials.ts @@ -0,0 +1,15 @@ +import { fetchGet, fetchPost } from "./fetch-utils"; +import { useRequest } from "@umijs/hooks"; +import { remove } from "lodash-es"; + +export const loadCourses = async () => { + return (await fetchGet("/api/testimonials/listcourses")) +}; + +export const loadTestimonials = async () => { + return (await fetchGet("/api/testimonials/listtestimonials")) +}; + +export const removeTestimonial = async (testimonialId: string) => { + return (await fetchPost(`/api/testimonials/removetestimonial/${testimonialId}/`, {})); +}; diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 6f6380bd2..a5d45fa00 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -283,7 +283,7 @@ const App: React.FC<{}> = () => { /> { return "Other answer to same question"; case 4: return "Comment to my document"; + case 5: + return "Update to my testimonial status"; default: return "Unknown"; } @@ -46,7 +48,7 @@ const UserNotificationsSettings: React.FC = ({ - {[1, 2, 3, 4].map(type => ( + {[1, 2, 3, 4, 5].map(type => ( {mapTypeToString(type)} diff --git a/frontend/src/components/user-testimonials.tsx b/frontend/src/components/user-testimonials.tsx new file mode 100644 index 000000000..122423b91 --- /dev/null +++ b/frontend/src/components/user-testimonials.tsx @@ -0,0 +1,294 @@ + +import { useUser } from "../auth"; +import { Modal, Alert, Space, Textarea, Card, Text, Box, Tooltip, Group, Flex, Button, Stack} from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { + loadTestimonials + } from "../api/testimonials"; +import { CourseTestimonial, Course, CourseWithTestimonial, getTableData } from "../pages/testimonials-page" +import { useRequest } from "@umijs/hooks"; +import { useDisclosure } from '@mantine/hooks'; +import {useState } from 'react'; +import { fetchPost, fetchGet } from '../api/fetch-utils'; +import { useForm } from '@mantine/form'; + +interface UserTestimonialsProps { + username: string; +} + +interface TestimonialsProps{ + currentUserId: string, + isAdmin: boolean, + testimonials: CourseTestimonial[] +} + +const Testimonials: React.FC = ({currentUserId, isAdmin, testimonials}) => { + return ( + <> + + {isAdmin? testimonials.map((testimonial, index) => + + ) : testimonials.filter((testimonial) => testimonial.authorId=currentUserId).map((testimonial, index) => + + )} + + ); +} + + +interface AdminTestimonialCardProps{ + username: String, displayName: String, course_code: String, course_name:String, yearTaken: String, testimonial:String + } + + + interface UserTestimonialCardProps{ + username: String, displayName: String, course_code: String, course_name:String, yearTaken: String, testimonial:String, testimonial_approved:boolean + } + +const UserTestimonialCard: React.FC = ({course_code, course_name, username, displayName, yearTaken, testimonial, testimonial_approved}) => { + + return( + <> + + + {testimonial_approved? " Approved ": " Pending "} + + + + Course: {course_code} - {course_name} + + + + + + + {displayName} + + + @{username} + + + · + + took the course in {yearTaken} + + + + + "{testimonial}" + + + + + + ) +} + +const AdminTestimonialCard: React.FC = ({course_code, course_name, username, displayName, yearTaken, testimonial}) => { + const [opened, { open, close }] = useDisclosure(false); + const [success, setSuccess] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + //useForm? + + const form = useForm({ + initialValues: { + message: '', + }, + + validate: { + message: (value: string) => (value ? null : 'Message is required.'), + }, + }); + + const disapproveTestimonial = async (values: typeof form.values) => { + setSuccess(null); + setErrorMessage(null); + + const dataToSend = { + author: username, + course_code: course_code, + title: "Testimonial not Approved", + message: values.message, + approval_status: true + }; + try { + console.log(dataToSend) + const response = await fetchPost('/api/testimonials/updatetestimonialapproval/', dataToSend); + console.log("Response Disapprove Testimonial") + console.log(response.value) + + if (response.value) { + setSuccess(true); + setTimeout(() => (window.location.reload()),1000); + } else { + setSuccess(false); + setErrorMessage(response.error || 'Unknown error during deletion.'); + } + } catch (error: any) { + setSuccess(false); + setErrorMessage(error || 'Network error during deletion.'); + } + } + + + const approveTestimonial = async () => { + setSuccess(null); + setErrorMessage(null); + + const dataToSend = { + author: username, + course_code: course_code, + title: "Testimonial Approved", + message: "Your testimonial has been approved.", + approval_status: true + }; + try { + console.log(dataToSend) + const response = await fetchPost('/api/testimonials/updatetestimonialapproval/', dataToSend); + console.log("Response Approve Testimonial") + console.log(response.value) + + if (response.value) { + setSuccess(true); + setTimeout(() => (window.location.reload()),1000); + } else { + setSuccess(false); + setErrorMessage(response.error || 'Unknown error during deletion.'); + } + } catch (error: any) { + setSuccess(false); + setErrorMessage(error || 'Network error during deletion.'); + } + } + + return( + <> + + + Are you sure you'd like to disapprove this testimonial? This action cannot be undone. + Disapproving a testimonial will send a notification to the user, with the reason input behind why it has been disapproved. + +
+ + + {success === true && ( + Successfully disapproved testimonial, refreshing page now... + )} + + {success === false && errorMessage && ( + Failed to disapprove testimonial due to {errorMessage} + )} + + + + + +
+
+ +
+ + + + + Course: {course_code} - {course_name} + + + + + + + {displayName} + + + @{username} + + + · + + took the course in {yearTaken} + + + + + "{testimonial}" + + + + + + + + + + + ) +} + +const UserTestimonials: React.FC = ({ username }) => { + + const { data : testimonials, loading: loading_testimonials, error: error_testimonials } = useRequest( + () => loadTestimonials() + ); + + const { username: currentUsername, isAdmin } = useUser()!; + + const isOwnProfile = username === currentUsername; + + return ( + <> + {isOwnProfile && ( + + + + + + Your Testimonials + + }> + {isAdmin ? + "As an admin, you can see all testimonials and flag testimonials that are toxic or contain names." : + "This page shows all your testimonials, including those under review."} + + + )} + {(testimonials && )|| + ((loading_testimonials || error_testimonials) && ) + } + ); +} + +export default UserTestimonials; \ No newline at end of file diff --git a/frontend/src/pages/add-testimonials-page.tsx b/frontend/src/pages/add-testimonials-page.tsx index b3172f67c..a6e309bc7 100644 --- a/frontend/src/pages/add-testimonials-page.tsx +++ b/frontend/src/pages/add-testimonials-page.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useParams } from 'react-router-dom'; import { Container, Alert, Text, Title, Autocomplete, Textarea, Notification, Modal, Group, NumberInput, Button, Rating, TextInput, Input, Flex, Center, Box, Card, Stack, useComputedColorScheme} from '@mantine/core'; import { useForm } from '@mantine/form'; import { fetchPost } from '../api/fetch-utils'; @@ -10,7 +10,7 @@ import { } from "@tabler/icons-react"; import { loadCourses, -} from "../api/hooks"; +} from "../api/testimonials"; import { useRequest } from "@umijs/hooks"; type Course = { @@ -23,28 +23,24 @@ type Course = { course_dpmt_link: string } const AddTestimonialsPage: React.FC = ({data}) => { + const { course_code, course_name } = useParams<{ course_code?: string; course_name?: string }>(); const history = useHistory(); const [uploadSuccess, setUploadSuccess] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const initialCourse = course_code? `${course_code} - ${course_name}` : '' const { data : courses, loading: loading_courses, error: error_courses} = useRequest( () => loadCourses() ); const form = useForm({ initialValues: { - courseName: '', + courseName: initialCourse, yearTakenValue: 2025, - difficultyRating: 0, - workloadRating: 0, - recommendabilityRating: 0, testimonialString: '', }, validate: { courseName: (value: string) => (value ? null : 'Course Name is required'), yearTakenValue: (value: number) => (value ? null : 'Year Taken is required'), - difficultyRating: (value: number) => (value ? null : 'Supervisors are required'), - workloadRating: (value: number) => (value ? null : 'PDF file is required'), - recommendabilityRating: (value: number) => (value ? null : 'Study level is required'), testimonialString: (value: string) => (value ? null : "Testimonial is required") }, }); @@ -54,26 +50,30 @@ const AddTestimonialsPage: React.FC = ({data}) => { setErrorMessage(null); // fetchPost expects a plain object, and it will construct FormData internally - console.log(values.courseName.split(" - ")[0]) const dataToSend = { course: values.courseName.split(" - ")[0], year_taken: values.yearTakenValue, - difficulty_rating: values.difficultyRating, - workload_rating: values.workloadRating, - recommendability_rating: values.recommendabilityRating, testimonial: values.testimonialString, }; - + //understand thens and comments try { const response = await fetchPost('/api/testimonials/addtestimonial/', dataToSend); console.log("Response Add Testimonial") console.log(response.value) - if (response.value) { + + if (response.value && response.value["approved"] == true) { setUploadSuccess(true); + form.setInitialValues({ + courseName: '', + yearTakenValue: 2025, + testimonialString: '', + }) form.reset(); - //history.push('/testimonials'); // Redirect to testimonials page on success - //reload the table - } else { + } else if (response.value && response.value["approved"] == false) { + setUploadSuccess(true); + setErrorMessage("Thank you for submitting your testimonial! We appreciate your feedback. However, we've detected some potentially inappropriate content, so your message will be reviewed by a moderator before it can be published."); + } + else { setUploadSuccess(false); setErrorMessage(response.error || 'Unknown error during upload.'); } @@ -89,7 +89,6 @@ const AddTestimonialsPage: React.FC = ({data}) => { Add a Course Testimonial
- course.course_code + " - " + course.course_name)} size={"md"} {...form.getInputProps('courseName')} label="Course Name" styles={{ label: {fontSize:"medium"} }} placeholder = "Course Name" required withAsterisk /> = ({data}) => { placeholder="Year" required withAsterisk /> - Ratings - - - - Recommendability * - {/* On a scale of 1-5, how likely are you to recommend this course? */} - - - - - - Workload * - {/* On a scale of 1-5, how much workload was involved in the course? */} - - - - - - Difficulty * - {/* On a scale of 1-5, how difficult did you find the course? */} - - - - - - - Testimonial (Experience, Advice, Study Tips) + + Testimonial (Experience, Advice, Study Tips) * + {/* text area */} - {uploadSuccess === true && ( + {uploadSuccess === true && errorMessage == null && ( setUploadSuccess(null)} icon={}> Thanks for submitting your review, your knowledge will be much appreciated by future UoE students. )} + {uploadSuccess === true && errorMessage && ( + setUploadSuccess(null)} icon={}> + {errorMessage} + + )} + {uploadSuccess === false && errorMessage && ( setUploadSuccess(null)} icon={}> {errorMessage} diff --git a/frontend/src/pages/testimonials-page.tsx b/frontend/src/pages/testimonials-page.tsx index 32c5b8e87..738a13bfd 100644 --- a/frontend/src/pages/testimonials-page.tsx +++ b/frontend/src/pages/testimonials-page.tsx @@ -1,30 +1,33 @@ import { useAutoAnimate } from '@formkit/auto-animate/react'; import { useRequest } from "@umijs/hooks"; +import sortBy from 'lodash/sortBy'; import {useHistory} from 'react-router-dom' +import { useDisclosure } from '@mantine/hooks'; import { fetchPost, fetchGet } from '../api/fetch-utils'; import dayjs from 'dayjs'; +import { notLoggedIn, SetUserContext, User, UserContext } from "../auth"; import {useEffect, useState } from 'react'; -import { DataTable } from 'mantine-datatable'; +import { DataTable, type DataTableSortStatus } from 'mantine-datatable'; import useTitle from '../hooks/useTitle'; -import { useDisclosure } from '@mantine/hooks'; +import { useDebouncedValue } from '@mantine/hooks'; import { useLocalStorageState } from '@umijs/hooks'; -import { Container, Text, Title, Autocomplete, Textarea, Notification, Modal, Group, NumberInput, Button, Rating, TextInput, Input, Flex, Center, Box, Card, Stack, useComputedColorScheme} from '@mantine/core'; +import { Alert, Container, Text, CloseButton, Title, Autocomplete, Textarea, Notification, Modal, Group, NumberInput, Button, Rating, TextInput, Input, Flex, Center, Box, Card, Stack, useComputedColorScheme, ActionIcon} from '@mantine/core'; import KawaiiBetterInformatics from "../assets/kawaii-betterinformatics.svg?react"; import ShimmerButton from '../components/shimmer-button'; import { - IconPlus,IconCalendarFilled, + IconPlus,IconSearch, IconX, IconInfoCircle } from "@tabler/icons-react"; import { loadCourses, loadTestimonials -} from "../api/hooks"; +} from "../api/testimonials"; import ContentContainer from '../components/secondary-container'; import { table } from 'console'; //mock data -type Course = { +export type Course = { course_code: string, course_name: string, course_delivery: string, @@ -34,17 +37,17 @@ type Course = { course_dpmt_link: string } -type CourseTestimonial = { - author: string, - course: string, - difficulty_rating: number, - workload_rating: number, - recommendability_rating: number, +export type CourseTestimonial = { + authorId: string, + authorDisplayName: string, + course_code: string, + course_name: string, testimonial: string, year_taken: number + approved: boolean, } -type CourseWithTestimonial = { //there is repetition here +export type CourseWithTestimonial = { course_code: string, course_name: string, course_delivery: string, @@ -52,19 +55,16 @@ type CourseWithTestimonial = { //there is repetition here course_work_exam_ratio: string, course_level: number, course_dpmt_link: string, - course_overall_rating: number, - course_recommendability_rating: number, - course_difficulty_rating: number, - course_workload_rating: number, testimonials: CourseTestimonial[] } export interface ReviewTableProps{ - data: CourseWithTestimonial[] + data: CourseWithTestimonial[], + user: User | undefined } -function getTableData(courses: any, testimonials: any) : CourseWithTestimonial[] { +export function getTableData(courses: any, testimonials: any) : CourseWithTestimonial[] { let tableData = new Array(1); if (courses && testimonials){ @@ -73,29 +73,11 @@ function getTableData(courses: any, testimonials: any) : CourseWithTestimonial[] tableData = new Array(courses['value'].length); for (let i = 0; i < courses['value'].length; i++){ let course = courses['value'][i]; + console.log(testimonials) let currentCourseTestimonials : CourseTestimonial[] = testimonials['value'].filter( - (testimonial: CourseTestimonial) => (testimonial.course == course.course_code + (testimonial: CourseTestimonial) => (testimonial.course_code == course.course_code && testimonial.approved == true )); - let average_course_difficulty = 0.0; - let average_course_workload = 0.0; - let average_course_recommendability = 0.0; - - if (currentCourseTestimonials.length == 0){ - average_course_difficulty = -1; - average_course_workload = -1; - average_course_recommendability = -1; - } else { - for (let j = 0; j < currentCourseTestimonials.length; j++){ - average_course_difficulty+= currentCourseTestimonials[j].difficulty_rating; - average_course_recommendability+= currentCourseTestimonials[j].recommendability_rating; - average_course_workload+= currentCourseTestimonials[j].workload_rating; - } - average_course_difficulty /= currentCourseTestimonials.length - average_course_recommendability /= currentCourseTestimonials.length - average_course_workload /= currentCourseTestimonials.length - } - //average of testimonials and etc! tableData[i] = { course_code: course.course_code, @@ -105,10 +87,6 @@ function getTableData(courses: any, testimonials: any) : CourseWithTestimonial[] course_work_exam_ratio: course.course_work_exam_ratio, course_level: course.course_level, course_dpmt_link: course.course_dpmt_link, - course_overall_rating: parseFloat(((average_course_recommendability + average_course_difficulty + average_course_workload)/3).toFixed(2)), - course_recommendability_rating: average_course_recommendability, - course_difficulty_rating: average_course_difficulty, - course_workload_rating: average_course_workload, testimonials: currentCourseTestimonials } } @@ -119,6 +97,32 @@ function getTableData(courses: any, testimonials: any) : CourseWithTestimonial[] const TestimonialsPage: React.FC<{}> = () => { useTitle("Testimonials"); + const [user, setUser] = useState(undefined); + useEffect(() => { + let cancelled = false; + if (user === undefined) { + fetchGet("/api/auth/me/").then( + res => { + if (cancelled) return; + setUser({ + loggedin: res.loggedin, + username: res.username, + displayname: res.displayname, + isAdmin: res.adminrights, + isCategoryAdmin: res.adminrightscat, + }); + }, + () => { + setUser(notLoggedIn); + }, + ); + } + console.log(user) + return () => { + cancelled = true; + }; + }, [user]); + const [uwu, _] = useLocalStorageState("uwu", false); const { data : courses, loading: loading_courses, error: error_courses} = useRequest( () => loadCourses() @@ -146,8 +150,8 @@ const TestimonialsPage: React.FC<{}> = () => { - {(courses && testimonials && )|| - ((loading_courses || error_courses || loading_testimonials || error_testimonials) && ) + {(courses && testimonials && )|| + ((loading_courses || error_courses || loading_testimonials || error_testimonials) && ) } @@ -155,140 +159,109 @@ const TestimonialsPage: React.FC<{}> = () => { ); } -function getRatingBox(rating: any) : JSX.Element{ - let ratingsBoxColor = "#A0AEC0" - if (rating==-1){ - ratingsBoxColor = "#A0AEC0" - } else if (rating >= 4){ - ratingsBoxColor = "green" - } else if (rating >= 3){ - ratingsBoxColor = "#D69E2E" - } else if (rating >= 2){ - ratingsBoxColor = "#DD6B20" //orange - } else { - ratingsBoxColor = "'#E53E3E" //red - } +const ReviewsTable: React.FC = ({data, user}) => { - return ( -
- {rating == -1? "N/A" : rating} -
); -} + + const [allData, setAllData] = useState(sortBy(data, 'course_name')); + + const [sortStatus, setSortStatus] = useState>({ + columnAccessor: 'course_name', + direction: 'asc', + }); -function addTestimonial(testimonial: CourseTestimonial) { - console.log(testimonial) + const [query, setQuery] = useState(''); + const [debouncedQuery] = useDebouncedValue(query, 200); -} + const computedColorScheme = useComputedColorScheme("light"); + const [parent] = useAutoAnimate(); -const ReviewsTable: React.FC = ({data}) => { + const history = useHistory(); - const [allData, setAllData] = useState(); useEffect(() => { - setAllData(data); - }, [data]); - const [opened, { open, close }] = useDisclosure(false); - - //Inputs - // const [courseName, setCourseName] = useState(""); - // const [yearTakenValue, setYearTakenValue] = useState(2025); //convert to this year - // const [difficultyRating, setDifficultyRating] = useState(0); - // const [workloadRating, setWorkloadRating] = useState(0); - // const [recommendabilityRating, setRecommendabilityRating] = useState(0); - // const [testimonialString, setTestimonialString] = useState(""); - - const { - data: courses, - loading: loadingCourses, - error: errorCourses, - refresh: refetchCourses, - } = useRequest(() => loadCourses()); - - const { - data: testimonials, - loading: loadingTestimonials, - error: errorTestimonials, - refresh: refetchTestimonials, - } = useRequest(() => loadTestimonials()); + setAllData(sortBy(data, 'course_name')) + }, [data]) - const computedColorScheme = useComputedColorScheme("light"); - const [parent] = useAutoAnimate(); + useEffect(() => { + const initial_data = sortBy(data, sortStatus.columnAccessor) as CourseWithTestimonial[]; + let updatedData = sortStatus.direction === 'desc' ? initial_data.reverse() : initial_data; + if (debouncedQuery !== ''){ + updatedData = updatedData.filter((course) => (course.course_name.toLowerCase().includes(debouncedQuery.toLowerCase()))) + } + setAllData(updatedData); + }, [sortStatus, debouncedQuery]); - const history = useHistory(); return <> : <> } - noRecordsText={allData?.length === 0 ? "No records to show" : ""} + // noRecordsIcon={allData?.length === 0 ? <> : <> } + // noRecordsText={allData?.length === 0 ? "No records to show" : ""} //scrollAreaProps={{ type: 'never' }} columns = {[ { accessor: 'course_code', title: 'Course Code', width: "12%", + sortable: true, }, { accessor: 'course_name', title: 'Course Name', + sortable: true, + render: (record, index) => ( + {record.course_name} + ), + filter: ( + } + rightSection={ + setQuery('')}> + + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + /> + ), + filtering: query !== '', // minSize:100, // maxSize:450, }, - // { - // accessor: 'course_delivery', - // title: 'Delivery', - // }, - // { - // accessor: 'course_credits', - // title: 'Credits', - // }, - // { - // accessor: 'course_work_exam_ratio', - // title: 'Work%/Exam%', - // }, - // { - // accessor: 'course_level', - // title: 'Level', - // }, { - accessor: 'course_overall_rating', - title: 'Overall', - render: ({course_overall_rating}) => (getRatingBox(course_overall_rating)), - width: "12%", - // Cell: ({ renderedCellValue, row }) => ( - // getRatingBox(renderedCellValue) - // ), - // size:50 + accessor: 'course_delivery', + title: 'Delivery', + sortable: true, }, { - accessor: 'course_recommendability_rating', - title: 'Recommendability', - width: "12%", - render: ({course_recommendability_rating}) => (getRatingBox(course_recommendability_rating)) - //width:50 + accessor: 'course_credits', + title: 'Credits', + sortable: true, }, { - accessor: 'course_workload_rating', - title: 'Workload', - render: ({course_workload_rating}) => (getRatingBox(course_workload_rating)), - width: "12%" - //width:50 + accessor: 'course_work_exam_ratio', + title: 'Work%/Exam%', + sortable: true, }, { - accessor: 'course_difficulty_rating', - title: 'Difficulty', - render: ({course_difficulty_rating}) => (getRatingBox(course_difficulty_rating)), - width: "12%" - //width:50 + accessor: 'course_level', + title: 'Level', + sortable: true, + }, + { + accessor: 'testimonials', + title: 'No. Testimonials', + sortable: true, + render: (record, index) => ( + {record.testimonials.length} + ) + } + ]} records ={allData} idAccessor="course_code" @@ -306,9 +279,8 @@ const ReviewsTable: React.FC = ({data}) => { content: ({ record }) => ( - Overall Recommendation: {record.course_overall_rating == -1? "N/A" : String(record.course_overall_rating)+"/5"} history.push('/addtestimonials')} + onClick={() => history.push(`/addtestimonials/${record.course_code}/${record.course_name}`)} leftSection={} color={computedColorScheme === "dark" ? "compsocMain" : "dark"} variant="outline" @@ -317,19 +289,22 @@ const ReviewsTable: React.FC = ({data}) => { { - record.testimonials.map((testimonial) => //add a key to the testimonial - + record.testimonials.map((testimonial, index) => //add a key to the testimonial + ) } ), }} //bodyRef={bodyRef} - fetching={allData? allData.length == 0 : false} + + //fetching={allData?.length == 0} // loaderType={"oval"} // loaderSize={"lg"} // loaderColor={"blue"} // loaderBackgroundBlur={1} + sortStatus={sortStatus} + onSortStatusChange={setSortStatus} /> ; }; @@ -345,29 +320,92 @@ const RatingBox: React.FC = ({ratingType, ratingLevel}) => { } interface reviewProps{ - typeOfStudent: String, yearTaken: String, recommendabilityRating:String, workloadRating:String, difficultyRating:String, testimonial:String + currentUser:String, username: String, displayName: String, course_code: String, yearTaken: String, testimonial:String } -const ReviewCard: React.FC = ({yearTaken, recommendabilityRating, workloadRating, difficultyRating, testimonial}) => { - return( - + +const ReviewCard: React.FC = ({currentUser, course_code, username, displayName, yearTaken, testimonial}) => { + const [opened, { open, close }] = useDisclosure(false); + const [deleteSuccess, setDeleteSuccess] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const deleteTestimonial = async () => { + setDeleteSuccess(null); + setErrorMessage(null); + + const dataToSend = { + username: username, + course_code: course_code, + }; + try { + console.log(dataToSend) + const response = await fetchPost('/api/testimonials/removetestimonial/', dataToSend); + console.log("Response Remove Testimonial") + console.log(response.value) + + if (response.value) { + setDeleteSuccess(true); + setTimeout(() => (window.location.reload()),1000); + } else { + setDeleteSuccess(false); + setErrorMessage(response.error || 'Unknown error during deletion.'); + } + } catch (error: any) { + setDeleteSuccess(false); + setErrorMessage(error || 'Network error during deletion.'); + } + } + + return( + <> + + + Are you sure you'd like to delete your testimonial? This action cannot be undone. + + {deleteSuccess === true && ( + Successfully deleted testimonial, refreshing page now... + )} + + {deleteSuccess === false && errorMessage && ( + Failed to delete testimonial due to {errorMessage} + )} + + + + + + + + + + - - s2236467 - - - - - - + + {displayName} + {currentUser==username && " (you)"} + + + @{username} + + + · + + took the course in {yearTaken} - Year Course Taken: {yearTaken} + {currentUser==username && } - - "{testimonial}" + + {testimonial} + ) } diff --git a/frontend/src/pages/userinfo-page.tsx b/frontend/src/pages/userinfo-page.tsx index de0e48588..c7e694af8 100644 --- a/frontend/src/pages/userinfo-page.tsx +++ b/frontend/src/pages/userinfo-page.tsx @@ -1,4 +1,4 @@ -import { Container, Alert, Tabs, LoadingOverlay, Space } from "@mantine/core"; +import { Container, Text, Alert, Tabs, LoadingOverlay, Space } from "@mantine/core"; import React, { useState } from "react"; import { useParams } from "react-router-dom"; import { useUserInfo } from "../api/hooks"; @@ -10,6 +10,7 @@ import UserNotificationsSettings from "../components/user-notification-settings" import UserDisplayNameSettings from "../components/user-displayname-settings"; import UserDocuments from "../components/user-documents"; import UserScoreCard from "../components/user-score-card"; +import UserTestimonials from "../components/user-testimonials" import useTitle from "../hooks/useTitle"; const UserPage: React.FC<{}> = () => { @@ -38,6 +39,7 @@ const UserPage: React.FC<{}> = () => { Answers Comments Documents + Testimonials {isMyself && Settings} @@ -55,6 +57,9 @@ const UserPage: React.FC<{}> = () => { + + + {isMyself && ( <> diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 01ec16077..1fc96f803 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2051,6 +2051,11 @@ acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== +agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -2282,11 +2287,16 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.3.1: +base64-js@^1.3.0, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +bignumber.js@^9.0.0: + version "9.3.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" + integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== + bl@^4.0.3: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -2321,6 +2331,11 @@ browserslist@^4.22.2: node-releases "^2.0.14" update-browserslist-db "^1.0.13" +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer@^5.5.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" @@ -2329,6 +2344,14 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.6.tgz#6c46675fc7a5e9de82d75a233d586c8b7ac0d931" @@ -2339,6 +2362,14 @@ call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6: get-intrinsic "^1.2.3" set-function-length "^1.2.0" +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -2622,6 +2653,11 @@ damerau-levenshtein@^1.0.8: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + date-fns@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.3.1.tgz#7581daca0892d139736697717a168afbb908cfed" @@ -2632,6 +2668,13 @@ dayjs@^1.11.13: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== +debug@4: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -2772,6 +2815,22 @@ dot-case@^3.0.4: no-case "^3.0.4" tslib "^2.0.3" +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + electron-to-chromium@^1.4.648: version "1.4.666" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.666.tgz#ecc65df60e5d3489962ff46b8a6b1dd3b8f863aa" @@ -2858,6 +2917,11 @@ es-array-method-boxes-properly@^1.0.0: resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.0.0, es-errors@^1.1.0, es-errors@^1.2.1, es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" @@ -2884,6 +2948,13 @@ es-iterator-helpers@^1.0.12, es-iterator-helpers@^1.0.15: iterator.prototype "^1.1.2" safe-array-concat "^1.1.0" +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + es-set-tostringtag@^2.0.1, es-set-tostringtag@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz#11f7cc9f63376930a5f20be4915834f4bc74f9c9" @@ -3216,7 +3287,7 @@ expand-template@^2.0.3: resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== -extend@^3.0.0: +extend@^3.0.0, extend@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -3266,6 +3337,14 @@ fault@^1.0.0: dependencies: format "^0.2.0" +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -3319,6 +3398,13 @@ format@^0.2.0: resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww== +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -3354,6 +3440,24 @@ functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +gaxios@^7.0.0, gaxios@^7.0.0-rc.4: + version "7.1.1" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-7.1.1.tgz#255b86ce09891e9ed16926443a1ce239c8f9fd51" + integrity sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ== + dependencies: + extend "^3.0.2" + https-proxy-agent "^7.0.1" + node-fetch "^3.3.2" + +gcp-metadata@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-7.0.1.tgz#43bb9cd482cf0590629b871ab9133af45b78382d" + integrity sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ== + dependencies: + gaxios "^7.0.0" + google-logging-utils "^1.0.0" + json-bigint "^1.0.0" + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -3370,11 +3474,35 @@ get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-nonce@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-symbol-description@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" @@ -3451,6 +3579,43 @@ globrex@^0.1.2: resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== +google-auth-library@^10.1.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-10.2.0.tgz#cdcee27c6021cf610fde6c1dd9abe85217b1bf6e" + integrity sha512-gy/0hRx8+Ye0HlUm3GrfpR4lbmJQ6bJ7F44DmN7GtMxxzWSojLzx0Bhv/hj7Wlj7a2On0FcT8jrz8Y1c1nxCyg== + dependencies: + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + gaxios "^7.0.0" + gcp-metadata "^7.0.0" + google-logging-utils "^1.0.0" + gtoken "^8.0.0" + jws "^4.0.0" + +google-logging-utils@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/google-logging-utils/-/google-logging-utils-1.1.1.tgz#4a1f44a69a187eb954629c88c5af89c0dfbca51a" + integrity sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A== + +googleapis-common@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/googleapis-common/-/googleapis-common-8.0.0.tgz#7a3ea3aa3863c9d3e7635070314d639dadfa164a" + integrity sha512-66if47It7y+Sab3HMkwEXx1kCq9qUC9px8ZXoj1CMrmLmUw81GpbnsNlXnlyZyGbGPGcj+tDD9XsZ23m7GLaJQ== + dependencies: + extend "^3.0.2" + gaxios "^7.0.0-rc.4" + google-auth-library "^10.1.0" + qs "^6.7.0" + url-template "^2.0.8" + +googleapis@^154.1.0: + version "154.1.0" + resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-154.1.0.tgz#2d44401ab6ebd8239ba74c9548c3b396da9e7eff" + integrity sha512-lsgKftflkjR+PNdKDjy5e4zZUQO3BMaNLO0Q91uvr9YCy3aOqtEwkDtfhcBguFtFLsqj+XjdfDu2on6rtHNvYg== + dependencies: + google-auth-library "^10.1.0" + googleapis-common "^8.0.0" + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -3458,11 +3623,24 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graphemer@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +gtoken@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-8.0.0.tgz#d67a0e346dd441bfb54ad14040ddc3b632886575" + integrity sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw== + dependencies: + gaxios "^7.0.0" + jws "^4.0.0" + has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" @@ -3495,6 +3673,11 @@ has-symbols@^1.0.2, has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0, has-tostringtag@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" @@ -3509,6 +3692,13 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + hast-util-from-dom@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz#c3c92fbd8d4e1c1625edeb3a773952b9e4ad64a8" @@ -3662,6 +3852,14 @@ html-url-attributes@^3.0.0: resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87" integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ== +https-proxy-agent@^7.0.1: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + iconv-lite@^0.6.2: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -4024,6 +4222,13 @@ jsesc@~0.5.0: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== +json-bigint@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1" + integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== + dependencies: + bignumber.js "^9.0.0" + json-buffer@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" @@ -4076,6 +4281,23 @@ jszip@^3.10.0: readable-stream "~2.3.6" setimmediate "^1.0.5" +jwa@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" + integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== + dependencies: + jwa "^2.0.0" + safe-buffer "^5.0.1" + jwt-decode@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b" @@ -4220,6 +4442,11 @@ markdown-table@^3.0.0: resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a" integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + mdast-util-find-and-replace@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz#70a3174c894e14df722abf43bc250cbae44b11df" @@ -4808,6 +5035,11 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-fetch@^1.0.1: version "1.7.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" @@ -4816,6 +5048,15 @@ node-fetch@^1.0.1: encoding "^0.1.11" is-stream "^1.0.1" +node-fetch@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" + integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + node-releases@^2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" @@ -4831,6 +5072,11 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== +object-inspect@^1.13.3: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -5179,6 +5425,13 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +qs@^6.7.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" + integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== + dependencies: + side-channel "^1.1.0" + qs@^6.9.1: version "6.11.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" @@ -5716,6 +5969,35 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.5.tgz#9a84546599b48909fb6af1211708d23b1946221b" @@ -5726,6 +6008,17 @@ side-channel@^1.0.4: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + simple-concat@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" @@ -6220,6 +6513,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-template@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21" + integrity sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw== + use-callback-ref@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf" @@ -6347,6 +6645,11 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== +web-streams-polyfill@^3.0.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== + whatwg-fetch@>=0.10.0: version "3.6.20" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70" From bc12174f34e5b76461b0c3f4194efb76afb1ed1c Mon Sep 17 00:00:00 2001 From: nikhen-s Date: Wed, 3 Sep 2025 14:39:53 +0700 Subject: [PATCH 07/10] Removed AI check for testimonials + Added approval flag in db for manual testimonial approvals --- backend/notifications/notification_util.py | 2 +- backend/testimonials/models.py | 16 ++-- backend/testimonials/views.py | 96 +++++++------------ frontend/src/components/user-testimonials.tsx | 19 ++-- frontend/src/pages/testimonials-page.tsx | 10 +- 5 files changed, 61 insertions(+), 82 deletions(-) diff --git a/backend/notifications/notification_util.py b/backend/notifications/notification_util.py index 3ddeddd1f..7786a59e5 100644 --- a/backend/notifications/notification_util.py +++ b/backend/notifications/notification_util.py @@ -224,7 +224,7 @@ def new_comment_to_document(document: Document, new_comment: DocumentComment): def update_to_testimonial_status(sender, receiver, title, message): send_notification( - sender, #Admin + sender, receiver, NotificationType.UPDATE_TO_TESTIMONIAL_APPROVAL_STATUS, title, diff --git a/backend/testimonials/models.py b/backend/testimonials/models.py index d5c3809e8..1fb7d106f 100644 --- a/backend/testimonials/models.py +++ b/backend/testimonials/models.py @@ -14,8 +14,12 @@ def __str__(self): #add a testimonial data type to courses +class ApprovalStatus(models.IntegerChoices): + APPROVED = 0, "Approved" + PENDING = 1, "Pending" + REJECTED = 2, "Rejected" + class Testimonial(models.Model): - #Link this to models.py ediauth username id = models.AutoField(primary_key=True) author = models.ForeignKey("auth.User", on_delete=models.CASCADE, default="") course = models.ForeignKey( # Link Testimonial to a Course @@ -24,11 +28,11 @@ class Testimonial(models.Model): ) #Course_id, author_id, id (testimonial_id) testimonial = models.TextField() year_taken = models.IntegerField() - approved = models.BooleanField(default=False) + approval_status = models.IntegerField( + choices=ApprovalStatus.choices, + default=ApprovalStatus.PENDING, + ) class Meta: # Enforce uniqueness across `student_number` and `course` - unique_together = ("author", "course") - - - + unique_together = ("author", "course") \ No newline at end of file diff --git a/backend/testimonials/views.py b/backend/testimonials/views.py index c0828576d..a90dcb0af 100644 --- a/backend/testimonials/views.py +++ b/backend/testimonials/views.py @@ -1,6 +1,6 @@ from util import response from ediauth import auth_check -from testimonials.models import Course, Testimonial +from testimonials.models import Course, Testimonial, ApprovalStatus from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 from datetime import timedelta @@ -8,8 +8,7 @@ import requests from django.views.decorators.csrf import csrf_exempt from notifications.notification_util import update_to_testimonial_status -import spacy -import json +import ediauth.auth_check as auth_check import os @response.request_get() @@ -42,7 +41,7 @@ def testimonial_metadata(request): "course_name": testimonial.course.name, "testimonial": testimonial.testimonial, "year_taken": testimonial.year_taken, - "approved": testimonial.approved, + "approval_status": testimonial.approval_status, } for testimonial in testimonials ] @@ -52,12 +51,10 @@ def testimonial_metadata(request): @auth_check.require_login def add_testimonial(request): author = request.user - course_code = request.POST.get('course') #course code instead of course name course = Course.objects.get(code=course_code) year_taken = request.POST.get('year_taken') testimonial = request.POST.get('testimonial') - #grade_band = request.POST.get('grade_band', None) # Added grade_band, optional if not author: return response.not_possible("Missing argument: author") @@ -75,55 +72,16 @@ def add_testimonial(request): for t in testimonials: if t.author == author and t.course.code == course_code: return response.not_possible("You can only add 1 testimonial for each course.") - - api_key = os.environ.get('PERSPECTIVE_API_KEY') - url = f"https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze?key={api_key}" - - headers = { - "Content-Type": "application/json" - } - - requested_atributtes = ["TOXICITY", "IDENTITY_ATTACK", "INSULT", "PROFANITY"] - - data = { - "comment": { - "text": testimonial - }, - "languages": ["en"], - "requestedAttributes": {attr: {} for attr in requested_atributtes} - } - - toxicity_response = requests.post(url, headers=headers, json=data) - toxicity_response_json = toxicity_response.json() - - - scores = [toxicity_response_json["attributeScores"][attr]["summaryScore"]["value"] for attr in requested_atributtes] - - nlp = spacy.load("en_core_web_sm") - doc = nlp(testimonial) - names = [ent.text for ent in doc.ents if ent.label_ == "PERSON"] - - - approved_flag = True - if names == []: - for score in scores: - if score > 0.3: #if score is below 0.3, add it as a review - approved_flag = False - break - else: - approved_flag = False - - scores_dict = {requested_atributtes[i]: scores[i] for i in range(len(requested_atributtes))} testimonial = Testimonial.objects.create( author=author, course=course, year_taken=year_taken, - approved= approved_flag, + approval_status= ApprovalStatus.PENDING, testimonial=testimonial, ) - return response.success(value={"testimonial_id" : testimonial.id, "approved" : approved_flag, "scores": scores_dict, "names_found": names}) + return response.success(value={"testimonial_id" : testimonial.id, "approved" : False}) @response.request_post("username", "course_code", optional=True) @auth_check.require_login @@ -151,31 +109,43 @@ def remove_testimonial(request): @response.request_post("title", "message", optional=True) @auth_check.require_login def update_testimonial_approval_status(request): - sender = request.user + has_admin_rights = auth_check.has_admin_rights(request) testimonial_author = request.POST.get('author') - receiver = get_object_or_404(User, username=testimonial_author) + receiver = get_object_or_404(User, username=testimonial_author) course_code = request.POST.get('course_code') title = request.POST.get('title') message = request.POST.get('message') approval_status = request.POST.get('approval_status') - approve_status = True if approval_status == "true" else False course = get_object_or_404(Course, code=course_code) testimonial = Testimonial.objects.filter(author=receiver, course=course) final_message = "" - - if approval_status == True: - testimonial.update(approved=approve_status) - final_message = "Your Testimonial has been Accepted, it is now available to see in the Testimonials tab." - update_to_testimonial_status(sender, receiver, title, final_message) #notification - return response.success(value="Testimonial Accepted and the notification has been sent to " + str(receiver) + ".") + print("TESTING===========") + print(has_admin_rights) + print(approval_status) + print(sender) + print(receiver) + if has_admin_rights: + testimonial.update(approval_status=approval_status) + print("test") + if approval_status == str(ApprovalStatus.APPROVED.value): + print("test2") + final_message = "Your Testimonial has been Accepted, it is now available to see in the Testimonials tab." + if (sender != receiver): + print("test3") + print("========USERNAME===========") + print(sender.username) + print(receiver.username) + update_to_testimonial_status(sender, receiver, title, final_message) #notification + return response.success(value="Testimonial Accepted and the notification has been sent to " + str(receiver) + ".") + elif approval_status == str(ApprovalStatus.REJECTED.value): + print("test4") + final_message = f'Your Testimonial to {course_code} - {course.name}: \n"{testimonial}." has not been accepted due to: {message}' + if (sender != receiver): + print("test5") + update_to_testimonial_status(sender, receiver, title, final_message) #notification + return response.success(value="Testimonial Not Accepted " + "and the notification has been sent to " + str(receiver) + ".") else: - final_message = f'Your Testimonial to {course_code} - {course.name}: \n"{testimonial.testimonial}." has not been accepted due to: {message}' - testimonial.delete() - update_to_testimonial_status(sender, receiver, title, final_message) #notification - return response.success(value="Testimonial Not Accepted " + "and the notification has been sent to " + str(receiver) + ".") - - - + return response.not_possible("No permission to approve/disapprove this testimonial.") \ No newline at end of file diff --git a/frontend/src/components/user-testimonials.tsx b/frontend/src/components/user-testimonials.tsx index 122423b91..f1faf54a6 100644 --- a/frontend/src/components/user-testimonials.tsx +++ b/frontend/src/components/user-testimonials.tsx @@ -5,7 +5,7 @@ import { IconInfoCircle } from "@tabler/icons-react"; import { loadTestimonials } from "../api/testimonials"; -import { CourseTestimonial, Course, CourseWithTestimonial, getTableData } from "../pages/testimonials-page" +import { CourseTestimonial, ApprovalStatus, Course, CourseWithTestimonial, getTableData } from "../pages/testimonials-page" import { useRequest } from "@umijs/hooks"; import { useDisclosure } from '@mantine/hooks'; import {useState } from 'react'; @@ -26,10 +26,10 @@ const Testimonials: React.FC = ({currentUserId, isAdmin, test return ( <> - {isAdmin? testimonials.map((testimonial, index) => + {isAdmin? testimonials.filter((testimonial) => testimonial.approval_status == ApprovalStatus.PENDING).map((testimonial, index) => ) : testimonials.filter((testimonial) => testimonial.authorId=currentUserId).map((testimonial, index) => - + )} ); @@ -42,22 +42,22 @@ interface AdminTestimonialCardProps{ interface UserTestimonialCardProps{ - username: String, displayName: String, course_code: String, course_name:String, yearTaken: String, testimonial:String, testimonial_approved:boolean + username: String, displayName: String, course_code: String, course_name:String, yearTaken: String, testimonial:String, testimonial_approval_status:Number } -const UserTestimonialCard: React.FC = ({course_code, course_name, username, displayName, yearTaken, testimonial, testimonial_approved}) => { +const UserTestimonialCard: React.FC = ({course_code, course_name, username, displayName, yearTaken, testimonial, testimonial_approval_status}) => { return( <> {testimonial_approved? " Approved ": " Pending "} + }}>{testimonial_approval_status==ApprovalStatus.APPROVED ? "Approved" : testimonial_approval_status==ApprovalStatus.REJECTED ? "Rejected" : "Pending"} @@ -122,7 +122,7 @@ const AdminTestimonialCard: React.FC = ({course_code, course_code: course_code, title: "Testimonial not Approved", message: values.message, - approval_status: true + approval_status: ApprovalStatus.REJECTED }; try { console.log(dataToSend) @@ -143,7 +143,6 @@ const AdminTestimonialCard: React.FC = ({course_code, } } - const approveTestimonial = async () => { setSuccess(null); setErrorMessage(null); @@ -153,7 +152,7 @@ const AdminTestimonialCard: React.FC = ({course_code, course_code: course_code, title: "Testimonial Approved", message: "Your testimonial has been approved.", - approval_status: true + approval_status: ApprovalStatus.APPROVED }; try { console.log(dataToSend) diff --git a/frontend/src/pages/testimonials-page.tsx b/frontend/src/pages/testimonials-page.tsx index 738a13bfd..264395aac 100644 --- a/frontend/src/pages/testimonials-page.tsx +++ b/frontend/src/pages/testimonials-page.tsx @@ -37,6 +37,12 @@ export type Course = { course_dpmt_link: string } +export enum ApprovalStatus { + APPROVED = 0, + PENDING = 1, + REJECTED = 2, +} + export type CourseTestimonial = { authorId: string, authorDisplayName: string, @@ -44,7 +50,7 @@ export type CourseTestimonial = { course_name: string, testimonial: string, year_taken: number - approved: boolean, + approval_status: number, } export type CourseWithTestimonial = { @@ -75,7 +81,7 @@ export function getTableData(courses: any, testimonials: any) : CourseWithTestim let course = courses['value'][i]; console.log(testimonials) let currentCourseTestimonials : CourseTestimonial[] = testimonials['value'].filter( - (testimonial: CourseTestimonial) => (testimonial.course_code == course.course_code && testimonial.approved == true + (testimonial: CourseTestimonial) => (testimonial.course_code == course.course_code && testimonial.approval_status == ApprovalStatus.APPROVED )); //average of testimonials and etc! From 6e0b0ab95d875f66597704346dd274dee6e3632e Mon Sep 17 00:00:00 2001 From: nikhen-s Date: Mon, 8 Sep 2025 19:41:03 +0700 Subject: [PATCH 08/10] Fixed Admin View of Testimonials + Keep rejected testimonials in db --- backend/notifications/notification_util.py | 25 +- backend/testimonials/models.py | 12 +- backend/testimonials/views.py | 42 +- frontend/src/components/user-testimonials.tsx | 383 ++++++++++-------- frontend/src/pages/add-testimonials-page.tsx | 5 +- frontend/src/pages/testimonials-page.tsx | 13 +- 6 files changed, 265 insertions(+), 215 deletions(-) diff --git a/backend/notifications/notification_util.py b/backend/notifications/notification_util.py index 7786a59e5..71151627d 100644 --- a/backend/notifications/notification_util.py +++ b/backend/notifications/notification_util.py @@ -128,21 +128,30 @@ def send_email_notification( ): return - dataOnMail = None + email_body = "" if isinstance(data, Document): - dataOnMail = data.display_name + email_body = f"BetterInformatics: {title} / {data.display_name}", + ( + f"Hello {receiver.profile.display_username}!\n" + f"{message}\n\n" + f"View it in context here: {get_absolute_notification_url(data)}" + ) elif isinstance(data, str): - dataOnMail = data + email_body = f"BetterInformatics: {title} / {data.display_name}", + ( + f"Hello {receiver.profile.display_username}!\n" + f"{message}\n\n" + ) else: - dataOnMail = data.answer_section.exam.displayname - - send_mail( - f"BetterInformatics: {title} / {dataOnMail}", + email_body = f"BetterInformatics: {title} / {data.answer_section.exam.displayname}", ( f"Hello {receiver.profile.display_username}!\n" f"{message}\n\n" f"View it in context here: {get_absolute_notification_url(data)}" - ), + ) + + send_mail( + email_body, f'"{sender.username} (via BetterInformatics)" <{settings.VERIF_CODE_FROM_EMAIL_ADDRESS}>', [receiver.email], fail_silently=False, diff --git a/backend/testimonials/models.py b/backend/testimonials/models.py index 1fb7d106f..f9e4c74fb 100644 --- a/backend/testimonials/models.py +++ b/backend/testimonials/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models import Q, UniqueConstraint class Course(models.Model): name = models.TextField() @@ -34,5 +35,12 @@ class Testimonial(models.Model): ) class Meta: - # Enforce uniqueness across `student_number` and `course` - unique_together = ("author", "course") \ No newline at end of file + #Only one row with (author, course) where approval_status is APPROVED or PENDING can exist. + #Multiple rejected rows can exist for (author, course) combination. + constraints = [ + UniqueConstraint( + fields=["author", "course"], + condition=Q(approval_status__in=[ApprovalStatus.APPROVED, ApprovalStatus.PENDING]), + name="unique_approved_or_pending_per_author_course", + ), + ] \ No newline at end of file diff --git a/backend/testimonials/views.py b/backend/testimonials/views.py index a90dcb0af..e7226af30 100644 --- a/backend/testimonials/views.py +++ b/backend/testimonials/views.py @@ -40,6 +40,7 @@ def testimonial_metadata(request): "course_code": testimonial.course.code, "course_name": testimonial.course.name, "testimonial": testimonial.testimonial, + "id": testimonial.id, "year_taken": testimonial.year_taken, "approval_status": testimonial.approval_status, } @@ -70,8 +71,10 @@ def add_testimonial(request): testimonials = Testimonial.objects.all() for t in testimonials: - if t.author == author and t.course.code == course_code: - return response.not_possible("You can only add 1 testimonial for each course.") + if t.author == author and t.course.code == course_code and (t.approval_status == ApprovalStatus.APPROVED): + return response.not_possible("You have written a testimonial for this course that has been approved.") + elif t.author == author and t.course.code == course_code and (t.approval_status == ApprovalStatus.PENDING): + return response.not_possible("You have written a testimonial for this course that is currently pending approval.") testimonial = Testimonial.objects.create( author=author, @@ -83,24 +86,19 @@ def add_testimonial(request): return response.success(value={"testimonial_id" : testimonial.id, "approved" : False}) -@response.request_post("username", "course_code", optional=True) +@response.request_post("username", "course_code", 'testimonial_id', optional=True) @auth_check.require_login def remove_testimonial(request): username = request.POST.get('username') course_code = request.POST.get('course_code') + testimonial_id = request.POST.get('testimonial_id') - testimonials = Testimonial.objects.all() - - testimonial = None + testimonial = Testimonial.objects.filter(id=testimonial_id) #Since id is primary key, always returns 1 or none. - for t in testimonials: - if t.author.username == username and t.course.code == course_code: - testimonial = t - if not testimonial: return response.not_possible("Testimonial not found for author: " + username + " and course: " + course_code) - if not (testimonial.author == request.user or auth_check.has_admin_rights(request)): + if not (testimonial[0].author == request.user or auth_check.has_admin_rights(request)): return response.not_possible("No permission to delete this.") testimonial.delete() @@ -114,38 +112,28 @@ def update_testimonial_approval_status(request): testimonial_author = request.POST.get('author') receiver = get_object_or_404(User, username=testimonial_author) course_code = request.POST.get('course_code') + testimonial_id = request.POST.get('testimonial_id') title = request.POST.get('title') message = request.POST.get('message') approval_status = request.POST.get('approval_status') course = get_object_or_404(Course, code=course_code) - testimonial = Testimonial.objects.filter(author=receiver, course=course) + testimonial = Testimonial.objects.filter(id=testimonial_id) final_message = "" - print("TESTING===========") - print(has_admin_rights) - print(approval_status) - print(sender) - print(receiver) if has_admin_rights: testimonial.update(approval_status=approval_status) - print("test") if approval_status == str(ApprovalStatus.APPROVED.value): - print("test2") - final_message = "Your Testimonial has been Accepted, it is now available to see in the Testimonials tab." + final_message = f'Your Testimonial to {course_code} - {course.name}: \n"{testimonial[0].testimonial}" has been Accepted, it is now available to see in the Testimonials tab.' if (sender != receiver): - print("test3") - print("========USERNAME===========") - print(sender.username) - print(receiver.username) update_to_testimonial_status(sender, receiver, title, final_message) #notification return response.success(value="Testimonial Accepted and the notification has been sent to " + str(receiver) + ".") elif approval_status == str(ApprovalStatus.REJECTED.value): - print("test4") - final_message = f'Your Testimonial to {course_code} - {course.name}: \n"{testimonial}." has not been accepted due to: {message}' + final_message = f'Your Testimonial to {course_code} - {course.name}: \n"{testimonial[0].testimonial}." has not been accepted due to: {message}' if (sender != receiver): - print("test5") update_to_testimonial_status(sender, receiver, title, final_message) #notification return response.success(value="Testimonial Not Accepted " + "and the notification has been sent to " + str(receiver) + ".") + else: + return response.not_possible("Cannot Update the Testimonial to approval_status: " + str(approval_status)) else: return response.not_possible("No permission to approve/disapprove this testimonial.") \ No newline at end of file diff --git a/frontend/src/components/user-testimonials.tsx b/frontend/src/components/user-testimonials.tsx index f1faf54a6..efdaeebdc 100644 --- a/frontend/src/components/user-testimonials.tsx +++ b/frontend/src/components/user-testimonials.tsx @@ -1,6 +1,6 @@ import { useUser } from "../auth"; -import { Modal, Alert, Space, Textarea, Card, Text, Box, Tooltip, Group, Flex, Button, Stack} from "@mantine/core"; +import { Modal, LoadingOverlay, SegmentedControl, Alert, Space, Textarea, Card, Text, Box, Tooltip, Group, Flex, Button, Stack} from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; import { loadTestimonials @@ -8,7 +8,7 @@ import { import { CourseTestimonial, ApprovalStatus, Course, CourseWithTestimonial, getTableData } from "../pages/testimonials-page" import { useRequest } from "@umijs/hooks"; import { useDisclosure } from '@mantine/hooks'; -import {useState } from 'react'; +import {useState,useEffect } from 'react'; import { fetchPost, fetchGet } from '../api/fetch-utils'; import { useForm } from '@mantine/form'; @@ -19,25 +19,226 @@ interface UserTestimonialsProps { interface TestimonialsProps{ currentUserId: string, isAdmin: boolean, - testimonials: CourseTestimonial[] } -const Testimonials: React.FC = ({currentUserId, isAdmin, testimonials}) => { +const Testimonials: React.FC = ({currentUserId, isAdmin}) => { + const { data : testimonials, loading: loading_testimonials, error: error_testimonials, refresh } = useRequest( + () => loadTestimonials() + ); + const [updatedTestimonials, setUpdatedTestimonials] = useState([]); + const [loadingTestimonials, setLoadingTestimonials] = useState(true); + const [errorTestimonials, setErrorTestimonials] = useState(false); + const [approvalStatusFilter, setApprovalStatusFilter] = useState("All"); + + useEffect(() => { + if (testimonials) { + setUpdatedTestimonials(testimonials['value']); + setLoadingTestimonials(loading_testimonials); + if (error_testimonials){ + setErrorTestimonials(true); + } + } + }, [testimonials]); + + const AdminTestimonialCard: React.FC = ({course_code, course_name, username, displayName, yearTaken, testimonial, testimonial_id}) => { + const [opened, { open, close }] = useDisclosure(false); + const [success, setSuccess] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + //useForm? + + const form = useForm({ + initialValues: { + message: '', + }, + + validate: { + message: (value: string) => (value ? null : 'Message is required.'), + }, + }); + + const disapproveTestimonial = async (values: typeof form.values) => { + + setLoadingTestimonials(true); + setSuccess(null); + setErrorMessage(null); + + const dataToSend = { + author: username, + course_code: course_code, + testimonial_id: testimonial_id, + title: "Testimonial not Approved", + message: values.message, + approval_status: ApprovalStatus.REJECTED + }; + try { + + console.log(dataToSend) + const response = await fetchPost('/api/testimonials/updatetestimonialapproval/', dataToSend); + console.log("Response Disapprove Testimonial") + console.log(response.value) + + if (response.value) { + setSuccess(true); + refresh(); + setLoadingTestimonials(false); + } else { + setSuccess(false); + setErrorMessage(response.error || 'Unknown error during deletion.'); + } + } catch (error: any) { + setSuccess(false); + setErrorMessage(error || 'Network error during deletion.'); + } + } + + const approveTestimonial = async () => { + setLoadingTestimonials(true); + setSuccess(null); + setErrorMessage(null); + + const dataToSend = { + author: username, + course_code: course_code, + testimonial_id: testimonial_id, + title: "Testimonial Approved", + message: "Your testimonial has been approved.", + approval_status: ApprovalStatus.APPROVED + }; + try { + console.log(dataToSend) + const response = await fetchPost('/api/testimonials/updatetestimonialapproval/', dataToSend); + console.log("Response Approve Testimonial") + console.log(response.value) + + if (response.value) { + setSuccess(true); + refresh(); + setLoadingTestimonials(false); + } else { + setSuccess(false); + setErrorMessage(response.error || 'Unknown error during deletion.'); + } + } catch (error: any) { + setSuccess(false); + setErrorMessage(error || 'Network error during deletion.'); + } + } + + return( + <> + + + Are you sure you'd like to disapprove this testimonial? This action cannot be undone. + When you disapprove a testimonial, the user will be notified. Please provide feedback below so they know what to improve. + + + + + {success === true && ( + Successfully disapproved testimonial, refreshing page now... + )} + + {success === false && errorMessage && ( + Failed to disapprove testimonial due to {errorMessage} + )} + + + + + + + + + + + + + + Course: {course_code} - {course_name} + + + + + + + {displayName} + + + @{username} + + + · + + took the course in {yearTaken} + + + + + "{testimonial}" + + + + + + + + + + + ) + } + console.log("UPDATED TESTIMONIALS") + console.log(updatedTestimonials); + console.log(loadingTestimonials) + + let approvalStatusMap = new Map([ + ["Approved", ApprovalStatus.APPROVED], + ["Pending", ApprovalStatus.PENDING], + ["Rejected", ApprovalStatus.REJECTED] + ]); + + return ( <> - {isAdmin? testimonials.filter((testimonial) => testimonial.approval_status == ApprovalStatus.PENDING).map((testimonial, index) => - - ) : testimonials.filter((testimonial) => testimonial.authorId=currentUserId).map((testimonial, index) => - - )} + + + + + {errorTestimonials? There has been an error with loading the testimonials. : + updatedTestimonials && + (isAdmin? + updatedTestimonials.filter((testimonial) => testimonial.approval_status === ApprovalStatus.PENDING).map((testimonial, index) => + + ) : + (approvalStatusFilter== "All"? updatedTestimonials.filter((testimonial) => testimonial.authorId===currentUserId).map((testimonial, index) => + + ) : updatedTestimonials.filter((testimonial) => testimonial.authorId===currentUserId && testimonial.approval_status===approvalStatusMap.get(approvalStatusFilter)).map((testimonial, index) => + + )) + ) + } ); } interface AdminTestimonialCardProps{ - username: String, displayName: String, course_code: String, course_name:String, yearTaken: String, testimonial:String + username: String, displayName: String, course_code: String, course_name:String, yearTaken: String, testimonial:String, testimonial_id:String } @@ -95,167 +296,9 @@ const UserTestimonialCard: React.FC = ({course_code, c ) } - -const AdminTestimonialCard: React.FC = ({course_code, course_name, username, displayName, yearTaken, testimonial}) => { - const [opened, { open, close }] = useDisclosure(false); - const [success, setSuccess] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - - //useForm? - - const form = useForm({ - initialValues: { - message: '', - }, - - validate: { - message: (value: string) => (value ? null : 'Message is required.'), - }, - }); - - const disapproveTestimonial = async (values: typeof form.values) => { - setSuccess(null); - setErrorMessage(null); - - const dataToSend = { - author: username, - course_code: course_code, - title: "Testimonial not Approved", - message: values.message, - approval_status: ApprovalStatus.REJECTED - }; - try { - console.log(dataToSend) - const response = await fetchPost('/api/testimonials/updatetestimonialapproval/', dataToSend); - console.log("Response Disapprove Testimonial") - console.log(response.value) - - if (response.value) { - setSuccess(true); - setTimeout(() => (window.location.reload()),1000); - } else { - setSuccess(false); - setErrorMessage(response.error || 'Unknown error during deletion.'); - } - } catch (error: any) { - setSuccess(false); - setErrorMessage(error || 'Network error during deletion.'); - } - } - - const approveTestimonial = async () => { - setSuccess(null); - setErrorMessage(null); - - const dataToSend = { - author: username, - course_code: course_code, - title: "Testimonial Approved", - message: "Your testimonial has been approved.", - approval_status: ApprovalStatus.APPROVED - }; - try { - console.log(dataToSend) - const response = await fetchPost('/api/testimonials/updatetestimonialapproval/', dataToSend); - console.log("Response Approve Testimonial") - console.log(response.value) - - if (response.value) { - setSuccess(true); - setTimeout(() => (window.location.reload()),1000); - } else { - setSuccess(false); - setErrorMessage(response.error || 'Unknown error during deletion.'); - } - } catch (error: any) { - setSuccess(false); - setErrorMessage(error || 'Network error during deletion.'); - } - } - - return( - <> - - - Are you sure you'd like to disapprove this testimonial? This action cannot be undone. - Disapproving a testimonial will send a notification to the user, with the reason input behind why it has been disapproved. - -
- - - {success === true && ( - Successfully disapproved testimonial, refreshing page now... - )} - - {success === false && errorMessage && ( - Failed to disapprove testimonial due to {errorMessage} - )} - - - - - -
-
- -
- - - - - Course: {course_code} - {course_name} - - - - - - - {displayName} - - - @{username} - - - · - - took the course in {yearTaken} - - - - - "{testimonial}" - - - - - - - - - - - ) -} const UserTestimonials: React.FC = ({ username }) => { - const { data : testimonials, loading: loading_testimonials, error: error_testimonials } = useRequest( - () => loadTestimonials() - ); - const { username: currentUsername, isAdmin } = useUser()!; const isOwnProfile = username === currentUsername; @@ -284,9 +327,7 @@ const UserTestimonials: React.FC = ({ username }) => {
)} - {(testimonials && )|| - ((loading_testimonials || error_testimonials) && ) - } + ); } diff --git a/frontend/src/pages/add-testimonials-page.tsx b/frontend/src/pages/add-testimonials-page.tsx index a6e309bc7..76c94b6d2 100644 --- a/frontend/src/pages/add-testimonials-page.tsx +++ b/frontend/src/pages/add-testimonials-page.tsx @@ -22,15 +22,18 @@ type Course = { course_level: number, course_dpmt_link: string } + const AddTestimonialsPage: React.FC = ({data}) => { const { course_code, course_name } = useParams<{ course_code?: string; course_name?: string }>(); const history = useHistory(); const [uploadSuccess, setUploadSuccess] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const initialCourse = course_code? `${course_code} - ${course_name}` : '' + const { data : courses, loading: loading_courses, error: error_courses} = useRequest( () => loadCourses() ); + const form = useForm({ initialValues: { courseName: initialCourse, @@ -71,7 +74,7 @@ const AddTestimonialsPage: React.FC = ({data}) => { form.reset(); } else if (response.value && response.value["approved"] == false) { setUploadSuccess(true); - setErrorMessage("Thank you for submitting your testimonial! We appreciate your feedback. However, we've detected some potentially inappropriate content, so your message will be reviewed by a moderator before it can be published."); + setErrorMessage("Thank you for submitting your testimonial! We appreciate your feedback. Your message will be reviewed by a moderator before it is published, we will notify you in your Account Page when it has been reviewed."); } else { setUploadSuccess(false); diff --git a/frontend/src/pages/testimonials-page.tsx b/frontend/src/pages/testimonials-page.tsx index 264395aac..6f988e084 100644 --- a/frontend/src/pages/testimonials-page.tsx +++ b/frontend/src/pages/testimonials-page.tsx @@ -4,7 +4,6 @@ import sortBy from 'lodash/sortBy'; import {useHistory} from 'react-router-dom' import { useDisclosure } from '@mantine/hooks'; import { fetchPost, fetchGet } from '../api/fetch-utils'; -import dayjs from 'dayjs'; import { notLoggedIn, SetUserContext, User, UserContext } from "../auth"; import {useEffect, useState } from 'react'; import { DataTable, type DataTableSortStatus } from 'mantine-datatable'; @@ -49,6 +48,7 @@ export type CourseTestimonial = { course_code: string, course_name: string, testimonial: string, + id: string, year_taken: number approval_status: number, } @@ -296,7 +296,7 @@ const ReviewsTable: React.FC = ({data, user}) => {
{ record.testimonials.map((testimonial, index) => //add a key to the testimonial - + ) }
@@ -326,11 +326,11 @@ const RatingBox: React.FC = ({ratingType, ratingLevel}) => { } interface reviewProps{ - currentUser:String, username: String, displayName: String, course_code: String, yearTaken: String, testimonial:String + currentUserUsername: String, isAdmin:boolean, username: String, displayName: String, course_code: String, yearTaken: String, testimonial:String, testimonial_id:String } -const ReviewCard: React.FC = ({currentUser, course_code, username, displayName, yearTaken, testimonial}) => { +const ReviewCard: React.FC = ({currentUserUsername, isAdmin, username, displayName, course_code, yearTaken, testimonial, testimonial_id}) => { const [opened, { open, close }] = useDisclosure(false); const [deleteSuccess, setDeleteSuccess] = useState(null); const [errorMessage, setErrorMessage] = useState(null); @@ -342,6 +342,7 @@ const ReviewCard: React.FC = ({currentUser, course_code, username, const dataToSend = { username: username, course_code: course_code, + testimonial_id: testimonial_id, }; try { console.log(dataToSend) @@ -395,7 +396,7 @@ const ReviewCard: React.FC = ({currentUser, course_code, username, {displayName} - {currentUser==username && " (you)"} + {currentUserUsername==username && " (you)"} @{username} @@ -405,7 +406,7 @@ const ReviewCard: React.FC = ({currentUser, course_code, username, took the course in {yearTaken} - {currentUser==username && } + {(currentUserUsername==username || isAdmin) && } {testimonial} From fcca3cae9f0dcf39d339c70365bd9ca23037decb Mon Sep 17 00:00:00 2001 From: nikhen-s Date: Tue, 9 Sep 2025 22:04:50 +0700 Subject: [PATCH 09/10] Fixed Django Models to align courses in categories table and testimonials table --- Dockerfile | 1 - backend/testimonials/models.py | 19 +--- backend/testimonials/urls.py | 1 - backend/testimonials/views.py | 49 +++-------- frontend/src/api/testimonials.ts | 11 +-- frontend/src/components/user-testimonials.tsx | 12 ++- frontend/src/pages/add-testimonials-page.tsx | 26 ++++-- frontend/src/pages/testimonials-page.tsx | 87 ++++++++++++------- 8 files changed, 103 insertions(+), 103 deletions(-) diff --git a/Dockerfile b/Dockerfile index c1a302a62..95ed034e6 100755 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,6 @@ RUN apt-get install -y --no-install-recommends \ smbclient poppler-utils \ pgbouncer RUN pip3 install -r requirements.txt -RUN python3 -m spacy download en_core_web_sm RUN rm -rf /var/lib/apt/lists/* COPY ./backend/ ./ diff --git a/backend/testimonials/models.py b/backend/testimonials/models.py index f9e4c74fb..5fee23c73 100644 --- a/backend/testimonials/models.py +++ b/backend/testimonials/models.py @@ -1,19 +1,6 @@ from django.db import models from django.db.models import Q, UniqueConstraint -class Course(models.Model): - name = models.TextField() - code = models.CharField(max_length=256, primary_key = True) #Course Code is the Primary Key - level = models.IntegerField() #Difficulty Level of Course - credits = models.IntegerField() - delivery = models.TextField() #SEM1, SEM2, YEAR - work_exam_ratio = models.TextField() - dpmt_link = models.TextField() - - def __str__(self): - return self.code # Use only a field of the model - #add a testimonial data type to courses - class ApprovalStatus(models.IntegerChoices): APPROVED = 0, "Approved" @@ -23,8 +10,8 @@ class ApprovalStatus(models.IntegerChoices): class Testimonial(models.Model): id = models.AutoField(primary_key=True) author = models.ForeignKey("auth.User", on_delete=models.CASCADE, default="") - course = models.ForeignKey( # Link Testimonial to a Course - "testimonials.Course", + euclid_code = models.ForeignKey( # Link Testimonial to a Course + "categories.EuclidCode", on_delete=models.CASCADE, # Delete testimonials if course is deleted ) #Course_id, author_id, id (testimonial_id) testimonial = models.TextField() @@ -39,7 +26,7 @@ class Meta: #Multiple rejected rows can exist for (author, course) combination. constraints = [ UniqueConstraint( - fields=["author", "course"], + fields=["author", "euclid_code"], condition=Q(approval_status__in=[ApprovalStatus.APPROVED, ApprovalStatus.PENDING]), name="unique_approved_or_pending_per_author_course", ), diff --git a/backend/testimonials/urls.py b/backend/testimonials/urls.py index 0cbfff4ae..e8b4c2a5e 100644 --- a/backend/testimonials/urls.py +++ b/backend/testimonials/urls.py @@ -1,7 +1,6 @@ from django.urls import path from testimonials import views urlpatterns = [ - path("listcourses/", views.course_metadata, name="course_list"), path("listtestimonials/", views.testimonial_metadata, name="testimonial_list"), path('addtestimonial/', views.add_testimonial, name='add_testimonial'), path('removetestimonial/', views.remove_testimonial, name='remove_testimonial'), diff --git a/backend/testimonials/views.py b/backend/testimonials/views.py index e7226af30..793b19bc0 100644 --- a/backend/testimonials/views.py +++ b/backend/testimonials/views.py @@ -1,33 +1,14 @@ from util import response from ediauth import auth_check -from testimonials.models import Course, Testimonial, ApprovalStatus +from testimonials.models import Testimonial, ApprovalStatus +from categories.models import EuclidCode from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 from datetime import timedelta from django.http import JsonResponse -import requests from django.views.decorators.csrf import csrf_exempt from notifications.notification_util import update_to_testimonial_status import ediauth.auth_check as auth_check -import os - -@response.request_get() -@auth_check.require_login -def course_metadata(request): - courses = Course.objects.all() - res = [ - { - "course_code": course.code, - "course_name": course.name, - "course_delivery": course.delivery, - "course_credits": course.credits, - "course_work_exam_ratio": course.work_exam_ratio, - "course_level": course.level, - "course_dpmt_link": course.dpmt_link - } - for course in courses - ] - return response.success(value=res) @response.request_get() @auth_check.require_login @@ -37,8 +18,8 @@ def testimonial_metadata(request): { "authorId": testimonial.author.username, "authorDisplayName": testimonial.author.profile.display_username, - "course_code": testimonial.course.code, - "course_name": testimonial.course.name, + "euclid_code": testimonial.euclid_code.code, + "course_name": testimonial.euclid_code.category.displayname, "testimonial": testimonial.testimonial, "id": testimonial.id, "year_taken": testimonial.year_taken, @@ -48,37 +29,33 @@ def testimonial_metadata(request): ] return response.success(value=res) -@response.request_post("course", "year_taken", optional=True) +@response.request_post("course_code", "year_taken", optional=True) @auth_check.require_login def add_testimonial(request): author = request.user - course_code = request.POST.get('course') #course code instead of course name - course = Course.objects.get(code=course_code) + course_code = request.POST.get('course_code') #course code instead of course name year_taken = request.POST.get('year_taken') testimonial = request.POST.get('testimonial') if not author: return response.not_possible("Missing argument: author") - if not course: - return response.not_possible("Missing argument: course") if not year_taken: return response.not_possible("Missing argument: year_taken") if not testimonial: return response.not_possible("Missing argument: testimonial") - # Create Dissertation entry in DB - testimonials = Testimonial.objects.all() + euclid_code_obj = EuclidCode.objects.filter(code=course_code) for t in testimonials: - if t.author == author and t.course.code == course_code and (t.approval_status == ApprovalStatus.APPROVED): + if t.author == author and t.euclid_code.code == course_code and (t.approval_status == ApprovalStatus.APPROVED): return response.not_possible("You have written a testimonial for this course that has been approved.") - elif t.author == author and t.course.code == course_code and (t.approval_status == ApprovalStatus.PENDING): + elif t.author == author and t.euclid_code.code == course_code and (t.approval_status == ApprovalStatus.PENDING): return response.not_possible("You have written a testimonial for this course that is currently pending approval.") testimonial = Testimonial.objects.create( author=author, - course=course, + euclid_code=euclid_code_obj[0], year_taken=year_taken, approval_status= ApprovalStatus.PENDING, testimonial=testimonial, @@ -116,7 +93,7 @@ def update_testimonial_approval_status(request): title = request.POST.get('title') message = request.POST.get('message') approval_status = request.POST.get('approval_status') - course = get_object_or_404(Course, code=course_code) + course_name = request.POST.get('course_name') testimonial = Testimonial.objects.filter(id=testimonial_id) @@ -124,12 +101,12 @@ def update_testimonial_approval_status(request): if has_admin_rights: testimonial.update(approval_status=approval_status) if approval_status == str(ApprovalStatus.APPROVED.value): - final_message = f'Your Testimonial to {course_code} - {course.name}: \n"{testimonial[0].testimonial}" has been Accepted, it is now available to see in the Testimonials tab.' + final_message = f'Your Testimonial to {course_code} - {course_name}: \n"{testimonial[0].testimonial}" has been Accepted, it is now available to see in the Testimonials tab.' if (sender != receiver): update_to_testimonial_status(sender, receiver, title, final_message) #notification return response.success(value="Testimonial Accepted and the notification has been sent to " + str(receiver) + ".") elif approval_status == str(ApprovalStatus.REJECTED.value): - final_message = f'Your Testimonial to {course_code} - {course.name}: \n"{testimonial[0].testimonial}." has not been accepted due to: {message}' + final_message = f'Your Testimonial to {course_code} - {course_name}: \n"{testimonial[0].testimonial}." has not been accepted due to: {message}' if (sender != receiver): update_to_testimonial_status(sender, receiver, title, final_message) #notification return response.success(value="Testimonial Not Accepted " + "and the notification has been sent to " + str(receiver) + ".") diff --git a/frontend/src/api/testimonials.ts b/frontend/src/api/testimonials.ts index 39e8806d5..9ef0c1df8 100644 --- a/frontend/src/api/testimonials.ts +++ b/frontend/src/api/testimonials.ts @@ -1,10 +1,11 @@ import { fetchGet, fetchPost } from "./fetch-utils"; -import { useRequest } from "@umijs/hooks"; -import { remove } from "lodash-es"; -export const loadCourses = async () => { - return (await fetchGet("/api/testimonials/listcourses")) -}; +export const loadEuclidList = async () => { + return (await fetchGet("/api/category/listeuclidcodes/")).value as { + code: string; + category: string; + }[]; + }; export const loadTestimonials = async () => { return (await fetchGet("/api/testimonials/listtestimonials")) diff --git a/frontend/src/components/user-testimonials.tsx b/frontend/src/components/user-testimonials.tsx index efdaeebdc..ca7efeca3 100644 --- a/frontend/src/components/user-testimonials.tsx +++ b/frontend/src/components/user-testimonials.tsx @@ -103,13 +103,11 @@ const Testimonials: React.FC = ({currentUserId, isAdmin}) => testimonial_id: testimonial_id, title: "Testimonial Approved", message: "Your testimonial has been approved.", - approval_status: ApprovalStatus.APPROVED + approval_status: ApprovalStatus.APPROVED, + course_name: course_name }; try { - console.log(dataToSend) const response = await fetchPost('/api/testimonials/updatetestimonialapproval/', dataToSend); - console.log("Response Approve Testimonial") - console.log(response.value) if (response.value) { setSuccess(true); @@ -223,12 +221,12 @@ const Testimonials: React.FC = ({currentUserId, isAdmin}) => updatedTestimonials && (isAdmin? updatedTestimonials.filter((testimonial) => testimonial.approval_status === ApprovalStatus.PENDING).map((testimonial, index) => - + ) : (approvalStatusFilter== "All"? updatedTestimonials.filter((testimonial) => testimonial.authorId===currentUserId).map((testimonial, index) => - + ) : updatedTestimonials.filter((testimonial) => testimonial.authorId===currentUserId && testimonial.approval_status===approvalStatusMap.get(approvalStatusFilter)).map((testimonial, index) => - + )) ) } diff --git a/frontend/src/pages/add-testimonials-page.tsx b/frontend/src/pages/add-testimonials-page.tsx index 76c94b6d2..1a35b13e5 100644 --- a/frontend/src/pages/add-testimonials-page.tsx +++ b/frontend/src/pages/add-testimonials-page.tsx @@ -8,11 +8,15 @@ import { IconInfoCircle } from '@tabler/icons-react'; import { IconPlus,IconCalendarFilled, } from "@tabler/icons-react"; +import { CategoryMetaData } from "../interfaces"; +import { useRequest } from "@umijs/hooks"; import { -loadCourses, + loadEuclidList, loadTestimonials, } from "../api/testimonials"; -import { useRequest } from "@umijs/hooks"; - +import { + useBICourseList, +} from "../api/hooks"; +import { getTableData } from './testimonials-page'; type Course = { course_code: string, course_name: string, @@ -22,7 +26,7 @@ type Course = { course_level: number, course_dpmt_link: string } - + const AddTestimonialsPage: React.FC = ({data}) => { const { course_code, course_name } = useParams<{ course_code?: string; course_name?: string }>(); const history = useHistory(); @@ -31,8 +35,14 @@ const AddTestimonialsPage: React.FC = ({data}) => { const initialCourse = course_code? `${course_code} - ${course_name}` : '' const { data : courses, loading: loading_courses, error: error_courses} = useRequest( - () => loadCourses() - ); + () => loadEuclidList() + ); + const { data : testimonials, loading: loading_testimonials, error: error_testimonials } = useRequest( + () => loadTestimonials() + ); + + const [bi_courses_error, bi_courses_loading, bi_courses_data] = useBICourseList(); + //also load the courses! const form = useForm({ initialValues: { @@ -54,7 +64,7 @@ const AddTestimonialsPage: React.FC = ({data}) => { // fetchPost expects a plain object, and it will construct FormData internally const dataToSend = { - course: values.courseName.split(" - ")[0], + course_code: values.courseName.split(" - ")[0], year_taken: values.yearTakenValue, testimonial: values.testimonialString, }; @@ -92,7 +102,7 @@ const AddTestimonialsPage: React.FC = ({data}) => { Add a Course Testimonial
- course.course_code + " - " + course.course_name)} size={"md"} {...form.getInputProps('courseName')} label="Course Name" styles={{ label: {fontSize:"medium"} }} placeholder = "Course Name" required withAsterisk /> + course.course_code + " - " + course.course_name)} size={"md"} {...form.getInputProps('courseName')} label="Course Name" styles={{ label: {fontSize:"medium"} }} placeholder = "Course Name" required withAsterisk /> (1); - if (courses && testimonials){ // console.log("Courses loaded in effect:", courses); // console.log("Testimonials loaded in effect:", testimonials); - tableData = new Array(courses['value'].length); - for (let i = 0; i < courses['value'].length; i++){ - let course = courses['value'][i]; - console.log(testimonials) + tableData = new Array(courses.length); + for (let i = 0; i < courses.length; i++){ + let course = courses[i]; let currentCourseTestimonials : CourseTestimonial[] = testimonials['value'].filter( - (testimonial: CourseTestimonial) => (testimonial.course_code == course.course_code && testimonial.approval_status == ApprovalStatus.APPROVED + (testimonial: CourseTestimonial) => (testimonial.euclid_code == course.code && testimonial.approval_status == ApprovalStatus.APPROVED )); //average of testimonials and etc! + let currentCourse = undefined + + for (let key in bi_courses_data) { + // Ensure the property belongs to the object itself, not its prototype chain + if (Object.prototype.hasOwnProperty.call(bi_courses_data, key)) { + const value = bi_courses_data[key]; + //console.log(`${key}: ${value.euclid_code}`); + //console.log(`${String(value.euclid_code)}: ${course.course_code}`) + if (String(value.euclid_code) === String(course.code) || String(value.euclid_code_shadow) === String(course.code)) { + currentCourse = { + code: course.code, + acronym: value.acronym, + name: value.name, + level: value.level, + delivery_ordinal: value.delivery_ordinal, + credits: value.credits, + cw_exam_ratio: value.cw_exam_ratio, + course_url: value.course_url, + euclid_url: value.euclid_url, + // Set the shadow property to the main course code if this is a shadow + shadow: value.euclid_code_shadow === course.code ? course.euclid_code : undefined, + } + } + } + } + tableData[i] = { - course_code: course.course_code, - course_name: course.course_name, - course_delivery: course.course_delivery, - course_credits: course.course_credits, - course_work_exam_ratio: course.course_work_exam_ratio, - course_level: course.course_level, - course_dpmt_link: course.course_dpmt_link, + course_code: course.code, + course_name: currentCourse? currentCourse.name : "undefined", + course_delivery: currentCourse? String(currentCourse.delivery_ordinal) : "undefined", + course_credits: currentCourse? Number(currentCourse.credits) : -1, + course_work_exam_ratio: currentCourse? String(currentCourse.cw_exam_ratio) : "undefined", + course_level: currentCourse? Number(currentCourse.level) : -1, + course_dpmt_link: currentCourse? currentCourse.course_url : "undefined", testimonials: currentCourseTestimonials } } @@ -123,19 +151,20 @@ const TestimonialsPage: React.FC<{}> = () => { }, ); } - console.log(user) return () => { cancelled = true; }; }, [user]); - const [uwu, _] = useLocalStorageState("uwu", false); - const { data : courses, loading: loading_courses, error: error_courses} = useRequest( - () => loadCourses() - ); - const { data : testimonials, loading: loading_testimonials, error: error_testimonials } = useRequest( - () => loadTestimonials() - ); + const [uwu, _] = useLocalStorageState("uwu", false); + const { data : courses, loading: loading_courses, error: error_courses} = useRequest( + () => loadEuclidList() + ); + const { data : testimonials, loading: loading_testimonials, error: error_testimonials } = useRequest( + () => loadTestimonials() + ); + + const [bi_courses_error, bi_courses_loading, bi_courses_data] = useBICourseList(); return ( <> @@ -156,8 +185,8 @@ const TestimonialsPage: React.FC<{}> = () => { - {(courses && testimonials && )|| - ((loading_courses || error_courses || loading_testimonials || error_testimonials) && ) + {(courses && testimonials && bi_courses_data && )|| + ((loading_courses || error_courses || loading_testimonials || error_testimonials || bi_courses_loading || bi_courses_error) && ) } @@ -296,7 +325,7 @@ const ReviewsTable: React.FC = ({data, user}) => { { record.testimonials.map((testimonial, index) => //add a key to the testimonial - + ) } From 2e5f1bcafcf6ef6f79a676f05f5383b7a5228998 Mon Sep 17 00:00:00 2001 From: nikhen-s Date: Sat, 13 Sep 2025 18:22:10 +0700 Subject: [PATCH 10/10] Connect Categories with Testimonials Backend --- backend/categories/models.py | 1 + backend/categories/urls.py | 1 + backend/categories/views.py | 16 +++ backend/notifications/notification_util.py | 5 +- backend/testimonials/models.py | 11 +- backend/testimonials/urls.py | 1 + backend/testimonials/views.py | 60 +++++++--- frontend/src/api/testimonials.ts | 10 +- frontend/src/app.tsx | 2 +- frontend/src/components/user-testimonials.tsx | 50 +++++---- frontend/src/interfaces.ts | 1 + frontend/src/pages/add-testimonials-page.tsx | 61 +++++++---- frontend/src/pages/category-page.tsx | 40 ++++++- frontend/src/pages/testimonials-page.tsx | 103 +++++++++--------- 14 files changed, 229 insertions(+), 133 deletions(-) diff --git a/backend/categories/models.py b/backend/categories/models.py index d38e5eba6..8ff82fe0b 100644 --- a/backend/categories/models.py +++ b/backend/categories/models.py @@ -2,6 +2,7 @@ class Category(models.Model): + id = models.AutoField(primary_key=True) displayname = models.CharField(max_length=256) slug = models.CharField(max_length=256, unique=True) form = models.CharField( diff --git a/backend/categories/urls.py b/backend/categories/urls.py index bb3047774..803d19f09 100644 --- a/backend/categories/urls.py +++ b/backend/categories/urls.py @@ -4,6 +4,7 @@ urlpatterns = [ path("list/", views.list_categories, name="list"), + path("listwithid/", views.list_categories_with_id, name="listwithid"), path("listwithmeta/", views.list_categories_with_meta, name="listwithmeta"), path("listonlyadmin/", views.list_categories_only_admin, name="listonlyadmin"), path("add/", views.add_category, name="add"), diff --git a/backend/categories/views.py b/backend/categories/views.py index 30715346e..9543e218f 100644 --- a/backend/categories/views.py +++ b/backend/categories/views.py @@ -9,6 +9,20 @@ from util import response, func_cache +@response.request_get() +def list_categories_with_id(request): + categories = Category.objects.order_by("displayname").all() + res = [ + { + "category_id": cat.id, + "displayname": cat.displayname, + "slug": cat.slug, + "euclid_codes": [euclidcode.code for euclidcode in cat.euclid_codes.all()] + } + for cat in categories + ] + return response.success(value=res) + @response.request_get() def list_categories(request): categories = Category.objects.order_by("displayname").all() @@ -27,6 +41,7 @@ def list_categories_with_meta(request): categories = Category.objects.select_related("meta").order_by("displayname").all() res = [ { + "category_id": cat.id, "displayname": cat.displayname, "slug": cat.slug, "examcountpublic": cat.meta.examcount_public, @@ -153,6 +168,7 @@ def list_exams(request, slug): def get_category_data(request, cat): res = { + "category_id": cat.id, "displayname": cat.displayname, "slug": cat.slug, "admins": [], diff --git a/backend/notifications/notification_util.py b/backend/notifications/notification_util.py index 71151627d..814ea822c 100644 --- a/backend/notifications/notification_util.py +++ b/backend/notifications/notification_util.py @@ -137,7 +137,7 @@ def send_email_notification( f"View it in context here: {get_absolute_notification_url(data)}" ) elif isinstance(data, str): - email_body = f"BetterInformatics: {title} / {data.display_name}", + email_body = f"BetterInformatics: {title}", ( f"Hello {receiver.profile.display_username}!\n" f"{message}\n\n" @@ -153,7 +153,8 @@ def send_email_notification( send_mail( email_body, f'"{sender.username} (via BetterInformatics)" <{settings.VERIF_CODE_FROM_EMAIL_ADDRESS}>', - [receiver.email], + from_email=[sender.email], + recipient_list=[receiver.email], fail_silently=False, ) diff --git a/backend/testimonials/models.py b/backend/testimonials/models.py index 5fee23c73..e531cecc0 100644 --- a/backend/testimonials/models.py +++ b/backend/testimonials/models.py @@ -7,13 +7,14 @@ class ApprovalStatus(models.IntegerChoices): PENDING = 1, "Pending" REJECTED = 2, "Rejected" + class Testimonial(models.Model): id = models.AutoField(primary_key=True) author = models.ForeignKey("auth.User", on_delete=models.CASCADE, default="") - euclid_code = models.ForeignKey( # Link Testimonial to a Course - "categories.EuclidCode", - on_delete=models.CASCADE, # Delete testimonials if course is deleted - ) #Course_id, author_id, id (testimonial_id) + category = models.ForeignKey( # Link Testimonial to a Category + "categories.Category", + on_delete=models.CASCADE# Delete testimonials if category is deleted + ) testimonial = models.TextField() year_taken = models.IntegerField() approval_status = models.IntegerField( @@ -26,7 +27,7 @@ class Meta: #Multiple rejected rows can exist for (author, course) combination. constraints = [ UniqueConstraint( - fields=["author", "euclid_code"], + fields=["author", "category"], condition=Q(approval_status__in=[ApprovalStatus.APPROVED, ApprovalStatus.PENDING]), name="unique_approved_or_pending_per_author_course", ), diff --git a/backend/testimonials/urls.py b/backend/testimonials/urls.py index e8b4c2a5e..9dbf679d7 100644 --- a/backend/testimonials/urls.py +++ b/backend/testimonials/urls.py @@ -2,6 +2,7 @@ from testimonials import views urlpatterns = [ path("listtestimonials/", views.testimonial_metadata, name="testimonial_list"), + path("gettestimonial/", views.get_testimonial_metadata_by_code, name="get_testimonial"), path('addtestimonial/', views.add_testimonial, name='add_testimonial'), path('removetestimonial/', views.remove_testimonial, name='remove_testimonial'), path('updatetestimonialapproval/', views.update_testimonial_approval_status, name="update_testimonial_approval_status") diff --git a/backend/testimonials/views.py b/backend/testimonials/views.py index 793b19bc0..97d437a7f 100644 --- a/backend/testimonials/views.py +++ b/backend/testimonials/views.py @@ -1,7 +1,7 @@ from util import response from ediauth import auth_check from testimonials.models import Testimonial, ApprovalStatus -from categories.models import EuclidCode +from categories.models import Category from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 from datetime import timedelta @@ -16,12 +16,13 @@ def testimonial_metadata(request): testimonials = Testimonial.objects.all() res = [ { - "authorId": testimonial.author.username, - "authorDisplayName": testimonial.author.profile.display_username, - "euclid_code": testimonial.euclid_code.code, - "course_name": testimonial.euclid_code.category.displayname, + "author_id": testimonial.author.username, + "author_diplay_name": testimonial.author.profile.display_username, + "category_id": testimonial.category.id, + "euclid_codes": [euclidcode.code for euclidcode in testimonial.category.euclid_codes.all()], + "course_name": testimonial.category.displayname, "testimonial": testimonial.testimonial, - "id": testimonial.id, + "testimonial_id": testimonial.id, "year_taken": testimonial.year_taken, "approval_status": testimonial.approval_status, } @@ -29,11 +30,36 @@ def testimonial_metadata(request): ] return response.success(value=res) -@response.request_post("course_code", "year_taken", optional=True) +@response.request_get("category_id") +@auth_check.require_login +def get_testimonial_metadata_by_code(request): + category_id = request.POST.get('category_id') + try: + category_obj = Category.objects.get(id=category_id) + except Category.DoesNotExist: + return response.not_possible(f"The category with id {category_id} does not exist in the database.") + testimonials = Testimonial.objects.filter(category=category_obj) + res = [ + { + "author_id": testimonial.author.username, + "author_diplay_name": testimonial.author.profile.display_username, + "category_id": testimonial.category.id, + "euclid_codes": testimonial.category.euclid_codes, + "course_name": testimonial.category.displayname, + "testimonial": testimonial.testimonial, + "testimonial_id": testimonial.id, + "year_taken": testimonial.year_taken, + "approval_status": testimonial.approval_status, + } + for testimonial in testimonials + ] + return response.success(value=res) + +@response.request_post("category_id", "year_taken", optional=True) @auth_check.require_login def add_testimonial(request): author = request.user - course_code = request.POST.get('course_code') #course code instead of course name + category_id = request.POST.get('category_id') #course code instead of course name year_taken = request.POST.get('year_taken') testimonial = request.POST.get('testimonial') @@ -45,17 +71,17 @@ def add_testimonial(request): return response.not_possible("Missing argument: testimonial") testimonials = Testimonial.objects.all() - euclid_code_obj = EuclidCode.objects.filter(code=course_code) + category_obj = Category.objects.get(id=category_id) for t in testimonials: - if t.author == author and t.euclid_code.code == course_code and (t.approval_status == ApprovalStatus.APPROVED): + if t.author == author and t.category == category_obj and (t.approval_status == ApprovalStatus.APPROVED): return response.not_possible("You have written a testimonial for this course that has been approved.") - elif t.author == author and t.euclid_code.code == course_code and (t.approval_status == ApprovalStatus.PENDING): + elif t.author == author and t.category == category_obj and (t.approval_status == ApprovalStatus.PENDING): return response.not_possible("You have written a testimonial for this course that is currently pending approval.") testimonial = Testimonial.objects.create( author=author, - euclid_code=euclid_code_obj[0], + category=category_obj, year_taken=year_taken, approval_status= ApprovalStatus.PENDING, testimonial=testimonial, @@ -63,17 +89,16 @@ def add_testimonial(request): return response.success(value={"testimonial_id" : testimonial.id, "approved" : False}) -@response.request_post("username", "course_code", 'testimonial_id', optional=True) +@response.request_post("username", 'testimonial_id', optional=True) @auth_check.require_login def remove_testimonial(request): username = request.POST.get('username') - course_code = request.POST.get('course_code') testimonial_id = request.POST.get('testimonial_id') testimonial = Testimonial.objects.filter(id=testimonial_id) #Since id is primary key, always returns 1 or none. if not testimonial: - return response.not_possible("Testimonial not found for author: " + username + " and course: " + course_code) + return response.not_possible("Testimonial not found for author: " + username + " with id " + testimonial_id) if not (testimonial[0].author == request.user or auth_check.has_admin_rights(request)): return response.not_possible("No permission to delete this.") @@ -88,7 +113,6 @@ def update_testimonial_approval_status(request): has_admin_rights = auth_check.has_admin_rights(request) testimonial_author = request.POST.get('author') receiver = get_object_or_404(User, username=testimonial_author) - course_code = request.POST.get('course_code') testimonial_id = request.POST.get('testimonial_id') title = request.POST.get('title') message = request.POST.get('message') @@ -101,12 +125,12 @@ def update_testimonial_approval_status(request): if has_admin_rights: testimonial.update(approval_status=approval_status) if approval_status == str(ApprovalStatus.APPROVED.value): - final_message = f'Your Testimonial to {course_code} - {course_name}: \n"{testimonial[0].testimonial}" has been Accepted, it is now available to see in the Testimonials tab.' + final_message = f'Your Testimonial to {course_name}: \n"{testimonial[0].testimonial}" has been Accepted, it is now available to see in the Testimonials tab.' if (sender != receiver): update_to_testimonial_status(sender, receiver, title, final_message) #notification return response.success(value="Testimonial Accepted and the notification has been sent to " + str(receiver) + ".") elif approval_status == str(ApprovalStatus.REJECTED.value): - final_message = f'Your Testimonial to {course_code} - {course_name}: \n"{testimonial[0].testimonial}." has not been accepted due to: {message}' + final_message = f'Your Testimonial to {course_name}: \n"{testimonial[0].testimonial}" has not been accepted due to: {message}' if (sender != receiver): update_to_testimonial_status(sender, receiver, title, final_message) #notification return response.success(value="Testimonial Not Accepted " + "and the notification has been sent to " + str(receiver) + ".") diff --git a/frontend/src/api/testimonials.ts b/frontend/src/api/testimonials.ts index 9ef0c1df8..4c8f68e21 100644 --- a/frontend/src/api/testimonials.ts +++ b/frontend/src/api/testimonials.ts @@ -1,9 +1,11 @@ import { fetchGet, fetchPost } from "./fetch-utils"; -export const loadEuclidList = async () => { - return (await fetchGet("/api/category/listeuclidcodes/")).value as { - code: string; - category: string; +export const listCategories = async () => { + return (await fetchGet("/api/category/listwithid")).value as { + category_id: string; + displayname: string; + slug: string; + euclid_codes: string[]; }[]; }; diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 09c55fc38..b13419cba 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -285,7 +285,7 @@ const App: React.FC<{}> = () => { /> = ({currentUserId, isAdmin}) => } }, [testimonials]); - const AdminTestimonialCard: React.FC = ({course_code, course_name, username, displayName, yearTaken, testimonial, testimonial_id}) => { + const AdminTestimonialCard: React.FC = ({category_id, course_name, username, displayName, yearTaken, testimonial, testimonial_id, testimonial_approval_status}) => { const [opened, { open, close }] = useDisclosure(false); const [success, setSuccess] = useState(null); const [errorMessage, setErrorMessage] = useState(null); @@ -65,18 +65,16 @@ const Testimonials: React.FC = ({currentUserId, isAdmin}) => const dataToSend = { author: username, - course_code: course_code, + category_id: category_id, testimonial_id: testimonial_id, + course_name: course_name, title: "Testimonial not Approved", message: values.message, approval_status: ApprovalStatus.REJECTED }; try { - console.log(dataToSend) const response = await fetchPost('/api/testimonials/updatetestimonialapproval/', dataToSend); - console.log("Response Disapprove Testimonial") - console.log(response.value) if (response.value) { setSuccess(true); @@ -99,7 +97,7 @@ const Testimonials: React.FC = ({currentUserId, isAdmin}) => const dataToSend = { author: username, - course_code: course_code, + category_id: category_id, testimonial_id: testimonial_id, title: "Testimonial Approved", message: "Your testimonial has been approved.", @@ -151,9 +149,18 @@ const Testimonials: React.FC = ({currentUserId, isAdmin}) => + + {testimonial_approval_status==ApprovalStatus.APPROVED ? "Approved" : testimonial_approval_status==ApprovalStatus.REJECTED ? "Rejected" : "Pending"} + - Course: {course_code} - {course_name} + Course: {course_name} = ({currentUserId, isAdmin}) => ) } - console.log("UPDATED TESTIMONIALS") - console.log(updatedTestimonials); - console.log(loadingTestimonials) let approvalStatusMap = new Map([ ["Approved", ApprovalStatus.APPROVED], @@ -220,13 +224,15 @@ const Testimonials: React.FC = ({currentUserId, isAdmin}) => {errorTestimonials? There has been an error with loading the testimonials. : updatedTestimonials && (isAdmin? - updatedTestimonials.filter((testimonial) => testimonial.approval_status === ApprovalStatus.PENDING).map((testimonial, index) => - - ) : - (approvalStatusFilter== "All"? updatedTestimonials.filter((testimonial) => testimonial.authorId===currentUserId).map((testimonial, index) => - - ) : updatedTestimonials.filter((testimonial) => testimonial.authorId===currentUserId && testimonial.approval_status===approvalStatusMap.get(approvalStatusFilter)).map((testimonial, index) => - + (approvalStatusFilter== "All"? updatedTestimonials.map((testimonial, index) => + + ) : updatedTestimonials.filter((testimonial) => testimonial.approval_status === approvalStatusMap.get(approvalStatusFilter)).map((testimonial, index) => + + )): + (approvalStatusFilter== "All"? updatedTestimonials.filter((testimonial) => testimonial.author_id===currentUserId).map((testimonial, index) => + + ) : updatedTestimonials.filter((testimonial) => testimonial.author_id===currentUserId && testimonial.approval_status===approvalStatusMap.get(approvalStatusFilter)).map((testimonial, index) => + )) ) } @@ -236,15 +242,15 @@ const Testimonials: React.FC = ({currentUserId, isAdmin}) => interface AdminTestimonialCardProps{ - username: String, displayName: String, course_code: String, course_name:String, yearTaken: String, testimonial:String, testimonial_id:String + username: String, displayName: String, category_id: String, course_name:String, yearTaken: String, testimonial:String, testimonial_id:String, testimonial_approval_status:Number } interface UserTestimonialCardProps{ - username: String, displayName: String, course_code: String, course_name:String, yearTaken: String, testimonial:String, testimonial_approval_status:Number + username: String, displayName: String, category_id: String, course_name:String, yearTaken: String, testimonial:String, testimonial_approval_status:Number } -const UserTestimonialCard: React.FC = ({course_code, course_name, username, displayName, yearTaken, testimonial, testimonial_approval_status}) => { +const UserTestimonialCard: React.FC = ({category_id, course_name, username, displayName, yearTaken, testimonial, testimonial_approval_status}) => { return( <> @@ -260,7 +266,7 @@ const UserTestimonialCard: React.FC = ({course_code, c - Course: {course_code} - {course_name} + Course: {course_name} = ({data}) => { - const { course_code, course_name } = useParams<{ course_code?: string; course_name?: string }>(); - const history = useHistory(); +interface CourseNameCategoryLabel { + value: string; + label: string; +} +const AddTestimonialsPage: React.FC = ({data}) => { + const { category_id, course_name } = useParams<{ category_id?: string; course_name?: string }>(); const [uploadSuccess, setUploadSuccess] = useState(null); const [errorMessage, setErrorMessage] = useState(null); - const initialCourse = course_code? `${course_code} - ${course_name}` : '' + const [listCoursesData, setListCoursesData] = useState([]); + const initialCourse = course_name? `${course_name}` : '' const { data : courses, loading: loading_courses, error: error_courses} = useRequest( - () => loadEuclidList() + () => listCategories() ); const { data : testimonials, loading: loading_testimonials, error: error_testimonials } = useRequest( () => loadTestimonials() @@ -44,6 +40,27 @@ const AddTestimonialsPage: React.FC = ({data}) => { const [bi_courses_error, bi_courses_loading, bi_courses_data] = useBICourseList(); //also load the courses! + + + useEffect(() => { + if (course_name && category_id && courses && testimonials && bi_courses_data) { + setListCoursesData(getTableData(courses, testimonials, bi_courses_data).map((course : CourseWithTestimonial) => ({ + value: `${course.course_name} - ${course.category_id}`, + label: course.course_name, + }))) + } + } + // const found = tableData.find( + // (course: CourseWithTestimonial) => + // course.course_name === course_name && + // course.category_id === category_id + // ); + + // if (found) { + // form.setFieldValue('courseName', `${found.course_name} - ${found.category_id}`); + // } + , [course_name, category_id, courses, testimonials, bi_courses_data]); + const form = useForm({ initialValues: { courseName: initialCourse, @@ -63,16 +80,15 @@ const AddTestimonialsPage: React.FC = ({data}) => { setErrorMessage(null); // fetchPost expects a plain object, and it will construct FormData internally + const selectedCategoryId = listCoursesData.find(item => item.label === values.courseName)?.value.split(" - ")[1]; const dataToSend = { - course_code: values.courseName.split(" - ")[0], + category_id: selectedCategoryId, year_taken: values.yearTakenValue, testimonial: values.testimonialString, }; //understand thens and comments try { const response = await fetchPost('/api/testimonials/addtestimonial/', dataToSend); - console.log("Response Add Testimonial") - console.log(response.value) if (response.value && response.value["approved"] == true) { setUploadSuccess(true); @@ -92,7 +108,6 @@ const AddTestimonialsPage: React.FC = ({data}) => { } } catch (error: any) { setUploadSuccess(false); - console.log(error) setErrorMessage(error || 'Network error during upload.'); } }; @@ -100,9 +115,9 @@ const AddTestimonialsPage: React.FC = ({data}) => { return ( Add a Course Testimonial - + {listCoursesData.length > 0 && - course.course_code + " - " + course.course_name)} size={"md"} {...form.getInputProps('courseName')} label="Course Name" styles={{ label: {fontSize:"medium"} }} placeholder = "Course Name" required withAsterisk /> + = ({data}) => { - + } ); }; diff --git a/frontend/src/pages/category-page.tsx b/frontend/src/pages/category-page.tsx index 81eb8d909..02225972a 100644 --- a/frontend/src/pages/category-page.tsx +++ b/frontend/src/pages/category-page.tsx @@ -26,6 +26,9 @@ import { useRemoveCategory, useBICourseList, } from "../api/hooks"; +import { + loadTestimonials +} from "../api/testimonials"; import { UserContext, useUser } from "../auth"; import CategoryMetaDataEditor from "../components/category-metadata-editor"; import ExamList from "../components/exam-list"; @@ -50,17 +53,22 @@ import { import { EuclidCodeBadge } from "../components/euclid-code-badge"; import { useCategoryTabs } from "../hooks/useCategoryTabs"; import { PieChart } from "@mantine/charts"; +import { CourseTestimonial, TestimonialCard, ApprovalStatus } from "./testimonials-page"; interface CategoryPageContentProps { onMetaDataChange: (newMetaData: CategoryMetaData) => void; + testimonials: CourseTestimonial[]; metaData: CategoryMetaData; } + + const CategoryPageContent: React.FC = ({ onMetaDataChange, + testimonials, metaData, }) => { const computedColorScheme = useComputedColorScheme("light"); - + console.log(testimonials) const { data, loading: _, @@ -155,11 +163,18 @@ const CategoryPageContent: React.FC = ({ // defining Routes, we use `path`, but for Link/navigation we use `url`. const { path, url } = useRouteMatch(); + //get testimonial count + + // let currentCourseTestimonials : CourseTestimonial[] = quickinfo_data.map((c) => c?.code).testimonials['value'].filter( + // (testimonial: CourseTestimonial) => (testimonial.euclid_code == c && testimonial.approval_status == ApprovalStatus.APPROVED + // )); + + let courseTestimonials = testimonials.filter((testimonial) => (testimonial.category_id === metaData.category_id && testimonial.approval_status == ApprovalStatus.APPROVED)) //filter based on approval status const tabs = useCategoryTabs([ { name: "Resources", id: "resources" }, - { name: "Testimonials", id: "testimonials", count: 0, disabled: true }, + { name: "Testimonials", id: "testimonials", count: courseTestimonials? courseTestimonials.length: 0}, //okay haven't finished. { name: "Grade Stats", id: "statistics", disabled: true }, - ]); + ]); //get testimonial with a specific id // TODO: switch to betterinformatics.com/courses.json "session" field once that's live const { thisYear, nextYearSuffix } = useMemo(() => { @@ -260,7 +275,7 @@ const CategoryPageContent: React.FC = ({ {tabs.Component} - + {tabs.currentTabId == "resources" && @@ -433,6 +448,13 @@ const CategoryPageContent: React.FC = ({ )} + } + + { + tabs.currentTabId=="testimonials" && courseTestimonials.map((testimonial, index) => //add a key to the testimonial + ) + } + @@ -458,11 +480,16 @@ const CategoryPage: React.FC<{}> = () => { ); useTitle(data?.displayname ?? slug); const user = useUser(); + const { data : testimonials, loading: loading_testimonials, error: error_testimonials, refresh } = useRequest( + () => loadTestimonials() + ); return ( {error && {error.message}} - {data === undefined && } - {data && ( + {(data === undefined && testimonials == undefined) && } + {console.log("DATA UPDATE")} + {console.log(data)} + {data && testimonials && ( = () => { > diff --git a/frontend/src/pages/testimonials-page.tsx b/frontend/src/pages/testimonials-page.tsx index 68b20f107..6f81bab0b 100644 --- a/frontend/src/pages/testimonials-page.tsx +++ b/frontend/src/pages/testimonials-page.tsx @@ -10,7 +10,7 @@ import { DataTable, type DataTableSortStatus } from 'mantine-datatable'; import useTitle from '../hooks/useTitle'; import { useDebouncedValue } from '@mantine/hooks'; import { useLocalStorageState } from '@umijs/hooks'; -import { Alert, Container, Text, CloseButton, Title, Autocomplete, Textarea, Notification, Modal, Group, NumberInput, Button, Rating, TextInput, Input, Flex, Center, Box, Card, Stack, useComputedColorScheme, ActionIcon} from '@mantine/core'; +import { Alert, Anchor, Container, Text, CloseButton, Title, Autocomplete, Textarea, Notification, Modal, Group, NumberInput, Button, Rating, TextInput, Input, Flex, Center, Box, Card, Stack, useComputedColorScheme, ActionIcon} from '@mantine/core'; import KawaiiBetterInformatics from "../assets/kawaii-betterinformatics.svg?react"; import ShimmerButton from '../components/shimmer-button'; import { @@ -18,12 +18,11 @@ import { } from "@tabler/icons-react"; import { - loadEuclidList, + listCategories, loadTestimonials } from "../api/testimonials"; import ContentContainer from '../components/secondary-container'; import { BICourseDict } from "../interfaces"; -import { EuclidCodeBadge } from "../components/euclid-code-badge"; import { useBICourseList, } from "../api/hooks"; @@ -35,7 +34,7 @@ export type Course = { course_name: string, course_delivery: string, course_credits: number, - course_work_exam_ratio: string, + course_work_exam_ratio: number[], course_level: number, course_dpmt_link: string } @@ -47,29 +46,31 @@ export enum ApprovalStatus { } export type CourseTestimonial = { - authorId: string, - authorDisplayName: string, - euclid_code: string, + author_id: string, + author_diplay_name: string, + category_id: string, + euclid_codes: string[], course_name: string, testimonial: string, - id: string, + testimonial_id: string, year_taken: number approval_status: number, } export type CourseWithTestimonial = { - course_code: string, + category_id: string, + course_codes: string[], course_name: string, course_delivery: string, course_credits: number, - course_work_exam_ratio: string, + course_work_exam_ratio: number[], course_level: number, course_dpmt_link: string, testimonials: CourseTestimonial[] } -export interface ReviewTableProps{ +export interface TestimonialsTableProps{ data: CourseWithTestimonial[], user: User | undefined } @@ -77,13 +78,13 @@ export interface ReviewTableProps{ export function getTableData(courses: any, testimonials: any, bi_courses_data: BICourseDict) : CourseWithTestimonial[] { let tableData = new Array(1); if (courses && testimonials){ - // console.log("Courses loaded in effect:", courses); - // console.log("Testimonials loaded in effect:", testimonials); tableData = new Array(courses.length); + for (let i = 0; i < courses.length; i++){ let course = courses[i]; + let currentCourseTestimonials : CourseTestimonial[] = testimonials['value'].filter( - (testimonial: CourseTestimonial) => (testimonial.euclid_code == course.code && testimonial.approval_status == ApprovalStatus.APPROVED + (testimonial: CourseTestimonial) => (testimonial.category_id == course.category_id && testimonial.approval_status == ApprovalStatus.APPROVED )); //average of testimonials and etc! @@ -93,15 +94,14 @@ export function getTableData(courses: any, testimonials: any, bi_courses_data: B // Ensure the property belongs to the object itself, not its prototype chain if (Object.prototype.hasOwnProperty.call(bi_courses_data, key)) { const value = bi_courses_data[key]; - //console.log(`${key}: ${value.euclid_code}`); - //console.log(`${String(value.euclid_code)}: ${course.course_code}`) - if (String(value.euclid_code) === String(course.code) || String(value.euclid_code_shadow) === String(course.code)) { + //loop through euclid codes, get the active one? + //course, euclid code, + if (String(value.name) === String(course.displayname) || String(value.euclid_code_shadow) === String(course.code)) { currentCourse = { - code: course.code, acronym: value.acronym, name: value.name, level: value.level, - delivery_ordinal: value.delivery_ordinal, + delivery: value.delivery, credits: value.credits, cw_exam_ratio: value.cw_exam_ratio, course_url: value.course_url, @@ -113,18 +113,19 @@ export function getTableData(courses: any, testimonials: any, bi_courses_data: B } } + tableData[i] = { - course_code: course.code, + category_id: course.category_id, + course_codes: course.euclid_codes, //i need the euclid codes babe course_name: currentCourse? currentCourse.name : "undefined", - course_delivery: currentCourse? String(currentCourse.delivery_ordinal) : "undefined", + course_delivery: currentCourse? String(currentCourse.delivery) : "undefined", course_credits: currentCourse? Number(currentCourse.credits) : -1, - course_work_exam_ratio: currentCourse? String(currentCourse.cw_exam_ratio) : "undefined", + course_work_exam_ratio: currentCourse? currentCourse.cw_exam_ratio : [], course_level: currentCourse? Number(currentCourse.level) : -1, course_dpmt_link: currentCourse? currentCourse.course_url : "undefined", testimonials: currentCourseTestimonials } } - console.log("Table data:", tableData); } return tableData; } @@ -158,7 +159,7 @@ const TestimonialsPage: React.FC<{}> = () => { const [uwu, _] = useLocalStorageState("uwu", false); const { data : courses, loading: loading_courses, error: error_courses} = useRequest( - () => loadEuclidList() + () => listCategories() ); const { data : testimonials, loading: loading_testimonials, error: error_testimonials } = useRequest( () => loadTestimonials() @@ -185,8 +186,8 @@ const TestimonialsPage: React.FC<{}> = () => { - {(courses && testimonials && bi_courses_data && )|| - ((loading_courses || error_courses || loading_testimonials || error_testimonials || bi_courses_loading || bi_courses_error) && ) + {(courses && testimonials && bi_courses_data && )|| + ((loading_courses || error_courses || loading_testimonials || error_testimonials || bi_courses_loading || bi_courses_error) && ) } @@ -194,7 +195,7 @@ const TestimonialsPage: React.FC<{}> = () => { ); } -const ReviewsTable: React.FC = ({data, user}) => { +const TestimonialsTable: React.FC = ({data, user}) => { const [allData, setAllData] = useState(sortBy(data, 'course_name')); @@ -236,17 +237,27 @@ const ReviewsTable: React.FC = ({data, user}) => { //scrollAreaProps={{ type: 'never' }} columns = {[ { - accessor: 'course_code', - title: 'Course Code', + accessor: 'category_id', + title: 'Category ID', width: "12%", sortable: true, + hidden: true, }, + // { + // accessor: 'course_codes', + // title: 'Course Code(s)', + // width: "12%", + // sortable: true, + // render: (record, index) => ( + // {record.course_codes.map((code) => code + " ")} + // ) + // }, { accessor: 'course_name', title: 'Course Name', sortable: true, render: (record, index) => ( - {record.course_name} + {record.course_name} ), filter: ( = ({data, user}) => { }, { accessor: 'course_work_exam_ratio', - title: 'Work%/Exam%', + title: 'CW/Exam', sortable: true, + render: (record, index) => ( + {record.course_work_exam_ratio.length===2? record.course_work_exam_ratio[0]+ "/" + record.course_work_exam_ratio[1] : ""} + ) }, { accessor: 'course_level', @@ -299,12 +313,8 @@ const ReviewsTable: React.FC = ({data, user}) => { ]} records ={allData} - idAccessor="course_code" + idAccessor="category_id" rowExpansion={{ - // expanded: { - // recordIds: expandedRecordIds, - // onRecordIdsChange: setExpandedRecordIds, - // }, collapseProps: { transitionDuration: 150, animateOpacity: false, @@ -315,7 +325,7 @@ const ReviewsTable: React.FC = ({data, user}) => { history.push(`/addtestimonials/${record.course_code}/${record.course_name}`)} + onClick={() => history.push(`/addtestimonials/${record.category_id}/${record.course_name}`)} leftSection={} color={computedColorScheme === "dark" ? "compsocMain" : "dark"} variant="outline" @@ -325,19 +335,12 @@ const ReviewsTable: React.FC = ({data, user}) => { { record.testimonials.map((testimonial, index) => //add a key to the testimonial - + ) } ), }} - //bodyRef={bodyRef} - - //fetching={allData?.length == 0} - // loaderType={"oval"} - // loaderSize={"lg"} - // loaderColor={"blue"} - // loaderBackgroundBlur={1} sortStatus={sortStatus} onSortStatusChange={setSortStatus} /> @@ -354,12 +357,12 @@ const RatingBox: React.FC = ({ratingType, ratingLevel}) => { ) } -interface reviewProps{ - currentUserUsername: String, isAdmin:boolean, username: String, displayName: String, course_code: String, yearTaken: String, testimonial:String, testimonial_id:String +interface testimonialCardProps{ + currentUserUsername: String, isAdmin:boolean, username: String, displayName: String, category_id: String, yearTaken: String, testimonial:String, testimonial_id:String } -const ReviewCard: React.FC = ({currentUserUsername, isAdmin, username, displayName, course_code, yearTaken, testimonial, testimonial_id}) => { +export const TestimonialCard: React.FC = ({currentUserUsername, isAdmin, username, displayName, category_id, yearTaken, testimonial, testimonial_id}) => { const [opened, { open, close }] = useDisclosure(false); const [deleteSuccess, setDeleteSuccess] = useState(null); const [errorMessage, setErrorMessage] = useState(null); @@ -370,14 +373,10 @@ const ReviewCard: React.FC = ({currentUserUsername, isAdmin, userna const dataToSend = { username: username, - course_code: course_code, testimonial_id: testimonial_id, }; try { - console.log(dataToSend) const response = await fetchPost('/api/testimonials/removetestimonial/', dataToSend); - console.log("Response Remove Testimonial") - console.log(response.value) if (response.value) { setDeleteSuccess(true);