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.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')

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."""
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

View File

@ -28,10 +28,11 @@
<iframe
width="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"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen>
</iframe>
</div>

View File

@ -151,6 +151,51 @@
{% endif %}
</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 -->
<div class="mb-4">
<h4 class="fw-bold mb-3">المواد المتاحة</h4>

View File

@ -65,13 +65,14 @@
</div>
{% endfor %}
{% if subject.youtube_live_url %}
{% if subject.get_youtube_id %}
<div class="mt-5 mb-5">
<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"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen>
</iframe>
</div>
@ -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 = `
<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>
`;
} else if (type === 'FILE' && fileUrl) {

View File

@ -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()

View File

@ -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/<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/cancel/', views.payment_cancel, name='payment_cancel'),
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>/delete/', views.delete_resource, name='delete_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 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}'})

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