diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index 7ccceb4..fa65535 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/settings.py b/config/settings.py index 04a4eca..7c015e4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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, -} \ No newline at end of file +} diff --git a/configuration/__init__.py b/configuration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/configuration/__pycache__/__init__.cpython-311.pyc b/configuration/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..336d306 Binary files /dev/null and b/configuration/__pycache__/__init__.cpython-311.pyc differ diff --git a/configuration/__pycache__/admin.cpython-311.pyc b/configuration/__pycache__/admin.cpython-311.pyc new file mode 100644 index 0000000..72a8709 Binary files /dev/null and b/configuration/__pycache__/admin.cpython-311.pyc differ diff --git a/configuration/__pycache__/apps.cpython-311.pyc b/configuration/__pycache__/apps.cpython-311.pyc new file mode 100644 index 0000000..00363d7 Binary files /dev/null and b/configuration/__pycache__/apps.cpython-311.pyc differ diff --git a/configuration/__pycache__/models.cpython-311.pyc b/configuration/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..e06df4b Binary files /dev/null and b/configuration/__pycache__/models.cpython-311.pyc differ diff --git a/configuration/admin.py b/configuration/admin.py new file mode 100644 index 0000000..875785c --- /dev/null +++ b/configuration/admin.py @@ -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',), + }), + ) \ No newline at end of file diff --git a/configuration/apps.py b/configuration/apps.py new file mode 100644 index 0000000..d440a2b --- /dev/null +++ b/configuration/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class ConfigurationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'configuration' + verbose_name = 'Settings' diff --git a/configuration/migrations/0001_initial.py b/configuration/migrations/0001_initial.py new file mode 100644 index 0000000..b8b06b2 --- /dev/null +++ b/configuration/migrations/0001_initial.py @@ -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',), + ), + ] diff --git a/configuration/migrations/__init__.py b/configuration/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/configuration/migrations/__pycache__/0001_initial.cpython-311.pyc b/configuration/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..9e277e0 Binary files /dev/null and b/configuration/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/configuration/migrations/__pycache__/__init__.cpython-311.pyc b/configuration/migrations/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..9fa991c Binary files /dev/null and b/configuration/migrations/__pycache__/__init__.cpython-311.pyc differ diff --git a/configuration/models.py b/configuration/models.py new file mode 100644 index 0000000..55ced39 --- /dev/null +++ b/configuration/models.py @@ -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" diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index e3f5397..b27f091 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index aa5bbba..7abaee6 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index d0c89de..3554283 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/thawani.cpython-311.pyc b/core/__pycache__/thawani.cpython-311.pyc new file mode 100644 index 0000000..03b715c Binary files /dev/null and b/core/__pycache__/thawani.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 544cde0..bf0527e 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index fdde019..dc24be3 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/__pycache__/wablas.cpython-311.pyc b/core/__pycache__/wablas.cpython-311.pyc new file mode 100644 index 0000000..8d34e2f Binary files /dev/null and b/core/__pycache__/wablas.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 5832a93..3402ea9 100644 --- a/core/admin.py +++ b/core/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from django import forms from django.utils.html import format_html from django.urls import reverse -from .models import Classroom, Teacher, Subject, Resource, Student, City, 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: diff --git a/core/forms.py b/core/forms.py index d2bdeb4..813bec2 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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 diff --git a/core/management/commands/__pycache__/create_test_users.cpython-311.pyc b/core/management/commands/__pycache__/create_test_users.cpython-311.pyc new file mode 100644 index 0000000..2bfe5d1 Binary files /dev/null and b/core/management/commands/__pycache__/create_test_users.cpython-311.pyc differ diff --git a/core/management/commands/create_test_users.py b/core/management/commands/create_test_users.py new file mode 100644 index 0000000..b9dbcc3 --- /dev/null +++ b/core/management/commands/create_test_users.py @@ -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")) \ No newline at end of file diff --git a/core/migrations/0006_student_email_otp_code_student_is_email_verified_and_more.py b/core/migrations/0006_student_email_otp_code_student_is_email_verified_and_more.py new file mode 100644 index 0000000..14357b1 --- /dev/null +++ b/core/migrations/0006_student_email_otp_code_student_is_email_verified_and_more.py @@ -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), + ), + ] diff --git a/core/migrations/0007_wablasconfiguration.py b/core/migrations/0007_wablasconfiguration.py new file mode 100644 index 0000000..43dd6d0 --- /dev/null +++ b/core/migrations/0007_wablasconfiguration.py @@ -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', + }, + ), + ] diff --git a/core/migrations/0008_thawaniconfiguration.py b/core/migrations/0008_thawaniconfiguration.py new file mode 100644 index 0000000..7907936 --- /dev/null +++ b/core/migrations/0008_thawaniconfiguration.py @@ -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', + }, + ), + ] diff --git a/core/migrations/0009_platformsettings_remove_student_is_mobile_verified_and_more.py b/core/migrations/0009_platformsettings_remove_student_is_mobile_verified_and_more.py new file mode 100644 index 0000000..ae9d621 --- /dev/null +++ b/core/migrations/0009_platformsettings_remove_student_is_mobile_verified_and_more.py @@ -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), + ), + ] diff --git a/core/migrations/0010_governorate_remove_student_moderate_remove_city_name_and_more.py b/core/migrations/0010_governorate_remove_student_moderate_remove_city_name_and_more.py new file mode 100644 index 0000000..af87c2e --- /dev/null +++ b/core/migrations/0010_governorate_remove_student_moderate_remove_city_name_and_more.py @@ -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', + ), + ] diff --git a/core/migrations/__pycache__/0006_student_email_otp_code_student_is_email_verified_and_more.cpython-311.pyc b/core/migrations/__pycache__/0006_student_email_otp_code_student_is_email_verified_and_more.cpython-311.pyc new file mode 100644 index 0000000..7145eb7 Binary files /dev/null and b/core/migrations/__pycache__/0006_student_email_otp_code_student_is_email_verified_and_more.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0007_wablasconfiguration.cpython-311.pyc b/core/migrations/__pycache__/0007_wablasconfiguration.cpython-311.pyc new file mode 100644 index 0000000..4757dbc Binary files /dev/null and b/core/migrations/__pycache__/0007_wablasconfiguration.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0008_thawaniconfiguration.cpython-311.pyc b/core/migrations/__pycache__/0008_thawaniconfiguration.cpython-311.pyc new file mode 100644 index 0000000..881ab21 Binary files /dev/null and b/core/migrations/__pycache__/0008_thawaniconfiguration.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0009_platformsettings_alter_city_options_and_more.cpython-311.pyc b/core/migrations/__pycache__/0009_platformsettings_alter_city_options_and_more.cpython-311.pyc new file mode 100644 index 0000000..19891b6 Binary files /dev/null and b/core/migrations/__pycache__/0009_platformsettings_alter_city_options_and_more.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0009_platformsettings_remove_student_is_mobile_verified_and_more.cpython-311.pyc b/core/migrations/__pycache__/0009_platformsettings_remove_student_is_mobile_verified_and_more.cpython-311.pyc new file mode 100644 index 0000000..0b4563b Binary files /dev/null and b/core/migrations/__pycache__/0009_platformsettings_remove_student_is_mobile_verified_and_more.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0010_governorate_remove_student_moderate_remove_city_name_and_more.cpython-311.pyc b/core/migrations/__pycache__/0010_governorate_remove_student_moderate_remove_city_name_and_more.cpython-311.pyc new file mode 100644 index 0000000..245a1c5 Binary files /dev/null and b/core/migrations/__pycache__/0010_governorate_remove_student_moderate_remove_city_name_and_more.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 5efebf0..4fb8391 100644 --- a/core/models.py +++ b/core/models.py @@ -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 \ No newline at end of file + 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" diff --git a/core/templates/base.html b/core/templates/base.html index 000cc1a..0cc6e8a 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -162,7 +162,7 @@ {% else %} {% trans "Register" %} - {% trans "Login" %} + {% trans "Login" %} {% endif %} diff --git a/core/templates/core/error.html b/core/templates/core/error.html new file mode 100644 index 0000000..34dd7a2 --- /dev/null +++ b/core/templates/core/error.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Error

