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
752 changes: 608 additions & 144 deletions django_email_learning/migrations/0001_initial.py

Large diffs are not rendered by default.

30 changes: 29 additions & 1 deletion django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ def encrypted_value(cls, value: str, salt: str) -> str:

def _encrypt_password(self, password: str) -> str:
if not self.salt:
self.salt = base64.urlsafe_b64encode(uuid.uuid4().bytes).decode().rstrip("=")
self.salt = (
base64.urlsafe_b64encode(uuid.uuid4().bytes).decode().rstrip("=")
)
f = self._fernet(self.salt)
return f.encrypt(password.encode()).decode()

Expand Down Expand Up @@ -446,6 +448,15 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
self.full_clean()
super().save(*args, **kwargs)

@property
def enrollments_count(self) -> dict[str, int]:
return {
"total": self.enrollment_set.count(),
"completed": self.enrollment_set.filter(
status=EnrollmentStatus.COMPLETED
).count(),
}

class Meta:
unique_together = [["organization", "email"]]

Expand Down Expand Up @@ -617,6 +628,23 @@ def schedule_first_content_delivery(self) -> None:
else:
raise ValidationError("No published content available to schedule.")

@property
def progress_percentage(self) -> int:
total_content = self.course.coursecontent_set.filter(is_published=True).count()
if total_content == 0:
return 0
delivered_content = (
ContentDelivery.objects.filter(
enrollment=self,
delivery_schedules__status=DeliveryStatus.DELIVERED,
course_content__is_published=True,
)
.distinct()
.count()
)
progress = int((delivered_content / total_content) * 100)
return progress


class Certificate(models.Model):
enrollment = models.OneToOneField(
Expand Down
8 changes: 8 additions & 0 deletions django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,12 +485,20 @@ class EnrollmentSummaryResponse(BaseModel):
id: int
course_title: str
status: EnrollmentStatus
progress: int
certificate_url: str | None = None


class EnrollmentsCount(BaseModel):
total: int
completed: int


class LearnerResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: str
enrollments_count: EnrollmentsCount


class EventType(enum.StrEnum):
Expand Down
15 changes: 15 additions & 0 deletions django_email_learning/platform/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
from django.urls import reverse
from django.contrib.auth.models import User
from django.contrib.auth.forms import PasswordResetForm
from datetime import timedelta, datetime
Expand All @@ -19,6 +20,7 @@
from django_email_learning.platform.api.pagniated_api_mixin import PaginatedApiMixin
from django_email_learning.models import (
ApiKey,
Certificate,
Course,
CourseContent,
Enrollment,
Expand Down Expand Up @@ -686,11 +688,24 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
enrollments = Enrollment.objects.filter(learner=learner)
enroolments_list = []
for enrollment in enrollments:
certificate = Certificate.objects.filter(enrollment=enrollment).first()
certificate_url = None
if certificate:
certificate_url = request.build_absolute_uri(
reverse(
"django_email_learning:personalised:certificate",
kwargs={
"certificate_number": certificate.certificate_number
},
)
)
enroolments_list.append(
serializers.EnrollmentSummaryResponse(
id=enrollment.id,
course_title=enrollment.course.title,
status=EnrollmentStatus(enrollment.status),
progress=enrollment.progress_percentage,
certificate_url=certificate_url,
)
)
return JsonResponse(
Expand Down
4 changes: 3 additions & 1 deletion django_email_learning/platform/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,15 @@ def get_shared_context(self) -> Dict[str, Any]:
"api_keys": _("API Keys"),
"content_delivery_job": _("Content Delivery Job"),
"last_run": _("Last Run:"),
"never_run": _("This job has never been executed."),
"content_delivery_tooltip": _(
"This job should run on a regular schedule to ensure timely content delivery. Configure a cron job or cloud scheduler to execute it at appropriate intervals, such as every five minutes."
),
}
| self.get_locale_messages(),
},
"activeOrganizationId": active_organization_id,
"favicon": DJANGO_EMAIL_LEARNING_SETTINGS.get("FAVICON"),
}

