Skip to content

Commit dd9c483

Browse files
committed
feat: #130 Add learners frontend view
1 parent 71b33b5 commit dd9c483

12 files changed

Lines changed: 332 additions & 29 deletions

File tree

django_email_learning/platform/urls.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
from django.urls import path
22
from django.views.generic import RedirectView
3-
from django_email_learning.platform.views import CourseView, Courses, Organizations
3+
from django_email_learning.platform.views import (
4+
CourseView,
5+
Courses,
6+
Organizations,
7+
Learners,
8+
)
49

510
app_name = "email_learning"
611

712
urlpatterns = [
813
path("courses/", Courses.as_view(), name="courses_view"),
914
path("courses/<int:course_id>/", CourseView.as_view(), name="course_detail_view"),
1015
path("organizations/", Organizations.as_view(), name="organizations_view"),
16+
path("learners/", Learners.as_view(), name="learners_view"),
1117
path(
1218
"",
1319
RedirectView.as_view(

django_email_learning/platform/views.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,14 @@ def get_context_data(self, **kwargs): # type: ignore[no-untyped-def]
9696
context = super().get_context_data(**kwargs)
9797
context["page_title"] = "Organizations"
9898
return context
99+
100+
101+
@method_decorator(login_required, name="dispatch")
102+
@method_decorator(is_platform_admin(), name="dispatch")
103+
class Learners(BasePlatformView):
104+
template_name = "platform/learner.html"
105+
106+
def get_context_data(self, **kwargs): # type: ignore[no-untyped-def]
107+
context = super().get_context_data(**kwargs)
108+
context["page_title"] = "Learners"
109+
return context
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{% extends "platform/base.html" %}
22
{% load django_vite %}
33
{% block extra_head %}
4-
{% vite_asset 'platform/users/Users.jsx' %}
4+
{% vite_asset 'platform/learners/Learners.jsx' %}
55
{% endblock %}

frontend/package-lock.json

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@emotion/styled": "^11.14.1",
1515
"@fontsource/roboto": "^5.2.6",
1616
"@mui/icons-material": "^7.3.2",
17+
"@mui/lab": "^7.0.1-beta.21",
1718
"@mui/material": "^7.3.2",
1819
"@tiptap/core": "^3.13.0",
1920
"@tiptap/extension-bold": "^3.13.0",
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import Base from '../../src/components/Base.jsx'
2+
import { InputBase, IconButton, Accordion, AccordionDetails, AccordionSummary, Box, Dialog, Grid, LinearProgress, Pagination, Paper, TableContainer, Table, TableBody, TableHead, TableCell, TableRow, Typography } from '@mui/material'
3+
import { Timeline, TimelineItem, TimelineContent, TimelineOppositeContent, TimelineSeparator, TimelineConnector, TimelineDot } from '@mui/lab'
4+
import { useState, useEffect, useRef } from 'react'
5+
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
6+
import AppRegistrationIcon from '@mui/icons-material/AppRegistration';
7+
import HowToRegIcon from '@mui/icons-material/HowToReg';
8+
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
9+
import BallotIcon from '@mui/icons-material/Ballot';
10+
import AssignmentReturnedIcon from '@mui/icons-material/AssignmentReturned';
11+
import SearchIcon from '@mui/icons-material/Search';
12+
import SchoolIcon from '@mui/icons-material/School';
13+
import BackspaceIcon from '@mui/icons-material/Backspace';
14+
import render from '../../src/render.jsx';
15+
import { getCookie } from '../../src/utils.js';
16+
import { Title } from '@mui/icons-material';
17+
18+
19+
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
20+
21+
22+
function EnrolmentList({enrollments, selectHandler}) {
23+
if (enrollments.length === 0) {
24+
return <Typography component="span">No enrollments found.</Typography>
25+
}
26+
27+
return (
28+
<TableContainer component={Paper} sx={{ maxHeight: 300, border: '1px solid', borderColor: 'grey.300', borderRadius: 1 }}>
29+
<Table>
30+
<TableHead>
31+
<TableRow>
32+
<TableCell>Course</TableCell>
33+
<TableCell>Status</TableCell>
34+
</TableRow>
35+
</TableHead>
36+
<TableBody>
37+
{enrollments.map((enrollment) => (
38+
<TableRow
39+
key={enrollment.id} sx={(theme) => ({':hover': {backgroundColor: theme.palette.background.dark, cursor: 'pointer', borderBottomColor: 'secondary.light', borderBottomWidth: 2, borderBottomStyle: 'solid'}})}
40+
onClick={() => selectHandler(enrollment.id)}>
41+
<TableCell>{enrollment.course_title}</TableCell>
42+
<TableCell>{enrollment.status}</TableCell>
43+
</TableRow>
44+
))}
45+
</TableBody>
46+
</Table>
47+
</TableContainer>
48+
)
49+
}
50+
51+
function Learners(initialQs="") {
52+
53+
const [organizationId, setOrganizationId] = useState(null);
54+
const [learners, setLearners] = useState([]);
55+
const searcchInputRef = useRef(null);
56+
const [dialogOpen, setDialogOpen] = useState(false);
57+
const [dialogContent, setDialogContent] = useState(null);
58+
const [qs, setQs] = useState(initialQs);
59+
const [showPagination, setShowPagination] = useState(false);
60+
const pageSize = 20;
61+
const [pagesCount, setPagesCount] = useState(1);
62+
const [currentPage, setCurrentPage] = useState(1);
63+
64+
const eventMap = {
65+
'registered': {icon: <AppRegistrationIcon sx={{ color: 'white' }} />, color: "#00bcd4", title: 'Learner Registered'},
66+
'verified': {icon: <HowToRegIcon />, color: "#66bb6a", title: 'Learner Verified Email'},
67+
'content_sent_lesson': {icon: <LibraryBooksIcon />, color: "#00acc1", title: 'Lesson Content Sent'},
68+
'content_sent_quiz': {icon: <BallotIcon />, color: "#26a69a", title: 'Quiz Sent'},
69+
'quiz_submitted': {icon: <AssignmentReturnedIcon />, color: "#26a69a", title: 'Quiz Submitted'},
70+
'course_completed': {icon: <SchoolIcon />, color: "#0097a7", title: 'Course Completed'},
71+
'deactivated': {icon: <BackspaceIcon />, color: "#b71c1c", title: 'Learner Deactivated'},
72+
};
73+
74+
75+
const showEnrollmentStatus = (enrollmentId) => {
76+
setDialogOpen(true);
77+
setDialogContent(<LinearProgress sx={{ m: 10 }} />);
78+
fetch(`${apiBaseUrl}/organizations/${organizationId}/enrollments/${enrollmentId}/`, {
79+
method: 'GET',
80+
headers: {
81+
'Content-Type': 'application/json',
82+
'X-CSRFToken': getCookie('csrftoken'),
83+
},
84+
})
85+
.then(response => response.json())
86+
.then(data => {
87+
setDialogContent(
88+
<Box p={2}>
89+
<Typography variant="h5" gutterBottom sx={{ mt: 2 }}>Enrollment Details</Typography>
90+
<Typography>{data.learner.email}</Typography>
91+
<Typography variant="subtitle1">{data.course.title}</Typography>
92+
<Typography>Enrollment id: {data.id}</Typography>
93+
<Timeline>
94+
{data.events.map((event, index) => (
95+
<TimelineItem key={index}>
96+
<TimelineOppositeContent
97+
sx={{ m: 'auto 0' }}
98+
align="right"
99+
variant="body2"
100+
color="text.secondary"
101+
>
102+
{event.timestamp.replace('T', ' ').replace('Z', '')}
103+
</TimelineOppositeContent>
104+
<TimelineSeparator>
105+
<TimelineConnector />
106+
<TimelineDot sx={{backgroundColor: event.type === "content_sent" ? eventMap["content_sent_"+event.event_data.course_content_type].color : eventMap[event.type].color }}>
107+
{ event.type=="content_sent" ? eventMap["content_sent_"+event.event_data.course_content_type].icon : eventMap[event.type].icon }
108+
</TimelineDot>
109+
<TimelineConnector />
110+
</TimelineSeparator>
111+
<TimelineContent sx={{ py: '12px', px: 2 }}>
112+
<Typography variant="h6" component="span">
113+
{ event.type === "content_sent" ? eventMap["content_sent_"+event.event_data.course_content_type].title : eventMap[event.type].title }
114+
</Typography>
115+
{ event.type === "quiz_submitted" && <>
116+
<Box><Typography>Score: {event.event_data.score}</Typography></Box>
117+
<Box><Typography>Result: {event.event_data.is_passed ? "Passed" : "Failed"}</Typography></Box>
118+
</>}
119+
{ event.type === "content_sent" && <>
120+
<Box><Typography>{event.event_data.course_content_title}</Typography></Box>
121+
</>}
122+
</TimelineContent>
123+
</TimelineItem>
124+
))}
125+
</Timeline>
126+
{/* Add more details as needed */}
127+
</Box>
128+
);
129+
130+
})
131+
.catch(error => {
132+
console.error('Error fetching enrollment details:', error);
133+
});
134+
135+
}
136+
137+
const reloadLearners = () => {
138+
139+
fetch(`${apiBaseUrl}/organizations/${organizationId}/learners/?${qs}&page_size=${pageSize}&page=${currentPage}`, {
140+
method: 'GET',
141+
headers: {
142+
'Content-Type': 'application/json',
143+
'X-CSRFToken': getCookie('csrftoken'),
144+
},
145+
})
146+
.then(response => response.json())
147+
.then(data => {
148+
setShowPagination(data.page != 1 || data.has_more);
149+
setPagesCount(Math.ceil(data.total_count / pageSize));
150+
setLearners(data.items.map(learner => ({
151+
id: learner.id,
152+
email: learner.email,
153+
enrollments: [],
154+
state: 0, // 0: not loaded, 1: loading, 2: loaded
155+
})));
156+
})
157+
.catch(error => {
158+
console.error('Error fetching learners:', error);
159+
});
160+
}
161+
162+
useEffect(() => {
163+
if (!organizationId) {
164+
return;
165+
}
166+
reloadLearners();
167+
}, [organizationId, qs, currentPage]);
168+
169+
const loadEnrollments = (learner) => {
170+
if (learner.state !== 0) {
171+
return;
172+
}
173+
learner.state = 1; // loading
174+
setLearners([...learners]);
175+
176+
const apiBaseUrl = localStorage.getItem('apiBaseUrl');
177+
fetch(`${apiBaseUrl}/organizations/${organizationId}/learners/${learner.id}`, {
178+
method: 'GET',
179+
headers: {
180+
'Content-Type': 'application/json',
181+
'X-CSRFToken': getCookie('csrftoken'),
182+
},
183+
})
184+
.then(response => response.json())
185+
.then(data => {
186+
learner.enrollments = data.enrollments;
187+
learner.state = 2; // loaded
188+
setLearners([...learners]);
189+
})
190+
.catch(error => {
191+
console.error('Error fetching enrollments for learner:', error);
192+
learner.state = 0; // reset to not loaded on error
193+
setLearners([...learners]);
194+
});
195+
}
196+
197+
const search = () => {
198+
setQs(`search=${encodeURIComponent(searcchInputRef.current.value)}`);
199+
}
200+
201+
return (
202+
<Base
203+
breadCrumbList={[{label: 'Learners', href: '#'}]}
204+
organizationIdRefreshCallback={setOrganizationId}
205+
>
206+
<Grid size={{xs: 12, lg: 8}} py={2} pl={2}>
207+
<Box p={2} sx={(theme) => ({ marginBottom: 2, border: '1px solid', borderColor: 'grey.300', borderRadius: 1, minHeight: 300, backgroundColor: theme.palette.background.nav })}>
208+
209+
<Paper
210+
sx={{ mb: '10px', p: '2px 4px', display: 'flex', alignItems: 'center', width: 400 }}
211+
>
212+
<InputBase
213+
sx={{ pl: 1, flex: 1 }}
214+
placeholder="Search Learners"
215+
inputRef={searcchInputRef}
216+
onKeyDown={(e) => { if (e.key === 'Enter') { search(); } }}
217+
/>
218+
<IconButton type="button" sx={{ p: '10px' }} aria-label="search" onClick={search}>
219+
<SearchIcon />
220+
</IconButton>
221+
</Paper>
222+
223+
<TableContainer component={Paper} sx={{ maxHeight: 440, border: '1px solid', borderColor: 'grey.300', borderRadius: 1 }}>
224+
<Table stickyHeader>
225+
<TableHead>
226+
<TableRow>
227+
<TableCell>Learners List</TableCell>
228+
</TableRow>
229+
</TableHead>
230+
<TableBody>
231+
{learners.map((learner) => (
232+
<TableRow key={learner.id}>
233+
<TableCell>
234+
<Accordion onChange={() => {loadEnrollments(learner)}}>
235+
<AccordionSummary
236+
expandIcon={<ExpandMoreIcon />}
237+
aria-controls="panel1-content"
238+
id="panel1-header"
239+
>
240+
<Typography component="span">{learner.email}</Typography>
241+
</AccordionSummary>
242+
<AccordionDetails>
243+
<Typography component="span">{learner.state === 0 ? "" : learner.state === 1 ? <LinearProgress /> : <EnrolmentList enrollments={learner.enrollments} selectHandler={showEnrollmentStatus}/>}</Typography>
244+
</AccordionDetails>
245+
</Accordion>
246+
</TableCell>
247+
</TableRow>
248+
))}
249+
</TableBody>
250+
</Table>
251+
</TableContainer>
252+
{ showPagination && <Pagination sx={{ mt: 2 }} count={pagesCount} onChange={(event, page) => setCurrentPage(page)} /> }
253+
</Box>
254+
</Grid>
255+
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="sm">
256+
{dialogContent}
257+
</Dialog>
258+
</Base>
259+
)
260+
}
261+
262+
render({children: <Learners />});

frontend/platform/users/Users.jsx

Lines changed: 0 additions & 21 deletions
This file was deleted.

0 commit comments

Comments
 (0)