+

{{ message }}

+ Go Back to Profile +
+
+{% endblock %} diff --git a/core/templates/core/login.html b/core/templates/core/login.html new file mode 100644 index 0000000..53d488a --- /dev/null +++ b/core/templates/core/login.html @@ -0,0 +1,51 @@ +{% extends 'base.html' %} +{% load i18n static %} + +{% block title %}{% trans "Login" %}{% endblock %} + +{% block content %} +
+
+
+
+
+

{% trans "Welcome Back" %}

+

{% trans "Sign in to continue your learning journey" %}

+
+ +
+ {% csrf_token %} + + {% if form.errors %} + + {% endif %} + +
+ + +
+ +
+ + +
+ + + +
+

+ {% trans "Don't have an account?" %} + {% trans "Register Here" %} +

+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/core/templates/core/profile.html b/core/templates/core/profile.html index 07e1156..3de8d90 100644 --- a/core/templates/core/profile.html +++ b/core/templates/core/profile.html @@ -42,8 +42,8 @@

{{ student_profile.city|default:"-" }}

- -

{{ student_profile.moderate|default:"-" }}

+ +

{{ student_profile.governorate|default:"-" }}

@@ -74,4 +74,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/core/templates/core/registration.html b/core/templates/core/registration.html index 01f554d..ffe2c95 100644 --- a/core/templates/core/registration.html +++ b/core/templates/core/registration.html @@ -7,110 +7,150 @@
-
-

