diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 066022d..76092ac 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 258e187..3512086 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/thawani.cpython-311.pyc b/core/__pycache__/thawani.cpython-311.pyc index 03b715c..3898737 100644 Binary files a/core/__pycache__/thawani.cpython-311.pyc and b/core/__pycache__/thawani.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 02ad606..91670a3 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index ded796e..e1e32a7 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index df378f7..1db7bb5 100644 --- a/core/admin.py +++ b/core/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from django import forms from django.utils.html import format_html from django.urls import reverse -from .models import Classroom, Teacher, Subject, Resource, Student, City, Governorate +from .models import Classroom, Teacher, Subject, Resource, Student, City, Governorate, Package class ActionsModelAdmin(admin.ModelAdmin): """ @@ -93,3 +93,11 @@ class StudentAdmin(ActionsModelAdmin): if obj and obj.classroom: form.base_fields['subscribed_subjects'].queryset = Subject.objects.filter(classroom=obj.classroom) return form + +@admin.register(Package) +class PackageAdmin(ActionsModelAdmin): + list_display = ('name_en', 'name_ar', 'classroom', 'price', 'is_active', 'actions_column') + list_filter = ('classroom', 'is_active') + list_editable = ('is_active',) + filter_horizontal = ('subjects',) + search_fields = ('name_en', 'name_ar') diff --git a/core/migrations/0015_package.py b/core/migrations/0015_package.py new file mode 100644 index 0000000..bbbc38c --- /dev/null +++ b/core/migrations/0015_package.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.7 on 2026-02-04 15:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_alter_city_options_alter_classroom_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Package', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name_en', models.CharField(max_length=200, verbose_name='الاسم (إنجليزي)')), + ('name_ar', models.CharField(max_length=200, verbose_name='الاسم (عربي)')), + ('description_en', models.TextField(blank=True, verbose_name='الوصف (إنجليزي)')), + ('description_ar', models.TextField(blank=True, verbose_name='الوصف (عربي)')), + ('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='السعر')), + ('image', models.ImageField(blank=True, null=True, upload_to='packages/', verbose_name='الصورة')), + ('is_active', models.BooleanField(default=True, verbose_name='نشط')), + ('subjects', models.ManyToManyField(related_name='packages', to='core.subject', verbose_name='المواد')), + ], + options={ + 'verbose_name': 'باقة', + 'verbose_name_plural': 'الباقات', + }, + ), + ] diff --git a/core/migrations/0016_package_classroom.py b/core/migrations/0016_package_classroom.py new file mode 100644 index 0000000..6c868df --- /dev/null +++ b/core/migrations/0016_package_classroom.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2026-02-04 16:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_package'), + ] + + operations = [ + migrations.AddField( + model_name='package', + name='classroom', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='packages', to='core.classroom', verbose_name='الصف'), + ), + ] diff --git a/core/migrations/__pycache__/0015_package.cpython-311.pyc b/core/migrations/__pycache__/0015_package.cpython-311.pyc new file mode 100644 index 0000000..7c77f81 Binary files /dev/null and b/core/migrations/__pycache__/0015_package.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0016_package_classroom.cpython-311.pyc b/core/migrations/__pycache__/0016_package_classroom.cpython-311.pyc new file mode 100644 index 0000000..71020d8 Binary files /dev/null and b/core/migrations/__pycache__/0016_package_classroom.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 86d6d42..0bbdba6 100644 --- a/core/models.py +++ b/core/models.py @@ -65,18 +65,24 @@ class Subject(models.Model): """Extracts the video ID from a YouTube URL.""" if not self.youtube_live_url: return None + + url = self.youtube_live_url.strip() + # Handle various formats: # https://youtu.be/VIDEO_ID # https://www.youtube.com/watch?v=VIDEO_ID # https://www.youtube.com/live/VIDEO_ID - import re + # https://www.youtube.com/embed/VIDEO_ID + # https://www.youtube.com/shorts/VIDEO_ID patterns = [ - r'(?:v=|\/)([0-9A-Za-z_-]{11}).*', + r'(?:v=|\/)([0-9A-Za-z_-]{11})(?:[?&]|$)', r'(?:youtu\.be\/)([0-9A-Za-z_-]{11})', r'(?:live\/)([0-9A-Za-z_-]{11})', + r'(?:embed\/)([0-9A-Za-z_-]{11})', + r'(?:shorts\/)([0-9A-Za-z_-]{11})', ] for pattern in patterns: - match = re.search(pattern, self.youtube_live_url) + match = re.search(pattern, url) if match: return match.group(1) return None @@ -107,14 +113,18 @@ class Resource(models.Model): """Extracts the video ID from the link if it is a YouTube URL.""" if not self.link or self.resource_type != 'VIDEO': return None + + url = self.link.strip() patterns = [ - r'(?:v=|\/)([0-9A-Za-z_-]{11}).*', + r'(?:v=|\/)([0-9A-Za-z_-]{11})(?:[?&]|$)', r'(?:youtu\.be\/)([0-9A-Za-z_-]{11})', r'(?:live\/)([0-9A-Za-z_-]{11})', + r'(?:embed\/)([0-9A-Za-z_-]{11})', + r'(?:shorts\/)([0-9A-Za-z_-]{11})', ] for pattern in patterns: - match = re.search(pattern, self.link) + match = re.search(pattern, url) if match: return match.group(1) return None @@ -219,3 +229,21 @@ class PlatformSettings(SingletonModel): def __str__(self): return "إعدادات المنصة" + +class Package(models.Model): + name_en = models.CharField("الاسم (إنجليزي)", max_length=200) + name_ar = models.CharField("الاسم (عربي)", max_length=200) + description_en = models.TextField("الوصف (إنجليزي)", blank=True) + description_ar = models.TextField("الوصف (عربي)", blank=True) + price = models.DecimalField("السعر", max_digits=10, decimal_places=2, default=0.00) + classroom = models.ForeignKey(Classroom, on_delete=models.CASCADE, related_name='packages', verbose_name="الصف", null=True, blank=True) + image = models.ImageField("الصورة", upload_to='packages/', blank=True, null=True) + subjects = models.ManyToManyField(Subject, related_name='packages', verbose_name="المواد") + is_active = models.BooleanField("نشط", default=True) + + class Meta: + verbose_name = "باقة" + verbose_name_plural = "الباقات" + + def __str__(self): + return self.name_ar or self.name_en \ No newline at end of file diff --git a/core/templates/core/live_classroom.html b/core/templates/core/live_classroom.html index 41a40e3..cb43040 100644 --- a/core/templates/core/live_classroom.html +++ b/core/templates/core/live_classroom.html @@ -28,10 +28,11 @@ diff --git a/core/templates/core/student_dashboard.html b/core/templates/core/student_dashboard.html index 5af9f45..89d6953 100644 --- a/core/templates/core/student_dashboard.html +++ b/core/templates/core/student_dashboard.html @@ -151,6 +151,51 @@ {% endif %} + + {% if packages %} +
+

الباقات المتاحة 🎁

+
+ {% for package in packages %} +
+
+ {% if package.image %} +
+ {{ package.name_en }} +
+ {% else %} +
+ 📦 +
+ {% endif %} +
+
{{ package.name_ar }}
+

{{ package.description_ar|truncatechars:100 }}

+ +
+ تشمل المواد: +
+ {% for subject in package.subjects.all|slice:":3" %} + {{ subject.name_ar }} + {% endfor %} + {% if package.subjects.count > 3 %} + +{{ package.subjects.count|add:"-3" }} + {% endif %} +
+
+ +
+ {{ package.price }} OMR + شراء الباقة +
+
+
+
+ {% endfor %} +
+
+ {% endif %} +

المواد المتاحة

diff --git a/core/templates/core/subject_detail.html b/core/templates/core/subject_detail.html index 0782b8d..80139d5 100644 --- a/core/templates/core/subject_detail.html +++ b/core/templates/core/subject_detail.html @@ -65,13 +65,14 @@
{% endfor %} - {% if subject.youtube_live_url %} + {% if subject.get_youtube_id %}
-
@@ -255,9 +256,14 @@ document.addEventListener('DOMContentLoaded', function() { modalBody.innerHTML = ''; // Clear previous content if (type === 'VIDEO' && youtubeId) { + // Use youtube-nocookie.com and add referrerpolicy modalBody.innerHTML = `
- +
`; } else if (type === 'FILE' && fileUrl) { diff --git a/core/thawani.py b/core/thawani.py index 6c0f16e..2430cdc 100644 --- a/core/thawani.py +++ b/core/thawani.py @@ -20,7 +20,7 @@ class ThawaniClient: else: self.base_url = "https://checkout.thawani.om/api/v1" - def create_checkout_session(self, subject, user, success_url, cancel_url): + def create_checkout_session(self, product, user, success_url, cancel_url): if not self.api_key: raise Exception("Thawani API Key is not configured.") @@ -31,24 +31,32 @@ class ThawaniClient: } # Thawani expects amount in Baisa (1 OMR = 1000 Baisa) - amount_baisa = int(subject.price * 1000) + amount_baisa = int(product.price * 1000) + metadata = { + "user_id": str(user.id) + } + + # Handle Subject or Package + # Package has 'subjects' ManyToMany field + if hasattr(product, 'subjects'): + metadata["package_id"] = str(product.id) + else: + metadata["subject_id"] = str(product.id) + payload = { "client_reference_id": str(user.id), "mode": "payment", "products": [ { - "name": subject.name_en, + "name": product.name_en, "quantity": 1, "unit_amount": amount_baisa } ], "success_url": success_url, "cancel_url": cancel_url, - "metadata": { - "subject_id": str(subject.id), - "user_id": str(user.id) - } + "metadata": metadata } response = httpx.post(url, json=payload, headers=headers) @@ -66,4 +74,4 @@ class ThawaniClient: response = httpx.get(url, headers=headers) response.raise_for_status() - return response.json() + return response.json() \ No newline at end of file diff --git a/core/urls.py b/core/urls.py index 0729d7d..dcf9f71 100644 --- a/core/urls.py +++ b/core/urls.py @@ -16,6 +16,7 @@ urlpatterns = [ path('profile/edit/teacher/', views.edit_teacher_profile, name='edit_teacher_profile'), path('logout/', views.custom_logout, name='logout'), path('subscribe//', views.subscribe_subject, name='subscribe_subject'), + path('package//subscribe/', views.subscribe_package, name='subscribe_package'), path('payment/success/', views.payment_success, name='payment_success'), path('payment/cancel/', views.payment_cancel, name='payment_cancel'), path('classroom//', views.live_classroom, name='live_classroom'), @@ -23,4 +24,4 @@ urlpatterns = [ path('resource//edit/', views.edit_resource, name='edit_resource'), path('resource//delete/', views.delete_resource, name='delete_resource'), path('resource//view/', views.view_resource, name='view_resource'), -] \ No newline at end of file +] diff --git a/core/views.py b/core/views.py index 7f1f356..1b11388 100644 --- a/core/views.py +++ b/core/views.py @@ -7,7 +7,8 @@ from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import login_required from django.contrib.auth import logout, login from django.urls import reverse -from .models import Classroom, Subject, Teacher, Student, City, Resource +from django.db.models import Q +from .models import Classroom, Subject, Teacher, Student, City, Resource, Package from .forms import StudentRegistrationForm, TeacherProfileForm, ResourceForm from .wablas import send_whatsapp_message from .thawani import ThawaniClient @@ -175,10 +176,20 @@ def profile(request): id__in=subscribed_subjects.values_list('id', flat=True) ) + # Get active packages + # Filter by student's classroom or global packages (classroom is None) + packages = Package.objects.filter(is_active=True) + if student_profile.classroom: + packages = packages.filter(Q(classroom=student_profile.classroom) | Q(classroom__isnull=True)) + else: + # If student has no classroom, show only global packages + packages = packages.filter(classroom__isnull=True) + return render(request, 'core/student_dashboard.html', { 'student_profile': student_profile, 'subscribed_subjects': subscribed_subjects, - 'available_subjects': available_subjects + 'available_subjects': available_subjects, + 'packages': packages }) # Fallback (Superuser or Admin without profile) @@ -237,6 +248,32 @@ def subscribe_subject(request, subject_id): print(f"Payment Error: {e}") return render(request, 'core/error.html', {'message': f'فشل بدء عملية الدفع: {str(e)}'}) +@login_required +def subscribe_package(request, package_id): + try: + student = request.user.student_profile + except Student.DoesNotExist: + return redirect('index') + + package = get_object_or_404(Package, pk=package_id) + + try: + thawani = ThawaniClient() + success_url = request.build_absolute_uri(reverse('payment_success')) + '?session_id={session_id}' + cancel_url = request.build_absolute_uri(reverse('payment_cancel')) + + session = thawani.create_checkout_session(package, request.user, success_url, cancel_url) + + session_id = session.get('data', {}).get('session_id') + if not session_id: + return render(request, 'core/error.html', {'message': 'تعذر إنشاء جلسة الدفع.'}) + + return redirect(f"{thawani.checkout_base_url}/{session_id}") + + except Exception as e: + print(f"Payment Error: {e}") + return render(request, 'core/error.html', {'message': f'فشل بدء عملية الدفع: {str(e)}'}) + @login_required def payment_success(request): session_id = request.GET.get('session_id') @@ -250,14 +287,26 @@ def payment_success(request): payment_status = data.get('payment_status') metadata = data.get('metadata', {}) subject_id = metadata.get('subject_id') + package_id = metadata.get('package_id') - if payment_status == 'paid' and subject_id: - try: - student = request.user.student_profile - subject = get_object_or_404(Subject, pk=subject_id) - student.subscribed_subjects.add(subject) - except Exception: - pass # Already handled or user mismatch? + if payment_status == 'paid': + student = request.user.student_profile + + if subject_id: + try: + subject = get_object_or_404(Subject, pk=subject_id) + student.subscribed_subjects.add(subject) + except Exception: + pass + + if package_id: + try: + package = get_object_or_404(Package, pk=package_id) + for subject in package.subjects.all(): + student.subscribed_subjects.add(subject) + except Exception: + pass + return redirect('profile') else: return render(request, 'core/error.html', {'message': f'لم تتم عملية الدفع بنجاح. الحالة: {payment_status}'}) diff --git a/media/resources/Voucher-_2.pdf b/media/resources/Voucher-_2.pdf new file mode 100644 index 0000000..32f9d65 Binary files /dev/null and b/media/resources/Voucher-_2.pdf differ diff --git a/media/resources/بسم_الله_الرحمن_الرحيم.docx b/media/resources/بسم_الله_الرحمن_الرحيم.docx new file mode 100644 index 0000000..1e8cc8f Binary files /dev/null and b/media/resources/بسم_الله_الرحمن_الرحيم.docx differ diff --git a/test_regex.py b/test_regex.py new file mode 100644 index 0000000..d9345d8 --- /dev/null +++ b/test_regex.py @@ -0,0 +1,39 @@ +import re + +def get_youtube_id(url): + """Extracts the video ID from a YouTube URL.""" + if not url: + return None + + # regex for extracting youtube id + # Supports: + # - https://www.youtube.com/watch?v=VIDEO_ID + # - https://youtu.be/VIDEO_ID + # - https://www.youtube.com/embed/VIDEO_ID + # - https://www.youtube.com/live/VIDEO_ID + # - https://m.youtube.com/watch?v=VIDEO_ID + + patterns = [ + r'(?:v=|\/)([0-9A-Za-z_-]{11}).*', + r'(?:youtu\.be\/)([0-9A-Za-z_-]{11})', + r'(?:embed\/)([0-9A-Za-z_-]{11})', + r'(?:live\/)([0-9A-Za-z_-]{11})', + ] + + for pattern in patterns: + match = re.search(pattern, url) + if match: + return match.group(1) + return None + +test_urls = [ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtu.be/dQw4w9WgXcQ", + "https://www.youtube.com/embed/dQw4w9WgXcQ", + "https://www.youtube.com/live/dQw4w9WgXcQ?feature=share", + "https://m.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ", + "invalid-url" +] + +for url in test_urls: + print(f"URL: {url} -> ID: {get_youtube_id(url)}") diff --git a/test_regex_v2.py b/test_regex_v2.py new file mode 100644 index 0000000..94cc9e0 --- /dev/null +++ b/test_regex_v2.py @@ -0,0 +1,29 @@ +import re + +def get_youtube_id(url): + if not url: + return None + patterns = [ + r'(?:v=|\/)([0-9A-Za-z_-]{11}).*', + r'(?:youtu\.be\/)([0-9A-Za-z_-]{11})', + r'(?:live\/)([0-9A-Za-z_-]{11})', + r'(?:embed\/)([0-9A-Za-z_-]{11})', + r'(?:shorts\/)([0-9A-Za-z_-]{11})', # Added shorts + ] + for pattern in patterns: + match = re.search(pattern, url) + if match: + return match.group(1) + return None + +urls = [ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtu.be/dQw4w9WgXcQ", + "https://www.youtube.com/embed/dQw4w9WgXcQ", + "https://www.youtube.com/live/dQw4w9WgXcQ", + "https://www.youtube.com/shorts/dQw4w9WgXcQ", + "https://m.youtube.com/watch?v=dQw4w9WgXcQ&feature=youtu.be" +] + +for url in urls: + print(f"{url} -> {get_youtube_id(url)}") diff --git a/test_regex_v3.py b/test_regex_v3.py new file mode 100644 index 0000000..dca8160 --- /dev/null +++ b/test_regex_v3.py @@ -0,0 +1,43 @@ +import re + +def get_youtube_id(url): + """Extracts the video ID from a YouTube URL.""" + if not url: + return None + + url = url.strip() + + # Handle various formats: + # https://youtu.be/VIDEO_ID + # https://www.youtube.com/watch?v=VIDEO_ID + # https://www.youtube.com/live/VIDEO_ID + # https://www.youtube.com/embed/VIDEO_ID + # https://www.youtube.com/shorts/VIDEO_ID + patterns = [ + r'(?:v=|\/)([0-9A-Za-z_-]{11})(?:[?&]|$)', + r'(?:youtu\.be\/)([0-9A-Za-z_-]{11})', + r'(?:live\/)([0-9A-Za-z_-]{11})', + r'(?:embed\/)([0-9A-Za-z_-]{11})', + r'(?:shorts\/)([0-9A-Za-z_-]{11})', + ] + for pattern in patterns: + match = re.search(pattern, url) + if match: + return match.group(1) + return None + +urls = [ + ("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"), + ("https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"), + ("https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"), + ("https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"), + ("https://www.youtube.com/live/dQw4w9WgXcQ", "dQw4w9WgXcQ"), + (" https://www.youtube.com/watch?v=dQw4w9WgXcQ ", "dQw4w9WgXcQ"), # Whitespace + ("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1s", "dQw4w9WgXcQ"), + ("https://m.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"), +] + +print("Testing YouTube ID extraction...") +for url, expected in urls: + result = get_youtube_id(url) + print(f"URL: {url.strip()} -> Expected: {expected}, Got: {result} -> {'PASS' if result == expected else 'FAIL'}")