add packeges and youtube

This commit is contained in:
Flatlogic Bot 2026-02-04 16:21:56 +00:00
parent c673d9b2b1
commit f4cb2d0af8
22 changed files with 335 additions and 28 deletions

View File

@ -2,7 +2,7 @@ from django.contrib import admin
from django import forms from django import forms
from django.utils.html import format_html from django.utils.html import format_html
from django.urls import reverse 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): class ActionsModelAdmin(admin.ModelAdmin):
""" """
@ -93,3 +93,11 @@ class StudentAdmin(ActionsModelAdmin):
if obj and obj.classroom: if obj and obj.classroom:
form.base_fields['subscribed_subjects'].queryset = Subject.objects.filter(classroom=obj.classroom) form.base_fields['subscribed_subjects'].queryset = Subject.objects.filter(classroom=obj.classroom)
return form 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')

View File

@ -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': 'الباقات',
},
),
]

View File

@ -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='الصف'),
),
]

View File

@ -65,18 +65,24 @@ class Subject(models.Model):
"""Extracts the video ID from a YouTube URL.""" """Extracts the video ID from a YouTube URL."""
if not self.youtube_live_url: if not self.youtube_live_url:
return None return None
url = self.youtube_live_url.strip()
# Handle various formats: # Handle various formats:
# https://youtu.be/VIDEO_ID # https://youtu.be/VIDEO_ID
# https://www.youtube.com/watch?v=VIDEO_ID # https://www.youtube.com/watch?v=VIDEO_ID
# https://www.youtube.com/live/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 = [ patterns = [
r'(?:v=|\/)([0-9A-Za-z_-]{11}).*', r'(?:v=|\/)([0-9A-Za-z_-]{11})(?:[?&]|$)',
r'(?:youtu\.be\/)([0-9A-Za-z_-]{11})', r'(?:youtu\.be\/)([0-9A-Za-z_-]{11})',
r'(?:live\/)([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: for pattern in patterns:
match = re.search(pattern, self.youtube_live_url) match = re.search(pattern, url)
if match: if match:
return match.group(1) return match.group(1)
return None return None
@ -107,14 +113,18 @@ class Resource(models.Model):
"""Extracts the video ID from the link if it is a YouTube URL.""" """Extracts the video ID from the link if it is a YouTube URL."""
if not self.link or self.resource_type != 'VIDEO': if not self.link or self.resource_type != 'VIDEO':
return None return None
url = self.link.strip()
patterns = [ patterns = [
r'(?:v=|\/)([0-9A-Za-z_-]{11}).*', r'(?:v=|\/)([0-9A-Za-z_-]{11})(?:[?&]|$)',
r'(?:youtu\.be\/)([0-9A-Za-z_-]{11})', r'(?:youtu\.be\/)([0-9A-Za-z_-]{11})',
r'(?:live\/)([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: for pattern in patterns:
match = re.search(pattern, self.link) match = re.search(pattern, url)
if match: if match:
return match.group(1) return match.group(1)
return None return None
@ -219,3 +229,21 @@ class PlatformSettings(SingletonModel):
def __str__(self): def __str__(self):
return "إعدادات المنصة" 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

View File

@ -28,10 +28,11 @@
<iframe <iframe
width="100%" width="100%"
height="100%" height="100%"
src="https://www.youtube.com/embed/{{ youtube_id }}?autoplay=1" src="https://www.youtube-nocookie.com/embed/{{ youtube_id }}?autoplay=1&playsinline=1"
title="YouTube video player" title="YouTube video player"
frameborder="0" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen> allowfullscreen>
</iframe> </iframe>
</div> </div>

View File

@ -151,6 +151,51 @@
{% endif %} {% endif %}
</div> </div>
<!-- Available Packages Section -->
{% if packages %}
<div class="mb-4">
<h4 class="fw-bold mb-3">الباقات المتاحة 🎁</h4>
<div class="row g-4">
{% for package in packages %}
<div class="col-md-6">
<div class="glass-card p-0 overflow-hidden h-100" style="border: 2px solid #ffc107;">
{% if package.image %}
<div style="height: 150px; overflow: hidden;">
<img src="{{ package.image.url }}" class="w-100 h-100 object-fit-cover" alt="{{ package.name_en }}">
</div>
{% else %}
<div class="bg-warning bg-opacity-25 d-flex align-items-center justify-content-center" style="height: 150px;">
<span class="text-muted display-4">📦</span>
</div>
{% endif %}
<div class="p-3">
<h5 class="fw-bold mb-2">{{ package.name_ar }}</h5>
<p class="text-muted small mb-3">{{ package.description_ar|truncatechars:100 }}</p>
<div class="mb-2">
<small class="text-muted">تشمل المواد:</small>
<div class="d-flex flex-wrap gap-1 mt-1">
{% for subject in package.subjects.all|slice:":3" %}
<span class="badge bg-secondary bg-opacity-10 text-dark">{{ subject.name_ar }}</span>
{% endfor %}
{% if package.subjects.count > 3 %}
<span class="badge bg-secondary bg-opacity-10 text-dark">+{{ package.subjects.count|add:"-3" }}</span>
{% endif %}
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="fw-bold text-success fs-5">{{ package.price }} OMR</span>
<a href="{% url 'subscribe_package' package.id %}" class="btn btn-sm btn-warning text-dark fw-bold rounded-pill">شراء الباقة</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Available Subjects Section --> <!-- Available Subjects Section -->
<div class="mb-4"> <div class="mb-4">
<h4 class="fw-bold mb-3">المواد المتاحة</h4> <h4 class="fw-bold mb-3">المواد المتاحة</h4>

View File

@ -65,13 +65,14 @@
</div> </div>
{% endfor %} {% endfor %}
{% if subject.youtube_live_url %} {% if subject.get_youtube_id %}
<div class="mt-5 mb-5"> <div class="mt-5 mb-5">
<div class="ratio ratio-16x9 rounded-4 overflow-hidden shadow-sm"> <div class="ratio ratio-16x9 rounded-4 overflow-hidden shadow-sm">
<iframe src="https://www.youtube.com/embed/{{ subject.get_youtube_id }}" <iframe src="https://www.youtube-nocookie.com/embed/{{ subject.get_youtube_id }}?rel=0&playsinline=1"
title="YouTube video player" title="YouTube video player"
frameborder="0" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen> allowfullscreen>
</iframe> </iframe>
</div> </div>
@ -255,9 +256,14 @@ document.addEventListener('DOMContentLoaded', function() {
modalBody.innerHTML = ''; // Clear previous content modalBody.innerHTML = ''; // Clear previous content
if (type === 'VIDEO' && youtubeId) { if (type === 'VIDEO' && youtubeId) {
// Use youtube-nocookie.com and add referrerpolicy
modalBody.innerHTML = ` modalBody.innerHTML = `
<div class="ratio ratio-16x9 h-100"> <div class="ratio ratio-16x9 h-100">
<iframe src="https://www.youtube.com/embed/${youtubeId}?autoplay=1" title="${title}" allowfullscreen></iframe> <iframe src="https://www.youtube-nocookie.com/embed/${youtubeId}?autoplay=1&rel=0&playsinline=1"
title="${title}"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen></iframe>
</div> </div>
`; `;
} else if (type === 'FILE' && fileUrl) { } else if (type === 'FILE' && fileUrl) {

View File

@ -20,7 +20,7 @@ class ThawaniClient:
else: else:
self.base_url = "https://checkout.thawani.om/api/v1" 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: if not self.api_key:
raise Exception("Thawani API Key is not configured.") raise Exception("Thawani API Key is not configured.")
@ -31,24 +31,32 @@ class ThawaniClient:
} }
# Thawani expects amount in Baisa (1 OMR = 1000 Baisa) # 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 = { payload = {
"client_reference_id": str(user.id), "client_reference_id": str(user.id),
"mode": "payment", "mode": "payment",
"products": [ "products": [
{ {
"name": subject.name_en, "name": product.name_en,
"quantity": 1, "quantity": 1,
"unit_amount": amount_baisa "unit_amount": amount_baisa
} }
], ],
"success_url": success_url, "success_url": success_url,
"cancel_url": cancel_url, "cancel_url": cancel_url,
"metadata": { "metadata": metadata
"subject_id": str(subject.id),
"user_id": str(user.id)
}
} }
response = httpx.post(url, json=payload, headers=headers) response = httpx.post(url, json=payload, headers=headers)
@ -66,4 +74,4 @@ class ThawaniClient:
response = httpx.get(url, headers=headers) response = httpx.get(url, headers=headers)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()

View File

@ -16,6 +16,7 @@ urlpatterns = [
path('profile/edit/teacher/', views.edit_teacher_profile, name='edit_teacher_profile'), path('profile/edit/teacher/', views.edit_teacher_profile, name='edit_teacher_profile'),
path('logout/', views.custom_logout, name='logout'), path('logout/', views.custom_logout, name='logout'),
path('subscribe/<int:subject_id>/', views.subscribe_subject, name='subscribe_subject'), path('subscribe/<int:subject_id>/', views.subscribe_subject, name='subscribe_subject'),
path('package/<int:package_id>/subscribe/', views.subscribe_package, name='subscribe_package'),
path('payment/success/', views.payment_success, name='payment_success'), path('payment/success/', views.payment_success, name='payment_success'),
path('payment/cancel/', views.payment_cancel, name='payment_cancel'), path('payment/cancel/', views.payment_cancel, name='payment_cancel'),
path('classroom/<int:subject_id>/', views.live_classroom, name='live_classroom'), path('classroom/<int:subject_id>/', views.live_classroom, name='live_classroom'),
@ -23,4 +24,4 @@ urlpatterns = [
path('resource/<int:resource_id>/edit/', views.edit_resource, name='edit_resource'), path('resource/<int:resource_id>/edit/', views.edit_resource, name='edit_resource'),
path('resource/<int:resource_id>/delete/', views.delete_resource, name='delete_resource'), path('resource/<int:resource_id>/delete/', views.delete_resource, name='delete_resource'),
path('resource/<int:resource_id>/view/', views.view_resource, name='view_resource'), path('resource/<int:resource_id>/view/', views.view_resource, name='view_resource'),
] ]

View File

@ -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.decorators import login_required
from django.contrib.auth import logout, login from django.contrib.auth import logout, login
from django.urls import reverse 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 .forms import StudentRegistrationForm, TeacherProfileForm, ResourceForm
from .wablas import send_whatsapp_message from .wablas import send_whatsapp_message
from .thawani import ThawaniClient from .thawani import ThawaniClient
@ -175,10 +176,20 @@ def profile(request):
id__in=subscribed_subjects.values_list('id', flat=True) 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', { return render(request, 'core/student_dashboard.html', {
'student_profile': student_profile, 'student_profile': student_profile,
'subscribed_subjects': subscribed_subjects, 'subscribed_subjects': subscribed_subjects,
'available_subjects': available_subjects 'available_subjects': available_subjects,
'packages': packages
}) })
# Fallback (Superuser or Admin without profile) # Fallback (Superuser or Admin without profile)
@ -237,6 +248,32 @@ def subscribe_subject(request, subject_id):
print(f"Payment Error: {e}") print(f"Payment Error: {e}")
return render(request, 'core/error.html', {'message': f'فشل بدء عملية الدفع: {str(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 @login_required
def payment_success(request): def payment_success(request):
session_id = request.GET.get('session_id') session_id = request.GET.get('session_id')
@ -250,14 +287,26 @@ def payment_success(request):
payment_status = data.get('payment_status') payment_status = data.get('payment_status')
metadata = data.get('metadata', {}) metadata = data.get('metadata', {})
subject_id = metadata.get('subject_id') subject_id = metadata.get('subject_id')
package_id = metadata.get('package_id')
if payment_status == 'paid' and subject_id: if payment_status == 'paid':
try: student = request.user.student_profile
student = request.user.student_profile
subject = get_object_or_404(Subject, pk=subject_id) if subject_id:
student.subscribed_subjects.add(subject) try:
except Exception: subject = get_object_or_404(Subject, pk=subject_id)
pass # Already handled or user mismatch? 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') return redirect('profile')
else: else:
return render(request, 'core/error.html', {'message': f'لم تتم عملية الدفع بنجاح. الحالة: {payment_status}'}) return render(request, 'core/error.html', {'message': f'لم تتم عملية الدفع بنجاح. الحالة: {payment_status}'})

Binary file not shown.

39
test_regex.py Normal file
View File

@ -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)}")

29
test_regex_v2.py Normal file
View File

@ -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)}")

43
test_regex_v3.py Normal file
View File

@ -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'}")