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 %}
+
+

+
+ {% 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'}")