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
18 changes: 16 additions & 2 deletions django_email_learning/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ class LessonCreate(BaseModel):
type: Literal["lesson"]


class LessonUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None

model_config = ConfigDict(extra="forbid")


class LessonResponse(BaseModel):
id: int
title: str
Expand Down Expand Up @@ -282,10 +289,17 @@ def from_seconds(cls, seconds: int) -> "WaitingPeriod":


class CreateCourseContentRequest(BaseModel):
priority: int = Field(gt=0, examples=[1])
priority: int | None = Field(gt=0, examples=[1], default=None)
waiting_period: WaitingPeriod
content: LessonCreate | QuizCreate = Field(discriminator="type")

@property
def required_priority(self) -> int:
if self.priority is not None:
return self.priority
else:
raise ValueError("Priority must be set before converting to Django model.")

def to_django_model(self, course: Course) -> CourseContent:
lesson = None
quiz = None
Expand Down Expand Up @@ -319,7 +333,7 @@ def to_django_model(self, course: Course) -> CourseContent:
content_type = "quiz"
course_content = CourseContent.objects.create(
course=course,
priority=self.priority,
priority=self.required_priority,
waiting_period=self.waiting_period.to_seconds(),
lesson=lesson,
quiz=quiz,
Expand Down
6 changes: 6 additions & 0 deletions django_email_learning/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django_email_learning.api.views import (
CourseView,
ImapConnectionView,
LessonView,
OrganizationsView,
SingleCourseView,
CourseContentView,
Expand Down Expand Up @@ -38,6 +39,11 @@
SingleCourseContentView.as_view(),
name="single_course_content_view",
),
path(
"organizations/<int:organization_id>/lessons/<int:lesson_id>/",
LessonView.as_view(),
name="lesson_view",
),
path("organizations/", OrganizationsView.as_view(), name="organizations_view"),
path("session", UpdateSessionView.as_view(), name="update_session_view"),
path("", page_not_found, name="root"),
Expand Down
35 changes: 35 additions & 0 deletions django_email_learning/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
from django.db.utils import IntegrityError
from django.http import JsonResponse
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models
from pydantic import ValidationError

from django_email_learning.api import serializers
from django_email_learning.models import (
Course,
CourseContent,
ImapConnection,
Lesson,
OrganizationUser,
Organization,
)
Expand Down Expand Up @@ -68,6 +70,14 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
try:
serializer = serializers.CreateCourseContentRequest.model_validate(payload)
course = Course.objects.get(id=kwargs["course_id"])
if serializer.priority is None:
# Set priority to max existing priority + 1
max_priority = (
CourseContent.objects.filter(course_id=course.id)
.aggregate(max_priority=models.Max("priority"))
.get("max_priority")
)
serializer.priority = (max_priority or 0) + 1
course_content = serializer.to_django_model(course=course)

return JsonResponse(
Expand Down Expand Up @@ -130,6 +140,8 @@ def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
except (IntegrityError, ValueError) as e:
return JsonResponse({"error": str(e)}, status=409)

# TODO: Implement POST method for updating course content.


@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
Expand Down Expand Up @@ -255,6 +267,29 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
return JsonResponse({"error": str(e)}, status=409)


@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
class LessonView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
payload = json.loads(request.body)
try:
serializer = serializers.LessonUpdate.model_validate(payload)
lesson = Lesson.objects.get(id=kwargs["lesson_id"])
if serializer.title is not None:
lesson.title = serializer.title
if serializer.content is not None:
lesson.content = serializer.content
lesson.save()

return JsonResponse(
{},
status=204,
)
except Lesson.DoesNotExist:
return JsonResponse({"error": "Lesson not found"}, status=404)
except ValidationError as e:
return JsonResponse({"error": e.errors()}, status=400)


@method_decorator(is_an_organization_member(), name="post")
class UpdateSessionView(View):
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
Expand Down
3 changes: 2 additions & 1 deletion django_email_learning/platform/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ class CourseView(BasePlatformView):
def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
context = super().get_context_data(**kwargs)
course = Course.objects.get(pk=self.kwargs["course_id"])
context["page_title"] = course.title
context["course"] = course
context["page_title"] = f"Course: {course.title}"
return context


Expand Down
Binary file added django_email_learning/static/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions django_email_learning/templates/platform/base.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{% load django_vite %}
{% load static %}
<!doctype html>
<html lang="en">
<head>
Expand All @@ -14,6 +15,7 @@
</script>
<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' %}" />
<title>{% block title %}{{ page_title }}{% endblock %}</title>
</head>
<body>
Expand Down
6 changes: 5 additions & 1 deletion django_email_learning/templates/platform/course.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{% extends "platform/base.html" %}
{% load django_vite %}
{% block extra_head %}
<!-- {% vite_asset 'courses/Courses.jsx' %} -->
<script>
let course_title = "{{ course.title|escapejs }}";
let course_id = "{{ course.id }}";
</script>
{% vite_asset 'course/Course.jsx' %}
{% endblock %}
11 changes: 9 additions & 2 deletions django_service/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@

CORS_ALLOW_CREDENTIALS = True
CSRF_COOKIE_SECURE = False
CSRF_COOKIE_SAMESITE = "None"
# CSRF_COOKIE_SAMESITE = "None"


ROOT_URLCONF = "django_service.urls"
Expand Down Expand Up @@ -148,7 +148,14 @@

STATIC_URL = "static/"

STATIC_ROOT = BASE_DIR / "static"
# For development - where Django looks for static files
STATICFILES_DIRS = [
BASE_DIR / "django_service" / "static",
]

# For production - where collectstatic puts files
STATIC_ROOT = BASE_DIR / "staticfiles"


# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
Expand Down
Binary file added django_service/static/src/assets/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added django_service/static/src/assets/logo2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions django_service/templates/django_service/home.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
Expand Down Expand Up @@ -43,6 +44,11 @@
</head>
<body>
<div class="container">
<p style="text-align: center;">
<img
src="{% static 'src/assets/logo2.png' %}" alt="Django Email Learning Logo"
width="200"
/>
<h1>Welcome to Django Email Learning Development Sample Service</h1>
<p>
This is a sample Django service demonstrating the Django Email Learning package,
Expand Down
9 changes: 9 additions & 0 deletions django_service/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
from django.views.generic import TemplateView
from django.contrib.auth import views as auth_views
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static


urlpatterns = [
path(
Expand All @@ -34,3 +37,9 @@
include("django_email_learning.urls", namespace="django_email_learning"),
),
]

# Serve static files during development
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# Uncomment the line below if you have media files (user uploads)
# urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
118 changes: 118 additions & 0 deletions frontend/course/Course.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import './styles.scss'

import 'vite/modulepreload-polyfill'
import render from '../src/render.jsx';
import Base from '../src/components/Base.jsx'
import FilterListIcon from '@mui/icons-material/FilterList';
import DescriptionIcon from '@mui/icons-material/Description';
import BallotIcon from '@mui/icons-material/Ballot';
import { useState } from 'react';
import { Box, Grid, Button, Dialog } from '@mui/material'
import LessonForm from './components/LessonForm.jsx';
import QuizForm from './components/QuizForm.jsx';
import ContentTable from './components/ContentTable.jsx';
import { getCookie } from '../src/utils.js';


function Course() {
const platformBaseUrl = localStorage.getItem('platformBaseUrl');
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogContent, setDialogContent] = useState(null)
const [lessonCache, setLessonCache] = useState("")
const [contentLoaded, setContentLoaded] = useState(false)

const apiBaseUrl = localStorage.getItem('apiBaseUrl');
const organizationId = localStorage.getItem('activeOrganizationId');

const resetDialog = () => {
setDialogOpen(false);
setContentLoaded(false);
}

const handleClose = (event, reason) => {
if (reason !== "backdropClick") {
setDialogOpen(false);
}
}
const getContent = async (contentId, ) => {
console.log("Fetching content with ID:", contentId);
const response = await fetch(`${apiBaseUrl}/organizations/${organizationId}/courses/${course_id}/contents/${contentId}/`, {
method: 'GET',
headers: {
'X-CSRFToken': getCookie('csrftoken')
},
});
if (response.ok) {
const data = await response.json();
console.log("Content data:", data);
return data;
} else {
console.error('Error fetching content:', response.statusText);
return null;
}
}

const tableEventHandler = async (event) => {
console.log("Event triggered from ContentTable", event);
if (event.type === 'content_loaded') {
setContentLoaded(true);
}
if (event.type === 'content_clicked') {
const content = await getContent(event.content_id);
if (content.type == 'lesson') {
console.log("Opening lesson editor for content:", content);
setDialogOpen(true);
setDialogContent(<LessonForm
header="Update Lesson"
initialTitle={content.lesson.title}
initialContent={content.lesson.content}
onContentChange={setLessonCache}
cancelCallback={() => {setLessonCache(""); setDialogOpen(false);}}
successCallback={resetDialog}
courseId={course_id}
lessonId={content.lesson.id} />);
}
}
}

return (
<Base
breadCrumbList={[
{label: 'Course Management', href: platformBaseUrl + '/courses', index: 0},
{label: course_title, href: '#', index: 1}
]}
bottomDrawerParams={{
icon: <FilterListIcon />,
children: <div>Filter Options Here</div>,
}}
showOrganizationSwitcher={false}
>
<Grid size={{xs: 12, md: 9}} py={2} pl={2}>
<Box p={2} sx={{ border: '1px solid', borderColor: 'grey.300', borderRadius: 1, minHeight: 300 }}>
<Button variant="outlined" startIcon={<DescriptionIcon />} sx={{ marginBottom: 2 }} onClick={() => {
setDialogContent(<LessonForm
header="New Lesson"
initialContent={lessonCache}
onContentChange={setLessonCache}
cancelCallback={() => setDialogOpen(false)}
successCallback={resetDialog}
courseId={course_id} />);
setDialogOpen(true);}}>Add a Lesson</Button>
<Button variant="outlined" startIcon={<BallotIcon />} sx={{ marginBottom: 2, marginLeft: 1 }} onClick={() => {
setDialogContent(<QuizForm
cancelCallback={() => setDialogOpen(false)}
successCallback={resetDialog}
courseId={course_id} />);
setDialogOpen(true);}}>Add a Quiz</Button>
<ContentTable courseId={course_id} loaded={contentLoaded} eventHandler={(event) => tableEventHandler(event)} />
</Box>
</Grid>

<Dialog open={dialogOpen} onClose={handleClose} fullWidth maxWidth="lg" sx={{ md: { width: '80%' }, lg: { maxWidth: '70%' } }}>
{dialogContent}
</Dialog>
</Base>
)
}

render({children: <Course />});
Loading