diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py index ed7f126..3efbda9 100644 --- a/django_email_learning/platform/api/serializers.py +++ b/django_email_learning/platform/api/serializers.py @@ -78,6 +78,10 @@ class UserResponse(BaseModel): model_config = ConfigDict(from_attributes=True) +class Identifier(BaseModel): + id: int + + class CreateCourseRequest(BaseModel): title: str = Field(min_length=1, examples=["Introduction to Python"]) slug: str = Field( diff --git a/django_email_learning/platform/api/urls.py b/django_email_learning/platform/api/urls.py index 0580694..bc29874 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 ( ApiKeyView, GetOrCreateUserByEmail, + SendLessonToPlatformUser, SingleApiKeyView, CourseView, EnrollmentsView, @@ -92,6 +93,11 @@ EnrollmentsStatisticsView.as_view(), name="enrollments_statistics_view", ), + path( + "organizations//send-lesson/", + SendLessonToPlatformUser.as_view(), + name="send_lesson_to_platform_user_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 f1f6363..f7442c7 100644 --- a/django_email_learning/platform/api/views.py +++ b/django_email_learning/platform/api/views.py @@ -18,6 +18,9 @@ from pydantic import ValidationError from enum import StrEnum from django_email_learning.services.command_models.enroll_command import EnrollCommand +from django_email_learning.services.command_models.send_lesson_command import ( + SendLessonCommand, +) from django_email_learning.services.command_models.verify_enrollment_command import ( VerifyEnrollmentCommand, ) @@ -628,6 +631,37 @@ 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 SendLessonToPlatformUser(View): + def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + if not request.user.email: + return JsonResponse( + {"error": "User does not have an email address"}, status=400 + ) + payload = json.loads(request.body) + serializer = serializers.Identifier.model_validate(payload) + email = request.user.email + try: + # Accept course content id directly (used by platform content table), + # with a lesson-id fallback for backward compatibility. + course_content = CourseContent.objects.filter( + id=serializer.id, + type="lesson", + course__organization_id=kwargs["organization_id"], + ).first() + if course_content is None: + course_content = CourseContent.objects.get( + lesson_id=serializer.id, + course__organization_id=kwargs["organization_id"], + ) + SendLessonCommand(content_id=course_content.id, email=email).execute() + except CourseContent.DoesNotExist: + return JsonResponse({"error": "Lesson not found"}, status=404) + return JsonResponse( + {"message": "Email content logged successfully"}, status=200 + ) + + @method_decorator(accessible_for(roles={"admin", "editor"}), name="post") @method_decorator(accessible_for(roles={"admin", "editor"}), name="delete") class FileView(View): diff --git a/django_email_learning/platform/views.py b/django_email_learning/platform/views.py index 822344d..74c31be 100644 --- a/django_email_learning/platform/views.py +++ b/django_email_learning/platform/views.py @@ -252,6 +252,7 @@ def get_locale_messages(self) -> Dict[str, str]: "waiting_time": _("Waiting Time"), "title": _("Title"), "add_quiz": _("Add Quiz"), + "send_lesson_to_yourself": _("Send it to yourself"), "add_lesson": _("Add Lesson"), "lesson": _("Lesson"), "quiz": _("Quiz"), @@ -284,6 +285,8 @@ def get_locale_messages(self) -> Dict[str, str]: "cancel": _("Cancel"), "delete": _("Delete"), "save_lesson": _("Save Lesson"), + "lesson_saved_success": _("Lesson content saved successfully."), + "lesson_unsaved_changes_hint": _("You have unsaved changes."), "save_quiz": _("Save Quiz"), "quiz_title": _("Quiz Title"), "add_question": _("Add Question"), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92fd549..297eed9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1800,9 +1800,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.0.tgz", - "integrity": "sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1814,9 +1814,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.0.tgz", - "integrity": "sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1828,9 +1828,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.0.tgz", - "integrity": "sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -1842,9 +1842,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.0.tgz", - "integrity": "sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1856,9 +1856,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.0.tgz", - "integrity": "sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1870,9 +1870,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.0.tgz", - "integrity": "sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1884,9 +1884,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.0.tgz", - "integrity": "sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -1898,9 +1898,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.0.tgz", - "integrity": "sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -1912,9 +1912,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.0.tgz", - "integrity": "sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -1926,9 +1926,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.0.tgz", - "integrity": "sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -1939,10 +1939,24 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.0.tgz", - "integrity": "sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -1954,9 +1968,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.0.tgz", - "integrity": "sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1968,9 +1996,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.0.tgz", - "integrity": "sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1982,9 +2010,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.0.tgz", - "integrity": "sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1996,9 +2024,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.0.tgz", - "integrity": "sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -2010,7 +2038,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.50.0", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -2022,7 +2052,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.50.0", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -2033,10 +2065,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.0.tgz", - "integrity": "sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -2048,9 +2094,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.0.tgz", - "integrity": "sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -2062,9 +2108,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.0.tgz", - "integrity": "sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -2075,10 +2121,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.50.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.0.tgz", - "integrity": "sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -3597,7 +3657,9 @@ } }, "node_modules/immutable": { - "version": "5.1.4", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "dev": true, "license": "MIT" }, @@ -4603,7 +4665,9 @@ } }, "node_modules/rollup": { - "version": "4.50.0", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -4617,27 +4681,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.50.0", - "@rollup/rollup-android-arm64": "4.50.0", - "@rollup/rollup-darwin-arm64": "4.50.0", - "@rollup/rollup-darwin-x64": "4.50.0", - "@rollup/rollup-freebsd-arm64": "4.50.0", - "@rollup/rollup-freebsd-x64": "4.50.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.50.0", - "@rollup/rollup-linux-arm-musleabihf": "4.50.0", - "@rollup/rollup-linux-arm64-gnu": "4.50.0", - "@rollup/rollup-linux-arm64-musl": "4.50.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.50.0", - "@rollup/rollup-linux-ppc64-gnu": "4.50.0", - "@rollup/rollup-linux-riscv64-gnu": "4.50.0", - "@rollup/rollup-linux-riscv64-musl": "4.50.0", - "@rollup/rollup-linux-s390x-gnu": "4.50.0", - "@rollup/rollup-linux-x64-gnu": "4.50.0", - "@rollup/rollup-linux-x64-musl": "4.50.0", - "@rollup/rollup-openharmony-arm64": "4.50.0", - "@rollup/rollup-win32-arm64-msvc": "4.50.0", - "@rollup/rollup-win32-ia32-msvc": "4.50.0", - "@rollup/rollup-win32-x64-msvc": "4.50.0", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/frontend/platform/course/components/ContentTable.jsx b/frontend/platform/course/components/ContentTable.jsx index 2805f58..83c3a0d 100644 --- a/frontend/platform/course/components/ContentTable.jsx +++ b/frontend/platform/course/components/ContentTable.jsx @@ -1,8 +1,9 @@ -import { IconButton, Switch, TableContainer, Table, TableHead, TableRow, TableBody, TableCell, Paper, Typography, Tab } from '@mui/material'; +import { Alert, CircularProgress, IconButton, Switch, TableContainer, Table, TableHead, TableRow, TableBody, TableCell, Paper, Tooltip, Typography } from '@mui/material'; import { useState, useEffect } from 'react'; import { getCookie } from '../../../src/utils.js'; import DeleteIcon from '@mui/icons-material/Delete'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import ForwardToInboxOutlinedIcon from '@mui/icons-material/ForwardToInboxOutlined'; import { useAppContext } from '../../../src/render.jsx'; @@ -10,6 +11,8 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => { const [contentList, setContentList] = useState([]); const [isDragging, setIsDragging] = useState(false); const [draggedContentId, setDraggedContentId] = useState(null); + const [sendingContentId, setSendingContentId] = useState(null); + const [sendSuccessMessage, setSendSuccessMessage] = useState(''); const startDrag = (event, contentId) => { event.preventDefault(); @@ -19,6 +22,7 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => { const { apiBaseUrl, userRole, localeMessages, direction } = useAppContext(); const organizationId = localStorage.getItem('activeOrganizationId'); + const canSendLesson = userRole === 'admin' || userRole === 'editor'; const formatPeriod = (period) => { if (!period) { @@ -61,6 +65,20 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => { }; }, [isDragging]); + useEffect(() => { + if (!sendSuccessMessage) { + return; + } + + const timeoutId = window.setTimeout(() => { + setSendSuccessMessage(''); + }, 4000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [sendSuccessMessage]); + const deleteContent = (contentId) => { eventHandler({ type: 'delete_content', content: contentList.find(content => content.id === contentId)}); } @@ -112,7 +130,43 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => { .catch(error => console.error('Error fetching content list:', error)); } + const sendLessonToCurrentUser = (contentId) => { + setSendingContentId(contentId); + fetch(`${apiBaseUrl}/organizations/${organizationId}/send-lesson/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + id: contentId, + }), + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Send lesson failed with status ${response.status}`); + } + return response.json(); + }) + .then(() => { + console.log('Lesson sent successfully'); + setSendSuccessMessage(localeMessages["lesson_sent_to_your_email"] || 'Lesson sent to your email.'); + }) + .catch((error) => { + console.error('Error sending lesson:', error); + }) + .finally(() => { + setSendingContentId(null); + }); + } + return ( + <> + {sendSuccessMessage && ( + + {sendSuccessMessage} + + )} @@ -166,20 +220,38 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => { />} {let event = {type: 'content_clicked', content_id: content.id}; eventHandler(event);}} - color='secondary.dark' sx={{ cursor: 'pointer'}}>{content.title} + sx={{ cursor: 'pointer', color: theme => theme.palette.mode === 'dark' ? theme.palette.secondary.main : theme.palette.secondary.dark }}>{content.title} {formatPeriod(content.waiting_period)} {localeMessages[content.type]} TogglePublishContent(content.id, !content.is_published)} disabled={userRole == 'viewer'} /> {userRole !== 'viewer' && + deleteContent(content.id)}> + {canSendLesson && content.type === 'lesson' && ( + sendingContentId === content.id ? ( + + ) : ( + + + sendLessonToCurrentUser(content.id)} + > + + + + + ) + )} } ))}
+ ); } diff --git a/frontend/platform/course/components/LessonForm.jsx b/frontend/platform/course/components/LessonForm.jsx index f046136..d3f0d7b 100644 --- a/frontend/platform/course/components/LessonForm.jsx +++ b/frontend/platform/course/components/LessonForm.jsx @@ -9,24 +9,53 @@ import { getCookie } from '../../../src/utils.js'; import { useAppContext } from '../../../src/render.jsx'; function LessonForm({ header, initialTitle, initialContent, cancelCallback, successCallback, courseId, lessonId, initialWaitingPeriod, contentId }) { + const initialWaitingPeriodValue = initialWaitingPeriod ? initialWaitingPeriod.period : 1; + const initialWaitingPeriodUnit = initialWaitingPeriod ? initialWaitingPeriod.type : "days"; const [title, setTitle] = useState(initialTitle || ""); const [content, setContent] = useState(initialContent || ""); - const [waitingPeriod, setWaitingPeriod] = useState(initialWaitingPeriod ? initialWaitingPeriod.period : 1); - const [waitingPeriodUnit, setWaitingPeriodUnit] = useState(initialWaitingPeriod ? initialWaitingPeriod.type : "days"); + const [waitingPeriod, setWaitingPeriod] = useState(initialWaitingPeriodValue); + const [waitingPeriodUnit, setWaitingPeriodUnit] = useState(initialWaitingPeriodUnit); const [titleHelperText, setTitleHelperText] = useState(""); const [contentHelperText, setContentHelperText] = useState(""); const [errorMessage, setErrorMessage] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); const [uploadedImages, setUploadedImages] = useState([]); const [imageUploadError, setImageUploadError] = useState(""); const [editorInstance, setEditorInstance] = useState(null); const [imagePendingDelete, setImagePendingDelete] = useState(null); const [deleteImageDialogOpen, setDeleteImageDialogOpen] = useState(false); const [isDeletingImage, setIsDeletingImage] = useState(false); + const [savedSnapshot, setSavedSnapshot] = useState({ + title: initialTitle || "", + content: initialContent || "", + waitingPeriod: String(initialWaitingPeriodValue), + waitingPeriodUnit: initialWaitingPeriodUnit, + }); const { localeMessages, apiBaseUrl, userRole, direction } = useAppContext(); const orgId = localStorage.getItem('activeOrganizationId'); + const hasUnsavedChanges = + title !== savedSnapshot.title + || content !== savedSnapshot.content + || String(waitingPeriod) !== savedSnapshot.waitingPeriod + || waitingPeriodUnit !== savedSnapshot.waitingPeriodUnit; + + useEffect(() => { + if (!successMessage) { + return; + } + + const timeoutId = window.setTimeout(() => { + setSuccessMessage(""); + }, 4000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [successMessage]); + const VisuallyHiddenInput = styled('input')({ clip: 'rect(0 0 0 0)', clipPath: 'inset(50%)', @@ -63,15 +92,27 @@ function LessonForm({ header, initialTitle, initialContent, cancelCallback, succ waiting_period: {"period": waitingPeriod, "type": waitingPeriodUnit}, }), }) - .then((response) => response.json()) + .then((response) => { + if (!response.ok) { + throw new Error('Lesson create failed'); + } + return response.json(); + }) .then((data) => { console.log('Lesson created successfully:', data); - setContent(""); - setTitle(""); - successCallback(); + setErrorMessage(""); + setSuccessMessage(localeMessages["lesson_saved_success"] || "Lesson content saved successfully."); + setSavedSnapshot({ + title, + content, + waitingPeriod: String(waitingPeriod), + waitingPeriodUnit, + }); }) .catch((error) => { console.error('Error creating lesson:', error); + setSuccessMessage(""); + setErrorMessage(localeMessages["save_failed"] || "Unable to save lesson content. Please try again."); }); } @@ -102,16 +143,28 @@ function LessonForm({ header, initialTitle, initialContent, cancelCallback, succ console.log(response) if (response.status === 200) { console.log('Lesson updated successfully'); - successCallback(); + setErrorMessage(""); + setSuccessMessage(localeMessages["lesson_saved_success"]); + setSavedSnapshot({ + title, + content, + waitingPeriod: String(waitingPeriod), + waitingPeriodUnit, + }); + return; } + throw new Error('Lesson update failed'); }) .catch((error) => { console.error('Error updating lesson:', error); + setSuccessMessage(""); + setErrorMessage(localeMessages["save_failed"] || "Unable to save lesson content. Please try again."); }); } const handleContentChange = (newContent) => { setContent(newContent); + setSuccessMessage(""); } const cancel = () => { @@ -292,7 +345,10 @@ function LessonForm({ header, initialTitle, initialContent, cancelCallback, succ {errorMessage} )} - setTitle(e.target.value)} helperText={titleHelperText} disabled={userRole === 'viewer'} /> + { + setTitle(e.target.value); + setSuccessMessage(""); + }} helperText={titleHelperText} disabled={userRole === 'viewer'} /> @@ -364,23 +420,43 @@ function LessonForm({ header, initialTitle, initialContent, cancelCallback, succ name="waiting_period" type="number" value={waitingPeriod} - onChange={(e) => setWaitingPeriod(e.target.value)} + onChange={(e) => { + setWaitingPeriod(e.target.value); + setSuccessMessage(""); + }} sx={{ width: '200px', mr: 2 }} inputProps={{ min: 1 }} disabled={userRole === 'viewer'} /> - { + setWaitingPeriodUnit(e.target.value); + setSuccessMessage(""); + }} name="waiting_period_unit" sx={{ width: '150px', mr: 2 }} disabled={userRole === 'viewer'}> {localeMessages["days"]} {localeMessages["hours"]} - - - {userRole !== 'viewer' && } + + + {successMessage && ( + + {successMessage} + + )} + {hasUnsavedChanges && userRole !== 'viewer' && ( + + {localeMessages["lesson_unsaved_changes_hint"]} + + )} + + + + {userRole !== 'viewer' && } + { + const href = editor.getAttributes('link').href; + if (!href) { + return; + } + window.open(href, '_blank', 'noopener,noreferrer'); + }; + + const unlinkActiveLink = () => { + editor.chain().focus().extendMarkRange('link').unsetLink().run(); + }; + + const canUndo = editor.can().chain().focus().undo().run(); + const canRedo = editor.can().chain().focus().redo().run(); + return ( @@ -142,7 +167,10 @@ function ContentEditor({ initialContent, contentUpdateCallback, disabled = false {!disabled && editor.chain().focus().toggleHeading({ level: 1 }).run()} @@ -165,6 +193,24 @@ function ContentEditor({ initialContent, contentUpdateCallback, disabled = false > H3 | + + editor.chain().focus().undo().run()} + size="small" + disabled={!canUndo} + > + + + + + editor.chain().focus().redo().run()} + size="small" + disabled={!canRedo} + > + + + editor.chain().focus().toggleBold().run()} @@ -293,18 +339,25 @@ function ContentEditor({ initialContent, contentUpdateCallback, disabled = false } }, '& pre': { - backgroundColor: 'grey.50', + backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'grey.700' : 'grey.50', borderRadius: 1, padding: 2, margin: '16px 0', fontFamily: 'Monaco, Consolas, monospace', fontSize: '14px', border: '1px solid', - borderColor: 'grey.100' + borderColor: (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.100' }, '& strong': { fontWeight: 'bold' }, + '& a': { + color: (theme) => theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.main, + textDecorationColor: (theme) => theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.main, + '&:hover': { + color: (theme) => theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.main, + }, + }, '& img': { display: 'inline-block', maxWidth: '100%', @@ -345,6 +398,54 @@ function ContentEditor({ initialContent, contentUpdateCallback, disabled = false } }} > + {!disabled && ( + ( + activeEditor.isFocused + && activeEditor.isActive('link') + && !state.selection.empty + )} + updateDelay={0} + tippyOptions={{ + duration: 0, + placement: 'top-start', + animation: false, + }} + > + + + + + + )} ({ + color: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.main, + textDecorationColor: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.main, + transition: 'color 0.2s ease, text-decoration-color 0.2s ease', + '&:hover': { + color: theme.palette.mode === 'dark' + ? alpha(theme.palette.primary.light, 0.85) + : alpha(theme.palette.primary.main, 0.85), + textDecorationColor: theme.palette.mode === 'dark' + ? alpha(theme.palette.primary.light, 0.85) + : alpha(theme.palette.primary.main, 0.85), + }, + }), + }, + }, + MuiLink: { + styleOverrides: { + root: ({ theme }) => ({ + color: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.main, + textDecorationColor: theme.palette.mode === 'dark' ? theme.palette.primary.light : theme.palette.primary.main, + transition: 'color 0.2s ease, text-decoration-color 0.2s ease', + '&:hover': { + color: theme.palette.mode === 'dark' + ? alpha(theme.palette.primary.light, 0.85) + : alpha(theme.palette.primary.main, 0.85), + textDecorationColor: theme.palette.mode === 'dark' + ? alpha(theme.palette.primary.light, 0.85) + : alpha(theme.palette.primary.main, 0.85), + }, + }), + }, + }, MuiTable: { defaultProps: { size: 'small', diff --git a/tests/platform/api/test_views/test_send_lesson_to_platform_user_view.py b/tests/platform/api/test_views/test_send_lesson_to_platform_user_view.py new file mode 100644 index 0000000..d8a60f9 --- /dev/null +++ b/tests/platform/api/test_views/test_send_lesson_to_platform_user_view.py @@ -0,0 +1,73 @@ +import json +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory + +from django_email_learning.platform.api.views import SendLessonToPlatformUser + + +@pytest.mark.parametrize("user_key", ["editor_user", "organization_admin"]) +@patch("django_email_learning.platform.api.views.SendLessonCommand") +def test_send_lesson_to_platform_user_sends_email_for_allowed_roles( + mock_send_lesson_command, + db, + users, + course_lesson_content, + user_key, +): + request = RequestFactory().post( + "/api/platform/organizations/1/lessons/send/", + data=json.dumps({"id": course_lesson_content.id}), + content_type="application/json", + ) + request.user = users[user_key] + + response = SendLessonToPlatformUser.as_view()(request, organization_id=1) + + assert response.status_code == 200 + mock_send_lesson_command.assert_called_once_with( + content_id=course_lesson_content.id, + email=users[user_key].email, + ) + mock_send_lesson_command.return_value.execute.assert_called_once_with() + + +@patch("django_email_learning.platform.api.views.SendLessonCommand") +def test_send_lesson_to_platform_user_forbidden_for_viewer( + mock_send_lesson_command, + db, + users, + course_lesson_content, +): + request = RequestFactory().post( + "/api/platform/organizations/1/lessons/send/", + data=json.dumps({"id": course_lesson_content.id}), + content_type="application/json", + ) + request.user = users["viewer_user"] + + response = SendLessonToPlatformUser.as_view()(request, organization_id=1) + + assert response.status_code == 403 + mock_send_lesson_command.assert_not_called() + + +@patch("django_email_learning.platform.api.views.SendLessonCommand") +def test_send_lesson_to_platform_user_unauthorized_for_anonymous( + mock_send_lesson_command, + db, + course_lesson_content, +): + request = RequestFactory().post( + "/api/platform/organizations/1/lessons/send/", + data=json.dumps({"id": course_lesson_content.id}), + content_type="application/json", + ) + request.user = AnonymousUser() + + response = SendLessonToPlatformUser.as_view()(request, organization_id=1) + + assert response.status_code == 401 + mock_send_lesson_command.assert_not_called()