From d8c3bd9d31c695d10d781ca9664e02db31231a88 Mon Sep 17 00:00:00 2001 From: Payam Date: Tue, 27 Jan 2026 23:54:16 +0400 Subject: [PATCH 1/2] Update version and menu hover style --- frontend/src/components/Base.jsx | 2 +- frontend/src/components/MenuBar.jsx | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Base.jsx b/frontend/src/components/Base.jsx index 31c37118..9d6097fc 100644 --- a/frontend/src/components/Base.jsx +++ b/frontend/src/components/Base.jsx @@ -111,7 +111,7 @@ function Base({breadCrumbList, children, bottomDrawerParams, organizationIdRefre target="_blank" rel="noopener noreferrer" sx={{ - color: 'secondary.main', + color: 'secondary.dark', textDecoration: 'none', '&:hover': { textDecoration: 'underline' diff --git a/frontend/src/components/MenuBar.jsx b/frontend/src/components/MenuBar.jsx index 8c8525b9..0ee8c14f 100644 --- a/frontend/src/components/MenuBar.jsx +++ b/frontend/src/components/MenuBar.jsx @@ -121,7 +121,7 @@ function MenuBar({activeOrganizationId, changeOrganizationCallback, showOrganiza } { pages.map((page) => ( - + {page.icon} diff --git a/pyproject.toml b/pyproject.toml index 45fdde62..d34f90b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "django-email-learning" -version = "0.1.21" +version = "0.1.22" description = "A platform for creating and delivering learning materials via email within a Django application. It provides tools for content management, user role-based administration, and scheduler integration for automated content delivery." authors = [ {name = "Payam Najafizadeh",email = "payam.nj@gmail.com"} From 3d00ba64283a8b1ec27443c657743638c403545c Mon Sep 17 00:00:00 2001 From: Payam Date: Wed, 28 Jan 2026 13:26:38 +0400 Subject: [PATCH 2/2] feat: #167 enrollments statistics --- django_email_learning/models.py | 67 ++-- .../platform/api/serializers.py | 1 + django_email_learning/platform/api/urls.py | 6 + django_email_learning/platform/api/views.py | 29 ++ .../templates/platform/course.html | 6 + .../templates/platform/courses.html | 1 + frontend/package-lock.json | 308 ++++++++++++++++++ frontend/package.json | 1 + frontend/platform/course/Course.jsx | 89 ++++- frontend/platform/courses/Courses.jsx | 4 +- frontend/src/theme/themes.js | 29 +- tests/conftest.py | 22 ++ .../api/test_views/test_course_view.py | 3 + .../test_views/test_enrollment_statistics.py | 19 ++ tests/test_models/test_course.py | 12 + 15 files changed, 555 insertions(+), 42 deletions(-) create mode 100644 tests/platform/api/test_views/test_enrollment_statistics.py create mode 100644 tests/test_models/test_course.py diff --git a/django_email_learning/models.py b/django_email_learning/models.py index 796a6431..f3b605c3 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -27,6 +27,28 @@ logger = logging.getLogger(__name__) +class EnrollmentStatus(StrEnum): + UNVERIFIED = "unverified" + ACTIVE = "active" + COMPLETED = "completed" + DEACTIVATED = "deactivated" + + +class DeactivationReason(StrEnum): + CANCELED = "canceled" + BLOCKED = "blocked" + FAILED = "failed" + INACTIVE = "inactive" + + +class DeliveryStatus(StrEnum): + SCHEDULED = "scheduled" + PROCESSING = "processing" + DELIVERED = "delivered" + CANCELED = "canceled" + BLOCKED = "blocked" + + def is_domain_or_ip(value: str) -> None: """ Validate if the given value is a valid domain name or IP address. @@ -149,6 +171,29 @@ def delete( ) return super().delete(using, keep_parents) + @property + def enrollments_count(self) -> dict[str, int]: + unverified_count = self.enrollment_set.filter( + status=EnrollmentStatus.UNVERIFIED + ).count() + active_count = self.enrollment_set.filter( + status=EnrollmentStatus.ACTIVE + ).count() + completed_count = self.enrollment_set.filter( + status=EnrollmentStatus.COMPLETED + ).count() + deactivated_count = self.enrollment_set.filter( + status=EnrollmentStatus.DEACTIVATED + ).count() + total_count = self.enrollment_set.count() + return { + EnrollmentStatus.UNVERIFIED: unverified_count, + EnrollmentStatus.ACTIVE: active_count, + EnrollmentStatus.COMPLETED: completed_count, + EnrollmentStatus.DEACTIVATED: deactivated_count, + "total": total_count, + } + class Lesson(models.Model): title = models.CharField(max_length=200) @@ -332,28 +377,6 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] super().save(*args, **kwargs) -class EnrollmentStatus(StrEnum): - UNVERIFIED = "unverified" - ACTIVE = "active" - COMPLETED = "completed" - DEACTIVATED = "deactivated" - - -class DeactivationReason(StrEnum): - CANCELED = "canceled" - BLOCKED = "blocked" - FAILED = "failed" - INACTIVE = "inactive" - - -class DeliveryStatus(StrEnum): - SCHEDULED = "scheduled" - PROCESSING = "processing" - DELIVERED = "delivered" - CANCELED = "canceled" - BLOCKED = "blocked" - - class Enrollment(models.Model): state_transitions = { EnrollmentStatus.UNVERIFIED: [ diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py index bb977fd9..7888ca32 100644 --- a/django_email_learning/platform/api/serializers.py +++ b/django_email_learning/platform/api/serializers.py @@ -112,6 +112,7 @@ class CourseResponse(BaseModel): organization_id: int imap_connection_id: Optional[int] enabled: bool + enrollments_count: dict[str, int] model_config = ConfigDict(from_attributes=True) diff --git a/django_email_learning/platform/api/urls.py b/django_email_learning/platform/api/urls.py index da087966..6119d388 100644 --- a/django_email_learning/platform/api/urls.py +++ b/django_email_learning/platform/api/urls.py @@ -3,6 +3,7 @@ from django_email_learning.platform.api.views import ( CourseView, EnrollmentView, + EnrollmentsStatisticsView, FileView, ImapConnectionView, OrganizationsView, @@ -64,6 +65,11 @@ EnrollmentView.as_view(), name="enrollment_view", ), + path( + "organizations//courses//enrollments/statistics/", + EnrollmentsStatisticsView.as_view(), + name="enrollments_statistics_view", + ), path( "organizations//file/", FileView.as_view(), diff --git a/django_email_learning/platform/api/views.py b/django_email_learning/platform/api/views.py index 48a67674..2c79d720 100644 --- a/django_email_learning/platform/api/views.py +++ b/django_email_learning/platform/api/views.py @@ -2,11 +2,14 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import ensure_csrf_cookie from django.db.utils import IntegrityError +from django.db.models.functions import TruncDate +from django.db.models import Count from django.http import JsonResponse from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models, transaction from django.core.files.storage import default_storage from django.utils import timezone +from datetime import timedelta from pydantic import ValidationError from django_email_learning.platform.api import serializers @@ -563,6 +566,32 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty return JsonResponse({"error": e.json()}, status=400) +@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get") +class EnrollmentsStatisticsView(View): + def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + course_id = kwargs["course_id"] + a_week_ago = timezone.now() - timedelta(days=7) + enrollments = ( + Enrollment.objects.filter(course_id=course_id, enrolled_at__gte=a_week_ago) + .annotate(created_date=TruncDate("enrolled_at")) + .values( + "created_date", + ) + .annotate(count=Count("id")) + .order_by("created_date") + ) + dates = [a_week_ago.date() + timedelta(days=i) for i in range(8)] + enrollments_dict = { + enrollment["created_date"]: enrollment["count"] + for enrollment in enrollments + } + stats = [ + {"date": date.isoformat(), "count": enrollments_dict.get(date, 0)} + for date in dates + ] + return JsonResponse({"statistics": stats}, status=200) + + class RootView(View): def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] return JsonResponse({"message": "Email Learning API is running."}, status=200) diff --git a/django_email_learning/templates/platform/course.html b/django_email_learning/templates/platform/course.html index df34b6a3..a00964ee 100644 --- a/django_email_learning/templates/platform/course.html +++ b/django_email_learning/templates/platform/course.html @@ -60,6 +60,12 @@ "lesson_title_required": "{% translate 'Lesson title is required.' %}", "lesson_content_required": "{% translate 'Lesson content is required.' %}", "delete_content_confirmation": "{% translate 'Are you sure you want to delete the content: CONTENT_TITLE?' %}", + "unverified": "{% translate 'Unverified' %}", + "active": "{% translate 'Active' %}", + "deactivated": "{% translate 'Deactivated' %}", + "completed": "{% translate 'Completed' %}", + "enrollments_distribution": "{% translate 'Enrollments Distribution' %}", + "weekly_enrollments": "{% translate 'Weekly Enrollments' %}", } {% vite_asset 'platform/course/Course.jsx' %} diff --git a/django_email_learning/templates/platform/courses.html b/django_email_learning/templates/platform/courses.html index 72d354de..248c0b3c 100644 --- a/django_email_learning/templates/platform/courses.html +++ b/django_email_learning/templates/platform/courses.html @@ -46,6 +46,7 @@ "port_required_helper_text": "{% translate 'The port is required.' %}", "invalid_port_helper_text": "{% translate 'The port must be a valid number.' %}", "invalid_email_helper_text": "{% translate 'The email must be a valid email address.' %}", + "total_enrollments": "{% translate 'Total Enrollments' %}", } {% vite_asset 'platform/courses/Courses.jsx' %} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a8648c7d..a77e6f1c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@mui/icons-material": "^7.3.2", "@mui/lab": "^7.0.1-beta.21", "@mui/material": "^7.3.2", + "@mui/x-charts": "^8.26.0", "@tiptap/core": "^3.13.0", "@tiptap/extension-bold": "^3.13.0", "@tiptap/extension-code-block": "^3.13.0", @@ -1492,6 +1493,105 @@ } } }, + "node_modules/@mui/x-charts": { + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.26.0.tgz", + "integrity": "sha512-TO9DvP1JWu1M2qbZ7QRMARsowTzyJ8MvBRFL+q+ojgD+Wn8aE3kkjHqs4QtPELfIoZx8Q9uWXn6Z0WIh780XIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-charts-vendor": "8.26.0", + "@mui/x-internal-gestures": "0.4.0", + "@mui/x-internals": "8.26.0", + "bezier-easing": "^2.1.0", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts-vendor": { + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.26.0.tgz", + "integrity": "sha512-R//+WSWvsLJRTjTRN90EKX9sgRzAb4HQBvtUA3cTQpkGrmEjmatD4BJAm3IdRdkSagf6yKWF+ypESctyRhbwnA==", + "license": "MIT AND ISC", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@types/d3-array": "^3.2.2", + "@types/d3-color": "^3.1.3", + "@types/d3-format": "^3.0.4", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-path": "^3.1.1", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/d3-time": "^3.0.4", + "@types/d3-time-format": "^4.0.3", + "@types/d3-timer": "^3.0.2", + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-format": "^3.1.0", + "d3-interpolate": "^3.0.1", + "d3-path": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-time-format": "^4.1.0", + "d3-timer": "^3.0.1", + "flatqueue": "^3.0.0", + "internmap": "^2.0.3" + } + }, + "node_modules/@mui/x-internal-gestures": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-0.4.0.tgz", + "integrity": "sha512-i0W6v9LoiNY8Yf1goOmaygtz/ncPJGBedhpDfvNg/i8BvzPwJcBaeW4rqPucJfVag9KQ8MSssBBrvYeEnrQmhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + } + }, + "node_modules/@mui/x-internals": { + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.26.0.tgz", + "integrity": "sha512-B9OZau5IQUvIxwpJZhoFJKqRpmWf5r0yMmSXjQuqb5WuqM755EuzWJOenY48denGoENzMLT8hQpA0hRTeU2IPA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -2374,6 +2474,75 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2560,6 +2729,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2784,6 +2959,118 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3220,6 +3507,12 @@ "node": ">=16" } }, + "node_modules/flatqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-3.0.0.tgz", + "integrity": "sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q==", + "license": "ISC" + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -3384,6 +3677,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4200,6 +4502,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6393b0c4..90335dea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@mui/icons-material": "^7.3.2", "@mui/lab": "^7.0.1-beta.21", "@mui/material": "^7.3.2", + "@mui/x-charts": "^8.26.0", "@tiptap/core": "^3.13.0", "@tiptap/extension-bold": "^3.13.0", "@tiptap/extension-code-block": "^3.13.0", diff --git a/frontend/platform/course/Course.jsx b/frontend/platform/course/Course.jsx index f78b8f6f..151e904a 100644 --- a/frontend/platform/course/Course.jsx +++ b/frontend/platform/course/Course.jsx @@ -6,12 +6,15 @@ 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 { useState, useEffect } from 'react'; +import { Box, Grid, Button, Dialog, Typography } from '@mui/material' +import { useTheme } from '@mui/material/styles'; import LessonForm from './components/LessonForm.jsx'; import QuizForm from './components/QuizForm.jsx'; import ContentTable from './components/ContentTable.jsx'; import DeleteContentForm from './components/DeleteContentForm.jsx'; +import { PieChart } from '@mui/x-charts/PieChart' +import { BarChart } from '@mui/x-charts/BarChart'; import { getCookie } from '../../src/utils.js'; @@ -22,17 +25,54 @@ function Course() { const [lessonCache, setLessonCache] = useState("") const [contentLoaded, setContentLoaded] = useState(false) const [dialogMaxWidth, setDialogMaxWidth] = useState('lg'); + const [enrollmentsCount, setEnrollmentsCount] = useState(null); + const [weeklyStats, setWeeklyStats] = useState(null); const userRole = localStorage.getItem('userRole'); const apiBaseUrl = localStorage.getItem('apiBaseUrl'); const organizationId = localStorage.getItem('activeOrganizationId'); + const theme = useTheme(); + const resetDialog = () => { setDialogOpen(false); setContentLoaded(false); } + useEffect(() => { + fetch(`${apiBaseUrl}/organizations/${organizationId}/courses/${course_id}/`, { + method: 'GET', + headers: { + 'X-CSRFToken': getCookie('csrftoken') + }, + }) + .then(response => response.json()) + .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["deactivated"], value: data.enrollments_count.deactivated, color: theme.palette.grey[300] }, + { label: localeMessages["completed"], value: data.enrollments_count.completed, color: theme.palette.primary.main }, + ]); + }) + .catch(error => console.error('Error fetching course data:', error)); + + fetch(`${apiBaseUrl}/organizations/${organizationId}/courses/${course_id}/enrollments/statistics/`, { + method: 'GET', + headers: { + 'X-CSRFToken': getCookie('csrftoken') + }, + }) + .then(response => response.json()) + .then(data => { + // Handle the statistics data here + console.log("Enrollment statistics:", data.statistics); + setWeeklyStats(data.statistics); + }) + .catch(error => console.error('Error fetching enrollment statistics:', error)); + }, []); + const handleClose = (event, reason) => { if (reason !== "backdropClick" && reason !== "escapeKeyDown") { setDialogOpen(false); @@ -187,6 +227,51 @@ function Course() { setDialogOpen(true);}}>{localeMessages["add_quiz"]} } tableEventHandler(event)} /> + + { enrollmentsCount && + + {localeMessages["enrollments_distribution"]} + + + } + { weeklyStats && + + {localeMessages["weekly_enrollments"]} + stat.date)}]} + series={[{ data: weeklyStats.map((stat) => stat.count), color: theme.palette.primary.main }]} + height={300} + /> + + } + diff --git a/frontend/platform/courses/Courses.jsx b/frontend/platform/courses/Courses.jsx index 6da490f0..f5916ece 100644 --- a/frontend/platform/courses/Courses.jsx +++ b/frontend/platform/courses/Courses.jsx @@ -121,7 +121,7 @@ function Courses() { {localeMessages["title"]} - {localeMessages["slug"]} + {localeMessages["total_enrollments"]} {localeMessages["enabled"]} {userRole !== 'viewer' && {localeMessages["actions"]}} @@ -135,7 +135,7 @@ function Courses() { {course.title} - {course.slug} + {course.enrollments_count.total}