Skip to content

Commit 2c3ea53

Browse files
committed
feat: #244 manual user enrolment
1 parent 428a387 commit 2c3ea53

10 files changed

Lines changed: 329 additions & 6 deletions

File tree

django_email_learning/platform/api/serializers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ def validate_email(cls, email: str) -> str:
6666
return email
6767

6868

69+
class CreateEnrollmentRequest(BaseModel):
70+
learner_email: str = Field(min_length=1, examples=["user@example.com"])
71+
72+
6973
class UserResponse(BaseModel):
7074
id: int
7175
email: str

django_email_learning/platform/api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
GetOrCreateUserByEmail,
66
SingleApiKeyView,
77
CourseView,
8+
EnrollmentsView,
89
EnrollmentView,
910
EnrollmentsStatisticsView,
1011
FileView,
@@ -81,6 +82,11 @@
8182
EnrollmentView.as_view(),
8283
name="enrollment_view",
8384
),
85+
path(
86+
"organizations/<int:organization_id>/courses/<int:course_id>/enrollments/",
87+
EnrollmentsView.as_view(),
88+
name="enrollments_view",
89+
),
8490
path(
8591
"organizations/<int:organization_id>/courses/<int:course_id>/enrollments/statistics/",
8692
EnrollmentsStatisticsView.as_view(),

django_email_learning/platform/api/views.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717
from urllib.parse import urlparse
1818
from pydantic import ValidationError
1919
from enum import StrEnum
20+
from django_email_learning.services.command_models.enroll_command import EnrollCommand
21+
from django_email_learning.services.command_models.verify_enrollment_command import (
22+
VerifyEnrollmentCommand,
23+
)
24+
from django_email_learning.services.command_models.exceptions.blocked_email_error import (
25+
BlockedEmailError,
26+
)
27+
from django_email_learning.services.command_models.exceptions.enrollment_already_exists_error import (
28+
EnrollmentAlreadyExistsError,
29+
)
2030
from django_email_learning.platform.api import serializers
2131
from django_email_learning.platform.api.pagniated_api_mixin import PaginatedApiMixin
2232
from django_email_learning.models import (
@@ -763,6 +773,50 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
763773
return JsonResponse({"error": "An internal error occurred."}, status=500)
764774

765775

776+
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
777+
class EnrollmentsView(PaginatedApiMixin, View):
778+
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
779+
payload = json.loads(request.body)
780+
try:
781+
serializer = serializers.CreateEnrollmentRequest.model_validate(payload)
782+
try:
783+
course = Course.objects.get(
784+
id=kwargs["course_id"], organization_id=kwargs["organization_id"]
785+
)
786+
except Course.DoesNotExist:
787+
return JsonResponse({"error": "Course not found"}, status=404)
788+
command = EnrollCommand(
789+
email=serializer.learner_email,
790+
course_slug=course.slug,
791+
organization_id=kwargs["organization_id"],
792+
no_verification=True, # skip verification email for manual enrollments through the API
793+
)
794+
try:
795+
command.execute()
796+
except BlockedEmailError as e:
797+
return JsonResponse({"error": str(e)}, status=403)
798+
except EnrollmentAlreadyExistsError as e:
799+
return JsonResponse({"error": str(e)}, status=409)
800+
801+
enrollment = Enrollment.objects.get(
802+
learner__email=serializer.learner_email, course_id=kwargs["course_id"]
803+
)
804+
verify_command = VerifyEnrollmentCommand(
805+
enrollment_id=enrollment.id,
806+
verification_code=enrollment.activation_code, # type: ignore[arg-type]
807+
)
808+
verify_command.execute()
809+
enrollment.refresh_from_db()
810+
return JsonResponse(
811+
serializers.EnrollmentResponse.from_django_model(
812+
enrollment
813+
).model_dump(),
814+
status=201,
815+
)
816+
except ValidationError as e:
817+
return JsonResponse({"error": e.json()}, status=400)
818+
819+
766820
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
767821
class EnrollmentView(View):
768822
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]

django_email_learning/public/api/views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
from django_email_learning.services.command_models.exceptions.invalid_course_slug_error import (
77
InvalidCourseSlugError,
88
)
9+
from django_email_learning.services.command_models.exceptions.blocked_email_error import (
10+
BlockedEmailError,
11+
)
12+
from django_email_learning.services.command_models.exceptions.enrollment_already_exists_error import (
13+
EnrollmentAlreadyExistsError,
14+
)
915
import json
1016
import logging
1117

@@ -27,6 +33,12 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
2733
try:
2834
command.execute()
2935
return JsonResponse({"status": "enrolled"}, status=200)
36+
except EnrollmentAlreadyExistsError as e:
37+
logger.info(f"Enrollment already exists: {e}")
38+
return JsonResponse({"status": "already_enrolled"}, status=200)
39+
except BlockedEmailError as e:
40+
logger.error(f"Blocked email error: {e}")
41+
return JsonResponse({"error": str(e)}, status=403)
3042
except InvalidCourseSlugError as e:
3143
logger.error(f"Invalid course slug error: {e}")
3244
return JsonResponse({"error": str(e)}, status=400)

django_email_learning/services/command_models/enroll_command.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
from django_email_learning.services.command_models.exceptions.invalid_course_slug_error import (
55
InvalidCourseSlugError,
66
)
7+
from django_email_learning.services.command_models.exceptions.enrollment_already_exists_error import (
8+
EnrollmentAlreadyExistsError,
9+
)
10+
from django_email_learning.services.command_models.exceptions.blocked_email_error import (
11+
BlockedEmailError,
12+
)
713
from django_email_learning.models import (
814
BlockedEmail,
915
Learner,
@@ -27,14 +33,15 @@ class EnrollCommand(AbstractCommand):
2733
email: str
2834
course_slug: str
2935
organization_id: int
36+
no_verification: bool = False
3037

3138
def execute(self) -> None:
3239
# Check if the email is blocked
3340
if BlockedEmail.objects.filter(email=self.email).exists():
3441
self.logger.info(
3542
f"Enrollment Rejected: {mask_email(self.email)} is blocked"
3643
)
37-
return
44+
raise BlockedEmailError(f"The email {mask_email(self.email)} is blocked.")
3845

3946
# Check if Learner with the email exists, if not create one
4047
learner, created = Learner.objects.get_or_create(
@@ -68,7 +75,9 @@ def execute(self) -> None:
6875
self.logger.info(
6976
f"Enrollment Skipped: Learner ID {learner.id} is already enrolled in course '{self.course_slug}'"
7077
)
71-
return
78+
raise EnrollmentAlreadyExistsError(
79+
f"Learner with email {mask_email(self.email)} is already enrolled in course '{self.course_slug}'"
80+
)
7281

7382
# Create the enrollment
7483
enrollment = Enrollment.objects.create(
@@ -79,6 +88,12 @@ def execute(self) -> None:
7988
f"Enrollment Successful: Learner ID {learner.id} enrolled in course '{self.course_slug}'. Enrollment ID: {enrollment.id}"
8089
)
8190

91+
if self.no_verification:
92+
self.logger.info(
93+
f"Verification email skipped for Enrollment ID: {enrollment.id} as per command parameter"
94+
)
95+
return
96+
8297
# Send verification email
8398

8499
token = jwt_service.generate_jwt(
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class BlockedEmailError(Exception):
2+
"""Exception raised when an email is blocked."""
3+
4+
pass
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class EnrollmentAlreadyExistsError(Exception):
2+
"""Exception raised when an enrollment already exists for a learner in a course."""
3+
4+
pass

frontend/platform/course/Course.jsx

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import render, { useAppContext } from '../../src/render.jsx';
55
import Base from '../../src/components/Base.jsx'
66
import DescriptionIcon from '@mui/icons-material/Description';
77
import BallotIcon from '@mui/icons-material/Ballot';
8+
import PersonAddAlt1Icon from '@mui/icons-material/PersonAddAlt1';
89
import { useState, useEffect } from 'react';
9-
import { Box, Grid, Button, Dialog, LinearProgress, Typography } from '@mui/material'
10+
import { Box, Grid, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, LinearProgress, Typography, Menu, MenuItem, Alert } from '@mui/material'
1011
import { useTheme } from '@mui/material/styles';
1112
import ContentTable from './components/ContentTable.jsx';
1213
import { PieChart } from '@mui/x-charts/PieChart'
@@ -27,6 +28,12 @@ function Course() {
2728
const [dialogMaxWidth, setDialogMaxWidth] = useState('lg');
2829
const [enrollmentsCount, setEnrollmentsCount] = useState(null);
2930
const [weeklyStats, setWeeklyStats] = useState(null);
31+
const [enrollMenuAnchorEl, setEnrollMenuAnchorEl] = useState(null);
32+
const [manualEnrollOpen, setManualEnrollOpen] = useState(false);
33+
const [manualEnrollEmail, setManualEnrollEmail] = useState('');
34+
const [manualEnrollError, setManualEnrollError] = useState('');
35+
const [manualEnrollSubmitting, setManualEnrollSubmitting] = useState(false);
36+
const [pageSuccessMessage, setPageSuccessMessage] = useState('');
3037
const organizationId = localStorage.getItem('activeOrganizationId');
3138

3239
const theme = useTheme();
@@ -75,6 +82,71 @@ function Course() {
7582
setDialogOpen(false);
7683
}
7784
}
85+
86+
const isEnrollMenuOpen = Boolean(enrollMenuAnchorEl);
87+
88+
const openEnrollMenu = (event) => {
89+
setEnrollMenuAnchorEl(event.currentTarget);
90+
}
91+
92+
const closeEnrollMenu = () => {
93+
setEnrollMenuAnchorEl(null);
94+
}
95+
96+
const openManualEnrollDialog = () => {
97+
closeEnrollMenu();
98+
setManualEnrollError('');
99+
setManualEnrollEmail('');
100+
setManualEnrollOpen(true);
101+
}
102+
103+
const closeManualEnrollDialog = () => {
104+
if (manualEnrollSubmitting) {
105+
return;
106+
}
107+
setManualEnrollOpen(false);
108+
setManualEnrollError('');
109+
}
110+
111+
const submitManualEnrollment = async () => {
112+
const email = manualEnrollEmail.trim();
113+
if (!email) {
114+
setManualEnrollError(localeMessages['email_required'] || 'Email is required.');
115+
return;
116+
}
117+
118+
setManualEnrollSubmitting(true);
119+
setManualEnrollError('');
120+
121+
try {
122+
const response = await fetch(`${apiBaseUrl}/organizations/${organizationId}/courses/${courseId}/enrollments/`, {
123+
method: 'POST',
124+
headers: {
125+
'Content-Type': 'application/json',
126+
'X-CSRFToken': getCookie('csrftoken')
127+
},
128+
body: JSON.stringify({
129+
learner_email: email,
130+
})
131+
});
132+
133+
const data = await response.json();
134+
135+
if (!response.ok) {
136+
setManualEnrollError(data?.error || (localeMessages['enrollment_failed'] || 'Failed to enroll learner.'));
137+
return;
138+
}
139+
140+
setPageSuccessMessage(localeMessages['enrollment_success'] || 'Learner enrolled successfully.');
141+
setManualEnrollEmail('');
142+
setManualEnrollOpen(false);
143+
} catch (error) {
144+
console.error('Error enrolling learner:', error);
145+
setManualEnrollError(localeMessages['enrollment_failed'] || 'Failed to enroll learner.');
146+
} finally {
147+
setManualEnrollSubmitting(false);
148+
}
149+
}
78150
const getContent = async (contentId, ) => {
79151
console.log("Fetching content with ID:", contentId);
80152
const response = await fetch(`${apiBaseUrl}/organizations/${organizationId}/courses/${courseId}/contents/${contentId}/`, {
@@ -200,6 +272,13 @@ function Course() {
200272
]}
201273
showOrganizationSwitcher={false}
202274
>
275+
{pageSuccessMessage && (
276+
<Grid size={{ xs: 12 }} px={2} pt={2}>
277+
<Alert severity="success" onClose={() => setPageSuccessMessage('')}>
278+
{pageSuccessMessage}
279+
</Alert>
280+
</Grid>
281+
)}
203282
<Grid size={{xs: 12}} py={2} pl={2}>
204283
<Box p={2} sx={{ border: '1px solid', borderColor: 'border.main', backgroundColor: 'background.box', borderRadius: 2, minHeight: 300 }}>
205284
{userRole !== 'viewer' && <><Button variant="contained" startIcon={<DescriptionIcon sx={{ marginLeft: direction == 'rtl' ? 1 : 0 }} />} sx={{ marginBottom: 2 }} onClick={() => {
@@ -214,7 +293,32 @@ function Course() {
214293
cancelCallback={() => setDialogOpen(false)}
215294
successCallback={resetDialog}
216295
courseId={courseId} /></Suspense>);
217-
setDialogOpen(true);}}>{localeMessages["add_quiz"]}</Button></> }
296+
setDialogOpen(true);}}>{localeMessages["add_quiz"]}</Button>
297+
{userRole === 'admin' && <>
298+
<Button
299+
variant="outlined"
300+
startIcon={<PersonAddAlt1Icon sx={{ marginLeft: direction == 'rtl' ? 1 : 0 }} />}
301+
sx={{ marginBottom: 2, minWidth: 190 }}
302+
onClick={openEnrollMenu}
303+
>
304+
{localeMessages['enroll_learner'] || 'Enroll Learner'}
305+
</Button>
306+
<Menu
307+
anchorEl={enrollMenuAnchorEl}
308+
open={isEnrollMenuOpen}
309+
onClose={closeEnrollMenu}
310+
PaperProps={{
311+
sx: {
312+
minWidth: 240,
313+
}
314+
}}
315+
>
316+
<MenuItem onClick={openManualEnrollDialog}>
317+
{localeMessages['manual_email'] || 'Manual email'}
318+
</MenuItem>
319+
</Menu>
320+
</>}
321+
</> }
218322
<ContentTable courseId={courseId} loaded={contentLoaded} eventHandler={(event) => tableEventHandler(event)} />
219323
</Box>
220324
<Grid container spacing={2}>
@@ -267,6 +371,31 @@ function Course() {
267371
<Dialog open={dialogOpen} onClose={handleClose} fullWidth maxWidth={dialogMaxWidth}>
268372
{dialogContent}
269373
</Dialog>
374+
375+
<Dialog open={manualEnrollOpen} onClose={closeManualEnrollDialog} fullWidth maxWidth="sm">
376+
<DialogTitle>{localeMessages['enroll_learner'] || 'Enroll Learner'}</DialogTitle>
377+
<DialogContent>
378+
<TextField
379+
fullWidth
380+
autoFocus
381+
margin="dense"
382+
type="email"
383+
label={localeMessages['email'] || 'Email'}
384+
value={manualEnrollEmail}
385+
onChange={(event) => setManualEnrollEmail(event.target.value)}
386+
disabled={manualEnrollSubmitting}
387+
/>
388+
{manualEnrollError && <Alert severity="error" sx={{ mt: 2 }}>{manualEnrollError}</Alert>}
389+
</DialogContent>
390+
<DialogActions>
391+
<Button onClick={closeManualEnrollDialog} disabled={manualEnrollSubmitting}>
392+
{localeMessages['cancel'] || 'Cancel'}
393+
</Button>
394+
<Button variant="contained" onClick={submitManualEnrollment} disabled={manualEnrollSubmitting}>
395+
{localeMessages['enroll'] || 'Enroll'}
396+
</Button>
397+
</DialogActions>
398+
</Dialog>
270399
</Base>
271400
)
272401
}

0 commit comments

Comments
 (0)