def get_locale_messages(self) -> Dict[str, str]:
Expand Down Expand Up @@ -397,7 +399,7 @@ def get_locale_messages(self) -> Dict[str, str]:
"failed": _("Failed"),
"enrollment_details": _("Enrollment Details"),
"enrollment_id": _("Enrollment ID"),
"unvierfied": _("Unverified"),
"unverified": _("Unverified"),
"active": _("Active"),
"completed": _("Completed"),
"deactivated": _("Deactivated"),
Expand Down
2 changes: 1 addition & 1 deletion django_email_learning/templates/platform/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
{% block extra_head %}{% endblock %}
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="{% static 'logo.png' %}" />
<link rel="icon" type="image/png" href="{% if favicon %}{{ favicon }}{% else %}{% static 'logo.png' %}{% endif %}" />
<title>{% block title %}{{ page_title }}{% endblock %}</title>
</head>

Expand Down
11 changes: 2 additions & 9 deletions django_service/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,6 @@
"SITE_BASE_URL": "http://localhost:8000",
"ENCRYPTION_SECRET_KEY": "your-very-secure-and-random-key",
"FROM_EMAIL": os.environ.get("FROM_EMAIL", "webmaster@localhost"),
"SIDEBAR": {
"CUSTOM_COMPONENT": {
"SCRIPT_URL": "https://cdn.jsdelivr.net/npm/ldrs/dist/auto/helix.js",
"STYLE_URL": None,
"COMPONENT_TAG": "<l-helix />",
}
},
"LOGO": {
"HORIZONTAL_LOCKUP": {
"LIGHT_BACKGROUND": None,
Expand All @@ -123,9 +116,9 @@
}


# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = os.environ.get("EMAIL_HOST")
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
EMAIL_USE_TLS = True
Expand Down
21 changes: 12 additions & 9 deletions frontend/personalised/certificate/Certificate.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ const CertificateContent = () => {
borderRadius: 3,
position: 'relative',
backgroundImage: (() => {
const band = alpha(theme.palette.secondary.main, 0.22);
const glow = alpha(theme.palette.primary.main, 0.35);
const baseStart = alpha(theme.palette.primary.main, 0.88);
const baseEnd = alpha(theme.palette.secondary.main, 0.88);
const band = alpha(theme.palette.primary.main, 0.22);
const glow = alpha(theme.palette.secondary.main, 0.35);
const baseStart = alpha(theme.palette.secondary.main, 0.88);
const baseEnd = alpha(theme.palette.primary.main, 0.88);
const hatch = alpha(theme.palette.common.white, 0.05);
return [
`linear-gradient(15deg, transparent 0 35%, ${band} 35% 41%, transparent 41% 100%)`,
Expand All @@ -77,7 +77,6 @@ const CertificateContent = () => {
padding: "1mm",
borderRadius: 2,
boxSizing: 'border-box',
overflow: 'scroll',
width: '100%',
height: '100%',
}}><Paper
Expand All @@ -87,10 +86,10 @@ const CertificateContent = () => {
height: '100%',
display: 'flex',
borderRadius: 2,
border: `2px solid ${alpha(theme.palette.primary.main, 0.5)}`,
border: `2px solid ${alpha(theme.palette.secondary.main, 0.5)}`,
position: 'relative',
backgroundImage: (() => {
const fade = alpha(theme.palette.primary.main, 0.02);
const fade = alpha(theme.palette.secondary.main, 0.02);
return [
`repeating-linear-gradient(135deg, ${fade} 0 1px, transparent 2px 6px)`,
].join(", ");
Expand All @@ -105,18 +104,22 @@ const CertificateContent = () => {
right: 16,
height: 6,
borderRadius: 999,
background: `linear-gradient(90deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 100%)`,
background: `linear-gradient(90deg, ${theme.palette.secondary.main} 0%, ${theme.palette.primary.main} 100%)`,
opacity: 0.25,
},
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 8,
boxSizing: 'border-box',
color: alpha(theme.palette.common.black, 0.88),
'& .MuiTypography-root': {
color: alpha(theme.palette.common.black, 0.88),
},
})}
>
{/* Your content here */}
<WorkspacePremiumIcon sx={(theme) => ({ fontSize: 86, color: theme.palette.secondary.main, mb: 2 })} />
<WorkspacePremiumIcon sx={(theme) => ({ fontSize: 86, color: theme.palette.primary.main, mb: 2 })} />
<Typography
variant="h1"
gutterBottom
Expand Down
6 changes: 3 additions & 3 deletions frontend/personalised/certificate_form/CertificateForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const CertificateForm = () => {

return (<Container maxWidth="sm" sx={{ mt: 4 }}>
<Box display="flex" alignItems="center" my={4} justifyContent="center">
<WorkspacePremiumIcon color="primary" sx={{ fontSize: 50, mr: 1 }} />
<WorkspacePremiumIcon color="secondary" sx={{ fontSize: 50, mr: 1 }} />
<Typography variant="h4" component="h1">
{localeMessages['form_title']}
</Typography>
Expand All @@ -70,15 +70,15 @@ const CertificateForm = () => {
onChange={(e) => setFullName(e.target.value)}
/>
{error && <Alert severity="error">{error}</Alert>}
<Button type="submit" variant="contained" color="primary" sx={{ mt: 3 }}>
<Button type="submit" variant="contained" color="secondary" sx={{ mt: 3 }}>
{localeMessages['submit']}
</Button>
</Box> : <Alert severity="success" sx={{ mt: 2 }}>{localeMessages['form_submission_success']}</Alert>}
{certificateUrl && (
<Box sx={{ mt: 2 }}>
<Button
variant="contained"
color="primary"
color="secondary"
href={certificateUrl}
target="_blank"
rel="noopener noreferrer"
Expand Down
12 changes: 6 additions & 6 deletions frontend/platform/course/Course.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ function Course() {
.then(data => {
setEnrollmentsCount([
{ label: localeMessages["unverified"], value: data.enrollments_count.unverified, color: theme.palette.indigo[200] },
{ label: localeMessages["active"], value: data.enrollments_count.active, color: theme.palette.secondary.main },
{ label: localeMessages["active"], value: data.enrollments_count.active, color: theme.palette.primary.main },
{ label: localeMessages["deactivated"], value: data.enrollments_count.deactivated, color: theme.palette.grey[300] },
{ label: localeMessages["completed"], value: data.enrollments_count.completed, color: theme.palette.primary.main },
{ label: localeMessages["completed"], value: data.enrollments_count.completed, color: theme.palette.secondary.main },
]);
})
.catch(error => console.error('Error fetching course data:', error));
Expand Down Expand Up @@ -201,7 +201,7 @@ function Course() {
showOrganizationSwitcher={false}
>
<Grid size={{xs: 12}} py={2} pl={2}>
<Box p={2} sx={{ border: '1px solid', borderColor: 'grey.300', borderRadius: 1, minHeight: 300 }}>
<Box p={2} sx={{ border: '1px solid', borderColor: 'border.main', backgroundColor: 'background.box', borderRadius: 2, minHeight: 300 }}>
{userRole !== 'viewer' && <><Button variant="contained" startIcon={<DescriptionIcon sx={{ marginLeft: direction == 'rtl' ? 1 : 0 }} />} sx={{ marginBottom: 2 }} onClick={() => {
setDialogContent(<Suspense fallback={<Box sx={{ p: 2 }}><LinearProgress /></Box>}><LessonForm
header={localeMessages["new_lesson"]}
Expand All @@ -219,7 +219,7 @@ function Course() {
</Box>
<Grid container spacing={2}>
{ enrollmentsCount && <Grid size={{xs: 12, lg: 6}} mt={2} mb={2} >
<Box py={2} sx={{ border: '1px solid', borderColor: 'grey.300', borderRadius: 1, backgroundColor: 'background.main', height: '100%' }}>
<Box py={2} sx={{ border: '1px solid', borderColor: 'border.main', borderRadius: 2, backgroundColor: 'background.box', height: '100%' }}>
<Typography variant="h6" align='center'>{localeMessages["enrollments_distribution"]}</Typography>
<PieChart
height={300}
Expand Down Expand Up @@ -249,14 +249,14 @@ function Course() {
</Box>
</Grid> }
{ weeklyStats && <Grid size={{xs: 12, lg: 6}} mt={2} mb={2} >
<Box py={2} sx={{ border: '1px solid', borderColor: 'grey.300', borderRadius: 1, backgroundColor: 'background.main', height: '100%' }}>
<Box py={2} sx={{ border: '1px solid', borderColor: 'border.main', borderRadius: 2, backgroundColor: 'background.box', height: '100%' }}>
<Typography variant="h6" align='center'>{localeMessages["weekly_enrollments"]}</Typography>
<BarChart
margin={{
top: 60,
}}
xAxis={[{data: weeklyStats.map((stat) => stat.date)}]}
series={[{ data: weeklyStats.map((stat) => stat.count), color: theme.palette.primary.main }]}
series={[{ data: weeklyStats.map((stat) => stat.count), color: theme.palette.secondary.main }]}
height={300}
/>
</Box>
Expand Down
6 changes: 3 additions & 3 deletions frontend/platform/course/components/ContentTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { IconButton, Switch, TableContainer, Table, TableHead, TableRow, TableBo
import { useState, useEffect } from 'react';
import { getCookie } from '../../../src/utils.js';
import DeleteIcon from '@mui/icons-material/Delete';
import DragHandleIcon from '@mui/icons-material/DragHandle';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { useAppContext } from '../../../src/render.jsx';


Expand Down Expand Up @@ -129,12 +129,12 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => {
eventHandler(event);
}
}}>
{ userRole !== 'viewer' && <TableCell align={direction == 'rtl' ? 'right' : 'left'} sx={{ cursor: 'grab', width: '40px', padding: '8px 0', textAlign: 'center' }}><DragHandleIcon
{ userRole !== 'viewer' && <TableCell align={direction == 'rtl' ? 'right' : 'left'} sx={{ cursor: 'grab', width: '40px', padding: '8px 0', textAlign: 'center' }}><DragIndicatorIcon fontSize="small"
onMouseDown={() => startDrag(content.id)}
/></TableCell>}
<TableCell align={direction == 'rtl' ? 'right' : 'left'}><Typography
onClick={() => {let event = {type: 'content_clicked', content_id: content.id}; eventHandler(event);}}
color='primary.dark' sx={{ cursor: 'pointer'}}>{content.title}</Typography></TableCell>
color='secondary.dark' sx={{ cursor: 'pointer'}}>{content.title}</Typography></TableCell>
<TableCell align={direction == 'rtl' ? 'right' : 'left'}>{formatPeriod(content.waiting_period)}</TableCell>
<TableCell align={direction == 'rtl' ? 'right' : 'left'}>{localeMessages[content.type]}</TableCell>
<TableCell align={direction == 'rtl' ? 'right' : 'left'}><Switch checked={content.is_published} onChange={() => TogglePublishContent(content.id, !content.is_published)} disabled={userRole == 'viewer'} /></TableCell>
Expand Down
4 changes: 2 additions & 2 deletions frontend/platform/course/components/QuestionForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const QuestionForm = ({question, index, eventHandler}) => {
<Box key={index} sx={{ mb: 1, p: 2, border: '1px solid', borderColor: 'grey.300', borderRadius: 1 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 9 }}>
{userRole !== 'viewer' && <EditIcon sx={{borderRadius: "50%", display: "inline-block", float: "left", mr: 1, fontSize: "0.9rem", border: 1, borderColor: "grey.200", color: "grey.400", padding: "4px", cursor: "pointer", ':hover': { backgroundColor: "primary.main", color: "white", borderColor: "primary.main" } }} onClick={editQuestion}/>}
{userRole !== 'viewer' && <EditIcon sx={{borderRadius: "50%", display: "inline-block", float: "left", mr: 1, fontSize: "0.9rem", border: 1, borderColor: "grey.200", color: "grey.400", padding: "4px", cursor: "pointer", ':hover': { backgroundColor: "secondary.main", color: "white", borderColor: "secondary.main" } }} onClick={editQuestion}/>}
{!editMode ? (
<Typography onClick={editQuestion}>{index + 1}. {questionText}</Typography>
) : (
Expand All @@ -79,7 +79,7 @@ const QuestionForm = ({question, index, eventHandler}) => {
)}
</Grid>
<Grid size={{ xs: 12, md: 3 }} sx={{ textAlign: 'right' }}>
{userRole !== 'viewer' && <><Button variant="outlined" color="primary" sx={{ fontSize: '0.75rem', mt: 1 }} onClick={() => setAddingOption(true)} >
{userRole !== 'viewer' && <><Button variant="outlined" color="secondary" sx={{ fontSize: '0.75rem', mt: 1 }} onClick={() => setAddingOption(true)} >
<RuleIcon /><Typography variant="button" sx={{ ml: 1, fontSize: '0.75rem' }}>{localeMessages["add_option"]}</Typography>
</Button>
<Button variant="outlined" onClick={deleteCallback} sx={{ mx: 1, mt: 1, fontSize: '0.75rem' }}>
Expand Down
Loading