Autosave: 20260204-060035

This commit is contained in:
Flatlogic Bot 2026-02-04 06:00:35 +00:00
parent 776c322148
commit 91858c53d5
50 changed files with 1482 additions and 187 deletions

View File

@ -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,
}
}

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

71
configuration/admin.py Normal file
View 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
View 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'

View 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',),
),
]

View File

20
configuration/models.py Normal file
View 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.

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, 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:

View File

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

View 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"))

View File

@ -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),
),
]

View 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',
},
),
]

View 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',
},
),
]

View File

@ -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),
),
]

View File

@ -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',
),
]

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

View File

@ -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'),
]

View File

@ -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
View 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

View File

@ -2,3 +2,4 @@ Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
django-jazzmin
httpx