From dd9c483da3c0224b7c2183fc57f81a40b24e107d Mon Sep 17 00:00:00 2001 From: Payam Date: Tue, 20 Jan 2026 19:22:30 +0400 Subject: [PATCH] feat: #130 Add learners frontend view --- django_email_learning/platform/urls.py | 8 +- django_email_learning/platform/views.py | 11 + .../platform/{users.html => learner.html} | 2 +- frontend/package-lock.json | 45 +++ frontend/package.json | 1 + frontend/platform/learners/Learners.jsx | 262 ++++++++++++++++++ .../platform/{users => learners}/index.html | 0 frontend/platform/users/Users.jsx | 21 -- frontend/src/components/MenuBar.jsx | 3 +- frontend/src/theme/themes.js | 4 +- frontend/vite.config.js | 2 +- scripts/build.py | 2 +- 12 files changed, 332 insertions(+), 29 deletions(-) rename django_email_learning/templates/platform/{users.html => learner.html} (64%) create mode 100644 frontend/platform/learners/Learners.jsx rename frontend/platform/{users => learners}/index.html (100%) delete mode 100644 frontend/platform/users/Users.jsx diff --git a/django_email_learning/platform/urls.py b/django_email_learning/platform/urls.py index 29fe6239..4f4825fb 100644 --- a/django_email_learning/platform/urls.py +++ b/django_email_learning/platform/urls.py @@ -1,6 +1,11 @@ from django.urls import path from django.views.generic import RedirectView -from django_email_learning.platform.views import CourseView, Courses, Organizations +from django_email_learning.platform.views import ( + CourseView, + Courses, + Organizations, + Learners, +) app_name = "email_learning" @@ -8,6 +13,7 @@ path("courses/", Courses.as_view(), name="courses_view"), path("courses//", CourseView.as_view(), name="course_detail_view"), path("organizations/", Organizations.as_view(), name="organizations_view"), + path("learners/", Learners.as_view(), name="learners_view"), path( "", RedirectView.as_view( diff --git a/django_email_learning/platform/views.py b/django_email_learning/platform/views.py index 7d2c05dd..c3d0bf29 100644 --- a/django_email_learning/platform/views.py +++ b/django_email_learning/platform/views.py @@ -96,3 +96,14 @@ def get_context_data(self, **kwargs): # type: ignore[no-untyped-def] context = super().get_context_data(**kwargs) context["page_title"] = "Organizations" return context + + +@method_decorator(login_required, name="dispatch") +@method_decorator(is_platform_admin(), name="dispatch") +class Learners(BasePlatformView): + template_name = "platform/learner.html" + + def get_context_data(self, **kwargs): # type: ignore[no-untyped-def] + context = super().get_context_data(**kwargs) + context["page_title"] = "Learners" + return context diff --git a/django_email_learning/templates/platform/users.html b/django_email_learning/templates/platform/learner.html similarity index 64% rename from django_email_learning/templates/platform/users.html rename to django_email_learning/templates/platform/learner.html index 1d9093fe..7de9da61 100644 --- a/django_email_learning/templates/platform/users.html +++ b/django_email_learning/templates/platform/learner.html @@ -1,5 +1,5 @@ {% extends "platform/base.html" %} {% load django_vite %} {% block extra_head %} - {% vite_asset 'platform/users/Users.jsx' %} + {% vite_asset 'platform/learners/Learners.jsx' %} {% endblock %} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a38c79a0..5c23c9dc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@emotion/styled": "^11.14.1", "@fontsource/roboto": "^5.2.6", "@mui/icons-material": "^7.3.2", + "@mui/lab": "^7.0.1-beta.21", "@mui/material": "^7.3.2", "@tiptap/core": "^3.13.0", "@tiptap/extension-bold": "^3.13.0", @@ -1250,6 +1251,50 @@ } } }, + "node_modules/@mui/lab": { + "version": "7.0.1-beta.21", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-7.0.1-beta.21.tgz", + "integrity": "sha512-9IWTUJsV1Jjv6Rc4Yybj75TmalXBnluwJPJG1Q0ZeU+HvaoIaQybzqv8B3MvXLan94gLzV80XVwQAVel35A81A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/system": "^7.3.7", + "@mui/types": "^7.4.10", + "@mui/utils": "^7.3.7", + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material": "^7.3.7", + "@mui/material-pigment-css": "^7.3.7", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index d4bbc7d0..6393b0c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.14.1", "@fontsource/roboto": "^5.2.6", "@mui/icons-material": "^7.3.2", + "@mui/lab": "^7.0.1-beta.21", "@mui/material": "^7.3.2", "@tiptap/core": "^3.13.0", "@tiptap/extension-bold": "^3.13.0", diff --git a/frontend/platform/learners/Learners.jsx b/frontend/platform/learners/Learners.jsx new file mode 100644 index 00000000..681bd54a --- /dev/null +++ b/frontend/platform/learners/Learners.jsx @@ -0,0 +1,262 @@ +import Base from '../../src/components/Base.jsx' +import { InputBase, IconButton, Accordion, AccordionDetails, AccordionSummary, Box, Dialog, Grid, LinearProgress, Pagination, Paper, TableContainer, Table, TableBody, TableHead, TableCell, TableRow, Typography } from '@mui/material' +import { Timeline, TimelineItem, TimelineContent, TimelineOppositeContent, TimelineSeparator, TimelineConnector, TimelineDot } from '@mui/lab' +import { useState, useEffect, useRef } from 'react' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import AppRegistrationIcon from '@mui/icons-material/AppRegistration'; +import HowToRegIcon from '@mui/icons-material/HowToReg'; +import LibraryBooksIcon from '@mui/icons-material/LibraryBooks'; +import BallotIcon from '@mui/icons-material/Ballot'; +import AssignmentReturnedIcon from '@mui/icons-material/AssignmentReturned'; +import SearchIcon from '@mui/icons-material/Search'; +import SchoolIcon from '@mui/icons-material/School'; +import BackspaceIcon from '@mui/icons-material/Backspace'; +import render from '../../src/render.jsx'; +import { getCookie } from '../../src/utils.js'; +import { Title } from '@mui/icons-material'; + + +const apiBaseUrl = localStorage.getItem('apiBaseUrl'); + + +function EnrolmentList({enrollments, selectHandler}) { + if (enrollments.length === 0) { + return No enrollments found. + } + + return ( + + + + + Course + Status + + + + {enrollments.map((enrollment) => ( + ({':hover': {backgroundColor: theme.palette.background.dark, cursor: 'pointer', borderBottomColor: 'secondary.light', borderBottomWidth: 2, borderBottomStyle: 'solid'}})} + onClick={() => selectHandler(enrollment.id)}> + {enrollment.course_title} + {enrollment.status} + + ))} + +
+
+ ) +} + +function Learners(initialQs="") { + + const [organizationId, setOrganizationId] = useState(null); + const [learners, setLearners] = useState([]); + const searcchInputRef = useRef(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogContent, setDialogContent] = useState(null); + const [qs, setQs] = useState(initialQs); + const [showPagination, setShowPagination] = useState(false); + const pageSize = 20; + const [pagesCount, setPagesCount] = useState(1); + const [currentPage, setCurrentPage] = useState(1); + + const eventMap = { + 'registered': {icon: , color: "#00bcd4", title: 'Learner Registered'}, + 'verified': {icon: , color: "#66bb6a", title: 'Learner Verified Email'}, + 'content_sent_lesson': {icon: , color: "#00acc1", title: 'Lesson Content Sent'}, + 'content_sent_quiz': {icon: , color: "#26a69a", title: 'Quiz Sent'}, + 'quiz_submitted': {icon: , color: "#26a69a", title: 'Quiz Submitted'}, + 'course_completed': {icon: , color: "#0097a7", title: 'Course Completed'}, + 'deactivated': {icon: , color: "#b71c1c", title: 'Learner Deactivated'}, + }; + + + const showEnrollmentStatus = (enrollmentId) => { + setDialogOpen(true); + setDialogContent(); + fetch(`${apiBaseUrl}/organizations/${organizationId}/enrollments/${enrollmentId}/`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken'), + }, + }) + .then(response => response.json()) + .then(data => { + setDialogContent( + + Enrollment Details + {data.learner.email} + {data.course.title} + Enrollment id: {data.id} + + {data.events.map((event, index) => ( + + + {event.timestamp.replace('T', ' ').replace('Z', '')} + + + + + { event.type=="content_sent" ? eventMap["content_sent_"+event.event_data.course_content_type].icon : eventMap[event.type].icon } + + + + + + { event.type === "content_sent" ? eventMap["content_sent_"+event.event_data.course_content_type].title : eventMap[event.type].title } + + { event.type === "quiz_submitted" && <> + Score: {event.event_data.score} + Result: {event.event_data.is_passed ? "Passed" : "Failed"} + } + { event.type === "content_sent" && <> + {event.event_data.course_content_title} + } + + + ))} + + {/* Add more details as needed */} + + ); + + }) + .catch(error => { + console.error('Error fetching enrollment details:', error); + }); + + } + + const reloadLearners = () => { + + fetch(`${apiBaseUrl}/organizations/${organizationId}/learners/?${qs}&page_size=${pageSize}&page=${currentPage}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken'), + }, + }) + .then(response => response.json()) + .then(data => { + setShowPagination(data.page != 1 || data.has_more); + setPagesCount(Math.ceil(data.total_count / pageSize)); + setLearners(data.items.map(learner => ({ + id: learner.id, + email: learner.email, + enrollments: [], + state: 0, // 0: not loaded, 1: loading, 2: loaded + }))); + }) + .catch(error => { + console.error('Error fetching learners:', error); + }); + } + + useEffect(() => { + if (!organizationId) { + return; + } + reloadLearners(); + }, [organizationId, qs, currentPage]); + + const loadEnrollments = (learner) => { + if (learner.state !== 0) { + return; + } + learner.state = 1; // loading + setLearners([...learners]); + + const apiBaseUrl = localStorage.getItem('apiBaseUrl'); + fetch(`${apiBaseUrl}/organizations/${organizationId}/learners/${learner.id}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken'), + }, + }) + .then(response => response.json()) + .then(data => { + learner.enrollments = data.enrollments; + learner.state = 2; // loaded + setLearners([...learners]); + }) + .catch(error => { + console.error('Error fetching enrollments for learner:', error); + learner.state = 0; // reset to not loaded on error + setLearners([...learners]); + }); + } + + const search = () => { + setQs(`search=${encodeURIComponent(searcchInputRef.current.value)}`); + } + + return ( + + + ({ marginBottom: 2, border: '1px solid', borderColor: 'grey.300', borderRadius: 1, minHeight: 300, backgroundColor: theme.palette.background.nav })}> + + + { if (e.key === 'Enter') { search(); } }} + /> + + + + + + + + + + Learners List + + + + {learners.map((learner) => ( + + + {loadEnrollments(learner)}}> + } + aria-controls="panel1-content" + id="panel1-header" + > + {learner.email} + + + {learner.state === 0 ? "" : learner.state === 1 ? : } + + + + + ))} + +
+
+ { showPagination && setCurrentPage(page)} /> } +
+
+ setDialogOpen(false)} fullWidth maxWidth="sm"> + {dialogContent} + + + ) +} + +render({children: }); diff --git a/frontend/platform/users/index.html b/frontend/platform/learners/index.html similarity index 100% rename from frontend/platform/users/index.html rename to frontend/platform/learners/index.html diff --git a/frontend/platform/users/Users.jsx b/frontend/platform/users/Users.jsx deleted file mode 100644 index 4a19cb9d..00000000 --- a/frontend/platform/users/Users.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import Base from '../../src/components/Base.jsx' -import { Box, Grid } from '@mui/material' -import render from '../../src/render.jsx'; - - -function Users() { - - return ( - - - - User Management Page - - - - ) -} - -render({children: }); diff --git a/frontend/src/components/MenuBar.jsx b/frontend/src/components/MenuBar.jsx index 8e6b47a5..8c622197 100644 --- a/frontend/src/components/MenuBar.jsx +++ b/frontend/src/components/MenuBar.jsx @@ -11,7 +11,6 @@ import logoHorizontalDarkUrl from '../assets/logo-h-dark.png' import logoVerticalLightUrl from '../assets/logo-v-light.png' import logoVerticalDarkUrl from '../assets/logo-v-dark.png' import { getCookie } from '../utils.js'; -import { useThemeContext } from '../theme/ThemeContext.jsx'; import { useTheme, useMediaQuery } from "@mui/material"; import ThemeSwitcher from './ThemeSwitcher.jsx'; @@ -89,7 +88,7 @@ function MenuBar({activeOrganizationId, changeOrganizationCallback, showOrganiza } pages.push({ name: 'Course Management', icon: , href: platformBaseUrl + '/courses/' }); - pages.push({ name: 'Learners', icon: , href: platformBaseUrl + '/users/' }); + pages.push({ name: 'Learners', icon: , href: platformBaseUrl + '/learners/' }); pages.push({ name: 'Analytics', icon: , href: platformBaseUrl + '/analytics/' }); diff --git a/frontend/src/theme/themes.js b/frontend/src/theme/themes.js index 64622ed4..06a4802f 100644 --- a/frontend/src/theme/themes.js +++ b/frontend/src/theme/themes.js @@ -181,14 +181,14 @@ const darkPalette = { }; const lightTheme = createTheme({ - palette: lightPalette, + palette: {...lightPalette}, components: { ...defaultOptions.components }, }); const darkTheme = createTheme({ - palette: darkPalette, + palette: {...darkPalette}, components: { ...defaultOptions.components }, diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 893e7ba4..4906e1ab 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -27,7 +27,7 @@ export default defineConfig({ courses: resolve(__dirname, 'platform/courses/index.html'), course: resolve(__dirname, 'platform/course/index.html'), organizations: resolve(__dirname, 'platform/organizations/index.html'), - users: resolve(__dirname, 'platform/users/index.html'), + users: resolve(__dirname, 'platform/learners/index.html'), organization: resolve(__dirname, 'public/organization/index.html'), quiz_public: resolve(__dirname, "personalised/quiz_public/index.html"), verify_enrollment: resolve(__dirname, "personalised/verify_enrollment/index.html"), diff --git a/scripts/build.py b/scripts/build.py index 406d73b2..db8163c1 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -8,7 +8,7 @@ ["platform", "courses"], ["platform", "course"], ["platform", "organizations"], - ["platform", "users"], + ["platform", "learners"], ["personalised", "quiz_public"], ["personalised", "verify_enrollment"], ["public", "organization"],