diff --git a/apps/lms/admin.py b/apps/lms/admin.py index 7506bcd..e06e867 100644 --- a/apps/lms/admin.py +++ b/apps/lms/admin.py @@ -31,10 +31,10 @@ class QuizQuestionInline(StackedInline): @admin.register(Course) class CourseAdmin(ModelAdmin): - list_display = ('title', 'category', 'language', 'level', 'instructor_name', 'is_published', 'is_featured', 'created_at') - list_display_links = ('title', 'category', 'instructor_name') - list_filter = ('category', 'language', 'level', 'is_published', 'is_featured') - search_fields = ('title', 'description', 'instructor_name', 'organization_name') + list_display = ('title', 'course_type', 'category', 'language', 'level', 'instructor_name', 'is_published', 'is_featured', 'created_at') + list_display_links = ('title', 'course_type', 'category', 'instructor_name') + list_filter = ('course_type', 'category', 'language', 'level', 'is_published', 'is_featured') + search_fields = ('title', 'description', 'instructor_name', 'organization_name', 'adobe_connect_url') prepopulated_fields = {'slug': ('title',)} inlines = [CourseModuleInline] diff --git a/apps/lms/management/__init__.py b/apps/lms/management/__init__.py new file mode 100644 index 0000000..cbc9111 --- /dev/null +++ b/apps/lms/management/__init__.py @@ -0,0 +1 @@ +# LMS management package diff --git a/apps/lms/management/commands/__init__.py b/apps/lms/management/commands/__init__.py new file mode 100644 index 0000000..c41d0ad --- /dev/null +++ b/apps/lms/management/commands/__init__.py @@ -0,0 +1 @@ +# LMS management commands diff --git a/apps/lms/management/commands/seed_online_courses.py b/apps/lms/management/commands/seed_online_courses.py new file mode 100644 index 0000000..474e568 --- /dev/null +++ b/apps/lms/management/commands/seed_online_courses.py @@ -0,0 +1,271 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from apps.profiles.models import Institution +from apps.lms.models import ( + Course, + CourseType, + CourseCategory, + CourseLevel, + CourseLanguage, + CourseModule, + Lesson, + QuizQuestion, +) + +User = get_user_model() + + +class Command(BaseCommand): + help = "Seeds initial sample online and hybrid courses with Adobe Connect links and live schedules." + + def handle(self, *args, **options): + self.stdout.write(self.style.NOTICE("Seeding sample online courses...")) + + admin_user = User.objects.filter(is_staff=True).first() or User.objects.first() + institution = Institution.objects.first() + + sample_courses = [ + { + "title": "Interactive Workshop: International Cultural Diplomacy & Interfaith Dialogue", + "slug": "online-cultural-diplomacy-interfaith", + "course_type": CourseType.ONLINE, + "adobe_connect_url": "https://live.astan.ir/cultural-diplomacy", + "live_schedule": "Sundays & Tuesdays at 17:00 UTC", + "category": CourseCategory.CULTURAL_DIPLOMACY, + "language": CourseLanguage.EN, + "level": CourseLevel.INTERMEDIATE, + "instructor_name": "Dr. Mohammad Javad Karimi", + "instructor_title": "Senior Fellow at Razavi Islamic Studies Institute", + "organization_name": "Razavi International Academy", + "description": ( + "A high-impact live online workshop series exploring strategic communication, " + "interfaith dialogue methodologies, and cultural diplomacy across global institutions. " + "All sessions are hosted in real-time via Adobe Connect virtual classroom with interactive Q&A." + ), + "duration_hours": 16.0, + "rating": 4.95, + "reviews_count": 38, + "is_published": True, + "is_featured": True, + "modules": [ + { + "title": "Module 1: Foundations of Cultural Diplomacy", + "order": 1, + "lessons": [ + { + "title": "Live Orientation & Syllabus Walkthrough", + "lesson_type": Lesson.LessonType.READING, + "duration_minutes": 30, + "content": ( + "# Live Course Welcome & Guidelines\n\n" + "Welcome to the **International Cultural Diplomacy** live series.\n\n" + "### How to Join the Live Classroom\n" + "- The live sessions take place every **Sunday and Tuesday at 17:00 UTC**.\n" + "- Please ensure your Adobe Connect client is updated.\n" + "- Interactive microphones and webcam access will be enabled during designated Q&A segments." + ), + "is_preview": True, + "order": 1, + }, + { + "title": "Session 1 Live Stream Recording & Summary", + "lesson_type": Lesson.LessonType.VIDEO, + "video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "duration_minutes": 60, + "content": "Archived lecture notes and summary discussion points from the first live lecture.", + "is_preview": False, + "order": 2, + }, + ], + }, + { + "title": "Module 2: Practical Interfaith Case Studies", + "order": 2, + "lessons": [ + { + "title": "Live Workshop: Interfaith Crisis Management", + "lesson_type": Lesson.LessonType.ASSIGNMENT, + "duration_minutes": 90, + "content": "Collaborative case study solving during the live Adobe Connect breakout rooms.", + "is_preview": False, + "order": 1, + }, + ], + }, + ], + }, + { + "title": "سیره و سبک زندگی رضوی در جامعه معاصر (دوره برخط زنده)", + "slug": "online-razavi-lifestyle-modern-society", + "course_type": CourseType.ONLINE, + "adobe_connect_url": "https://live.astan.ir/razavi-ethics", + "live_schedule": "پنجشنبه‌ها ساعت ۱۸:۳۰ به وقت تهران", + "category": CourseCategory.RAZAVI_TEACHINGS, + "language": CourseLanguage.FA, + "level": CourseLevel.INTRODUCTORY, + "instructor_name": "استاد سید علیرضا حسینی", + "instructor_title": "مدرس حوزه و پژوهشگر سیره و معارف اهل‌بیت (ع)", + "organization_name": "مرکز ارتباطات بین‌الملل آستان قدس رضوی", + "description": ( + "دوره تعاملی و برخط پیرامون آموزه‌های کاربردی امام رضا (ع) در مدیریت خانواده، اخلاق حرفه‌ای، " + "و مسئولیت اجتماعی در بستر کلاس مجازی ادوبی کانکت با پاسخگویی زنده به پرسش‌های دانشجویان." + ), + "duration_hours": 10.0, + "rating": 5.0, + "reviews_count": 64, + "is_published": True, + "is_featured": True, + "modules": [ + { + "title": "فصل اول: مبانی اخلاق گفت‌وگو و مدارا در سیره امام رضا (ع)", + "order": 1, + "lessons": [ + { + "title": "راهنمای ورود به کلاس مجازی ادوبی کانکت و تقویم جلسات", + "lesson_type": Lesson.LessonType.READING, + "duration_minutes": 20, + "content": ( + "# راهنمای شرکت در جلسات برخط\n\n" + "دانش‌پژوهان گرامی، کلاس‌های زنده هفتگی از طریق لینک ادوبی کانکت اختصاصی دوره برگزار می‌گردد.\n" + "جهت شرکت در مباحث، پس از ورود به سامانه ادوبی، با نام کامل خود وارد اتاق جلسه شوید." + ), + "is_preview": True, + "order": 1, + }, + { + "title": "جلسه زنده اول: مناظرات رضوی و درس‌های آن برای دنیای امروز", + "lesson_type": Lesson.LessonType.READING, + "duration_minutes": 75, + "content": "متن و نکات کلیدی جلسه برخط اول همراه با فایل اسلایدها و منابع تکمیلی.", + "is_preview": False, + "order": 2, + }, + ], + }, + ], + }, + { + "title": "Advanced Islamic Center & Community Leadership (Live Cohort)", + "slug": "online-center-community-leadership", + "course_type": CourseType.ONLINE, + "adobe_connect_url": "https://live.astan.ir/center-leadership", + "live_schedule": "Every Saturday at 14:00 GMT", + "category": CourseCategory.CENTER_MANAGEMENT, + "language": CourseLanguage.EN, + "level": CourseLevel.ADVANCED, + "instructor_name": "Sheikh Bilal Al-Kazemi", + "instructor_title": "Director of International Community Engagement", + "organization_name": "Federation of Global Islamic Centers", + "description": ( + "A specialized leadership masterclass for center directors, mosque trustees, and community organizers. " + "Features live panel discussions, strategic planning exercises, and real-time governance case studies." + ), + "duration_hours": 18.0, + "rating": 4.88, + "reviews_count": 29, + "is_published": True, + "is_featured": False, + "modules": [ + { + "title": "Module 1: Strategic Governance and Crisis Leadership", + "order": 1, + "lessons": [ + { + "title": "Cohort Onboarding & Live Room Setup", + "lesson_type": Lesson.LessonType.READING, + "duration_minutes": 25, + "content": ( + "# Live Cohort Setup\n\n" + "Sessions convene every Saturday at 14:00 GMT via Adobe Connect.\n" + "Please review the community governance code before attending Session 1." + ), + "is_preview": True, + "order": 1, + }, + ], + }, + ], + }, + { + "title": "Comprehensive Quranic Arabic for Researchers (Hybrid Course)", + "slug": "hybrid-quranic-arabic-researchers", + "course_type": CourseType.HYBRID, + "adobe_connect_url": "https://live.astan.ir/quranic-arabic", + "live_schedule": "Mondays & Wednesdays at 19:00 Mecca Time", + "category": CourseCategory.SPECIALIZED_LANGUAGES, + "language": CourseLanguage.AR, + "level": CourseLevel.INTERMEDIATE, + "instructor_name": "د. طارق الهاشمي", + "instructor_title": "أستاذ اللغويات والترجمة التخصصية", + "organization_name": "معهد الدراسات اللغوية التخصصية", + "description": ( + "دورة هجينة تجمع بين الدروس المسجلة الذاتية وورش العمل الحية الأسبوعية عبر أدوبي كونكت " + "لتحليل النصوص الدينية والمصطلحات القرآنية التخصصية." + ), + "duration_hours": 24.0, + "rating": 4.92, + "reviews_count": 45, + "is_published": True, + "is_featured": True, + "modules": [ + { + "title": "الوحدة الأولى: البنية الصرفية والنحوية المتقدمة", + "order": 1, + "lessons": [ + { + "title": "مقدمة البرنامج وجدول البث المباشر", + "lesson_type": Lesson.LessonType.READING, + "duration_minutes": 20, + "content": ( + "# جدول الجلسات التفاعلية المباشرة\n\n" + "تنعقد الورش الحية يومي الإثنين والأربعاء الساعة 19:00 بتوقيت مكة المكرمة عبر قاعة أدوبي كونكت." + ), + "is_preview": True, + "order": 1, + }, + ], + }, + ], + }, + ] + + created_count = 0 + for data in sample_courses: + modules_data = data.pop("modules", []) + slug = data["slug"] + + course, created = Course.objects.update_or_create( + slug=slug, + defaults={ + **data, + "institution": institution, + "created_by": admin_user, + } + ) + + if created: + created_count += 1 + self.stdout.write(self.style.SUCCESS(f"Created course: {course.title} ({course.course_type})")) + else: + self.stdout.write(self.style.WARNING(f"Updated course: {course.title} ({course.course_type})")) + + # Create modules and lessons + for m_data in modules_data: + lessons_data = m_data.pop("lessons", []) + module, _ = CourseModule.objects.update_or_create( + course=course, + order=m_data["order"], + defaults={"title": m_data["title"]}, + ) + for l_data in lessons_data: + Lesson.objects.update_or_create( + module=module, + order=l_data["order"], + defaults=l_data, + ) + + self.stdout.write( + self.style.SUCCESS( + f"Successfully seeded {len(sample_courses)} online/hybrid courses (New: {created_count})!" + ) + ) diff --git a/apps/lms/migrations/0002_course_adobe_connect_url_course_course_type_and_more.py b/apps/lms/migrations/0002_course_adobe_connect_url_course_course_type_and_more.py new file mode 100644 index 0000000..e68206b --- /dev/null +++ b/apps/lms/migrations/0002_course_adobe_connect_url_course_course_type_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.30 on 2026-10-22 06:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lms', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='adobe_connect_url', + field=models.URLField(blank=True, max_length=500, null=True, verbose_name='Adobe Connect / Online Meeting Link'), + ), + migrations.AddField( + model_name='course', + name='course_type', + field=models.CharField(choices=[('recorded', 'Recorded / Self-Paced'), ('online', 'Live Online (Adobe Connect)'), ('hybrid', 'Hybrid (Recorded & Live Online)')], default='recorded', max_length=20, verbose_name='Course Delivery Type'), + ), + migrations.AddField( + model_name='course', + name='live_schedule', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Live Schedule (e.g. Saturdays at 18:00 UTC)'), + ), + ] diff --git a/apps/lms/models/__init__.py b/apps/lms/models/__init__.py index 5aa2220..d121dd2 100644 --- a/apps/lms/models/__init__.py +++ b/apps/lms/models/__init__.py @@ -1,5 +1,6 @@ from apps.lms.models.course import ( Course, + CourseType, CourseModule, Lesson, QuizQuestion, @@ -13,6 +14,7 @@ from apps.lms.models.course import ( __all__ = [ 'Course', + 'CourseType', 'CourseModule', 'Lesson', 'QuizQuestion', @@ -23,3 +25,4 @@ __all__ = [ 'CourseLevel', 'CourseLanguage', ] + diff --git a/apps/lms/models/course.py b/apps/lms/models/course.py index 6ee6d3a..af8feb4 100644 --- a/apps/lms/models/course.py +++ b/apps/lms/models/course.py @@ -31,6 +31,12 @@ class CourseLanguage(models.TextChoices): FR = 'fr', _('French') +class CourseType(models.TextChoices): + RECORDED = 'recorded', _('Recorded / Self-Paced') + ONLINE = 'online', _('Live Online (Adobe Connect)') + HYBRID = 'hybrid', _('Hybrid (Recorded & Live Online)') + + class Course(models.Model): title = models.CharField(max_length=255, verbose_name=_('Course Title')) slug = models.SlugField( @@ -41,6 +47,24 @@ class Course(models.Model): allow_unicode=True, verbose_name=_('Slug / URL Identifier') ) + course_type = models.CharField( + max_length=20, + choices=CourseType.choices, + default=CourseType.RECORDED, + verbose_name=_('Course Delivery Type') + ) + adobe_connect_url = models.URLField( + max_length=500, + blank=True, + null=True, + verbose_name=_('Adobe Connect / Online Meeting Link') + ) + live_schedule = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name=_('Live Schedule (e.g. Saturdays at 18:00 UTC)') + ) category = models.CharField( max_length=50, choices=CourseCategory.choices, diff --git a/apps/lms/serializers/course_serializers.py b/apps/lms/serializers/course_serializers.py index 33bbce9..c3a240b 100644 --- a/apps/lms/serializers/course_serializers.py +++ b/apps/lms/serializers/course_serializers.py @@ -102,9 +102,9 @@ class CourseListSerializer(serializers.ModelSerializer): class Meta: model = Course fields = [ - 'id', 'title', 'slug', 'category', 'language', - 'level', 'cover_image', 'instructor_name', - 'instructor_title', 'instructor_avatar', + 'id', 'title', 'slug', 'course_type', 'live_schedule', + 'category', 'language', 'level', 'cover_image', + 'instructor_name', 'instructor_title', 'instructor_avatar', 'organization_name', 'duration_hours', 'rating', 'reviews_count', 'is_featured', 'total_lessons', 'total_students', 'is_enrolled', 'progress_percentage', @@ -134,11 +134,13 @@ class CourseDetailSerializer(serializers.ModelSerializer): is_enrolled = serializers.SerializerMethodField() progress_percentage = serializers.SerializerMethodField() certificate_code = serializers.SerializerMethodField() + adobe_connect_url = serializers.SerializerMethodField() class Meta: model = Course fields = [ - 'id', 'title', 'slug', 'category', 'language', + 'id', 'title', 'slug', 'course_type', 'live_schedule', + 'adobe_connect_url', 'category', 'language', 'level', 'cover_image', 'instructor_name', 'instructor_title', 'instructor_avatar', 'institution', 'organization_name', 'description', @@ -172,12 +174,32 @@ class CourseDetailSerializer(serializers.ModelSerializer): return cert.certificate_code if cert else None return None + @extend_schema_field(OpenApiTypes.STR) + def get_adobe_connect_url(self, obj): + """Only enrolled students, course creator, or admins can see the Adobe Connect link.""" + if not obj.adobe_connect_url: + return None + request = self.context.get('request') + if not request or not request.user or not request.user.is_authenticated: + return None + user = request.user + if getattr(user, 'is_super_admin', False) or user.is_staff: + return obj.adobe_connect_url + if obj.created_by == user: + return obj.adobe_connect_url + if obj.institution and obj.institution.is_editor(user): + return obj.adobe_connect_url + if CourseEnrollment.objects.filter(user=user, course=obj).exists(): + return obj.adobe_connect_url + return None + class CourseCreateUpdateSerializer(serializers.ModelSerializer): class Meta: model = Course fields = [ - 'id', 'title', 'category', 'language', 'level', + 'id', 'title', 'course_type', 'adobe_connect_url', 'live_schedule', + 'category', 'language', 'level', 'cover_image', 'instructor_name', 'instructor_title', 'instructor_avatar', 'institution', 'organization_name', 'description', 'duration_hours', 'is_published', 'is_featured', diff --git a/apps/lms/tests/test_phase8_lms.py b/apps/lms/tests/test_phase8_lms.py index fa01e5e..7655c21 100644 --- a/apps/lms/tests/test_phase8_lms.py +++ b/apps/lms/tests/test_phase8_lms.py @@ -6,6 +6,7 @@ from rest_framework import status from apps.profiles.models import Institution from apps.lms.models import ( Course, + CourseType, CourseModule, Lesson, QuizQuestion, @@ -294,3 +295,61 @@ class Phase8LMSAPITests(TestCase): self.assertEqual(my_certs_res.status_code, status.HTTP_200_OK) self.assertEqual(len(my_certs_res.data), 1) self.assertEqual(my_certs_res.data[0]["certificate_code"], cert_code) + + def test_online_course_adobe_connect_flow(self): + """ + Verify: + 1. Course can be created with course_type=online, adobe_connect_url, and live_schedule. + 2. Non-enrolled user cannot see adobe_connect_url (returns None). + 3. Enrolling in course returns the adobe_connect_url in response. + 4. Enrolled user can now see adobe_connect_url in course details. + 5. Filtering by course_type=online works. + """ + online_course = Course.objects.create( + title="Live Webinar on Islamic Jurisprudence", + course_type=CourseType.ONLINE, + adobe_connect_url="https://live.razaviacademy.org/fiqh-live", + live_schedule="Every Friday at 16:00 UTC", + category=CourseCategory.GENERAL, + language=CourseLanguage.EN, + level=CourseLevel.ADVANCED, + instructor_name="Ayatollah Mohammadi", + institution=self.institution, + description="Live interactive lectures via Adobe Connect virtual classroom.", + is_published=True, + created_by=self.admin_user, + ) + + # 1. Unauthenticated or non-enrolled user cannot see the Adobe Connect URL + anon_client = APIClient() + detail_res_anon = anon_client.get(f"/api/v1/lms/courses/{online_course.slug}/") + self.assertEqual(detail_res_anon.status_code, status.HTTP_200_OK) + self.assertEqual(detail_res_anon.data["course_type"], "online") + self.assertEqual(detail_res_anon.data["live_schedule"], "Every Friday at 16:00 UTC") + self.assertIsNone(detail_res_anon.data["adobe_connect_url"]) + + # Authenticated student before enrollment + self.client.force_authenticate(user=self.student_user) + detail_res_student = self.client.get(f"/api/v1/lms/courses/{online_course.slug}/") + self.assertEqual(detail_res_student.status_code, status.HTTP_200_OK) + self.assertIsNone(detail_res_student.data["adobe_connect_url"]) + + # 2. Student enrolls in the online course + enroll_res = self.client.post(f"/api/v1/lms/courses/{online_course.slug}/enroll/") + self.assertEqual(enroll_res.status_code, status.HTTP_201_CREATED) + self.assertEqual(enroll_res.data["course_type"], "online") + self.assertEqual(enroll_res.data["adobe_connect_url"], "https://live.razaviacademy.org/fiqh-live") + self.assertEqual(enroll_res.data["live_schedule"], "Every Friday at 16:00 UTC") + + # 3. After enrollment, student can access the Adobe Connect URL in course details + detail_res_enrolled = self.client.get(f"/api/v1/lms/courses/{online_course.slug}/") + self.assertEqual(detail_res_enrolled.status_code, status.HTTP_200_OK) + self.assertTrue(detail_res_enrolled.data["is_enrolled"]) + self.assertEqual(detail_res_enrolled.data["adobe_connect_url"], "https://live.razaviacademy.org/fiqh-live") + + # 4. Catalog filtering by course_type + list_res = self.client.get("/api/v1/lms/courses/?course_type=online") + self.assertEqual(list_res.status_code, status.HTTP_200_OK) + self.assertTrue(any(c["id"] == online_course.id for c in list_res.data)) + self.assertTrue(all(c["course_type"] == "online" for c in list_res.data)) + diff --git a/apps/lms/views/course_views.py b/apps/lms/views/course_views.py index af12025..c4611bf 100644 --- a/apps/lms/views/course_views.py +++ b/apps/lms/views/course_views.py @@ -55,6 +55,7 @@ class CourseListCreateView(GenericAPIView): description="Retrieves a list of published courses with category, language, level, and keyword filtering.", parameters=[ OpenApiParameter('category', str, description="Filter by course category (e.g. razavi_teachings, cultural_diplomacy)"), + OpenApiParameter('course_type', str, description="Filter by delivery type (recorded, online, hybrid)"), OpenApiParameter('language', str, description="Filter by language code (fa, ar, en, ur, fr)"), OpenApiParameter('level', str, description="Filter by difficulty level (introductory, intermediate, advanced)"), OpenApiParameter('search', str, description="Keyword search in title, instructor, or description"), @@ -69,6 +70,10 @@ class CourseListCreateView(GenericAPIView): if not (request.user and request.user.is_authenticated and request.user.is_staff): qs = qs.filter(is_published=True) + course_type = request.query_params.get('course_type') + if course_type: + qs = qs.filter(course_type=course_type) + category = request.query_params.get('category') if category: qs = qs.filter(category=category) @@ -201,6 +206,9 @@ class CourseEnrollView(GenericAPIView): 'message': _("Enrolled successfully.") if created else _("Already enrolled in this course."), 'course_id': course.id, 'course_title': course.title, + 'course_type': course.course_type, + 'adobe_connect_url': course.adobe_connect_url, + 'live_schedule': course.live_schedule, 'enrolled_at': enrollment.enrolled_at, 'progress_percentage': enrollment.progress_percentage, }, status=status_code) diff --git a/apps/profiles/management/commands/seed_almadinah_rich_data.py b/apps/profiles/management/commands/seed_almadinah_rich_data.py index 384c2f1..dd6147e 100644 --- a/apps/profiles/management/commands/seed_almadinah_rich_data.py +++ b/apps/profiles/management/commands/seed_almadinah_rich_data.py @@ -287,12 +287,12 @@ class Command(BaseCommand): inst.save() self.stdout.write(self.style.SUCCESS(f"Updated institution profile: {inst.name} (ID: {inst.id})")) - # Ensure members are assigned for permissions - for u in User.objects.all(): + # Ensure designated admin is assigned for permissions + if admin_user: InstitutionMember.objects.get_or_create( institution=inst, - user=u, - defaults={"role": InstitutionMember.MemberRole.ADMIN, "title": "Staff / Researcher"} + user=admin_user, + defaults={"role": InstitutionMember.MemberRole.ADMIN, "title": "Director of Administration"} ) # Populate Timeline entries