Autosave: 20260204-060035
This commit is contained in:
parent
776c322148
commit
91858c53d5
Binary file not shown.
@ -58,6 +58,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'core',
|
||||
'configuration',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@ -196,6 +197,11 @@ if EMAIL_USE_SSL:
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Authentication Redirects
|
||||
LOGIN_URL = 'login'
|
||||
LOGIN_REDIRECT_URL = 'profile'
|
||||
LOGOUT_REDIRECT_URL = 'index'
|
||||
|
||||
JAZZMIN_SETTINGS = {
|
||||
# title of the window (Will default to current_admin_site.site_title if absent or None)
|
||||
"site_title": "School Admin",
|
||||
@ -265,7 +271,7 @@ JAZZMIN_SETTINGS = {
|
||||
"hide_models": [],
|
||||
|
||||
# List of apps (and/or models) to base side menu ordering off of (does not need to contain all apps/models)
|
||||
"order_with_respect_to": ["core", "auth"],
|
||||
"order_with_respect_to": ["core", "configuration", "auth"],
|
||||
|
||||
# Custom icons for side menu apps/models See https://fontawesome.com/icons?d=gallery&m=free&v=5.0.0,5.0.1,5.0.10,5.0.11,5.0.12,5.0.13,5.1.0,5.1.1,5.2.0,5.3.0,5.3.1,5.4.0,5.4.1,5.4.2,5.13.0,5.12.0,5.11.2,5.11.1,5.10.0,5.5.0,5.6.0,5.6.1,5.6.3,5.7.0,5.7.1,5.7.2,5.8.0,5.8.1,5.8.2,5.9.0
|
||||
"icons": {
|
||||
@ -277,6 +283,9 @@ JAZZMIN_SETTINGS = {
|
||||
"core.Subject": "fas fa-book",
|
||||
"core.Resource": "fas fa-file-alt",
|
||||
"core.Classroom": "fas fa-layer-group",
|
||||
"configuration.ThawaniSettings": "fas fa-credit-card",
|
||||
"configuration.WablasSettings": "fas fa-comment-alt",
|
||||
"configuration.PlatformProfile": "fas fa-cogs",
|
||||
},
|
||||
# Icons that are used when one is not manually specified
|
||||
"default_icon_parents": "fas fa-chevron-circle-right",
|
||||
@ -298,4 +307,4 @@ JAZZMIN_SETTINGS = {
|
||||
"use_google_fonts_cdn": True,
|
||||
# Whether to show the UI customizer on the sidebar
|
||||
"show_ui_builder": True,
|
||||
}
|
||||
}
|
||||
|
||||
0
configuration/__init__.py
Normal file
0
configuration/__init__.py
Normal file
BIN
configuration/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
configuration/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
configuration/__pycache__/admin.cpython-311.pyc
Normal file
BIN
configuration/__pycache__/admin.cpython-311.pyc
Normal file
Binary file not shown.
BIN
configuration/__pycache__/apps.cpython-311.pyc
Normal file
BIN
configuration/__pycache__/apps.cpython-311.pyc
Normal file
Binary file not shown.
BIN
configuration/__pycache__/models.cpython-311.pyc
Normal file
BIN
configuration/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
71
configuration/admin.py
Normal file
71
configuration/admin.py
Normal file
@ -0,0 +1,71 @@
|
||||
from django.contrib import admin
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from .models import ThawaniSettings, WablasSettings, PlatformProfile
|
||||
|
||||
class SingletonAdmin(admin.ModelAdmin):
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
model = self.model
|
||||
if model.objects.exists():
|
||||
obj = model.objects.first()
|
||||
return redirect(reverse(f'admin:{model._meta.app_label}_{model._meta.model_name}_change', args=[obj.pk]))
|
||||
else:
|
||||
return redirect(reverse(f'admin:{model._meta.app_label}_{model._meta.model_name}_add'))
|
||||
|
||||
def has_add_permission(self, request):
|
||||
if self.model.objects.exists():
|
||||
return False
|
||||
return super().has_add_permission(request)
|
||||
|
||||
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||
extra_context = extra_context or {}
|
||||
extra_context['show_save_and_add_another'] = False
|
||||
# extra_context['show_save_and_continue'] = False # Optional: keep if user wants to save and stay
|
||||
return super().change_view(request, object_id, form_url, extra_context=extra_context)
|
||||
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
extra_context = extra_context or {}
|
||||
extra_context['show_save_and_add_another'] = False
|
||||
return super().add_view(request, form_url, extra_context=extra_context)
|
||||
|
||||
@admin.register(ThawaniSettings)
|
||||
class ThawaniSettingsAdmin(SingletonAdmin):
|
||||
fieldsets = (
|
||||
('API Credentials', {
|
||||
'fields': ('api_key', 'publishable_key'),
|
||||
'description': 'Enter your Thawani Pay API credentials.'
|
||||
}),
|
||||
('Environment', {
|
||||
'fields': ('is_sandbox',),
|
||||
'description': 'Toggle Sandbox mode for testing.'
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(WablasSettings)
|
||||
class WablasSettingsAdmin(SingletonAdmin):
|
||||
fieldsets = (
|
||||
('Connection Details', {
|
||||
'fields': ('api_url', 'api_token'),
|
||||
'description': 'Configuration for WhatsApp integration via Wablas.'
|
||||
}),
|
||||
('Security', {
|
||||
'fields': ('secret_key',),
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(PlatformProfile)
|
||||
class PlatformProfileAdmin(SingletonAdmin):
|
||||
fieldsets = (
|
||||
('Identity', {
|
||||
'fields': ('name', 'logo', 'description'),
|
||||
'classes': ('wide',),
|
||||
}),
|
||||
('Contact Information', {
|
||||
'fields': ('contact_email', 'contact_phone', 'address'),
|
||||
'classes': ('wide',),
|
||||
}),
|
||||
('Social Presence', {
|
||||
'fields': ('facebook_link', 'twitter_link', 'instagram_link'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
6
configuration/apps.py
Normal file
6
configuration/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class ConfigurationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'configuration'
|
||||
verbose_name = 'Settings'
|
||||
54
configuration/migrations/0001_initial.py
Normal file
54
configuration/migrations/0001_initial.py
Normal file
@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-04 05:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_platformsettings_remove_student_is_mobile_verified_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PlatformProfile',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Platform Profile',
|
||||
'verbose_name_plural': 'Platform Profile',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('core.platformsettings',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ThawaniSettings',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Thawani API',
|
||||
'verbose_name_plural': 'Thawani API',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('core.thawaniconfiguration',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WablasSettings',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Wablas API',
|
||||
'verbose_name_plural': 'Wablas API',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('core.wablasconfiguration',),
|
||||
),
|
||||
]
|
||||
0
configuration/migrations/__init__.py
Normal file
0
configuration/migrations/__init__.py
Normal file
Binary file not shown.
BIN
configuration/migrations/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
configuration/migrations/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
20
configuration/models.py
Normal file
20
configuration/models.py
Normal file
@ -0,0 +1,20 @@
|
||||
from django.db import models
|
||||
from core.models import ThawaniConfiguration, WablasConfiguration, PlatformSettings
|
||||
|
||||
class ThawaniSettings(ThawaniConfiguration):
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = "Thawani API"
|
||||
verbose_name_plural = "Thawani API"
|
||||
|
||||
class WablasSettings(WablasConfiguration):
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = "Wablas API"
|
||||
verbose_name_plural = "Wablas API"
|
||||
|
||||
class PlatformProfile(PlatformSettings):
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = "Platform Profile"
|
||||
verbose_name_plural = "Platform Profile"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/thawani.cpython-311.pyc
Normal file
BIN
core/__pycache__/thawani.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/wablas.cpython-311.pyc
Normal file
BIN
core/__pycache__/wablas.cpython-311.pyc
Normal file
Binary file not shown.
@ -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, Moderate
|
||||
from .models import Classroom, Teacher, Subject, Resource, Student, City, Governorate
|
||||
|
||||
class ActionsModelAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
@ -41,11 +41,12 @@ class SubjectAdmin(ActionsModelAdmin):
|
||||
|
||||
@admin.register(City)
|
||||
class CityAdmin(ActionsModelAdmin):
|
||||
list_display = ('name', 'actions_column')
|
||||
list_display = ('name_en', 'name_ar', 'governorate', 'actions_column')
|
||||
list_filter = ('governorate',)
|
||||
|
||||
@admin.register(Moderate)
|
||||
class ModerateAdmin(ActionsModelAdmin):
|
||||
list_display = ('name', 'actions_column')
|
||||
@admin.register(Governorate)
|
||||
class GovernorateAdmin(ActionsModelAdmin):
|
||||
list_display = ('name_en', 'name_ar', 'actions_column')
|
||||
|
||||
class ResourceAdminForm(forms.ModelForm):
|
||||
classroom = forms.ModelChoiceField(
|
||||
@ -75,8 +76,8 @@ class ResourceAdmin(ActionsModelAdmin):
|
||||
|
||||
@admin.register(Student)
|
||||
class StudentAdmin(ActionsModelAdmin):
|
||||
list_display = ('user', 'classroom', 'mobile_number', 'city', 'moderate', 'actions_column')
|
||||
list_filter = ('classroom', 'city', 'moderate')
|
||||
list_display = ('user', 'classroom', 'mobile_number', 'city', 'governorate', 'actions_column')
|
||||
list_filter = ('classroom', 'city', 'governorate')
|
||||
filter_horizontal = ('subscribed_subjects',)
|
||||
|
||||
class Media:
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from .models import Student, Subject, Classroom
|
||||
from .models import Student, Subject, Classroom, City, Governorate
|
||||
|
||||
class StudentRegistrationForm(forms.ModelForm):
|
||||
first_name = forms.CharField(max_length=30, required=True, label="Full Name (First)")
|
||||
last_name = forms.CharField(max_length=30, required=True, label="Full Name (Last)")
|
||||
full_name = forms.CharField(max_length=150, required=True, label="Full Name")
|
||||
username = forms.CharField(max_length=150, required=True)
|
||||
email = forms.EmailField(required=True)
|
||||
password = forms.CharField(widget=forms.PasswordInput, required=True)
|
||||
password_confirm = forms.CharField(widget=forms.PasswordInput, required=True, label="Confirm Password")
|
||||
|
||||
classroom = forms.ModelChoiceField(queryset=Classroom.objects.all(), required=True, empty_label="Select Classroom")
|
||||
subjects = forms.ModelMultipleChoiceField(
|
||||
@ -19,13 +19,22 @@ class StudentRegistrationForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Student
|
||||
fields = ['mobile_number', 'moderate', 'city', 'avatar']
|
||||
fields = ['mobile_number', 'governorate', 'city', 'avatar']
|
||||
widgets = {
|
||||
'avatar': forms.FileInput(attrs={'accept': 'image/*', 'capture': 'camera'}), # generic hint, but we'll use custom JS
|
||||
'avatar': forms.FileInput(attrs={'accept': 'image/*', 'capture': 'camera'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Add Bootstrap classes
|
||||
for field_name, field in self.fields.items():
|
||||
if field.widget.__class__.__name__ == 'CheckboxInput':
|
||||
field.widget.attrs['class'] = 'form-check-input'
|
||||
elif field.widget.__class__.__name__ != 'CheckboxSelectMultiple':
|
||||
field.widget.attrs['class'] = 'form-control'
|
||||
field.widget.attrs['placeholder'] = field.label # Important for Floating Labels!
|
||||
|
||||
# optimize subjects loading
|
||||
self.fields['subjects'].queryset = Subject.objects.none()
|
||||
|
||||
@ -38,14 +47,42 @@ class StudentRegistrationForm(forms.ModelForm):
|
||||
elif self.instance.pk and self.instance.classroom:
|
||||
self.fields['subjects'].queryset = Subject.objects.filter(classroom=self.instance.classroom)
|
||||
|
||||
# optimize city loading
|
||||
self.fields['city'].queryset = City.objects.none()
|
||||
|
||||
if 'governorate' in self.data:
|
||||
try:
|
||||
governorate_id = int(self.data.get('governorate'))
|
||||
self.fields['city'].queryset = City.objects.filter(governorate_id=governorate_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif self.instance.pk and self.instance.governorate:
|
||||
self.fields['city'].queryset = City.objects.filter(governorate=self.instance.governorate)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
password = cleaned_data.get("password")
|
||||
password_confirm = cleaned_data.get("password_confirm")
|
||||
|
||||
if password and password_confirm and password != password_confirm:
|
||||
self.add_error('password_confirm', "Passwords do not match")
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
# We need to save the User first, then the Student
|
||||
full_name = self.cleaned_data['full_name']
|
||||
if " " in full_name:
|
||||
first_name, last_name = full_name.split(" ", 1)
|
||||
else:
|
||||
first_name = full_name
|
||||
last_name = ""
|
||||
|
||||
user = User.objects.create_user(
|
||||
username=self.cleaned_data['username'],
|
||||
email=self.cleaned_data['email'],
|
||||
password=self.cleaned_data['password'],
|
||||
first_name=self.cleaned_data['first_name'],
|
||||
last_name=self.cleaned_data['last_name']
|
||||
first_name=first_name,
|
||||
last_name=last_name
|
||||
)
|
||||
student = super().save(commit=False)
|
||||
student.user = user
|
||||
|
||||
Binary file not shown.
60
core/management/commands/create_test_users.py
Normal file
60
core/management/commands/create_test_users.py
Normal file
@ -0,0 +1,60 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import User
|
||||
from core.models import Teacher, Student, Classroom, City, Governorate
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Creates test users: one teacher and one student'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# 1. Setup Dependencies
|
||||
gov, _ = Governorate.objects.get_or_create(
|
||||
name_en="Muscat",
|
||||
defaults={'name_ar': "مسقط"}
|
||||
)
|
||||
city, _ = City.objects.get_or_create(
|
||||
name_en="Seeb",
|
||||
defaults={'name_ar': "السيب", 'governorate': gov}
|
||||
)
|
||||
classroom, _ = Classroom.objects.get_or_create(
|
||||
name_en="Grade 10",
|
||||
defaults={'name_ar': "الصف 10", 'description': "Test Classroom"}
|
||||
)
|
||||
|
||||
# 2. Create Teacher
|
||||
t_user, created = User.objects.get_or_create(username='teacher_test')
|
||||
if created:
|
||||
t_user.set_password('password123')
|
||||
t_user.email = 'teacher@example.com'
|
||||
t_user.first_name = 'John'
|
||||
t_user.last_name = 'Doe (Teacher)'
|
||||
t_user.save()
|
||||
|
||||
Teacher.objects.create(
|
||||
user=t_user,
|
||||
bio="I am a test teacher specializing in Mathematics.",
|
||||
specialization="Mathematics"
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Created teacher: {t_user.username} (password123)"))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(f"User {t_user.username} already exists"))
|
||||
|
||||
# 3. Create Student
|
||||
s_user, created = User.objects.get_or_create(username='student_test')
|
||||
if created:
|
||||
s_user.set_password('password123')
|
||||
s_user.email = 'student@example.com'
|
||||
s_user.first_name = 'Jane'
|
||||
s_user.last_name = 'Smith (Student)'
|
||||
s_user.save()
|
||||
|
||||
Student.objects.create(
|
||||
user=s_user,
|
||||
classroom=classroom,
|
||||
city=city,
|
||||
governorate=gov,
|
||||
mobile_number="1234567890",
|
||||
is_email_verified=True
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Created student: {s_user.username} (password123)"))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(f"User {s_user.username} already exists"))
|
||||
@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-04 04:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_city_moderate_student_mobile_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='email_otp_code',
|
||||
field=models.CharField(blank=True, max_length=6, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='is_email_verified',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='is_mobile_verified',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='mobile_otp_code',
|
||||
field=models.CharField(blank=True, max_length=6, null=True),
|
||||
),
|
||||
]
|
||||
26
core/migrations/0007_wablasconfiguration.py
Normal file
26
core/migrations/0007_wablasconfiguration.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-04 04:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_student_email_otp_code_student_is_email_verified_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WablasConfiguration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('api_url', models.URLField(default='https://kudus.wablas.com/api/send-message', verbose_name='API URL')),
|
||||
('api_token', models.CharField(max_length=500, verbose_name='API Token')),
|
||||
('secret_key', models.CharField(blank=True, max_length=500, null=True, verbose_name='Secret Key')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Wablas Configuration',
|
||||
'verbose_name_plural': 'Wablas Configuration',
|
||||
},
|
||||
),
|
||||
]
|
||||
26
core/migrations/0008_thawaniconfiguration.py
Normal file
26
core/migrations/0008_thawaniconfiguration.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-04 05:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_wablasconfiguration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ThawaniConfiguration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('api_key', models.CharField(max_length=500, verbose_name='Secret Key')),
|
||||
('publishable_key', models.CharField(max_length=500, verbose_name='Publishable Key')),
|
||||
('is_sandbox', models.BooleanField(default=True, verbose_name='Sandbox Mode')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Thawani Configuration',
|
||||
'verbose_name_plural': 'Thawani Configuration',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,70 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-04 05:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_thawaniconfiguration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PlatformSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='My School Platform', max_length=100)),
|
||||
('logo', models.ImageField(blank=True, null=True, upload_to='platform/')),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('contact_email', models.EmailField(blank=True, max_length=254, null=True)),
|
||||
('contact_phone', models.CharField(blank=True, max_length=20, null=True)),
|
||||
('address', models.TextField(blank=True, null=True)),
|
||||
('facebook_link', models.URLField(blank=True, null=True)),
|
||||
('twitter_link', models.URLField(blank=True, null=True)),
|
||||
('instagram_link', models.URLField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Platform Profile',
|
||||
'verbose_name_plural': 'Platform Profile',
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='student',
|
||||
name='is_mobile_verified',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='student',
|
||||
name='mobile_otp_code',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='thawaniconfiguration',
|
||||
name='api_key',
|
||||
field=models.CharField(help_text='Thawani Secret Key', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='thawaniconfiguration',
|
||||
name='is_sandbox',
|
||||
field=models.BooleanField(default=True, help_text='Check to use Sandbox environment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='thawaniconfiguration',
|
||||
name='publishable_key',
|
||||
field=models.CharField(help_text='Thawani Publishable Key', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wablasconfiguration',
|
||||
name='api_token',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wablasconfiguration',
|
||||
name='api_url',
|
||||
field=models.URLField(default='https://texas.wablas.com'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wablasconfiguration',
|
||||
name='secret_key',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,57 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-04 05:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_platformsettings_remove_student_is_mobile_verified_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Governorate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name_en', models.CharField(default='', max_length=100, verbose_name='Name (English)')),
|
||||
('name_ar', models.CharField(default='', max_length=100, verbose_name='Name (Arabic)')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Governorate',
|
||||
'verbose_name_plural': 'Governorates',
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='student',
|
||||
name='moderate',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='city',
|
||||
name='name',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='city',
|
||||
name='name_ar',
|
||||
field=models.CharField(default='', max_length=100, verbose_name='Name (Arabic)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='city',
|
||||
name='name_en',
|
||||
field=models.CharField(default='', max_length=100, verbose_name='Name (English)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='city',
|
||||
name='governorate',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cities', to='core.governorate', verbose_name='Governorate'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='student',
|
||||
name='governorate',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.governorate', verbose_name='Governorate'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Moderate',
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -2,6 +2,19 @@ from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class SingletonModel(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
super(SingletonModel, self).save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
obj, created = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
class Teacher(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='teacher_profile')
|
||||
bio = models.TextField(_("Bio"), blank=True)
|
||||
@ -49,25 +62,28 @@ class Resource(models.Model):
|
||||
def __str__(self):
|
||||
return self.title_en
|
||||
|
||||
class Governorate(models.Model):
|
||||
name_en = models.CharField(_("Name (English)"), max_length=100, default="")
|
||||
name_ar = models.CharField(_("Name (Arabic)"), max_length=100, default="")
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Governorate")
|
||||
verbose_name_plural = _("Governorates")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name_en}"
|
||||
|
||||
class City(models.Model):
|
||||
name = models.CharField(_("Name"), max_length=100)
|
||||
governorate = models.ForeignKey(Governorate, on_delete=models.CASCADE, related_name='cities', verbose_name=_("Governorate"), null=True, blank=True)
|
||||
name_en = models.CharField(_("Name (English)"), max_length=100, default="")
|
||||
name_ar = models.CharField(_("Name (Arabic)"), max_length=100, default="")
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("City")
|
||||
verbose_name_plural = _("Cities")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Moderate(models.Model):
|
||||
name = models.CharField(_("Name"), max_length=100)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Moderate")
|
||||
verbose_name_plural = _("Moderates")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
return f"{self.name_en}"
|
||||
|
||||
class Student(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='student_profile')
|
||||
@ -76,9 +92,59 @@ class Student(models.Model):
|
||||
mobile_number = models.CharField(_("Mobile Number"), max_length=20, blank=True)
|
||||
subscribed_subjects = models.ManyToManyField(Subject, blank=True, related_name='subscribers')
|
||||
|
||||
moderate = models.ForeignKey(Moderate, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Moderate"))
|
||||
governorate = models.ForeignKey(Governorate, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Governorate"))
|
||||
city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("City"))
|
||||
avatar = models.ImageField(_("Picture"), upload_to='students/', blank=True, null=True)
|
||||
|
||||
# Email Verification
|
||||
email_otp_code = models.CharField(max_length=6, blank=True, null=True)
|
||||
is_email_verified = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.user.get_full_name() or self.user.username
|
||||
return self.user.get_full_name() or self.user.username
|
||||
|
||||
# --- Configuration Models ---
|
||||
|
||||
class WablasConfiguration(SingletonModel):
|
||||
api_url = models.URLField(default="https://texas.wablas.com")
|
||||
api_token = models.CharField(max_length=255)
|
||||
secret_key = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Wablas Configuration"
|
||||
verbose_name_plural = "Wablas Configuration"
|
||||
|
||||
def __str__(self):
|
||||
return "Wablas Configuration"
|
||||
|
||||
class ThawaniConfiguration(SingletonModel):
|
||||
api_key = models.CharField(max_length=255, help_text="Thawani Secret Key")
|
||||
publishable_key = models.CharField(max_length=255, help_text="Thawani Publishable Key")
|
||||
is_sandbox = models.BooleanField(default=True, help_text="Check to use Sandbox environment")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Thawani Configuration"
|
||||
verbose_name_plural = "Thawani Configuration"
|
||||
|
||||
def __str__(self):
|
||||
return "Thawani Configuration"
|
||||
|
||||
class PlatformSettings(SingletonModel):
|
||||
name = models.CharField(max_length=100, default="My School Platform")
|
||||
logo = models.ImageField(upload_to='platform/', blank=True, null=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
contact_email = models.EmailField(blank=True, null=True)
|
||||
contact_phone = models.CharField(max_length=20, blank=True, null=True)
|
||||
address = models.TextField(blank=True, null=True)
|
||||
|
||||
# Social Media
|
||||
facebook_link = models.URLField(blank=True, null=True)
|
||||
twitter_link = models.URLField(blank=True, null=True)
|
||||
instagram_link = models.URLField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Platform Profile"
|
||||
verbose_name_plural = "Platform Profile"
|
||||
|
||||
def __str__(self):
|
||||
return "Platform Profile"
|
||||
|
||||
@ -162,7 +162,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{% url 'register_student' %}" class="btn btn-outline-primary me-2">{% trans "Register" %}</a>
|
||||
<a href="/admin/login/" class="btn btn-primary">{% trans "Login" %}</a>
|
||||
<a href="{% url 'login' %}" class="btn btn-primary">{% trans "Login" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
11
core/templates/core/error.html
Normal file
11
core/templates/core/error.html
Normal file
@ -0,0 +1,11 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="alert alert-danger">
|
||||
<h4>Error</h4>
|
||||
<p>{{ message }}</p>
|
||||
<a href="{% url 'profile' %}" class="btn btn-secondary">Go Back to Profile</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
51
core/templates/core/login.html
Normal file
51
core/templates/core/login.html
Normal file
@ -0,0 +1,51 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Login" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container hero-section">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="glass-card p-5">
|
||||
<div class="text-center mb-4">
|
||||
<h2 class="text-primary fw-bold">{% trans "Welcome Back" %}</h2>
|
||||
<p class="text-muted">{% trans "Sign in to continue your learning journey" %}</p>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{% trans "Invalid username or password." %}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input type="text" name="username" autofocus autocapitalize="none" autocomplete="username" maxlength="150" required id="id_username" class="form-control" placeholder="{% trans 'Username' %}">
|
||||
<label for="id_username"><i class="fas fa-user me-2"></i>{% trans "Username" %}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-4">
|
||||
<input type="password" name="password" autocomplete="current-password" required id="id_password" class="form-control" placeholder="{% trans 'Password' %}">
|
||||
<label for="id_password"><i class="fas fa-lock me-2"></i>{% trans "Password" %}</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 py-3 fs-5 fw-bold shadow-sm">
|
||||
{% trans "Login" %} <i class="fas fa-arrow-right ms-2"></i>
|
||||
</button>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<p class="text-muted mb-0">
|
||||
{% trans "Don't have an account?" %}
|
||||
<a href="{% url 'register_student' %}" class="text-primary text-decoration-none fw-bold">{% trans "Register Here" %}</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -42,8 +42,8 @@
|
||||
<p class="fw-bold">{{ student_profile.city|default:"-" }}</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label text-muted">{% trans "Shift (Moderate)" %}</label>
|
||||
<p class="fw-bold">{{ student_profile.moderate|default:"-" }}</p>
|
||||
<label class="form-label text-muted">{% trans "Governorate" %}</label>
|
||||
<p class="fw-bold">{{ student_profile.governorate|default:"-" }}</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label text-muted">{% trans "Mobile Number" %}</label>
|
||||
@ -74,4 +74,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -7,110 +7,150 @@
|
||||
<div class="container hero-section">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="glass-card">
|
||||
<h2 class="text-center mb-4 text-primary">{% trans "Student Registration" %}</h2>
|
||||
<div class="glass-card p-4 p-md-5">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="text-primary fw-bold">{% trans "Create Your Account" %}</h2>
|
||||
<p class="text-muted">{% trans "Join our learning platform today" %}</p>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" id="registrationForm">
|
||||
<form method="post" enctype="multipart/form-data" id="registrationForm" class="needs-validation">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
{{ form.errors }}
|
||||
<div class="alert alert-danger mb-4">
|
||||
<ul class="mb-0">
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<li>{{ field.label }}: {{ error }}</li>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- User Details -->
|
||||
<h5 class="mb-3 border-bottom pb-2">{% trans "Personal Information" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{{ form.first_name.label }}</label>
|
||||
{{ form.first_name }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">{{ form.last_name.label }}</label>
|
||||
{{ form.last_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ form.username.label }}</label>
|
||||
{{ form.username }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ form.email.label }}</label>
|
||||
{{ form.email }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ form.password.label }}</label>
|
||||
{{ form.password }}
|
||||
</div>
|
||||
|
||||
<!-- Student Details -->
|
||||
<h5 class="mb-3 border-bottom pb-2 mt-4">{% trans "Student Details" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">{{ form.mobile_number.label }}</label>
|
||||
{{ form.mobile_number }}
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">{{ form.moderate.label }}</label>
|
||||
{{ form.moderate }}
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">{{ form.city.label }}</label>
|
||||
{{ form.city }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classroom & Subjects -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ form.classroom.label }}</label>
|
||||
{{ form.classroom }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Subjects" %}</label>
|
||||
<div id="subjects-container" class="border p-3 rounded bg-white" style="min-height: 100px;">
|
||||
<p class="text-muted text-center mt-3">{% trans "Select a classroom to see subjects." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 p-3 bg-primary text-white rounded">
|
||||
<h4 class="m-0">{% trans "Total Amount:" %}</h4>
|
||||
<h4 class="m-0"><span id="total-amount">0.00</span></h4>
|
||||
</div>
|
||||
|
||||
<!-- Webcam -->
|
||||
<h5 class="mb-3 border-bottom pb-2">{% trans "Profile Picture" %}</h5>
|
||||
<div class="mb-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6 d-flex flex-column align-items-center">
|
||||
<label class="mb-2">{% trans "Webcam" %}</label>
|
||||
<div id="video-container" style="position: relative;">
|
||||
<video id="video" width="240" height="180" autoplay style="border: 2px solid #ddd; border-radius: 8px; background: #000;"></video>
|
||||
<button type="button" id="snap" class="btn btn-sm btn-light position-absolute bottom-0 start-50 translate-middle-x mb-2">
|
||||
📸 {% trans "Snap" %}
|
||||
</button>
|
||||
<!-- Section: Personal Info -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title text-primary mb-3"><i class="fas fa-user-circle me-2"></i>{% trans "Personal Information" %}</h5>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
{{ form.full_name }}
|
||||
<label for="{{ form.full_name.id_for_label }}">{% trans "Full Name" %}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
{{ form.username }}
|
||||
<label for="{{ form.username.id_for_label }}">{% trans "Username" %}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
{{ form.email }}
|
||||
<label for="{{ form.email.id_for_label }}">{% trans "Email Address" %}</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-floating">
|
||||
{{ form.password }}
|
||||
<label for="{{ form.password.id_for_label }}">{% trans "Password" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-floating">
|
||||
{{ form.password_confirm }}
|
||||
<label for="{{ form.password_confirm.id_for_label }}">{% trans "Confirm Password" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 d-flex flex-column align-items-center">
|
||||
<label class="mb-2">{% trans "Preview" %}</label>
|
||||
<canvas id="canvas" width="240" height="180" style="display:none;"></canvas>
|
||||
<div id="preview-placeholder" style="width: 240px; height: 180px; border: 2px dashed #ddd; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #aaa;">
|
||||
{% trans "No photo taken" %}
|
||||
</div>
|
||||
<img id="photo-preview" width="240" height="180" class="rounded" style="display:none; border: 2px solid #4ECDC4;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-label small">{% trans "Or upload file:" %}</label>
|
||||
{{ form.avatar }}
|
||||
</div>
|
||||
|
||||
<!-- Section: Student Details -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title text-primary mb-3"><i class="fas fa-id-card me-2"></i>{% trans "Student Profile" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-floating">
|
||||
{{ form.mobile_number }}
|
||||
<label for="{{ form.mobile_number.id_for_label }}">{% trans "Mobile Number" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="form-floating">
|
||||
{{ form.governorate }}
|
||||
<label for="{{ form.governorate.id_for_label }}">{% trans "Governorate" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-12 mb-3">
|
||||
<div class="form-floating">
|
||||
{{ form.city }}
|
||||
<label for="{{ form.city.id_for_label }}">{% trans "City" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 py-2 fs-5">{% trans "Register Now" %}</button>
|
||||
<!-- Section: Education -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title text-primary mb-3"><i class="fas fa-graduation-cap me-2"></i>{% trans "Education" %}</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small">{% trans "Select Classroom" %}</label>
|
||||
{{ form.classroom }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small">{% trans "Select Subjects" %}</label>
|
||||
<div id="subjects-container" class="border p-3 rounded bg-light" style="min-height: 100px;">
|
||||
<p class="text-muted text-center mt-3 small"><i class="fas fa-chalkboard me-1"></i> {% trans "Select a classroom first." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center p-3 bg-primary bg-opacity-10 text-primary rounded">
|
||||
<h5 class="m-0">{% trans "Total Amount:" %}</h5>
|
||||
<h4 class="m-0 fw-bold"><span id="total-amount">0.00</span></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Photo -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title text-primary mb-3"><i class="fas fa-camera me-2"></i>{% trans "Profile Picture" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 d-flex flex-column align-items-center mb-3 mb-md-0">
|
||||
<label class="mb-2 text-muted small">{% trans "Webcam Capture" %}</label>
|
||||
<div id="video-container" style="position: relative;" class="shadow-sm rounded overflow-hidden">
|
||||
<video id="video" width="240" height="180" autoplay style="background: #000;"></video>
|
||||
<button type="button" id="snap" class="btn btn-sm btn-light position-absolute bottom-0 start-50 translate-middle-x mb-2 rounded-pill px-3">
|
||||
<i class="fas fa-camera"></i> {% trans "Snap" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 d-flex flex-column align-items-center">
|
||||
<label class="mb-2 text-muted small">{% trans "Preview" %}</label>
|
||||
<canvas id="canvas" width="240" height="180" style="display:none;"></canvas>
|
||||
<div id="preview-placeholder" class="bg-light rounded d-flex align-items-center justify-content-center text-muted small" style="width: 240px; height: 180px; border: 2px dashed #ddd;">
|
||||
{% trans "No photo" %}
|
||||
</div>
|
||||
<img id="photo-preview" width="240" height="180" class="rounded shadow-sm" style="display:none; border: 2px solid #4ECDC4;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="form-label text-muted small">{% trans "Or upload file:" %}</label>
|
||||
{{ form.avatar }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 py-3 fs-5 fw-bold shadow-sm rounded-pill">
|
||||
{% trans "Complete Registration" %} <i class="fas fa-check-circle ms-2"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@ -121,19 +161,7 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 1. Add Bootstrap classes to inputs
|
||||
const inputs = document.querySelectorAll('input, select');
|
||||
inputs.forEach(input => {
|
||||
if (input.type !== 'checkbox' && input.type !== 'radio' && input.type !== 'file') {
|
||||
input.classList.add('form-control');
|
||||
} else if (input.type === 'file') {
|
||||
input.classList.add('form-control');
|
||||
} else if (input.type === 'checkbox') {
|
||||
input.classList.add('form-check-input');
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Classroom & Subjects Logic
|
||||
// 1. Classroom & Subjects Logic
|
||||
const classroomSelect = document.getElementById('id_classroom');
|
||||
const subjectsContainer = document.getElementById('subjects-container');
|
||||
const totalAmountSpan = document.getElementById('total-amount');
|
||||
@ -141,7 +169,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
classroomSelect.addEventListener('change', function() {
|
||||
const classroomId = this.value;
|
||||
if (!classroomId) {
|
||||
subjectsContainer.innerHTML = '<p class="text-muted text-center mt-3">{% trans "Select a classroom to see subjects." %}</p>';
|
||||
subjectsContainer.innerHTML = '<p class="text-muted text-center mt-3 small"><i class="fas fa-chalkboard me-1"></i> {% trans "Select a classroom first." %}</p>';
|
||||
updateTotal();
|
||||
return;
|
||||
}
|
||||
@ -151,18 +179,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
.then(data => {
|
||||
subjectsContainer.innerHTML = '';
|
||||
if (data.length === 0) {
|
||||
subjectsContainer.innerHTML = '<p class="text-warning text-center">{% trans "No subjects found for this classroom." %}</p>';
|
||||
subjectsContainer.innerHTML = '<p class="text-warning text-center small">{% trans "No subjects found for this classroom." %}</p>';
|
||||
} else {
|
||||
data.forEach(subject => {
|
||||
// Default price 0 if null
|
||||
const price = parseFloat(subject.price) || 0;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'form-check mb-2';
|
||||
div.className = 'form-check mb-2 bg-white p-2 rounded border';
|
||||
div.innerHTML = `
|
||||
<input class="form-check-input subject-checkbox" type="checkbox" name="subjects" value="${subject.id}" id="subject_${subject.id}" data-price="${price}" checked>
|
||||
<label class="form-check-label d-flex justify-content-between" for="subject_${subject.id}">
|
||||
<span>${subject.name_en}</span>
|
||||
<span class="badge bg-secondary rounded-pill">${price.toFixed(2)}</span>
|
||||
<input class="form-check-input subject-checkbox" type="checkbox" name="subjects" value="${subject.id}" id="subject_${subject.id}" data-price="${price}" checked style="transform: scale(1.2); margin-top: 0.3rem;">
|
||||
<label class="form-check-label d-flex justify-content-between w-100 ms-2" for="subject_${subject.id}">
|
||||
<span class="fw-medium">${subject.name_en}</span>
|
||||
<span class="badge bg-primary rounded-pill align-self-center">${price.toFixed(2)}</span>
|
||||
</label>
|
||||
`;
|
||||
subjectsContainer.appendChild(div);
|
||||
@ -172,11 +199,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error fetching subjects:', err);
|
||||
subjectsContainer.innerHTML = '<p class="text-danger">{% trans "Error loading subjects." %}</p>';
|
||||
subjectsContainer.innerHTML = '<p class="text-danger small">{% trans "Error loading subjects." %}</p>';
|
||||
});
|
||||
});
|
||||
|
||||
// Event delegation for dynamically created checkboxes
|
||||
subjectsContainer.addEventListener('change', function(e) {
|
||||
if (e.target.classList.contains('subject-checkbox')) {
|
||||
updateTotal();
|
||||
@ -191,6 +217,39 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
totalAmountSpan.textContent = total.toFixed(2);
|
||||
}
|
||||
|
||||
// 2. Governorate & City Logic
|
||||
const governorateSelect = document.getElementById('id_governorate');
|
||||
const citySelect = document.getElementById('id_city');
|
||||
|
||||
if (governorateSelect && citySelect) {
|
||||
governorateSelect.addEventListener('change', function() {
|
||||
const governorateId = this.value;
|
||||
citySelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>';
|
||||
|
||||
if (!governorateId) {
|
||||
citySelect.innerHTML = '<option value="">---------</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`{% url 'get_cities_by_governorate' %}?governorate_id=${governorateId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
citySelect.innerHTML = '<option value="">---------</option>';
|
||||
const currentLang = "{{ LANGUAGE_CODE }}";
|
||||
data.forEach(city => {
|
||||
const option = document.createElement('option');
|
||||
option.value = city.id;
|
||||
option.textContent = currentLang === 'ar' ? city.name_ar : city.name_en;
|
||||
citySelect.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error fetching cities:', err);
|
||||
citySelect.innerHTML = '<option value="">{% trans "Error loading cities" %}</option>';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Webcam Logic
|
||||
const video = document.getElementById('video');
|
||||
const videoContainer = document.getElementById('video-container');
|
||||
@ -202,15 +261,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
function showCameraError(message) {
|
||||
videoContainer.innerHTML = `
|
||||
<div class="alert alert-warning p-2 small text-center" style="height: 180px; display: flex; flex-direction: column; justify-content: center; align-items: center;">
|
||||
<i class="fas fa-exclamation-triangle mb-2" style="font-size: 1.5rem;"></i>
|
||||
<div class="alert alert-light p-2 small text-center text-muted" style="height: 180px; display: flex; flex-direction: column; justify-content: center; align-items: center;">
|
||||
<i class="fas fa-video-slash mb-2" style="font-size: 1.5rem;"></i>
|
||||
<div>${message}</div>
|
||||
<div class="mt-1 text-muted small">{% trans "Please use the file upload below." %}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Request camera access
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
navigator.mediaDevices.getUserMedia({ video: true })
|
||||
.then(function(stream) {
|
||||
@ -218,19 +275,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
video.play();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.log("An error occurred: " + err);
|
||||
let msg = "{% trans 'Camera access denied.' %}";
|
||||
if (err.name === 'NotAllowedError') {
|
||||
msg = "{% trans 'Permission denied. Allow camera access.' %}";
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
msg = "{% trans 'No camera found.' %}";
|
||||
} else if (window.isSecureContext === false) {
|
||||
msg = "{% trans 'Camera requires HTTPS.' %}";
|
||||
}
|
||||
showCameraError(msg);
|
||||
showCameraError("{% trans 'Camera access denied or unavailable.' %}");
|
||||
});
|
||||
} else {
|
||||
showCameraError("{% trans 'Browser does not support camera API or not secure.' %}");
|
||||
showCameraError("{% trans 'Browser does not support camera.' %}");
|
||||
}
|
||||
|
||||
if (snap) {
|
||||
@ -238,17 +286,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (video.srcObject) {
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(video, 0, 0, 240, 180);
|
||||
|
||||
canvas.toBlob(function(blob) {
|
||||
// Create a File object
|
||||
const file = new File([blob], "webcam_capture.jpg", { type: "image/jpeg" });
|
||||
|
||||
// Create a DataTransfer to manipulate the file input
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
avatarInput.files = dataTransfer.files;
|
||||
|
||||
// Show preview
|
||||
const url = URL.createObjectURL(blob);
|
||||
photoPreview.src = url;
|
||||
photoPreview.style.display = 'block';
|
||||
@ -259,4 +301,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
182
core/templates/core/student_dashboard.html
Normal file
182
core/templates/core/student_dashboard.html
Normal file
@ -0,0 +1,182 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Student Dashboard" %} - EduPlatform{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="margin-top: 100px; margin-bottom: 50px;">
|
||||
<!-- Welcome Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h2 class="fw-bold">{% trans "Welcome back," %} <span style="color: var(--primary-color);">{{ user.first_name|default:user.username }}</span>! 👋</h2>
|
||||
<p class="text-muted">{% trans "Here is an overview of your learning journey." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Sidebar / Profile Card -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="glass-card h-100 text-center">
|
||||
<div class="mb-3 position-relative d-inline-block">
|
||||
{% if student_profile.avatar %}
|
||||
<img src="{{ student_profile.avatar.url }}" alt="Avatar" class="rounded-circle img-thumbnail" style="width: 150px; height: 150px; object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center mx-auto" style="width: 150px; height: 150px;">
|
||||
<span class="text-muted display-4">{{ user.first_name|first|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if student_profile.is_mobile_verified and student_profile.is_email_verified %}
|
||||
<span class="position-absolute bottom-0 end-0 bg-success border border-white rounded-circle p-2" title="Verified Account">
|
||||
<span class="visually-hidden">Verified</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h4 class="fw-bold mb-1">{{ user.get_full_name|default:user.username }}</h4>
|
||||
<p class="text-muted mb-3">{% trans "Student" %} {% if student_profile.classroom %} • {{ student_profile.classroom.name_en }}{% endif %}</p>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="text-start">
|
||||
<div class="mb-3">
|
||||
<small class="text-muted d-block">{% trans "Email" %}</small>
|
||||
<span>{{ user.email }}</span>
|
||||
{% if student_profile.is_email_verified %}
|
||||
<span class="badge bg-success ms-1">{% trans "Verified" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark ms-1">{% trans "Unverified" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted d-block">{% trans "Phone" %}</small>
|
||||
<span>{{ student_profile.mobile_number|default:"-" }}</span>
|
||||
{% if student_profile.is_mobile_verified %}
|
||||
<span class="badge bg-success ms-1">{% trans "Verified" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark ms-1">{% trans "Unverified" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted d-block">{% trans "City" %}</small>
|
||||
<span>{{ student_profile.city.name|default:"-" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="#" class="btn btn-outline-primary w-100 rounded-pill">{% trans "Edit Profile" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Stats Row -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6 mb-3 mb-md-0">
|
||||
<div class="glass-card py-3 px-4 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-0">{% trans "Enrolled Subjects" %}</h6>
|
||||
<h2 class="fw-bold mb-0 text-primary">{{ subscribed_subjects.count }}</h2>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 p-3 rounded-circle text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-book" viewBox="0 0 16 16">
|
||||
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811V2.828zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.99c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492V2.687zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="glass-card py-3 px-4 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-0">{% trans "Classroom" %}</h6>
|
||||
<h5 class="fw-bold mb-0 text-success">{{ student_profile.classroom.name_en|default:"Not assigned" }}</h5>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 p-3 rounded-circle text-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-mortarboard" viewBox="0 0 16 16">
|
||||
<path d="M8.211 2.047a.5.5 0 0 0-.422 0l-7.5 3.5a.5.5 0 0 0 .025.917l7.5 3a.5.5 0 0 0 .372 0L14 7.14V13a1 1 0 0 0-1 1v2h3v-2a1 1 0 0 0-1-1V6.739l.686-.275a.5.5 0 0 0 .025-.917l-7.5-3.5ZM8 8.46 1.758 5.965 8 3.052l6.242 2.913L8 8.46Z"/>
|
||||
<path d="M4.176 9.032a.5.5 0 0 0-.656.327l-.5 1.7a.5.5 0 0 0 .294.605l4.5 1.8a.5.5 0 0 0 .372 0l4.5-1.8a.5.5 0 0 0 .294-.605l-.5-1.7a.5.5 0 0 0-.656-.327L8 10.466 4.176 9.032Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Subjects Section -->
|
||||
<div class="mb-4">
|
||||
<h4 class="fw-bold mb-3">{% trans "My Subjects" %}</h4>
|
||||
{% if subscribed_subjects %}
|
||||
<div class="row g-4">
|
||||
{% for subject in subscribed_subjects %}
|
||||
<div class="col-md-6">
|
||||
<div class="glass-card p-0 overflow-hidden h-100">
|
||||
{% if subject.image %}
|
||||
<div style="height: 150px; overflow: hidden;">
|
||||
<img src="{{ subject.image.url }}" class="w-100 h-100 object-fit-cover" alt="{{ subject.name_en }}">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-light d-flex align-items-center justify-content-center" style="height: 150px;">
|
||||
<span class="text-muted">{% trans "No Image" %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-3">
|
||||
<h5 class="fw-bold mb-2">{{ subject.name_en }}</h5>
|
||||
<p class="text-muted small mb-3 text-truncate">{{ subject.description_en }}</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary">
|
||||
{{ subject.teacher.user.get_full_name|default:"No Teacher" }}
|
||||
</span>
|
||||
<a href="{% url 'subject_detail' subject.id %}" class="btn btn-sm btn-primary rounded-pill">{% trans "Go to Class" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="glass-card text-center py-5">
|
||||
<h5 class="text-muted">{% trans "You haven't enrolled in any subjects yet." %}</h5>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Available Subjects Section -->
|
||||
<div class="mb-4">
|
||||
<h4 class="fw-bold mb-3">{% trans "Available Subjects" %}</h4>
|
||||
{% if available_subjects %}
|
||||
<div class="row g-4">
|
||||
{% for subject in available_subjects %}
|
||||
<div class="col-md-6">
|
||||
<div class="glass-card p-0 overflow-hidden h-100">
|
||||
{% if subject.image %}
|
||||
<div style="height: 150px; overflow: hidden;">
|
||||
<img src="{{ subject.image.url }}" class="w-100 h-100 object-fit-cover" alt="{{ subject.name_en }}">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-light d-flex align-items-center justify-content-center" style="height: 150px;">
|
||||
<span class="text-muted">{% trans "No Image" %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-3">
|
||||
<h5 class="fw-bold mb-2">{{ subject.name_en }}</h5>
|
||||
<p class="text-muted small mb-3 text-truncate">{{ subject.description_en }}</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold text-success">{{ subject.price }} OMR</span>
|
||||
<a href="{% url 'subscribe_subject' subject.id %}" class="btn btn-sm btn-success rounded-pill">{% trans "Subscribe" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="glass-card text-center py-5">
|
||||
<h5 class="text-muted">{% trans "No new subjects available." %}</h5>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
129
core/templates/core/teacher_dashboard.html
Normal file
129
core/templates/core/teacher_dashboard.html
Normal file
@ -0,0 +1,129 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Teacher Dashboard" %} - EduPlatform{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="margin-top: 100px; margin-bottom: 50px;">
|
||||
<!-- Welcome Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h2 class="fw-bold">{% trans "Welcome, Teacher" %} <span style="color: var(--primary-color);">{{ user.last_name|default:user.username }}</span>! 👨🏫</h2>
|
||||
<p class="text-muted">{% trans "Manage your classes and students here." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Sidebar / Profile Card -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="glass-card h-100 text-center">
|
||||
<div class="mb-3 position-relative d-inline-block">
|
||||
{% if teacher_profile.avatar %}
|
||||
<img src="{{ teacher_profile.avatar.url }}" alt="Avatar" class="rounded-circle img-thumbnail" style="width: 150px; height: 150px; object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center mx-auto" style="width: 150px; height: 150px;">
|
||||
<span class="text-muted display-4">{{ user.first_name|first|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h4 class="fw-bold mb-1">{{ user.get_full_name|default:user.username }}</h4>
|
||||
<p class="text-muted mb-2">{{ teacher_profile.specialization|default:"Teacher" }}</p>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="text-start">
|
||||
<h6 class="fw-bold mb-2">{% trans "Bio" %}</h6>
|
||||
<p class="text-muted small">
|
||||
{{ teacher_profile.bio|default:"No bio provided yet." }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="#" class="btn btn-outline-primary w-100 rounded-pill">{% trans "Edit Profile" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Stats Row -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6 mb-3 mb-md-0">
|
||||
<div class="glass-card py-3 px-4 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-0">{% trans "Subjects Taught" %}</h6>
|
||||
<h2 class="fw-bold mb-0 text-primary">{{ subjects.count }}</h2>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 p-3 rounded-circle text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-collection" viewBox="0 0 16 16">
|
||||
<path d="M2.5 3.5a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-11zm2-2a.5.5 0 0 1 0-1h7a.5.5 0 0 1 0 1h-7zM0 13a1.5 1.5 0 0 0 1.5 1.5h13A1.5 1.5 0 0 0 16 13V6a1.5 1.5 0 0 0-1.5-1.5h-13A1.5 1.5 0 0 0 0 6v7zm1.5.5A.5.5 0 0 1 1 13V6a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-13z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Placeholder for future stats like "Total Students" -->
|
||||
<div class="col-md-6">
|
||||
<div class="glass-card py-3 px-4 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="text-muted mb-0">{% trans "Status" %}</h6>
|
||||
<h5 class="fw-bold mb-0 text-success">Active</h5>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 p-3 rounded-circle text-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-person-badge" viewBox="0 0 16 16">
|
||||
<path d="M6.5 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1h-3zM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path d="M4.5 0A2.5 2.5 0 0 0 2 2.5V14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2.5A2.5 2.5 0 0 0 11.5 0h-7zM3 2.5A1.5 1.5 0 0 1 4.5 1h7A1.5 1.5 0 0 1 13 2.5v10.795a4.2 4.2 0 0 0-.776-.492C11.392 12.387 10.063 12 8 12s-3.392.387-4.224.803a4.2 4.2 0 0 0-.776.492V2.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Subjects Section -->
|
||||
<div class="mb-4">
|
||||
<h4 class="fw-bold mb-3">{% trans "My Classes" %}</h4>
|
||||
{% if subjects %}
|
||||
<div class="row g-4">
|
||||
{% for subject in subjects %}
|
||||
<div class="col-md-6">
|
||||
<div class="glass-card p-0 overflow-hidden h-100">
|
||||
{% if subject.image %}
|
||||
<div style="height: 150px; overflow: hidden;">
|
||||
<img src="{{ subject.image.url }}" class="w-100 h-100 object-fit-cover" alt="{{ subject.name_en }}">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-light d-flex align-items-center justify-content-center" style="height: 150px;">
|
||||
<span class="text-muted">{% trans "No Image" %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-3">
|
||||
<h5 class="fw-bold mb-2">{{ subject.name_en }}</h5>
|
||||
<p class="text-muted small mb-2 text-truncate">{{ subject.description_en }}</p>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="badge bg-info bg-opacity-10 text-info">
|
||||
{{ subject.classroom.name_en }}
|
||||
</span>
|
||||
<span class="text-muted small">
|
||||
{{ subject.subscribers.count }} {% trans "Students" %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a href="{% url 'subject_detail' subject.id %}" class="btn btn-sm btn-primary w-100 rounded-pill">{% trans "Manage Class" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="glass-card text-center py-5">
|
||||
<h5 class="text-muted">{% trans "You are not assigned to any subjects yet." %}</h5>
|
||||
<p class="text-muted small">{% trans "Contact the administrator to assign classes." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
52
core/templates/core/verify_otp.html
Normal file
52
core/templates/core/verify_otp.html
Normal file
@ -0,0 +1,52 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Verify Account" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container hero-section">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="glass-card">
|
||||
<h2 class="text-center mb-4 text-primary">{% trans "Account Verification" %}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-muted text-center mb-4">
|
||||
{% trans "We have sent verification codes to your mobile number and email address. Please enter them below." %}
|
||||
</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Mobile Verification Code" %}</label>
|
||||
<input type="text" name="mobile_otp" class="form-control" placeholder="6-digit code" {% if student.is_mobile_verified %}value="Verified" disabled{% endif %} required>
|
||||
{% if student.is_mobile_verified %}
|
||||
<small class="text-success"><i class="fas fa-check-circle"></i> {% trans "Mobile Verified" %}</small>
|
||||
{% else %}
|
||||
<small class="text-muted">{% trans "Sent to" %} {{ student.mobile_number }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Email Verification Code" %}</label>
|
||||
<input type="text" name="email_otp" class="form-control" placeholder="6-digit code" {% if student.is_email_verified %}value="Verified" disabled{% endif %} required>
|
||||
{% if student.is_email_verified %}
|
||||
<small class="text-success"><i class="fas fa-check-circle"></i> {% trans "Email Verified" %}</small>
|
||||
{% else %}
|
||||
<small class="text-muted">{% trans "Sent to" %} {{ student.user.email }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success w-100 py-2 fs-5">{% trans "Verify" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
69
core/thawani.py
Normal file
69
core/thawani.py
Normal file
@ -0,0 +1,69 @@
|
||||
import httpx
|
||||
from core.models import ThawaniConfiguration
|
||||
from django.conf import settings
|
||||
|
||||
class ThawaniClient:
|
||||
def __init__(self):
|
||||
config = ThawaniConfiguration.objects.first()
|
||||
if not config:
|
||||
# Fallback or error if not configured
|
||||
self.api_key = None
|
||||
self.publishable_key = None
|
||||
self.is_sandbox = True
|
||||
else:
|
||||
self.api_key = config.api_key
|
||||
self.publishable_key = config.publishable_key
|
||||
self.is_sandbox = config.is_sandbox
|
||||
|
||||
if self.is_sandbox:
|
||||
self.base_url = "https://uat-checkout.thawani.om/api/v1"
|
||||
else:
|
||||
self.base_url = "https://checkout.thawani.om/api/v1"
|
||||
|
||||
def create_checkout_session(self, subject, user, success_url, cancel_url):
|
||||
if not self.api_key:
|
||||
raise Exception("Thawani API Key is not configured.")
|
||||
|
||||
url = f"{self.base_url}/checkout/session"
|
||||
headers = {
|
||||
"thawani-api-key": self.api_key,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# Thawani expects amount in Baisa (1 OMR = 1000 Baisa)
|
||||
amount_baisa = int(subject.price * 1000)
|
||||
|
||||
payload = {
|
||||
"client_reference_id": str(user.id),
|
||||
"mode": "payment",
|
||||
"products": [
|
||||
{
|
||||
"name": subject.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)
|
||||
}
|
||||
}
|
||||
|
||||
response = httpx.post(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_checkout_session(self, session_id):
|
||||
if not self.api_key:
|
||||
raise Exception("Thawani API Key is not configured.")
|
||||
|
||||
url = f"{self.base_url}/checkout/session/{session_id}"
|
||||
headers = {
|
||||
"thawani-api-key": self.api_key
|
||||
}
|
||||
|
||||
response = httpx.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@ -1,4 +1,5 @@
|
||||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
@ -6,8 +7,14 @@ urlpatterns = [
|
||||
path('set-language/<str:lang_code>/', views.set_language, name='set_language'),
|
||||
path('subject/<int:pk>/', views.subject_detail, name='subject_detail'),
|
||||
path('ajax/get-subjects-by-level/', views.get_subjects_by_level, name='get_subjects_by_level'),
|
||||
path('ajax/get-cities-by-governorate/', views.get_cities_by_governorate, name='get_cities_by_governorate'),
|
||||
path('register/', views.register_student, name='register_student'),
|
||||
path('login/', auth_views.LoginView.as_view(template_name='core/login.html'), name='login'),
|
||||
path('verify-otp/', views.verify_otp, name='verify_otp'),
|
||||
path('ajax/get-classroom-subjects/', views.get_classroom_subjects, name='get_classroom_subjects'),
|
||||
path('profile/', views.profile, name='profile'),
|
||||
path('logout/', views.custom_logout, name='logout'),
|
||||
path('subscribe/<int:subject_id>/', views.subscribe_subject, name='subscribe_subject'),
|
||||
path('payment/success/', views.payment_success, name='payment_success'),
|
||||
path('payment/cancel/', views.payment_cancel, name='payment_cancel'),
|
||||
]
|
||||
203
core/views.py
203
core/views.py
@ -4,29 +4,28 @@ from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth import logout
|
||||
from .models import Classroom, Subject, Teacher, Student
|
||||
from django.contrib.auth import logout, login
|
||||
from django.urls import reverse
|
||||
from .models import Classroom, Subject, Teacher, Student, City
|
||||
from .forms import StudentRegistrationForm
|
||||
from .wablas import send_whatsapp_message
|
||||
from .thawani import ThawaniClient
|
||||
import random
|
||||
import string
|
||||
|
||||
def index(request):
|
||||
# Fetch levels with their related subjects using prefetch_related for efficiency
|
||||
levels = Classroom.objects.prefetch_related('subjects').all()
|
||||
|
||||
context = {
|
||||
'levels': levels,
|
||||
}
|
||||
context = {'levels': levels}
|
||||
return render(request, 'core/index.html', context)
|
||||
|
||||
def set_language(request, lang_code):
|
||||
next_url = request.GET.get('next', '/')
|
||||
response = redirect(next_url)
|
||||
|
||||
if lang_code in [lang[0] for lang in settings.LANGUAGES]:
|
||||
translation.activate(lang_code)
|
||||
if hasattr(request, 'session'):
|
||||
request.session['_language'] = lang_code
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, lang_code)
|
||||
|
||||
return response
|
||||
|
||||
def subject_detail(request, pk):
|
||||
@ -42,34 +41,206 @@ def get_subjects_by_level(request):
|
||||
return JsonResponse(list(subjects), safe=False)
|
||||
|
||||
def get_classroom_subjects(request):
|
||||
"""Public endpoint for registration form"""
|
||||
classroom_id = request.GET.get('classroom_id')
|
||||
if not classroom_id:
|
||||
return JsonResponse([], safe=False)
|
||||
subjects = Subject.objects.filter(classroom_id=classroom_id).values('id', 'name_en', 'price')
|
||||
return JsonResponse(list(subjects), safe=False)
|
||||
|
||||
def get_cities_by_governorate(request):
|
||||
governorate_id = request.GET.get('governorate_id')
|
||||
if not governorate_id:
|
||||
return JsonResponse([], safe=False)
|
||||
cities = City.objects.filter(governorate_id=governorate_id).values('id', 'name_en', 'name_ar')
|
||||
return JsonResponse(list(cities), safe=False)
|
||||
|
||||
def generate_otp():
|
||||
return ''.join(random.choices(string.digits, k=6))
|
||||
|
||||
def register_student(request):
|
||||
if request.method == 'POST':
|
||||
form = StudentRegistrationForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
student = form.save()
|
||||
# Redirect to success page or login
|
||||
return redirect('index') # Placeholder
|
||||
|
||||
# Generate OTPs
|
||||
mobile_otp = generate_otp()
|
||||
email_otp = generate_otp()
|
||||
|
||||
student.mobile_otp_code = mobile_otp
|
||||
student.email_otp_code = email_otp
|
||||
student.save()
|
||||
|
||||
# Send OTP via WhatsApp
|
||||
if student.mobile_number:
|
||||
# Attempt to send WhatsApp message
|
||||
# Note: This will only work if Wablas is configured in Admin
|
||||
send_whatsapp_message(
|
||||
student.mobile_number,
|
||||
f"Your Verification Code is: {mobile_otp}"
|
||||
)
|
||||
|
||||
# Simulate sending OTPs (Log to console)
|
||||
print(f"========================================")
|
||||
print(f"SIMULATED OTP SENDING:")
|
||||
print(f"User: {student.user.username}")
|
||||
print(f"Mobile OTP for {student.mobile_number}: {mobile_otp}")
|
||||
print(f"Email OTP for {student.user.email}: {email_otp}")
|
||||
print(f"========================================")
|
||||
|
||||
# Log the user in
|
||||
login(request, student.user)
|
||||
|
||||
# Redirect to verification page
|
||||
return redirect('verify_otp')
|
||||
else:
|
||||
form = StudentRegistrationForm()
|
||||
|
||||
return render(request, 'core/registration.html', {'form': form})
|
||||
|
||||
@login_required
|
||||
def profile(request):
|
||||
def verify_otp(request):
|
||||
try:
|
||||
student_profile = request.user.student_profile
|
||||
student = request.user.student_profile
|
||||
except Student.DoesNotExist:
|
||||
student_profile = None
|
||||
return redirect('index')
|
||||
|
||||
if student.is_mobile_verified and student.is_email_verified:
|
||||
return redirect('profile')
|
||||
|
||||
error = None
|
||||
if request.method == 'POST':
|
||||
entered_mobile_otp = request.POST.get('mobile_otp')
|
||||
entered_email_otp = request.POST.get('email_otp')
|
||||
|
||||
mobile_ok = student.is_mobile_verified
|
||||
email_ok = student.is_email_verified
|
||||
|
||||
if not mobile_ok:
|
||||
if entered_mobile_otp == student.mobile_otp_code:
|
||||
student.is_mobile_verified = True
|
||||
mobile_ok = True
|
||||
else:
|
||||
error = "Invalid Mobile OTP"
|
||||
|
||||
if not email_ok and (error is None or "Mobile" not in error):
|
||||
if entered_email_otp == student.email_otp_code:
|
||||
student.is_email_verified = True
|
||||
email_ok = True
|
||||
else:
|
||||
error = "Invalid Email OTP"
|
||||
|
||||
if mobile_ok and email_ok:
|
||||
student.mobile_otp_code = "" # Clear codes
|
||||
student.email_otp_code = ""
|
||||
student.save()
|
||||
return redirect('profile')
|
||||
else:
|
||||
student.save() # Save partial verification if any
|
||||
|
||||
return render(request, 'core/verify_otp.html', {'error': error, 'student': student})
|
||||
|
||||
@login_required
|
||||
def profile(request):
|
||||
user = request.user
|
||||
|
||||
return render(request, 'core/profile.html', {'student_profile': student_profile})
|
||||
# Check for Teacher
|
||||
if hasattr(user, 'teacher_profile'):
|
||||
teacher_profile = user.teacher_profile
|
||||
subjects = teacher_profile.subjects.all()
|
||||
return render(request, 'core/teacher_dashboard.html', {
|
||||
'teacher_profile': teacher_profile,
|
||||
'subjects': subjects
|
||||
})
|
||||
|
||||
# Check for Student
|
||||
elif hasattr(user, 'student_profile'):
|
||||
student_profile = user.student_profile
|
||||
subscribed_subjects = student_profile.subscribed_subjects.all()
|
||||
|
||||
# Get available subjects (in same classroom, not yet subscribed)
|
||||
available_subjects = []
|
||||
if student_profile.classroom:
|
||||
available_subjects = Subject.objects.filter(
|
||||
classroom=student_profile.classroom
|
||||
).exclude(
|
||||
id__in=subscribed_subjects.values_list('id', flat=True)
|
||||
)
|
||||
|
||||
return render(request, 'core/student_dashboard.html', {
|
||||
'student_profile': student_profile,
|
||||
'subscribed_subjects': subscribed_subjects,
|
||||
'available_subjects': available_subjects
|
||||
})
|
||||
|
||||
# Fallback (Superuser or Admin without profile)
|
||||
else:
|
||||
student_profile = None
|
||||
return render(request, 'core/profile.html', {'student_profile': student_profile})
|
||||
|
||||
def custom_logout(request):
|
||||
logout(request)
|
||||
return redirect('index')
|
||||
|
||||
@login_required
|
||||
def subscribe_subject(request, subject_id):
|
||||
try:
|
||||
student = request.user.student_profile
|
||||
except Student.DoesNotExist:
|
||||
return redirect('index')
|
||||
|
||||
subject = get_object_or_404(Subject, pk=subject_id)
|
||||
|
||||
# Check if already subscribed
|
||||
if subject in student.subscribed_subjects.all():
|
||||
return redirect('profile')
|
||||
|
||||
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(subject, 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': 'Could not create payment session.'})
|
||||
|
||||
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'Payment initialization failed: {str(e)}'})
|
||||
|
||||
@login_required
|
||||
def payment_success(request):
|
||||
session_id = request.GET.get('session_id')
|
||||
if not session_id:
|
||||
return redirect('profile')
|
||||
|
||||
try:
|
||||
thawani = ThawaniClient()
|
||||
response = thawani.get_checkout_session(session_id)
|
||||
data = response.get('data', {})
|
||||
payment_status = data.get('payment_status')
|
||||
metadata = data.get('metadata', {})
|
||||
subject_id = metadata.get('subject_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?
|
||||
return redirect('profile')
|
||||
else:
|
||||
return render(request, 'core/error.html', {'message': f'Payment was not successful. Status: {payment_status}'})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Payment Verification Error: {e}")
|
||||
return render(request, 'core/error.html', {'message': f'Payment verification failed: {str(e)}'})
|
||||
|
||||
@login_required
|
||||
def payment_cancel(request):
|
||||
return redirect('profile')
|
||||
44
core/wablas.py
Normal file
44
core/wablas.py
Normal file
@ -0,0 +1,44 @@
|
||||
import httpx
|
||||
from .models import WablasConfiguration
|
||||
|
||||
def send_whatsapp_message(phone_number, message):
|
||||
"""
|
||||
Sends a WhatsApp message using the Wablas API.
|
||||
"""
|
||||
try:
|
||||
settings = WablasConfiguration.objects.first()
|
||||
if not settings or not settings.api_token:
|
||||
print("Wablas settings not configured.")
|
||||
return False
|
||||
|
||||
# Wablas API usually takes: phone, message
|
||||
# And Authorization header.
|
||||
|
||||
headers = {
|
||||
"Authorization": settings.api_token,
|
||||
}
|
||||
|
||||
# Depending on the specific Wablas endpoint version, payload might differ.
|
||||
# Common v2/v3 payload:
|
||||
payload = {
|
||||
"phone": phone_number,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
# If secret is used, sometimes it's passed in body
|
||||
if settings.secret_key:
|
||||
payload["secret"] = settings.secret_key
|
||||
|
||||
# Using a timeout to prevent hanging
|
||||
response = httpx.post(settings.api_url, data=payload, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code >= 200 and response.status_code < 300:
|
||||
print(f"WhatsApp message sent to {phone_number}: {response.json()}")
|
||||
return True
|
||||
else:
|
||||
print(f"Failed to send WhatsApp message. Status: {response.status_code}, Body: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error sending WhatsApp message: {e}")
|
||||
return False
|
||||
@ -2,3 +2,4 @@ Django==5.2.7
|
||||
mysqlclient==2.2.7
|
||||
python-dotenv==1.1.1
|
||||
django-jazzmin
|
||||
httpx
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user