{% trans "Student Registration" %}

+
+
+

{% trans "Create Your Account" %}

+

{% trans "Join our learning platform today" %}

+
-
+ {% csrf_token %} {% if form.errors %} -
- {{ form.errors }} +
+
    + {% for field in form %} + {% for error in field.errors %} +
  • {{ field.label }}: {{ error }}
  • + {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} +
{% endif %} - -
{% trans "Personal Information" %}
-
-
- - {{ form.first_name }} -
-
- - {{ form.last_name }} -
-
- -
- - {{ form.username }} -
- -
- - {{ form.email }} -
- -
- - {{ form.password }} -
- - -
{% trans "Student Details" %}
-
-
- - {{ form.mobile_number }} -
-
- - {{ form.moderate }} -
-
- - {{ form.city }} -
-
- - -
- - {{ form.classroom }} -
- -
- -
-

{% trans "Select a classroom to see subjects." %}

-
-
- -
-

{% trans "Total Amount:" %}

-

0.00

-
- - -
{% trans "Profile Picture" %}
-
-
-
- -
- - + +
+
+
{% trans "Personal Information" %}
+ +
+ {{ form.full_name }} + +
+ +
+ {{ form.username }} + +
+ +
+ {{ form.email }} + +
+ +
+
+
+ {{ form.password }} + +
+
+
+
+ {{ form.password_confirm }} + +
-
- - -
- {% trans "No photo taken" %} -
- -
-
- - {{ form.avatar }} +
+ + +
+
+
{% trans "Student Profile" %}
+
+
+
+ {{ form.mobile_number }} + +
+
+
+
+ {{ form.governorate }} + +
+
+
+
+ {{ form.city }} + +
+
+
- + +
+
+
{% trans "Education" %}
+
+ + {{ form.classroom }} +
+ +
+ +
+

{% trans "Select a classroom first." %}

+
+
+ +
+
{% trans "Total Amount:" %}
+

0.00

