Skip to content

Commit 689311a

Browse files
authored
Merge pull request #248 from AvaCodeSolutions/feat/244/admin-enroll
Feat/244/admin enroll
2 parents e2d4898 + 8bdb0a8 commit 689311a

15 files changed

Lines changed: 487 additions & 73 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"}), 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/platform/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
220220
def get_locale_messages(self) -> Dict[str, str]:
221221
return {
222222
"actions": _("Actions"),
223+
"enroll_learner": _("Enroll Learner"),
224+
"manual_email": _("Manual Email"),
223225
"published": _("Published"),
224226
"type": _("Type"),
225227
"waiting_time": _("Waiting Time"),

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: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import './styles.scss'
33
import 'vite/modulepreload-polyfill'
44
import render, { useAppContext } from '../../src/render.jsx';
55
import Base from '../../src/components/Base.jsx'
6+
import EnrollMenu from './components/EnrollMenu.jsx';
67
import DescriptionIcon from '@mui/icons-material/Description';
78
import BallotIcon from '@mui/icons-material/Ballot';
89
import { useState, useEffect } from 'react';
9-
import { Box, Grid, Button, Dialog, LinearProgress, Typography } from '@mui/material'
10+
import { Box, Grid, Button, Dialog, LinearProgress, Typography, 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,9 @@ function Course() {
2728
const [dialogMaxWidth, setDialogMaxWidth] = useState('lg');
2829
const [enrollmentsCount, setEnrollmentsCount] = useState(null);
2930
const [weeklyStats, setWeeklyStats] = useState(null);
31+
32+
const [pageSuccessMessage, setPageSuccessMessage] = useState('');
33+
3034
const organizationId = localStorage.getItem('activeOrganizationId');
3135

3236
const theme = useTheme();
@@ -75,6 +79,7 @@ function Course() {
7579
setDialogOpen(false);
7680
}
7781
}
82+
7883
const getContent = async (contentId, ) => {
7984
console.log("Fetching content with ID:", contentId);
8085
const response = await fetch(`${apiBaseUrl}/organizations/${organizationId}/courses/${courseId}/contents/${contentId}/`, {
@@ -200,6 +205,13 @@ function Course() {
200205
]}
201206
showOrganizationSwitcher={false}
202207
>
208+
{pageSuccessMessage && (
209+
<Grid size={{ xs: 12 }} px={2} pt={2}>
210+
<Alert severity="success" onClose={() => setPageSuccessMessage('')}>
211+
{pageSuccessMessage}
212+
</Alert>
213+
</Grid>
214+
)}
203215
<Grid size={{xs: 12}} py={2} pl={2}>
204216
<Box p={2} sx={{ border: '1px solid', borderColor: 'border.main', backgroundColor: 'background.box', borderRadius: 2, minHeight: 300 }}>
205217
{userRole !== 'viewer' && <><Button variant="contained" startIcon={<DescriptionIcon sx={{ marginLeft: direction == 'rtl' ? 1 : 0 }} />} sx={{ marginBottom: 2 }} onClick={() => {
@@ -214,7 +226,9 @@ function Course() {
214226
cancelCallback={() => setDialogOpen(false)}
215227
successCallback={resetDialog}
216228
courseId={courseId} /></Suspense>);
217-
setDialogOpen(true);}}>{localeMessages["add_quiz"]}</Button></> }
229+
setDialogOpen(true);}}>{localeMessages["add_quiz"]}</Button>
230+
{userRole === 'admin' && <EnrollMenu successCallback={() => {setPageSuccessMessage(localeMessages['enrollment_success'] || 'Learner enrolled successfully.'); setTimeout(() => setPageSuccessMessage(''), 3000);}} />}
231+
</> }
218232
<ContentTable courseId={courseId} loaded={contentLoaded} eventHandler={(event) => tableEventHandler(event)} />
219233
</Box>
220234
<Grid container spacing={2}>

0 commit comments

Comments
 (0)