Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion django_email_learning/platform/urls.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
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"

urlpatterns = [
path("courses/", Courses.as_view(), name="courses_view"),
path("courses/<int:course_id>/", 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(
Expand Down
11 changes: 11 additions & 0 deletions django_email_learning/platform/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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 %}
45 changes: 45 additions & 0 deletions frontend/package-lock.json

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

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
262 changes: 262 additions & 0 deletions frontend/platform/learners/Learners.jsx
Original file line number Diff line number Diff line change
@@ -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 <Typography component="span">No enrollments found.</Typography>
}

return (
<TableContainer component={Paper} sx={{ maxHeight: 300, border: '1px solid', borderColor: 'grey.300', borderRadius: 1 }}>
<Table>
<TableHead>
<TableRow>
<TableCell>Course</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{enrollments.map((enrollment) => (
<TableRow
key={enrollment.id} sx={(theme) => ({':hover': {backgroundColor: theme.palette.background.dark, cursor: 'pointer', borderBottomColor: 'secondary.light', borderBottomWidth: 2, borderBottomStyle: 'solid'}})}
onClick={() => selectHandler(enrollment.id)}>
<TableCell>{enrollment.course_title}</TableCell>
<TableCell>{enrollment.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}

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: <AppRegistrationIcon sx={{ color: 'white' }} />, color: "#00bcd4", title: 'Learner Registered'},
'verified': {icon: <HowToRegIcon />, color: "#66bb6a", title: 'Learner Verified Email'},
'content_sent_lesson': {icon: <LibraryBooksIcon />, color: "#00acc1", title: 'Lesson Content Sent'},
'content_sent_quiz': {icon: <BallotIcon />, color: "#26a69a", title: 'Quiz Sent'},
'quiz_submitted': {icon: <AssignmentReturnedIcon />, color: "#26a69a", title: 'Quiz Submitted'},
'course_completed': {icon: <SchoolIcon />, color: "#0097a7", title: 'Course Completed'},
'deactivated': {icon: <BackspaceIcon />, color: "#b71c1c", title: 'Learner Deactivated'},
};


const showEnrollmentStatus = (enrollmentId) => {
setDialogOpen(true);
setDialogContent(<LinearProgress sx={{ m: 10 }} />);
fetch(`${apiBaseUrl}/organizations/${organizationId}/enrollments/${enrollmentId}/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken'),
},
})
.then(response => response.json())
.then(data => {
setDialogContent(
<Box p={2}>
<Typography variant="h5" gutterBottom sx={{ mt: 2 }}>Enrollment Details</Typography>
<Typography>{data.learner.email}</Typography>
<Typography variant="subtitle1">{data.course.title}</Typography>
<Typography>Enrollment id: {data.id}</Typography>
<Timeline>
{data.events.map((event, index) => (
<TimelineItem key={index}>
<TimelineOppositeContent
sx={{ m: 'auto 0' }}
align="right"
variant="body2"
color="text.secondary"
>
{event.timestamp.replace('T', ' ').replace('Z', '')}
</TimelineOppositeContent>
<TimelineSeparator>
<TimelineConnector />
<TimelineDot sx={{backgroundColor: event.type === "content_sent" ? eventMap["content_sent_"+event.event_data.course_content_type].color : eventMap[event.type].color }}>
{ event.type=="content_sent" ? eventMap["content_sent_"+event.event_data.course_content_type].icon : eventMap[event.type].icon }
</TimelineDot>
<TimelineConnector />
</TimelineSeparator>
<TimelineContent sx={{ py: '12px', px: 2 }}>
<Typography variant="h6" component="span">
{ event.type === "content_sent" ? eventMap["content_sent_"+event.event_data.course_content_type].title : eventMap[event.type].title }
</Typography>
{ event.type === "quiz_submitted" && <>
<Box><Typography>Score: {event.event_data.score}</Typography></Box>
<Box><Typography>Result: {event.event_data.is_passed ? "Passed" : "Failed"}</Typography></Box>
</>}
{ event.type === "content_sent" && <>
<Box><Typography>{event.event_data.course_content_title}</Typography></Box>
</>}
</TimelineContent>
</TimelineItem>
))}
</Timeline>
{/* Add more details as needed */}
</Box>
);

})
.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 (
<Base
breadCrumbList={[{label: 'Learners', href: '#'}]}
organizationIdRefreshCallback={setOrganizationId}
>
<Grid size={{xs: 12, lg: 8}} py={2} pl={2}>
<Box p={2} sx={(theme) => ({ marginBottom: 2, border: '1px solid', borderColor: 'grey.300', borderRadius: 1, minHeight: 300, backgroundColor: theme.palette.background.nav })}>

<Paper
sx={{ mb: '10px', p: '2px 4px', display: 'flex', alignItems: 'center', width: 400 }}
>
<InputBase
sx={{ pl: 1, flex: 1 }}
placeholder="Search Learners"
inputRef={searcchInputRef}
onKeyDown={(e) => { if (e.key === 'Enter') { search(); } }}
/>
<IconButton type="button" sx={{ p: '10px' }} aria-label="search" onClick={search}>
<SearchIcon />
</IconButton>
</Paper>

<TableContainer component={Paper} sx={{ maxHeight: 440, border: '1px solid', borderColor: 'grey.300', borderRadius: 1 }}>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell>Learners List</TableCell>
</TableRow>
</TableHead>
<TableBody>
{learners.map((learner) => (
<TableRow key={learner.id}>
<TableCell>
<Accordion onChange={() => {loadEnrollments(learner)}}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1-content"
id="panel1-header"
>
<Typography component="span">{learner.email}</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography component="span">{learner.state === 0 ? "" : learner.state === 1 ? <LinearProgress /> : <EnrolmentList enrollments={learner.enrollments} selectHandler={showEnrollmentStatus}/>}</Typography>
</AccordionDetails>
</Accordion>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{ showPagination && <Pagination sx={{ mt: 2 }} count={pagesCount} onChange={(event, page) => setCurrentPage(page)} /> }
</Box>
</Grid>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="sm">
{dialogContent}
</Dialog>
</Base>
)
}

render({children: <Learners />});
21 changes: 0 additions & 21 deletions frontend/platform/users/Users.jsx

This file was deleted.

Loading