+
+
+
+ + +
+
+
{% trans "Profile Picture" %}
+
+
+ +
+ + +
+
+
+ + +
+ {% trans "No photo" %} +
+ +
+
+
+ + {{ form.avatar }} +
+
+
+ +
@@ -121,19 +161,7 @@ {% block extra_js %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/core/templates/core/student_dashboard.html b/core/templates/core/student_dashboard.html new file mode 100644 index 0000000..a93be93 --- /dev/null +++ b/core/templates/core/student_dashboard.html @@ -0,0 +1,182 @@ +{% extends 'base.html' %} +{% load i18n static %} + +{% block title %}{% trans "Student Dashboard" %} - EduPlatform{% endblock %} + +{% block content %} +
+ +
+
+

{% trans "Welcome back," %} {{ user.first_name|default:user.username }}! 👋

+

{% trans "Here is an overview of your learning journey." %}

+
+
+ +
+ +
+
+
+ {% if student_profile.avatar %} + Avatar + {% else %} +
+ {{ user.first_name|first|upper }} +
+ {% endif %} + + {% if student_profile.is_mobile_verified and student_profile.is_email_verified %} + + Verified + + {% endif %} +
+ +

{{ user.get_full_name|default:user.username }}

+

{% trans "Student" %} {% if student_profile.classroom %} • {{ student_profile.classroom.name_en }}{% endif %}

+ +
+ +
+
+ {% trans "Email" %} + {{ user.email }} + {% if student_profile.is_email_verified %} + {% trans "Verified" %} + {% else %} + {% trans "Unverified" %} + {% endif %} +
+
+ {% trans "Phone" %} + {{ student_profile.mobile_number|default:"-" }} + {% if student_profile.is_mobile_verified %} + {% trans "Verified" %} + {% else %} + {% trans "Unverified" %} + {% endif %} +
+
+ {% trans "City" %} + {{ student_profile.city.name|default:"-" }} +
+
+ + +
+
+ + +
+ +
+
+
+
+
{% trans "Enrolled Subjects" %}
+

{{ subscribed_subjects.count }}

+
+
+ + + +
+
+
+
+
+
+
{% trans "Classroom" %}
+
{{ student_profile.classroom.name_en|default:"Not assigned" }}
+
+
+ + + + +
+
+
+
+ + +
+

{% trans "My Subjects" %}

+ {% if subscribed_subjects %} +
+ {% for subject in subscribed_subjects %} +
+
+ {% if subject.image %} +
+ {{ subject.name_en }} +
+ {% else %} +
+ {% trans "No Image" %} +
+ {% endif %} +
+
{{ subject.name_en }}
+

{{ subject.description_en }}

+
+ + {{ subject.teacher.user.get_full_name|default:"No Teacher" }} + + {% trans "Go to Class" %} +
+
+
+
+ {% endfor %} +
+ {% else %} +
+
{% trans "You haven't enrolled in any subjects yet." %}
+
+ {% endif %} +
+ + +
+

{% trans "Available Subjects" %}

+ {% if available_subjects %} +
+ {% for subject in available_subjects %} +
+
+ {% if subject.image %} +
+ {{ subject.name_en }} +
+ {% else %} +
+ {% trans "No Image" %} +
+ {% endif %} +
+
{{ subject.name_en }}
+

{{ subject.description_en }}

+
+ {{ subject.price }} OMR + {% trans "Subscribe" %} +
+
+
+
+ {% endfor %} +
+ {% else %} +
+
{% trans "No new subjects available." %}
+
+ {% endif %} +
+ +
+
+
+{% endblock %} diff --git a/core/templates/core/teacher_dashboard.html b/core/templates/core/teacher_dashboard.html new file mode 100644 index 0000000..b2ca55f --- /dev/null +++ b/core/templates/core/teacher_dashboard.html @@ -0,0 +1,129 @@ +{% extends 'base.html' %} +{% load i18n static %} + +{% block title %}{% trans "Teacher Dashboard" %} - EduPlatform{% endblock %} + +{% block content %} +
+ +
+
+

{% trans "Welcome, Teacher" %} {{ user.last_name|default:user.username }}! 👨‍🏫

+

{% trans "Manage your classes and students here." %}

+
+
+ +
+ +
+
+
+ {% if teacher_profile.avatar %} + Avatar + {% else %} +
+ {{ user.first_name|first|upper }} +
+ {% endif %} +
+ +

{{ user.get_full_name|default:user.username }}

+

{{ teacher_profile.specialization|default:"Teacher" }}

+ +
+ +
+
{% trans "Bio" %}
+

+ {{ teacher_profile.bio|default:"No bio provided yet." }} +

+
+ + +
+
+ + +
+ +
+
+
+
+
{% trans "Subjects Taught" %}
+

{{ subjects.count }}

+
+
+ + + +
+
+
+ +
+
+
+
{% trans "Status" %}
+
Active
+
+
+ + + + +
+
+
+
+ + +
+

{% trans "My Classes" %}

+ {% if subjects %} +
+ {% for subject in subjects %} +
+
+ {% if subject.image %} +
+ {{ subject.name_en }} +
+ {% else %} +
+ {% trans "No Image" %} +
+ {% endif %} +
+
{{ subject.name_en }}
+

{{ subject.description_en }}

+
+ + {{ subject.classroom.name_en }} + + + {{ subject.subscribers.count }} {% trans "Students" %} + +
+ +
+
+
+ {% endfor %} +
+ {% else %} +
+
{% trans "You are not assigned to any subjects yet." %}
+

{% trans "Contact the administrator to assign classes." %}

+
+ {% endif %} +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/core/templates/core/verify_otp.html b/core/templates/core/verify_otp.html new file mode 100644 index 0000000..6d62f06 --- /dev/null +++ b/core/templates/core/verify_otp.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} +{% load i18n static %} + +{% block title %}{% trans "Verify Account" %}{% endblock %} + +{% block content %} +
+
+
+
+

{% trans "Account Verification" %}

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +

+ {% trans "We have sent verification codes to your mobile number and email address. Please enter them below." %} +

+ +
+ {% csrf_token %} + +
+ + + {% if student.is_mobile_verified %} + {% trans "Mobile Verified" %} + {% else %} + {% trans "Sent to" %} {{ student.mobile_number }} + {% endif %} +
+ +
+ + + {% if student.is_email_verified %} + {% trans "Email Verified" %} + {% else %} + {% trans "Sent to" %} {{ student.user.email }} + {% endif %} +
+ + +
+
+
+
+
+{% endblock %} diff --git a/core/thawani.py b/core/thawani.py new file mode 100644 index 0000000..6c0f16e --- /dev/null +++ b/core/thawani.py @@ -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() diff --git a/core/urls.py b/core/urls.py index defe932..ed37225 100644 --- a/core/urls.py +++ b/core/urls.py @@ -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//', views.set_language, name='set_language'), path('subject//', 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//', views.subscribe_subject, name='subscribe_subject'), + path('payment/success/', views.payment_success, name='payment_success'), + path('payment/cancel/', views.payment_cancel, name='payment_cancel'), ] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 665968d..b1f44dc 100644 --- a/core/views.py +++ b/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') \ No newline at end of file diff --git a/core/wablas.py b/core/wablas.py new file mode 100644 index 0000000..c530bd7 --- /dev/null +++ b/core/wablas.py @@ -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 diff --git a/requirements.txt b/requirements.txt index 7ce7d6c..45b378b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Django==5.2.7 mysqlclient==2.2.7 python-dotenv==1.1.1 django-jazzmin +httpx