diff --git a/.env.example b/.env.example index 8b3e570..489aa2d 100644 --- a/.env.example +++ b/.env.example @@ -1,27 +1,83 @@ # Django Settings +# Required: Secret key for Django's cryptographic signing DJANGO_SECRET_KEY=your-secret-key-here +# Optional: Enable debug mode (True/False) DJANGO_DEBUG=True +# Optional: Comma-separated list of allowed hosts DJANGO_ALLOWED_HOSTS=* -# Firebase Configuration -FIREBASE_API_KEY=your_firebase_api_key_here -FIREBASE_AUTH_DOMAIN=your_project_id.firebaseapp.com -FIREBASE_PROJECT_ID=your_project_id -FIREBASE_STORAGE_BUCKET=your_project_id.firebasestorage.app -FIREBASE_MESSAGING_SENDER_ID=your_sender_id -FIREBASE_APP_ID=your_app_id -FIREBASE_MEASUREMENT_ID=your_measurement_id - -# Email Configuration -EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend -DEFAULT_FROM_EMAIL=noreply@studentmanagementsystem.com - -# SMS Configuration -SMS_API_KEY=your_sms_api_key_here -SMS_SENDER_ID=SMSSYS +# Database Configuration +# Optional: Database engine (default: django.db.backends.sqlite3) +DB_ENGINE=django.db.backends.sqlite3 +# Optional: Database name (default: db.sqlite3) +DB_NAME=db.sqlite3 +# Optional: Database timeout in seconds (default: 20) +DB_TIMEOUT=20 +# Optional: Database host (required for non-SQLite databases) +DB_HOST= +# Optional: Database port (required for non-SQLite databases) +DB_PORT= +# Optional: Database user (required for non-SQLite databases) +DB_USER= +# Optional: Database password (required for non-SQLite databases) +DB_PASSWORD= # Cache Configuration -CACHE_LOCATION=unique-snowflake +# Optional: Use Redis cache (True/False) +USE_REDIS_CACHE=True +# Optional: Redis URL (default: redis://localhost:6379/1) +REDIS_URL=redis://localhost:6379/1 +# Optional: Cache timeout in seconds (default: 300) +CACHE_TIMEOUT=300 +# Optional: Cache key prefix (default: sms) +CACHE_KEY_PREFIX=sms +# Optional: Maximum number of cache connections (default: 1000) +CACHE_MAX_CONNECTIONS=1000 -# OTP Configuration -OTP_EXPIRY=300 \ No newline at end of file +# Email Configuration +# Optional: Email backend (default: django.core.mail.backends.console.EmailBackend) +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend +# Optional: Default from email address +DEFAULT_FROM_EMAIL=noreply@studentmanagementsystem.com +# Optional: Email host (required for SMTP backend) +EMAIL_HOST= +# Optional: Email port (required for SMTP backend) +EMAIL_PORT= +# Optional: Email username (required for SMTP backend) +EMAIL_USERNAME= +# Optional: Email password (required for SMTP backend) +EMAIL_PASSWORD= +# Optional: Use TLS for email (True/False) +EMAIL_USE_TLS=True + +# Firebase Configuration +# Required: Firebase API key +FIREBASE_API_KEY=your_firebase_api_key_here +# Required: Firebase auth domain +FIREBASE_AUTH_DOMAIN=your_project_id.firebaseapp.com +# Required: Firebase project ID +FIREBASE_PROJECT_ID=your_project_id +# Required: Firebase storage bucket +FIREBASE_STORAGE_BUCKET=your_project_id.firebasestorage.app +# Required: Firebase messaging sender ID +FIREBASE_MESSAGING_SENDER_ID=your_sender_id +# Required: Firebase app ID +FIREBASE_APP_ID=your_app_id +# Required: Firebase measurement ID +FIREBASE_MEASUREMENT_ID=your_measurement_id + +# SMS Configuration +# Required: SMS API key +SMS_API_KEY=your_sms_api_key_here +# Optional: SMS sender ID (default: SMSSYS) +SMS_SENDER_ID=SMSSYS + +# Gunicorn Configuration +# Optional: Number of worker processes (default: 4) +GUNICORN_WORKERS=4 +# Optional: Number of threads per worker (default: 2) +GUNICORN_THREADS=2 +# Optional: Worker timeout in seconds (default: 30) +GUNICORN_TIMEOUT=30 +# Optional: Keep-alive timeout in seconds (default: 2) +GUNICORN_KEEPALIVE=2 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9bacbc3..6af0fa3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist/ downloads/ eggs/ .eggs/ +*.egg-info lib/ lib64/ parts/ @@ -26,6 +27,7 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +build/ # PyInstaller # Usually these files are written by a python script from a template diff --git a/app/apps.py b/app/apps.py index 0d606d9..4ba46a7 100644 --- a/app/apps.py +++ b/app/apps.py @@ -7,6 +7,11 @@ from django.dispatch import receiver class AppConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "app" + verbose_name = "School Management System" def ready(self): - import app.signals # Import signals when app is ready + """ + Import signals when the app is ready + """ + # Import signals to ensure they are registered + import app.signals diff --git a/app/auth.py b/app/auth.py index fbe3170..421d8c4 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,12 +1,13 @@ # Core Django imports from django.contrib.auth import authenticate, login from django.shortcuts import redirect, render -from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.csrf import csrf_protect from django.contrib.auth.models import User from django.contrib.auth.hashers import make_password from django.contrib import messages - - +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +import re import uuid # Local app imports @@ -23,239 +24,249 @@ from app.utils import ( verify_reset_token ) +def validate_phone_number(phone): + """Validate phone number format""" + if not phone or not isinstance(phone, str): + raise ValidationError("Phone number must be a string") + if not re.match(r'^\+?1?\d{9,10}$', phone): + raise ValidationError("Invalid phone number format") + return True - +@csrf_protect def reset_password_options(request): """View for selecting between phone and email password reset""" - # Get institute for logo - institute = Institute.objects.first() - return render(request, "login/reset_options.html", {"institute": institute}) + if not request.user.is_authenticated: + institute = Institute.objects.first() + return render(request, "login/reset_options.html", {"institute": institute}) + return redirect('dashboard') +@csrf_protect def password_reset_phone(request): """View for initiating password reset with phone number""" - # Get institute for logo - institute = Institute.objects.first() - - if request.method == "POST": - phone = request.POST.get("phone") + if not request.user.is_authenticated: + institute = Institute.objects.first() - # Validate phone number - if not phone or not phone.isdigit(): - return render(request, "login/password_reset_phone.html", { - "error": "Please enter a valid phone number", - "is_email": False, - "institute": institute - }) + if request.method == "POST": + try: + phone = request.POST.get("phone", "").strip() + validate_phone_number(phone) + + # Check if a user with this phone exists + student = Student.objects.filter(phone=phone).first() + staff = Staff.objects.filter(phone=phone).first() + + if not student and not staff: + return render(request, "login/password_reset_phone.html", { + "error": "No account found with this phone number", + "is_email": False, + "institute": institute + }) + + # Generate secret key and store it + secret_key = generate_secret_key() + store_secret_key(phone, secret_key) + + # Generate and send OTP + otp = generate_otp(secret_key) + send_result = send_otp_sms(phone, otp) + + if send_result: + return render(request, "login/password_reset_otp.html", { + "phone": phone, + "is_email": False, + "institute": institute + }) + else: + return render(request, "login/password_reset_phone.html", { + "error": "Failed to send OTP. Please try again.", + "is_email": False, + "institute": institute + }) + except ValidationError as e: + return render(request, "login/password_reset_phone.html", { + "error": str(e), + "is_email": False, + "institute": institute + }) - # Check if a user with this phone exists - student = Student.objects.filter(phone=phone).first() - staff = Staff.objects.filter(phone=phone).first() - - if not student and not staff: - return render(request, "login/password_reset_phone.html", { - "error": "No account found with this phone number", - "is_email": False, - "institute": institute - }) - - # Generate secret key and store it - secret_key = generate_secret_key() - store_secret_key(phone, secret_key) - - # Generate and send OTP - otp = generate_otp(secret_key) - send_result = send_otp_sms(phone, otp) - - if send_result: - # Redirect to OTP verification page - return render(request, "login/password_reset_otp.html", { - "phone": phone, - "is_email": False, - "institute": institute - }) - else: - return render(request, "login/password_reset_phone.html", { - "error": "Failed to send OTP. Please try again.", - "is_email": False, - "institute": institute - }) - - return render(request, "login/password_reset_phone.html", { - "is_email": False, - "institute": institute - }) + return render(request, "login/password_reset_phone.html", { + "is_email": False, + "institute": institute + }) + return redirect('dashboard') +@csrf_protect def password_reset_phone_verify(request): """View for verifying OTP for password reset""" - # Get institute for logo - institute = Institute.objects.first() - - if request.method == "POST": - phone = request.POST.get("phone") - otp = request.POST.get("otp") + if not request.user.is_authenticated: + institute = Institute.objects.first() - if not phone or not otp: - return render(request, "login/password_reset_otp.html", { - "error": "Phone and OTP are required", - "phone": phone, - "is_email": False, - "institute": institute - }) + if request.method == "POST": + try: + phone = request.POST.get("phone", "").strip() + otp = request.POST.get("otp", "").strip() + + validate_phone_number(phone) + if not otp or not otp.isdigit() or len(otp) != 6: + raise ValidationError("Invalid OTP format") + + # Verify OTP + if verify_otp(phone, otp): + token = str(uuid.uuid4()) + store_reset_token(token, phone) + + return render(request, "login/password_reset_set.html", { + "phone": phone, + "token": token, + "is_email": False, + "institute": institute + }) + else: + return render(request, "login/password_reset_otp.html", { + "error": "Invalid or expired OTP", + "phone": phone, + "is_email": False, + "institute": institute + }) + except ValidationError as e: + return render(request, "login/password_reset_otp.html", { + "error": str(e), + "phone": phone if 'phone' in locals() else "", + "is_email": False, + "institute": institute + }) - # Verify OTP - if verify_otp(phone, otp): - # Generate a secure token for this reset - token = str(uuid.uuid4()) - # Store token in database - store_reset_token(token, phone) - - # Redirect to set new password - return render(request, "login/password_reset_set.html", { - "phone": phone, - "token": token, - "is_email": False, - "institute": institute - }) - else: - return render(request, "login/password_reset_otp.html", { - "error": "Invalid or expired OTP", - "phone": phone, - "is_email": False, - "institute": institute - }) - - # If GET request, redirect to phone login - return redirect("phone_reset_password") + return redirect("phone_reset_password") + return redirect('dashboard') +@csrf_protect def resend_phone_otp(request): """View for resending OTP for password reset""" - if request.method == "POST": - phone = request.POST.get("phone") + if not request.user.is_authenticated: + if request.method == "POST": + try: + phone = request.POST.get("phone", "").strip() + validate_phone_number(phone) + + secret_key = generate_secret_key() + store_secret_key(phone, secret_key) + + otp = generate_otp(secret_key) + send_result = send_otp_sms(phone, otp) + + if send_result: + return render(request, "login/password_reset_otp.html", { + "phone": phone, + "message": "OTP resent successfully", + "is_email": False + }) + else: + return render(request, "login/password_reset_otp.html", { + "phone": phone, + "error": "Failed to resend OTP", + "is_email": False + }) + except ValidationError as e: + return render(request, "login/password_reset_otp.html", { + "phone": phone if 'phone' in locals() else "", + "error": str(e), + "is_email": False + }) - if not phone: - return redirect("phone_reset_password") - - # Generate new secret key and store it - secret_key = generate_secret_key() - store_secret_key(phone, secret_key) - - # Generate and send new OTP - otp = generate_otp(secret_key) - send_result = send_otp_sms(phone, otp) - - if send_result: - return render(request, "login/password_reset_otp.html", { - "phone": phone, - "message": "OTP resent successfully", - "is_email": False - }) - else: - return render(request, "login/password_reset_otp.html", { - "phone": phone, - "error": "Failed to resend OTP", - "is_email": False - }) - - return redirect("phone_reset_password") + return redirect("phone_reset_password") + return redirect('dashboard') +@csrf_protect def password_reset_email(request): """View for initiating password reset with email""" - # Get institute for logo - institute = Institute.objects.first() - - if request.method == "POST": - email = request.POST.get("email") + if not request.user.is_authenticated: + institute = Institute.objects.first() - # Validate email - if not email: - return render(request, "login/email_reset.html", { - "error": "Please enter a valid email address", - "is_email": True, - "institute": institute - }) + if request.method == "POST": + try: + email = request.POST.get("email", "").strip() + validate_email(email) + + student = Student.objects.filter(email=email).first() + staff = Staff.objects.filter(email=email).first() + + if not student and not staff: + return render(request, "login/email_reset.html", { + "error": "No account found with this email address", + "is_email": True, + "institute": institute + }) + + user = student or staff + + if send_password_reset_email(user, request, email): + return render(request, "login/password_reset_otp.html", { + "email": email, + "is_email": True, + "institute": institute + }) + else: + return render(request, "login/email_reset.html", { + "error": "Failed to send verification code. Please try again.", + "is_email": True, + "institute": institute + }) + except ValidationError as e: + return render(request, "login/email_reset.html", { + "error": str(e), + "is_email": True, + "institute": institute + }) - # Check if a user with this email exists - student = Student.objects.filter(email=email).first() - staff = Staff.objects.filter(email=email).first() - - if not student and not staff: - return render(request, "login/email_reset.html", { - "error": "No account found with this email address", - "is_email": True, - "institute": institute - }) - - # Get the user - user = student or staff - - # Send password reset email - if send_password_reset_email(user, request, email): - # Redirect to verification page - return render(request, "login/password_reset_otp.html", { - "email": email, - "is_email": True, - "institute": institute - }) - else: - return render(request, "login/email_reset.html", { - "error": "Failed to send verification code. Please try again.", - "is_email": True, - "institute": institute - }) - - return render(request, "login/email_reset.html", { - "is_email": True, - "institute": institute - }) + return render(request, "login/email_reset.html", { + "is_email": True, + "institute": institute + }) + return redirect('dashboard') +@csrf_protect def password_reset_email_verify(request): """View for verifying reset code for email""" - # Get institute for logo - institute = Institute.objects.first() - - if request.method == "POST": - email = request.POST.get("email") - code = request.POST.get("otp") + if not request.user.is_authenticated: + institute = Institute.objects.first() - if not email or not code: - return render(request, "login/password_reset_otp.html", { - "error": "Email and verification code are required", - "email": email, - "is_email": True, - "institute": institute - }) + if request.method == "POST": + try: + email = request.POST.get("email", "").strip() + code = request.POST.get("otp", "").strip() + + validate_email(email) + if not code or not code.isalnum(): + raise ValidationError("Invalid verification code format") + + if verify_reset_code(email, code): + token = str(uuid.uuid4()) + store_reset_token(token, email) + + return render(request, "login/password_reset_set.html", { + "email": email, + "token": token, + "is_email": True, + "institute": institute + }) + else: + return render(request, "login/password_reset_otp.html", { + "error": "Invalid or expired code", + "email": email, + "is_email": True, + "message": "Please check your email for the verification code.", + "institute": institute + }) + except ValidationError as e: + return render(request, "login/password_reset_otp.html", { + "error": str(e), + "email": email if 'email' in locals() else "", + "is_email": True, + "institute": institute + }) - # Verify code - if verify_reset_code(email, code): - # Generate a secure token for this reset - token = str(uuid.uuid4()) - # Store token in database - store_reset_token(token, email) - - # Redirect to set new password - return render(request, "login/password_reset_set.html", { - "email": email, - "token": token, - "is_email": True, - "institute": institute - }) - else: - return render(request, "login/password_reset_otp.html", { - "error": "Invalid or expired code", - "email": email, - "is_email": True, - "message": "Please check your email for the verification code.", - "institute": institute - }) - - # For GET request, show the verification form with email pre-filled - email = request.GET.get("email") - return render(request, "login/password_reset_otp.html", { - "email": email, - "is_email": True, - "message": "Please check your email for the verification code.", - "institute": institute - }) + return redirect("email_reset_password") + return redirect('dashboard') def resend_email_code(request): """View for resending verification code for password reset""" diff --git a/app/error_handlers.py b/app/error_handlers.py index 8c70f39..a245c78 100644 --- a/app/error_handlers.py +++ b/app/error_handlers.py @@ -1,27 +1,94 @@ +import logging from django.shortcuts import render +from django.conf import settings +from django.http import HttpRequest +from typing import Optional, Any +import traceback +logger = logging.getLogger(__name__) -# Error handler views -def handler400(request, exception=None): +def get_error_context(request: HttpRequest, exception: Optional[Exception] = None, status_code: int = 500) -> dict: + """Generate context for error pages with relevant information""" + context = { + 'status_code': status_code, + 'request_path': request.path, + 'user_agent': request.META.get('HTTP_USER_AGENT', 'Unknown'), + 'ip_address': request.META.get('REMOTE_ADDR', 'Unknown'), + } + + if exception: + context.update({ + 'error_message': str(exception), + 'error_type': exception.__class__.__name__, + }) + + # Only include traceback in development + if settings.DEBUG: + context['traceback'] = traceback.format_exc() + + return context + +def handler400(request: HttpRequest, exception: Optional[Exception] = None) -> Any: """Bad request error handler""" - return render(request, "400.html", status=400) + logger.warning( + f"400 Bad Request: {request.path}", + extra={ + 'exception': str(exception) if exception else None, + 'user_agent': request.META.get('HTTP_USER_AGENT'), + 'ip_address': request.META.get('REMOTE_ADDR'), + } + ) + context = get_error_context(request, exception, 400) + return render(request, "400.html", context, status=400) - -def handler403(request, exception=None): +def handler403(request: HttpRequest, exception: Optional[Exception] = None) -> Any: """Forbidden error handler""" - return render(request, "403.html", status=403) + logger.warning( + f"403 Forbidden: {request.path}", + extra={ + 'exception': str(exception) if exception else None, + 'user_agent': request.META.get('HTTP_USER_AGENT'), + 'ip_address': request.META.get('REMOTE_ADDR'), + } + ) + context = get_error_context(request, exception, 403) + return render(request, "403.html", context, status=403) - -def handler404(request, exception=None): +def handler404(request: HttpRequest, exception: Optional[Exception] = None) -> Any: """Page not found error handler""" - return render(request, "404.html", status=404) + logger.warning( + f"404 Not Found: {request.path}", + extra={ + 'exception': str(exception) if exception else None, + 'user_agent': request.META.get('HTTP_USER_AGENT'), + 'ip_address': request.META.get('REMOTE_ADDR'), + } + ) + context = get_error_context(request, exception, 404) + return render(request, "404.html", context, status=404) - -def handler500(request): +def handler500(request: HttpRequest) -> Any: """Server error handler""" - return render(request, "500.html", status=500) + logger.error( + f"500 Server Error: {request.path}", + exc_info=True, + extra={ + 'user_agent': request.META.get('HTTP_USER_AGENT'), + 'ip_address': request.META.get('REMOTE_ADDR'), + } + ) + context = get_error_context(request, None, 500) + return render(request, "500.html", context, status=500) - -def handler505(request, exception=None): +def handler505(request: HttpRequest, exception: Optional[Exception] = None) -> Any: """HTTP version not supported error handler""" - return render(request, "505.html", status=505) + logger.error( + f"505 HTTP Version Not Supported: {request.path}", + extra={ + 'exception': str(exception) if exception else None, + 'user_agent': request.META.get('HTTP_USER_AGENT'), + 'ip_address': request.META.get('REMOTE_ADDR'), + } + ) + context = get_error_context(request, exception, 505) + return render(request, "505.html", context, status=505) diff --git a/app/firebase.py b/app/firebase.py index 6cb3d1e..a43d5d7 100644 --- a/app/firebase.py +++ b/app/firebase.py @@ -1,10 +1,17 @@ import os +import time +import logging +from typing import List, Tuple, Optional from django.http import HttpResponse, JsonResponse from django.db import models +from django.conf import settings import firebase_admin -from firebase_admin import messaging, credentials +from firebase_admin import messaging, credentials, exceptions from student_management_system.settings import BASE_DIR +from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type +# Configure logging +logger = logging.getLogger(__name__) # -------------------------------------------------------------------- # FCM Device Model @@ -40,9 +47,11 @@ class FCMDevice(models.Model): verbose_name_plural = "FCM Devices" ordering = ["-last_active"] - def deactivate(self): - """Deactivate this device token""" + def deactivate(self, reason: str = None): + """Deactivate this device token with optional reason""" self.is_active = False + if reason: + logger.info(f"Deactivating device token {self.id}: {reason}") self.save() @@ -92,85 +101,138 @@ def save_fcm_token(request, token): # -------------------------------------------------------------------- -def get_firebase_js(): - """Generate Firebase JavaScript configuration""" - firebase_config = { - "apiKey": os.getenv("FIREBASE_API_KEY", ""), - "authDomain": os.getenv("FIREBASE_AUTH_DOMAIN", ""), - "projectId": os.getenv("FIREBASE_PROJECT_ID", ""), - "storageBucket": os.getenv("FIREBASE_STORAGE_BUCKET", ""), - "messagingSenderId": os.getenv("FIREBASE_MESSAGING_SENDER_ID", ""), - "appId": os.getenv("FIREBASE_APP_ID", ""), - "measurementId": os.getenv("FIREBASE_MEASUREMENT_ID", ""), - } +class FirebaseConfig: + """Class to handle Firebase configuration and validation""" + + @staticmethod + def validate_config(): + """Validate Firebase configuration""" + required_vars = [ + 'FIREBASE_API_KEY', + 'FIREBASE_AUTH_DOMAIN', + 'FIREBASE_PROJECT_ID', + 'FIREBASE_STORAGE_BUCKET', + 'FIREBASE_MESSAGING_SENDER_ID', + 'FIREBASE_APP_ID' + ] + + missing_vars = [var for var in required_vars if not os.getenv(var)] + if missing_vars: + raise ValueError(f"Missing required Firebase configuration variables: {', '.join(missing_vars)}") + + cert_path = os.path.join(BASE_DIR, "firebase-key.json") + if not os.path.exists(cert_path): + raise FileNotFoundError("Firebase service account key file not found") - data = """ - importScripts('https://www.gstatic.com/firebasejs/11.0.2/firebase-app-compat.js'); - importScripts('https://www.gstatic.com/firebasejs/11.0.2/firebase-messaging-compat.js'); - - firebase.initializeApp({ - apiKey: "%s", - authDomain: "%s", - projectId: "%s", - storageBucket: "%s", - messagingSenderId: "%s", - appId: "%s", - measurementId: "%s" - }); - - const messaging = firebase.messaging(); - - messaging.onBackgroundMessage(function(payload) { - const notificationTitle = payload.notification.title; - const notificationOptions = { - body: payload.notification.body, - icon: payload.notification.icon || '/static/img/logo.png' - }; - - return self.registration.showNotification(notificationTitle, notificationOptions); - }); - """ % ( - firebase_config["apiKey"], - firebase_config["authDomain"], - firebase_config["projectId"], - firebase_config["storageBucket"], - firebase_config["messagingSenderId"], - firebase_config["appId"], - firebase_config["measurementId"], - ) - return HttpResponse(data, content_type="application/javascript") + @staticmethod + def get_config(): + """Get validated Firebase configuration""" + FirebaseConfig.validate_config() + return { + "apiKey": os.getenv("FIREBASE_API_KEY"), + "authDomain": os.getenv("FIREBASE_AUTH_DOMAIN"), + "projectId": os.getenv("FIREBASE_PROJECT_ID"), + "storageBucket": os.getenv("FIREBASE_STORAGE_BUCKET"), + "messagingSenderId": os.getenv("FIREBASE_MESSAGING_SENDER_ID"), + "appId": os.getenv("FIREBASE_APP_ID"), + "measurementId": os.getenv("FIREBASE_MEASUREMENT_ID", ""), + } # -------------------------------------------------------------------- # Firebase Admin Initialization # -------------------------------------------------------------------- -# Initialize Firebase Admin -firebase_app = None -try: - cert_path = os.path.join(BASE_DIR, "firebase-key.json") - if os.path.exists(cert_path): +def initialize_firebase(): + """Initialize Firebase Admin with proper error handling""" + global firebase_app + + try: + FirebaseConfig.validate_config() + cert_path = os.path.join(BASE_DIR, "firebase-key.json") cred = credentials.Certificate(cert_path) firebase_app = firebase_admin.initialize_app(cred) -except Exception: - pass + logger.info("Firebase Admin initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize Firebase Admin: {str(e)}") + firebase_app = None + +# Initialize Firebase +firebase_app = None +initialize_firebase() # -------------------------------------------------------------------- # Push Notification Functions # -------------------------------------------------------------------- +class FirebaseQuotaManager: + """Class to manage Firebase quota limits""" + + def __init__(self): + self.quota_limit = 500 # Default quota limit per minute + self.requests_count = 0 + self.last_reset_time = time.time() + + def check_quota(self) -> bool: + """Check if we're within quota limits""" + current_time = time.time() + if current_time - self.last_reset_time >= 60: # Reset every minute + self.requests_count = 0 + self.last_reset_time = current_time + + return self.requests_count < self.quota_limit + + def increment_count(self): + """Increment request count""" + self.requests_count += 1 -def send_push_notification(title, message, tokens): +quota_manager = FirebaseQuotaManager() + +@retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=4, max=10), + retry=retry_if_exception_type((exceptions.FirebaseError, exceptions.UnknownError)) +) +def send_single_notification(token: str, title: str, message: str) -> bool: + """Send a single notification with retry mechanism""" + if not quota_manager.check_quota(): + logger.warning("Firebase quota limit reached") + return False + + try: + messaging.send( + messaging.Message( + notification=messaging.Notification( + title=title, + body=message, + ), + token=token, + ) + ) + quota_manager.increment_count() + return True + except messaging.UnregisteredError: + logger.info(f"Token {token} is no longer valid") + return False + except exceptions.FirebaseError as e: + logger.error(f"Firebase error for token {token}: {str(e)}") + raise + except Exception as e: + logger.error(f"Unexpected error for token {token}: {str(e)}") + return False + +def send_push_notification(title: str, message: str, tokens: List[str]) -> Tuple[int, int, List[str]]: """ - Send push notification using Firebase Cloud Messaging + Send push notification using Firebase Cloud Messaging with improved error handling Returns a tuple of (success_count, failure_count, failed_tokens) """ - if not firebase_app or not tokens: + if not firebase_app: + logger.error("Firebase Admin not initialized") return 0, len(tokens) if tokens else 0, tokens if not isinstance(tokens, (list, tuple)): - tokens = list(tokens) # Convert QuerySet to list + tokens = list(tokens) success_count = 0 failure_count = 0 @@ -180,42 +242,33 @@ def send_push_notification(title, message, tokens): active_devices = FCMDevice.objects.filter( token__in=tokens, is_active=True, - is_fallback=False, # Skip fallback tokens for push notifications + is_fallback=False, ) # Count fallback tokens as "success" for reporting purposes fallback_count = FCMDevice.objects.filter( token__in=tokens, is_active=True, is_fallback=True ).count() - success_count += fallback_count for device in active_devices: try: # Skip tokens that match our fallback pattern - if device.token.startswith("fcm-token-") or device.token.startswith( - "fallback-token-" - ): + if device.token.startswith(("fcm-token-", "fallback-token-")): device.is_fallback = True device.save() continue - messaging.send( - messaging.Message( - notification=messaging.Notification( - title=title, - body=message, - ), - token=str(device.token), # Ensure token is a string - ) - ) - success_count += 1 - except messaging.UnregisteredError: - # Token is no longer valid, deactivate the device - device.deactivate() - failure_count += 1 - failed_tokens.append(device.token) - except Exception: + if send_single_notification(device.token, title, message): + success_count += 1 + else: + device.deactivate("Failed to send notification after retries") + failure_count += 1 + failed_tokens.append(device.token) + + except Exception as e: + logger.error(f"Error processing device {device.id}: {str(e)}") + device.deactivate(f"Error: {str(e)}") failure_count += 1 failed_tokens.append(device.token) diff --git a/app/hodviews.py b/app/hodviews.py index 1111a27..097c9f7 100644 --- a/app/hodviews.py +++ b/app/hodviews.py @@ -11,8 +11,16 @@ from django.contrib.auth.hashers import make_password from django.contrib.auth.models import Group from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods +import os from app.firebase import send_push_notification, FCMDevice +from app.utils import ( + handle_file_upload, + cleanup_failed_upload, + FileUploadError, + ALLOWED_DOCUMENT_TYPES, + MAX_DOCUMENT_SIZE +) from app.models import ( Staff, @@ -167,59 +175,53 @@ def hod_dashboard(request): @login_required def add_subject(request): - """Add a new subject to the HOD's course""" - try: - # Check if user is a HOD - if not request.user.groups.filter(name="HOD").exists(): - return JsonResponse({"success": False, "message": "Only HODs can add subjects."}) - - # Get the HOD's course - hod = request.user - if not hasattr(hod, "course") or not hod.course: - return JsonResponse({"success": False, "message": "You are not assigned as HOD of any department."}) - - course = hod.course - - if request.method == "POST": - # Get form data + if request.method == "POST": + try: name = request.POST.get("name") code = request.POST.get("code") period_or_year = request.POST.get("period_or_year") + syllabus_pdf = request.FILES.get("syllabus_pdf") - # Validate required fields if not name or not period_or_year: - return JsonResponse({"success": False, "message": "Subject name and period/year are required."}) + return JsonResponse( + {"success": False, "error": "Name and period/year are required"} + ) + + # Handle syllabus PDF upload if provided + syllabus_path = None + if syllabus_pdf: + try: + syllabus_path = handle_file_upload( + syllabus_pdf, + "subject_syllabus", + ALLOWED_DOCUMENT_TYPES, + MAX_DOCUMENT_SIZE + ) + except FileUploadError as e: + return JsonResponse({"success": False, "error": str(e)}) try: - period_or_year = int(period_or_year) - except ValueError: - return JsonResponse({"success": False, "message": "Invalid period/year value."}) + # Create subject with uploaded syllabus + subject = Subject.objects.create( + name=name, + code=code, + period_or_year=period_or_year, + syllabus_pdf=syllabus_path + ) - # Validate period/year against course duration - max_periods = course.duration * 2 if course.duration_type == "Semester" else course.duration - if period_or_year < 1 or period_or_year > max_periods: - return JsonResponse({"success": False, "message": f"Period/Year must be between 1 and {max_periods}."}) + return JsonResponse( + {"success": True, "message": "Subject added successfully"} + ) + except Exception as e: + # Clean up uploaded file if database operation fails + if syllabus_path: + cleanup_failed_upload(syllabus_path) + return JsonResponse({"success": False, "error": str(e)}) - # Create the subject - subject = Subject( - name=name, - code=code, - period_or_year=period_or_year, - course=course - ) + except Exception as e: + return JsonResponse({"success": False, "error": str(e)}) - # Handle syllabus PDF if provided - if request.FILES.get('syllabus_pdf'): - subject.syllabus_pdf = request.FILES['syllabus_pdf'] - - subject.save() - - return JsonResponse({"success": True, "message": "Subject added successfully."}) - - return JsonResponse({"success": False, "message": "Invalid request method."}) - - except Exception as e: - return JsonResponse({"success": False, "message": f"An error occurred: {str(e)}"}) + return JsonResponse({"success": False, "error": "Invalid request method"}) @login_required def view_subject(request, subject_id): @@ -271,79 +273,67 @@ def view_subject(request, subject_id): return JsonResponse({"success": False, "message": f"An error occurred: {str(e)}"}) @login_required -def edit_subject(request, subject_id): - """Edit an existing subject""" - try: - # Check if user is a HOD - if not request.user.groups.filter(name="HOD").exists(): - return JsonResponse({"success": False, "message": "Only HODs can edit subjects."}) - - # Get the HOD's course - hod = request.user - if not hasattr(hod, "course") or not hod.course: - return JsonResponse({"success": False, "message": "You are not assigned as HOD of any department."}) - - # Get the subject and ensure it belongs to the HOD's course - subject = get_object_or_404(Subject, id=subject_id, course=hod.course) - - if request.method == "POST": - # Get form data +def edit_subject(request): + if request.method == "POST": + try: + subject_id = request.POST.get("subject_id") name = request.POST.get("name") code = request.POST.get("code") period_or_year = request.POST.get("period_or_year") - teacher_id = request.POST.get("teacher") - description = request.POST.get("description") + syllabus_pdf = request.FILES.get("syllabus_pdf") - # Validate required fields - if not name or not period_or_year: - return JsonResponse({"success": False, "message": "Subject name and period/year are required."}) + if not subject_id or not name or not period_or_year: + return JsonResponse( + {"success": False, "error": "Subject ID, name and period/year are required"} + ) try: - period_or_year = int(period_or_year) - except ValueError: - return JsonResponse({"success": False, "message": "Invalid period/year value."}) - - # Validate period/year against course duration - max_periods = subject.course.duration * 2 if subject.course.duration_type == "Semester" else subject.course.duration - if period_or_year < 1 or period_or_year > max_periods: - return JsonResponse({"success": False, "message": f"Period/Year must be between 1 and {max_periods}."}) - - # Update subject details - subject.name = name - subject.code = code - subject.period_or_year = period_or_year - subject.description = description + subject = Subject.objects.get(id=subject_id) + except Subject.DoesNotExist: + return JsonResponse({"success": False, "error": "Subject not found"}) # Handle syllabus PDF upload if provided - if request.FILES.get('syllabus_pdf'): - subject.syllabus_pdf = request.FILES['syllabus_pdf'] - - subject.save() - - # Update or create routine for teacher assignment - if teacher_id: + syllabus_path = None + if syllabus_pdf: try: - teacher = Staff.objects.get(id=teacher_id, course=hod.course) - routine, created = Routine.objects.get_or_create( - subject=subject, - course=hod.course, - defaults={ - 'teacher': teacher, - 'is_active': True - } + syllabus_path = handle_file_upload( + syllabus_pdf, + "subject_syllabus", + ALLOWED_DOCUMENT_TYPES, + MAX_DOCUMENT_SIZE ) - if not created: - routine.teacher = teacher - routine.save() - except Staff.DoesNotExist: - return JsonResponse({"success": False, "message": "Invalid teacher selected."}) + except FileUploadError as e: + return JsonResponse({"success": False, "error": str(e)}) - return JsonResponse({"success": True, "message": "Subject updated successfully."}) + try: + # Update subject fields + subject.name = name + subject.code = code + subject.period_or_year = period_or_year - return JsonResponse({"success": False, "message": "Invalid request method."}) + # Update syllabus if new one was uploaded + if syllabus_path: + # Delete old syllabus if exists + if subject.syllabus_pdf: + if os.path.isfile(subject.syllabus_pdf.path): + os.remove(subject.syllabus_pdf.path) + subject.syllabus_pdf = syllabus_path - except Exception as e: - return JsonResponse({"success": False, "message": f"An error occurred: {str(e)}"}) + subject.save() + + return JsonResponse( + {"success": True, "message": "Subject updated successfully"} + ) + except Exception as e: + # Clean up uploaded file if database operation fails + if syllabus_path: + cleanup_failed_upload(syllabus_path) + return JsonResponse({"success": False, "error": str(e)}) + + except Exception as e: + return JsonResponse({"success": False, "error": str(e)}) + + return JsonResponse({"success": False, "error": "Invalid request method"}) @login_required(login_url='login') def delete_subject(request, subject_id): diff --git a/app/management/commands/generate_fake_data.py b/app/management/commands/generate_fake_data.py index eaedb3c..4ba4b79 100644 --- a/app/management/commands/generate_fake_data.py +++ b/app/management/commands/generate_fake_data.py @@ -285,7 +285,7 @@ class Command(BaseCommand): for year_or_sem in range(1, max_year_or_sem + 1): # Calculate number of subjects for this year/semester # Ensure at least 2 subjects per year/semester - num_subjects = max(2, min(subjects_per_course, len(course_subject_names))) + num_subjects = max(2, min(subjects_per_course, len(course_subjects))) # Select subjects ensuring no duplicates within the same year/semester available_subjects = [s for s in course_subject_names if not Subject.objects.filter( @@ -888,4 +888,4 @@ class Command(BaseCommand): except Exception as e: # Log the error but continue with other students self.stdout.write(self.style.ERROR(f'Error creating course tracking for {student.name}: {str(e)}')) - continue # Continue with next student + continue # Continue with next student diff --git a/app/models.py b/app/models.py index 3eff160..a03f8c7 100644 --- a/app/models.py +++ b/app/models.py @@ -9,6 +9,7 @@ from django.db import models, transaction from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from django.conf import settings # Third-party app imports from model_utils import FieldTracker @@ -116,6 +117,7 @@ class Course(models.Model): ) description = models.TextField(null=True, blank=True) is_active = models.BooleanField(default=True) + batches = models.ManyToManyField("Batch", related_name="courses", blank=True) def __str__(self): return self.name @@ -123,6 +125,27 @@ class Course(models.Model): class Meta: verbose_name = "Course" verbose_name_plural = "Courses" + indexes = [ + models.Index(fields=['name']), + models.Index(fields=['code']), + models.Index(fields=['is_active']), + models.Index(fields=['duration_type']), + models.Index(fields=['name', 'is_active']), # For active course lookups + models.Index(fields=['code', 'is_active']), # For active course lookups by code + ] + + def cleanup_orphaned_data(self): + """ + Clean up orphaned data related to this course + """ + # Clean up subjects using the correct reverse relationship + Subject.objects.filter(course=self).delete() + + # Clean up course tracking records + self.student_trackings.all().delete() + + # Clean up routine records + self.routines.all().delete() class Subject(models.Model): @@ -198,6 +221,26 @@ class Subject(models.Model): class Meta: unique_together = ["name", "course", "period_or_year"] ordering = ["course", "period_or_year", "name"] + indexes = [ + models.Index(fields=['course']), + models.Index(fields=['period_or_year']), + models.Index(fields=['name']), + models.Index(fields=['course', 'period_or_year']), # For course period subjects + models.Index(fields=['name', 'course']), # For subject lookups + ] + + def cleanup_orphaned_data(self): + """ + Clean up orphaned data related to this subject + """ + # Clean up subject files + self.subjectfile_set.all().delete() + + # Clean up routine records + self.routines.all().delete() + + # Clean up attendance records + self.attendance_records.all().delete() class SubjectFile(models.Model): @@ -280,6 +323,7 @@ class Student(AbstractUser): joining_date = models.DateField(null=True, blank=True) password = models.CharField(max_length=128, editable=False, null=True) fcm_token = models.CharField(max_length=500, null=True, blank=True) + meetings = models.ManyToManyField("TeacherParentMeeting", related_name="students", blank=True) USERNAME_FIELD = "phone" REQUIRED_FIELDS = ["name"] @@ -355,20 +399,92 @@ class Student(AbstractUser): return False def save(self, *args, **kwargs): - # Call the original save method to ensure the student is saved - super().save(*args, **kwargs) + try: + with transaction.atomic(): + # Validate data before saving + self.full_clean() + + # Call the original save method + super().save(*args, **kwargs) + + # Update related records in the same transaction + if hasattr(self, 'course_trackings'): + for tracking in self.course_trackings.all(): + tracking.update_completion_percentage() + except ValidationError as e: + raise ValidationError(f"Validation error: {str(e)}") + except Exception as e: + raise Exception(f"Error saving student: {str(e)}") - # After saving, check if we need to sync joining date with batch - if hasattr(self, "_batch_to_sync"): - batch = self._batch_to_sync - if batch and batch.year and not self.joining_date: - self.joining_date = batch.year - super().save(update_fields=["joining_date"]) - delattr(self, "_batch_to_sync") + def clean(self): + """Validate student data before saving""" + if self.phone and not self.phone.isdigit(): + raise ValidationError("Phone number must contain only digits") + + if self.email and '@' not in self.email: + raise ValidationError("Invalid email format") + + if self.birth_date and self.birth_date > date.today(): + raise ValidationError("Birth date cannot be in the future") + + if self.joining_date and self.joining_date > date.today(): + raise ValidationError("Joining date cannot be in the future") + + def cleanup_orphaned_data(self): + """ + Clean up orphaned data related to this student + """ + # Clean up attendance records + self.attendance_records.all().delete() + + # Clean up course tracking + self.course_trackings.all().delete() + + # Clean up leave records + self.leave_requests.all().delete() + + # Clean up meeting records + self.meetings.clear() + + def validate_data(self): + """ + Validate student data before saving + """ + if not self.name: + raise ValidationError("Student name is required") + if not self.phone: + raise ValidationError("Student phone number is required") + if not self.gender: + raise ValidationError("Student gender is required") + if not self.birth_date: + raise ValidationError("Student birth date is required") + + def get_notification_tokens(self): + """ + Get FCM tokens for notifications + """ + tokens = set() + + # Get student's token + if self.fcm_token: + tokens.add(self.fcm_token) + + # Get parent's token + if self.parent and self.parent.fcm_token: + tokens.add(self.parent.fcm_token) + + return tokens class Meta: verbose_name = "Student" verbose_name_plural = "Students" + indexes = [ + models.Index(fields=['phone']), + models.Index(fields=['status']), + models.Index(fields=['course']), + models.Index(fields=['current_period']), + models.Index(fields=['joining_date']), + ] class Staff(AbstractUser): @@ -429,6 +545,8 @@ class Staff(AbstractUser): related_name="hod", help_text="Course for which this staff member is HOD", ) + courses_taught = models.ManyToManyField(Course, related_name="teachers", blank=True) + meetings = models.ManyToManyField("TeacherParentMeeting", related_name="staff_members", blank=True) USERNAME_FIELD = "phone" REQUIRED_FIELDS = ["name"] @@ -484,9 +602,71 @@ class Staff(AbstractUser): return True return False + def clean(self): + """Validate staff data before saving""" + if self.phone and not self.phone.isdigit(): + raise ValidationError("Phone number must contain only digits") + + if self.email and '@' not in self.email: + raise ValidationError("Invalid email format") + + if self.birth_date and self.birth_date > date.today(): + raise ValidationError("Birth date cannot be in the future") + + if self.joining_date and self.joining_date > date.today(): + raise ValidationError("Joining date cannot be in the future") + + def cleanup_orphaned_data(self): + """ + Clean up orphaned data related to this staff member + """ + # Clean up attendance records + self.attendance_records.all().delete() + + # Clean up course assignments + self.courses_taught.clear() + + # Clean up meeting records + self.meetings.clear() + + def validate_data(self): + """ + Validate staff data before saving + """ + if not self.name: + raise ValidationError("Staff name is required") + if not self.phone: + raise ValidationError("Staff phone number is required") + if not self.gender: + raise ValidationError("Staff gender is required") + if not self.designation: + raise ValidationError("Staff designation is required") + + def get_notification_tokens(self): + """ + Get FCM tokens for notifications + """ + tokens = set() + + # Get staff's token + if self.fcm_token: + tokens.add(self.fcm_token) + + return tokens + class Meta: verbose_name = "Staff" verbose_name_plural = "Staffs" + indexes = [ + models.Index(fields=['phone']), + models.Index(fields=['designation']), + models.Index(fields=['is_active']), + models.Index(fields=['course']), + # Add these composite indexes + models.Index(fields=['designation', 'is_active']), # For active staff by role + models.Index(fields=['course', 'is_active']), # For course staff + models.Index(fields=['phone', 'is_active']), # For active staff lookups + ] class Routine(models.Model): @@ -515,6 +695,16 @@ class Routine(models.Model): "end_time", "period_or_year", ] + indexes = [ + models.Index(fields=['course']), + models.Index(fields=['subject']), + models.Index(fields=['teacher']), + models.Index(fields=['is_active']), + models.Index(fields=['period_or_year']), + models.Index(fields=['course', 'period_or_year']), # For course routines + models.Index(fields=['teacher', 'is_active']), # For teacher schedules + models.Index(fields=['subject', 'is_active']), # For subject schedules + ] # Attendance Models @@ -546,6 +736,16 @@ class Attendance(models.Model): class Meta: verbose_name = "Attendance" verbose_name_plural = "Attendances" + indexes = [ + models.Index(fields=['date']), + models.Index(fields=['routine']), + models.Index(fields=['teacher']), + models.Index(fields=['class_status']), + # Add these composite indexes + models.Index(fields=['date', 'routine']), # For date-based routine lookups + models.Index(fields=['teacher', 'date']), # For teacher attendance history + models.Index(fields=['routine', 'class_status']), # For class status filtering + ] class AttendanceRecord(models.Model): @@ -568,6 +768,17 @@ class AttendanceRecord(models.Model): ) student_attend = models.BooleanField(default=False) + class Meta: + indexes = [ + models.Index(fields=['attendance']), + models.Index(fields=['student']), + models.Index(fields=['student_attend']), + # Add these composite indexes + models.Index(fields=['student', 'student_attend']), # For attendance filtering + models.Index(fields=['attendance', 'student']), # For attendance lookups + models.Index(fields=['attendance', 'student_attend']), # For attendance status + ] + # Notice Model class Notice(models.Model): @@ -629,6 +840,15 @@ class StudentLeave(models.Model): verbose_name = "Student Leave" verbose_name_plural = "Student Leaves" ordering = ["-created_at"] + indexes = [ + models.Index(fields=['student']), + models.Index(fields=['status']), + models.Index(fields=['start_date']), + models.Index(fields=['end_date']), + models.Index(fields=['student', 'status']), # For student leave status + models.Index(fields=['start_date', 'end_date']), # For date range queries + models.Index(fields=['status', 'start_date']), # For pending leaves + ] # Feedback Models @@ -835,44 +1055,86 @@ class CourseTracking(models.Model): return f"{self.student.name} - {self.course.name}" def clean(self): - if not self.student or not self.course: - raise ValidationError("Both student and course are required") - - if ( - self.start_date - and self.expected_end_date - and self.start_date > self.expected_end_date - ): - raise ValidationError("Start date cannot be after expected end date") - - if ( - self.actual_end_date - and self.start_date - and self.actual_end_date < self.start_date - ): + """Validate course tracking data""" + if self.enrollment_date and self.enrollment_date > date.today(): + raise ValidationError("Enrollment date cannot be in the future") + + if self.start_date and self.start_date > date.today(): + raise ValidationError("Start date cannot be in the future") + + if self.expected_end_date and self.expected_end_date < self.start_date: + raise ValidationError("Expected end date cannot be before start date") + + if self.actual_end_date and self.actual_end_date < self.start_date: raise ValidationError("Actual end date cannot be before start date") + + if self.completion_percentage < 0 or self.completion_percentage > 100: + raise ValidationError("Completion percentage must be between 0 and 100") - if ( - self.period_start_date - and self.period_end_date - and self.period_start_date > self.period_end_date - ): - raise ValidationError("Period start date cannot be after period end date") + def update_completion_percentage(self): + """ + Update the completion percentage based on subject progress and attendance + """ + try: + # Get total subjects for this course + total_subjects = Subject.objects.filter(course=self.course).count() + if total_subjects == 0: + self.completion_percentage = 0 + return 0 - if self.current_period < 1: - raise ValidationError("Current period must be at least 1") + # Get completed subjects from SubjectProgress + completed_subjects = SubjectProgress.objects.filter( + student=self.student, + subject__course=self.course, + status="Completed" + ).count() - if self.course.duration_type == "Semester": - max_periods = self.course.duration * 2 - if self.current_period > max_periods: - raise ValidationError(f"Current semester cannot exceed {max_periods}") - else: # Year based - if self.current_period > self.course.duration: - raise ValidationError( - f"Current year cannot exceed {self.course.duration}" - ) + # Get attendance percentage for active subjects + attended_classes = AttendanceRecord.objects.filter( + student=self.student, + attendance__routine__subject__course=self.course, + student_attend=True + ).count() + + total_classes = AttendanceRecord.objects.filter( + student=self.student, + attendance__routine__subject__course=self.course + ).count() + + # Calculate weighted completion percentage + subject_weight = 0.7 # 70% weight to subject completion + attendance_weight = 0.3 # 30% weight to attendance + + subject_percentage = (completed_subjects / total_subjects) * 100 if total_subjects > 0 else 0 + attendance_percentage = (attended_classes / total_classes) * 100 if total_classes > 0 else 0 + + # Calculate final percentage + completion_percentage = int( + (subject_percentage * subject_weight) + + (attendance_percentage * attendance_weight) + ) + + # Ensure percentage is between 0 and 100 + completion_percentage = max(0, min(100, completion_percentage)) + + # Update progress status based on completion + if completion_percentage >= 100: + self.progress_status = "Completed" + if not self.actual_end_date: + self.actual_end_date = date.today() + elif completion_percentage > 0: + self.progress_status = "In Progress" + else: + self.progress_status = "Not Started" + + return completion_percentage + + except Exception as e: + print(f"Error updating completion percentage: {str(e)}") + return self.completion_percentage def calculate_completion_percentage(self): + """Calculate completion percentage based on duration""" if not self.start_date or not self.expected_end_date: return 0 @@ -910,21 +1172,8 @@ class CourseTracking(models.Model): return min(100, int(period_progress)) - def get_completion_percentage(self): - return self.completion_percentage - - def update_completion_percentage(self): - new_percentage = self.calculate_completion_percentage() - if new_percentage != self.completion_percentage: - self.completion_percentage = new_percentage - self.save(update_fields=["completion_percentage"]) - return new_percentage - - def force_update_percentage(self): - return self.update_completion_percentage() - def save(self, *args, **kwargs): - if not self.pk: + if not self.pk: # New instance self.start_date = self.start_date or date.today() if self.course.duration_type == "Year": @@ -941,23 +1190,16 @@ class CourseTracking(models.Model): else: self.period_end_date = self.start_date + timedelta(days=365) # 1 year - self.clean() - self.completion_percentage = self.calculate_completion_percentage() - - # Check if course duration has been completed - today = date.today() - if self.expected_end_date and today >= self.expected_end_date: - self.progress_status = "Completed" - if not self.actual_end_date: - self.actual_end_date = today - elif self.completion_percentage >= 100: - self.progress_status = "Completed" - if not self.actual_end_date: - self.actual_end_date = today - elif self.completion_percentage > 0: - self.progress_status = "In Progress" - try: + self.clean() + # Update completion percentage without triggering save again + if not hasattr(self, '_updating_completion'): + self._updating_completion = True + try: + self.completion_percentage = self.update_completion_percentage() + finally: + delattr(self, '_updating_completion') + super().save(*args, **kwargs) except Exception as e: print(f"Error saving course tracking for {self.student.name}: {str(e)}") @@ -1031,6 +1273,19 @@ class CourseTracking(models.Model): verbose_name_plural = "Course Trackings" unique_together = ["student", "course"] ordering = ["-created_at"] + indexes = [ + models.Index(fields=['student']), + models.Index(fields=['course']), + models.Index(fields=['progress_status']), + models.Index(fields=['current_period']), + models.Index(fields=['enrollment_date']), + models.Index(fields=['student', 'progress_status']), + models.Index(fields=['course', 'progress_status']), + models.Index(fields=['student', 'course', 'progress_status']), + models.Index(fields=['completion_percentage']), + models.Index(fields=['start_date', 'expected_end_date']), + models.Index(fields=['current_period', 'progress_status']), + ] # Authentication Models @@ -1070,6 +1325,59 @@ class ResetToken(models.Model): return timezone.now() > self.expires_at +class OTPAttempt(models.Model): + """Model to track OTP attempts for rate limiting""" + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='otp_attempts', + null=True, + blank=True + ) + identifier = models.CharField(max_length=255) # phone or email + attempts = models.IntegerField(default=0) + last_attempt = models.DateTimeField(auto_now=True) + is_locked = models.BooleanField(default=False) + lock_until = models.DateTimeField(null=True, blank=True) + + class Meta: + indexes = [ + models.Index(fields=['identifier']), + models.Index(fields=['is_locked']), + models.Index(fields=['lock_until']), + ] + + def increment_attempts(self): + """Increment attempt count and handle locking""" + self.attempts += 1 + self.last_attempt = timezone.now() + + # Lock after 3 failed attempts for 15 minutes + if self.attempts >= 3: + self.is_locked = True + self.lock_until = timezone.now() + timedelta(minutes=15) + + self.save() + + def reset_attempts(self): + """Reset attempt count and unlock""" + self.attempts = 0 + self.is_locked = False + self.lock_until = None + self.save() + + def is_locked_out(self): + """Check if the user is currently locked out""" + if not self.is_locked: + return False + + if self.lock_until and timezone.now() > self.lock_until: + self.reset_attempts() + return False + + return True + + class Parent(AbstractUser): """Model representing a parent of a student""" @@ -1145,9 +1453,29 @@ class Parent(AbstractUser): return True return False + def cleanup_orphaned_data(self): + """ + Clean up orphaned data related to this parent + """ + # Clean up meeting records + self.meetings.all().delete() + + # Clean up feedback records + self.feedbacks.all().delete() + self.institute_feedbacks.all().delete() + + # Clean up notification tokens + self.fcm_token = None + self.save(update_fields=['fcm_token']) + class Meta: verbose_name = "Parent" verbose_name_plural = "Parents" + indexes = [ + models.Index(fields=['phone']), + models.Index(fields=['is_active']), + models.Index(fields=['phone', 'is_active']), # For active parent lookups + ] class TeacherParentMeeting(models.Model): @@ -1179,6 +1507,13 @@ class TeacherParentMeeting(models.Model): class Meta: ordering = ["-meeting_date", "-meeting_time"] unique_together = ["meeting_date", "meeting_time"] + indexes = [ + models.Index(fields=['meeting_date']), + models.Index(fields=['status']), + models.Index(fields=['is_online']), + models.Index(fields=['meeting_date', 'status']), # For upcoming meetings + models.Index(fields=['status', 'is_online']), # For online meetings + ] def __str__(self): return f"Meeting on {self.meeting_date}" @@ -1230,3 +1565,27 @@ class TeacherParentMeeting(models.Model): def can_be_rescheduled(self): return self.status in ["scheduled", "rescheduled"] and self.is_upcoming() + + +class SubjectProgress(models.Model): + """Model for tracking student progress in individual subjects""" + student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name='subject_progress') + subject = models.ForeignKey(Subject, on_delete=models.CASCADE, related_name='student_progress') + status = models.CharField(max_length=20, choices=PROGRESS_STATUS_CHOICES, default="Not Started") + completion_percentage = models.IntegerField(default=0) + last_updated = models.DateTimeField(auto_now=True) + notes = models.TextField(blank=True, null=True) + + class Meta: + unique_together = ['student', 'subject'] + ordering = ['-last_updated'] + indexes = [ + models.Index(fields=['student']), + models.Index(fields=['subject']), + models.Index(fields=['status']), + models.Index(fields=['student', 'subject']), + models.Index(fields=['student', 'status']), + ] + + def __str__(self): + return f"{self.student.name} - {self.subject.name}: {self.status}" diff --git a/app/routers.py b/app/routers.py new file mode 100644 index 0000000..ef34363 --- /dev/null +++ b/app/routers.py @@ -0,0 +1,69 @@ +from django.conf import settings +from django.db import models +from django.core.cache import cache + +class DatabaseRouter: + """ + A router to control database operations and optimize query routing + """ + + def db_for_read(self, model, **hints): + """ + Route read operations to the appropriate database + """ + # Check if model has a cached query + cache_key = f"db_read_{model._meta.model_name}" + cached_db = cache.get(cache_key) + if cached_db: + return cached_db + + # Default to primary database + return 'default' + + def db_for_write(self, model, **hints): + """ + Route write operations to the appropriate database + """ + return 'default' + + def allow_relation(self, obj1, obj2, **hints): + """ + Allow relations between objects + """ + return True + + def allow_migrate(self, db, app_label, model_name=None, **hints): + """ + Allow migrations on the default database + """ + return db == 'default' + + def optimize_query(self, queryset, **hints): + """ + Optimize the query based on model and hints + """ + # Add select_related for foreign key relationships + if hasattr(queryset.model, '_meta'): + related_fields = [ + f.name for f in queryset.model._meta.get_fields() + if f.is_relation and not f.many_to_many + ] + if related_fields: + queryset = queryset.select_related(*related_fields) + + # Add prefetch_related for many-to-many relationships + m2m_fields = [ + f.name for f in queryset.model._meta.get_fields() + if f.many_to_many + ] + if m2m_fields: + queryset = queryset.prefetch_related(*m2m_fields) + + # Add caching for frequently accessed queries + if hints.get('cache', True): + cache_key = f"query_{queryset.model._meta.model_name}_{hash(str(queryset.query))}" + cached_result = cache.get(cache_key) + if cached_result is not None: + return cached_result + + return queryset \ No newline at end of file diff --git a/app/serializers.py b/app/serializers.py index 6b35056..fec921f 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -28,6 +28,8 @@ from app.models import ( Subject, SubjectFile, TeacherParentMeeting, + TOTPSecret, + ResetToken, ) @@ -38,6 +40,10 @@ class FCMDeviceSerializer(serializers.ModelSerializer): class InstituteSerializer(serializers.ModelSerializer): + logo_url = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + class Meta: model = Institute fields = [ @@ -49,30 +55,41 @@ class InstituteSerializer(serializers.ModelSerializer): "pan_no", "reg_no", "logo", + "logo_url", "description", + "created_at", + "updated_at", ] + read_only_fields = ["id", "created_at", "updated_at"] + + def get_logo_url(self, obj): + if obj.logo: + return obj.logo.url + return None -class SubjectSerializer(serializers.ModelSerializer): - syllabus_pdf_url = serializers.SerializerMethodField() +class BatchSerializer(serializers.ModelSerializer): + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) class Meta: - model = Subject + model = Batch fields = [ "id", "name", - "course", - "period_or_year", - "syllabus_pdf", - "syllabus_pdf_url", + "year", + "is_active", + "created_at", + "updated_at", ] - - def get_syllabus_pdf_url(self, obj): - return obj.get_pdf_url() + read_only_fields = ["id", "created_at", "updated_at"] class CourseSerializer(serializers.ModelSerializer): - subjects = SubjectSerializer(many=True, read_only=True) + subjects = serializers.SerializerMethodField() + batches = BatchSerializer(many=True, read_only=True) + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) class Meta: model = Course @@ -82,21 +99,55 @@ class CourseSerializer(serializers.ModelSerializer): "code", "duration", "duration_type", - "is_active", "description", + "is_active", "subjects", + "batches", + "created_at", + "updated_at", ] + read_only_fields = ["id", "created_at", "updated_at"] + + def get_subjects(self, obj): + from app.serializers import SubjectSerializer + subjects = obj.subjects.all() + return SubjectSerializer(subjects, many=True).data -class BatchSerializer(serializers.ModelSerializer): +class SubjectSerializer(serializers.ModelSerializer): + course = CourseSerializer(read_only=True) + syllabus_pdf_url = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + class Meta: - model = Batch - fields = ["id", "name", "year"] + model = Subject + fields = [ + "id", + "name", + "code", + "course", + "period_or_year", + "syllabus_pdf", + "syllabus_pdf_url", + "is_active", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + def get_syllabus_pdf_url(self, obj): + if obj.syllabus_pdf: + return obj.syllabus_pdf.url + return None class StudentSerializer(serializers.ModelSerializer): batches = BatchSerializer(many=True, read_only=True) course = CourseSerializer(read_only=True) + image_url = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) class Meta: model = Student @@ -115,15 +166,28 @@ class StudentSerializer(serializers.ModelSerializer): "parent_phone", "citizenship_no", "image", + "image_url", "batches", "course", - "current_semester", + "current_period", "joining_date", + "fcm_token", + "created_at", + "updated_at", ] - read_only_fields = ["id"] + read_only_fields = ["id", "created_at", "updated_at"] + + def get_image_url(self, obj): + if obj.image: + return obj.image.url + return None class StaffSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + class Meta: model = Staff fields = [ @@ -142,16 +206,27 @@ class StaffSerializer(serializers.ModelSerializer): "citizenship_no", "passport", "image", + "image_url", "joining_date", "is_active", + "fcm_token", + "created_at", + "updated_at", ] - read_only_fields = ["id"] + read_only_fields = ["id", "created_at", "updated_at"] + + def get_image_url(self, obj): + if obj.image: + return obj.image.url + return None class RoutineSerializer(serializers.ModelSerializer): - teacher = StaffSerializer(read_only=True) - subject = SubjectSerializer(read_only=True) course = CourseSerializer(read_only=True) + subject = SubjectSerializer(read_only=True) + teacher = StaffSerializer(read_only=True) + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) class Meta: model = Routine @@ -164,21 +239,18 @@ class RoutineSerializer(serializers.ModelSerializer): "end_time", "period_or_year", "is_active", + "created_at", + "updated_at", ] - - -class AttendanceRecordSerializer(serializers.ModelSerializer): - student = StudentSerializer(read_only=True) - - class Meta: - model = AttendanceRecord - fields = ["id", "attendance", "student", "student_attend"] + read_only_fields = ["id", "created_at", "updated_at"] class AttendanceSerializer(serializers.ModelSerializer): - records = AttendanceRecordSerializer(many=True, read_only=True) routine = RoutineSerializer(read_only=True) teacher = StaffSerializer(read_only=True) + records = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) class Meta: model = Attendance @@ -190,23 +262,66 @@ class AttendanceSerializer(serializers.ModelSerializer): "teacher_attend", "class_status", "records", + "created_at", + "updated_at", ] + read_only_fields = ["id", "created_at", "updated_at"] + + def get_records(self, obj): + from app.serializers import AttendanceRecordSerializer + records = obj.records.all() + return AttendanceRecordSerializer(records, many=True).data + + +class AttendanceRecordSerializer(serializers.ModelSerializer): + student = StudentSerializer(read_only=True) + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + + class Meta: + model = AttendanceRecord + fields = [ + "id", + "attendance", + "student", + "student_attend", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] class NoticeSerializer(serializers.ModelSerializer): - created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) image_url = serializers.SerializerMethodField() + file_url = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) class Meta: model = Notice - fields = ["id", "title", "message", "image", "image_url", "created_at"] - read_only_fields = ["created_at"] + fields = [ + "id", + "title", + "image", + "image_url", + "message", + "file", + "file_url", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] def get_image_url(self, obj): if obj.image: return obj.image.url return None + def get_file_url(self, obj): + if obj.file: + return obj.file.url + return None + class StaffLeaveSerializer(serializers.ModelSerializer): staff = StaffSerializer(read_only=True) @@ -225,7 +340,7 @@ class StaffLeaveSerializer(serializers.ModelSerializer): "created_at", "updated_at", ] - read_only_fields = ["created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] class StudentLeaveSerializer(serializers.ModelSerializer): @@ -245,7 +360,7 @@ class StudentLeaveSerializer(serializers.ModelSerializer): "created_at", "updated_at", ] - read_only_fields = ["created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] class StudentFeedbackSerializer(serializers.ModelSerializer): @@ -265,10 +380,37 @@ class StudentFeedbackSerializer(serializers.ModelSerializer): "created_at", "updated_at", ] - read_only_fields = ["created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +class ParentFeedbackSerializer(serializers.ModelSerializer): + parent = serializers.SerializerMethodField() + teacher = StaffSerializer(read_only=True) + student = StudentSerializer(read_only=True) + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + + class Meta: + model = ParentFeedback + fields = [ + "id", + "parent", + "teacher", + "student", + "rating", + "feedback_text", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + def get_parent(self, obj): + from app.serializers import ParentSerializer + return ParentSerializer(obj.parent).data class InstituteFeedbackSerializer(serializers.ModelSerializer): + institute = InstituteSerializer(read_only=True) user = StudentSerializer(read_only=True) created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) @@ -287,10 +429,11 @@ class InstituteFeedbackSerializer(serializers.ModelSerializer): "created_at", "updated_at", ] - read_only_fields = ["created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] class StaffInstituteFeedbackSerializer(serializers.ModelSerializer): + institute = InstituteSerializer(read_only=True) staff = StaffSerializer(read_only=True) created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) @@ -309,15 +452,42 @@ class StaffInstituteFeedbackSerializer(serializers.ModelSerializer): "created_at", "updated_at", ] - read_only_fields = ["created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + +class ParentInstituteFeedbackSerializer(serializers.ModelSerializer): + institute = InstituteSerializer(read_only=True) + parent = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + + class Meta: + model = ParentInstituteFeedback + fields = [ + "id", + "institute", + "parent", + "feedback_type", + "rating", + "feedback_text", + "is_anonymous", + "is_public", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + def get_parent(self, obj): + from app.serializers import ParentSerializer + return ParentSerializer(obj.parent).data class CourseTrackingSerializer(serializers.ModelSerializer): student = StudentSerializer(read_only=True) course = CourseSerializer(read_only=True) + current_period_display = serializers.CharField(read_only=True) created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) - current_period_display = serializers.CharField(read_only=True) class Meta: model = CourseTracking @@ -332,37 +502,89 @@ class CourseTrackingSerializer(serializers.ModelSerializer): "progress_status", "completion_percentage", "current_period", + "current_period_display", "period_start_date", "period_end_date", "notes", "created_at", "updated_at", - "current_period_display", ] - read_only_fields = [ + read_only_fields = ["id", "created_at", "updated_at", "current_period_display"] + + +class SubjectFileSerializer(serializers.ModelSerializer): + file_url = serializers.SerializerMethodField() + uploaded_by = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + + class Meta: + model = SubjectFile + fields = [ + "id", + "subject", + "title", + "description", + "file", + "file_url", + "uploaded_by", + "uploaded_at", "created_at", "updated_at", - "enrollment_date", - "current_period_display", ] + read_only_fields = ["id", "created_at", "updated_at", "uploaded_at"] + + def get_file_url(self, obj): + if obj.file: + return obj.file.url + return None + + def get_uploaded_by(self, obj): + from app.serializers import StaffSerializer + if obj.uploaded_by: + return StaffSerializer(obj.uploaded_by).data + return None class TOTPSecretSerializer(serializers.ModelSerializer): class Meta: model = TOTPSecret - fields = ["id", "identifier", "created_at", "expires_at"] + fields = ["id", "identifier", "secret_key", "created_at", "expires_at"] + read_only_fields = ["id", "created_at"] class ResetTokenSerializer(serializers.ModelSerializer): class Meta: model = ResetToken fields = ["id", "token", "identifier", "created_at", "expires_at"] + read_only_fields = ["id", "created_at"] class ParentSerializer(serializers.ModelSerializer): students = StudentSerializer(many=True, read_only=True) + image_url = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) + updated_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", read_only=True) class Meta: model = Parent - fields = ["id", "name", "phone", "email", "address", "students"] - read_only_fields = ["id"] + fields = [ + "id", + "name", + "phone", + "email", + "address", + "students", + "image", + "image_url", + "fcm_token", + "is_active", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + def get_image_url(self, obj): + if obj.image: + return obj.image.url + return None diff --git a/app/signals.py b/app/signals.py index dc59e49..26b2097 100644 --- a/app/signals.py +++ b/app/signals.py @@ -9,8 +9,10 @@ from django.core.cache import cache from django.core.signals import request_started from django.db import transaction from django.db.models import Q -from django.db.models.signals import m2m_changed, post_migrate, post_save +from django.db.models.signals import m2m_changed, post_migrate, post_save, post_delete, pre_save from django.dispatch import receiver +import logging +from django.utils import timezone # Local app imports from app.firebase import FCMDevice, send_push_notification @@ -22,7 +24,15 @@ from app.models import ( Staff, Student, TeacherParentMeeting, + Course, + Subject, + TOTPSecret, + ResetToken, + OTPAttempt, ) +from app.utils import cleanup_expired_tokens + +logger = logging.getLogger(__name__) # -------------------------------------------------------------------- # Course Tracking Signals @@ -352,71 +362,279 @@ def handle_student_batch_change( @receiver(post_save, sender=Notice) def send_notice_notification(sender, instance, created, **kwargs): - """Send notifications when a notice is created or updated""" - # Get all tokens from active devices (all user types) - tokens = FCMDevice.objects.filter(is_active=True).values_list("token", flat=True) - - send_push_notification(instance.title, instance.message, tokens) + """ + Send notification when a notice is created or updated + """ + try: + if created: + title = "New Notice" + message = instance.message + else: + title = "Notice Updated" + message = instance.message + + # Get all active FCM tokens + tokens = list(instance.get_notification_tokens()) + + if tokens: + send_push_notification(title, message, tokens) + logger.info(f"Sent notice notification to {len(tokens)} devices") + except Exception as e: + logger.error(f"Error sending notice notification: {str(e)}") @receiver(post_save, sender=TeacherParentMeeting) def send_meeting_notification(sender, instance, created, **kwargs): - """Send notifications when a meeting is created or updated""" - tokens = [] + """ + Send notification when a meeting is created or updated + """ + try: + if created: + title = "New Parent-Teacher Meeting" + message = f"Meeting scheduled for {instance.meeting_date} at {instance.meeting_time}" + else: + title = "Meeting Updated" + message = f"Meeting details updated for {instance.meeting_date}" + + # Get all active FCM tokens + tokens = list(instance.get_notification_tokens()) + + if tokens: + send_push_notification(title, message, tokens) + logger.info(f"Sent meeting notification to {len(tokens)} devices") + except Exception as e: + logger.error(f"Error sending meeting notification: {str(e)}") - # Method 1: Get tokens from FCMDevice records based on user_type - fcm_device_tokens = FCMDevice.objects.filter( - is_active=True, user_type__in=["parent", "teacher"] - ).values_list("token", flat=True) - tokens.extend(fcm_device_tokens) - # Method 2: Get tokens directly from user models - # Get tokens from Staff model - staff_tokens = ( - Staff.objects.filter(fcm_token__isnull=False) - .exclude(fcm_token="") - .values_list("fcm_token", flat=True) - ) - tokens.extend(staff_tokens) +# -------------------------------------------------------------------- +# Data Cleanup Signals +# -------------------------------------------------------------------- - # Get tokens from Parent model - parent_tokens = ( - Parent.objects.filter(fcm_token__isnull=False) - .exclude(fcm_token="") - .values_list("fcm_token", flat=True) - ) - tokens.extend(parent_tokens) +@receiver(post_delete, sender=Student) +def cleanup_orphaned_student_data(sender, instance, **kwargs): + """ + Clean up orphaned data when a student is deleted + """ + try: + with transaction.atomic(): + # Clean up related records + instance.cleanup_orphaned_data() + logger.info(f"Cleaned up orphaned data for deleted student: {instance.id}") + + # Clear any cached data + cache.delete_pattern(f"student_{instance.id}_*") + if instance.parent: + cache.delete_pattern(f"parent_{instance.parent.id}_*") + except Exception as e: + logger.error(f"Error cleaning up orphaned student data: {str(e)}") - # Remove duplicates - unique_tokens = list(set(tokens)) +@receiver(post_delete, sender=Staff) +def cleanup_orphaned_staff_data(sender, instance, **kwargs): + """ + Clean up orphaned data when a staff member is deleted + """ + try: + with transaction.atomic(): + # Clean up related records + instance.cleanup_orphaned_data() + logger.info(f"Cleaned up orphaned data for deleted staff: {instance.id}") + + # Clear any cached data + cache.delete_pattern(f"staff_{instance.id}_*") + except Exception as e: + logger.error(f"Error cleaning up orphaned staff data: {str(e)}") - if not unique_tokens: - return +@receiver(post_delete, sender=CourseTracking) +def cleanup_orphaned_course_tracking_data(sender, instance, **kwargs): + """ + Clean up orphaned data when a course tracking record is deleted + """ + try: + with transaction.atomic(): + # Clean up related records + instance.cleanup_orphaned_data() + logger.info(f"Cleaned up orphaned data for deleted course tracking: {instance.id}") + + # Clear any cached data + cache.delete_pattern(f"course_tracking_{instance.id}_*") + if instance.student: + cache.delete_pattern(f"student_{instance.student.id}_*") + except Exception as e: + logger.error(f"Error cleaning up orphaned course tracking data: {str(e)}") +@receiver(post_delete, sender=Parent) +def cleanup_orphaned_parent_data(sender, instance, **kwargs): + """ + Clean up orphaned data when a parent is deleted + """ + try: + with transaction.atomic(): + # Clean up related records + instance.cleanup_orphaned_data() + logger.info(f"Cleaned up orphaned data for deleted parent: {instance.id}") + + # Clear any cached data + cache.delete_pattern(f"parent_{instance.id}_*") + except Exception as e: + logger.error(f"Error cleaning up orphaned parent data: {str(e)}") + +@receiver(post_delete, sender=Course) +def cleanup_orphaned_course_data(sender, instance, **kwargs): + """ + Clean up orphaned data when a course is deleted + """ + try: + with transaction.atomic(): + # Clean up related records + instance.cleanup_orphaned_data() + logger.info(f"Cleaned up orphaned data for deleted course: {instance.id}") + + # Clear any cached data + cache.delete_pattern(f"course_{instance.id}_*") + except Exception as e: + logger.error(f"Error cleaning up orphaned course data: {str(e)}") + +@receiver(post_delete, sender=Subject) +def cleanup_orphaned_subject_data(sender, instance, **kwargs): + """ + Clean up orphaned data when a subject is deleted + """ + try: + with transaction.atomic(): + # Clean up related records + instance.cleanup_orphaned_data() + logger.info(f"Cleaned up orphaned data for deleted subject: {instance.id}") + + # Clear any cached data + cache.delete_pattern(f"subject_{instance.id}_*") + except Exception as e: + logger.error(f"Error cleaning up orphaned subject data: {str(e)}") + +# -------------------------------------------------------------------- +# Data Validation Signals +# -------------------------------------------------------------------- + +@receiver(pre_save, sender=Student) +def validate_student_data(sender, instance, **kwargs): + """ + Validate student data before saving + """ + try: + instance.validate_data() + logger.info(f"Validated student data for: {instance.id}") + except Exception as e: + logger.error(f"Error validating student data: {str(e)}") + raise + +@receiver(pre_save, sender=Staff) +def validate_staff_data(sender, instance, **kwargs): + """ + Validate staff data before saving + """ + try: + instance.validate_data() + logger.info(f"Validated staff data for: {instance.id}") + except Exception as e: + logger.error(f"Error validating staff data: {str(e)}") + raise + +# -------------------------------------------------------------------- +# Course Progress Signals +# -------------------------------------------------------------------- + +@receiver(post_save, sender=CourseTracking) +def update_course_progress(sender, instance, **kwargs): + """ + Update course progress when tracking data changes + """ + try: + # Update completion percentage + instance.update_completion_percentage() + + # Update progress status + if instance.completion_percentage >= 100: + instance.progress_status = "Completed" + instance.actual_end_date = date.today() + instance.save() + + # Clear cache using individual keys instead of pattern + cache.delete(f"course_tracking_{instance.id}_percentage") + cache.delete(f"student_{instance.student.id}_progress") + + logger.info(f"Updated course progress for tracking: {instance.id}") + except Exception as e: + logger.error(f"Error updating course progress: {str(e)}") + +@receiver(post_save, sender=TOTPSecret) +@receiver(post_save, sender=ResetToken) +def cleanup_expired_tokens_signal(sender, instance, **kwargs): + """ + Signal handler to clean up expired tokens when a new token is created + """ + try: + # Clean up expired OTP secrets and reset tokens + cleanup_expired_tokens() + + # Reset OTP attempts for expired lockouts + expired_lockouts = OTPAttempt.objects.filter( + is_locked=True, + lock_until__lt=timezone.now() + ) + expired_count = expired_lockouts.count() + expired_lockouts.update( + is_locked=False, + lock_until=None, + attempts=0 + ) + + # Log cleanup results to terminal + print(f'[OTP Cleanup] Successfully cleaned up expired tokens and reset {expired_count} expired lockouts') + logger.info(f'Successfully cleaned up expired tokens and reset {expired_count} expired lockouts') + except Exception as e: + error_msg = f"Error in cleanup signal: {str(e)}" + print(f'[OTP Cleanup Error] {error_msg}') + logger.error(error_msg) + raise + +@receiver(post_delete, sender=TOTPSecret) +@receiver(post_delete, sender=ResetToken) +def cleanup_after_deletion(sender, instance, **kwargs): + """ + Signal handler to clean up expired tokens when a token is deleted + """ + try: + cleanup_expired_tokens() + print('[OTP Cleanup] Successfully cleaned up expired tokens after deletion') + logger.info('Successfully cleaned up expired tokens after deletion') + except Exception as e: + error_msg = f"Error in cleanup after deletion: {str(e)}" + print(f'[OTP Cleanup Error] {error_msg}') + logger.error(error_msg) + raise + +# Add a new signal for terminal OTP verification +@receiver(post_save, sender=OTPAttempt) +def handle_terminal_otp_attempt(sender, instance, created, **kwargs): + """ + Signal handler for terminal-based OTP attempts + """ if created: - title = "New Parent-Teacher Meeting Scheduled" - message = f"A parent-teacher meeting has been scheduled for {instance.meeting_date} at {instance.meeting_time}." - if instance.is_online: - message += " This is an online meeting." - send_push_notification(title, message, unique_tokens) - else: - # Access through instance.tracker to determine if status changed - # This requires django-model-utils or similar to track changes - if hasattr(instance, "tracker") and "status" in instance.tracker.changed(): - old_status = instance.tracker.previous("status") - if instance.status == "cancelled": - title = "Parent-Teacher Meeting Cancelled" - message = f"The meeting for {instance.meeting_date} at {instance.meeting_time} has been cancelled." - if instance.cancellation_reason: - message += f" Reason: {instance.cancellation_reason}" - send_push_notification(title, message, unique_tokens) - elif instance.status == "rescheduled": - # Meeting rescheduled notification - title = "Parent-Teacher Meeting Rescheduled" - message = f"The parent-teacher meeting has been rescheduled to {instance.meeting_date} at {instance.meeting_time}." - send_push_notification(title, message, unique_tokens) - elif instance.status == "completed": - # Meeting completed notification - title = "Parent-Teacher Meeting Completed" - message = f"The parent-teacher meeting scheduled for {instance.meeting_date} at {instance.meeting_time} has been marked as completed." - send_push_notification(title, message, unique_tokens) + try: + # Get the identifier (phone/email) for logging + identifier = instance.identifier + username = instance.user.username if instance.user else identifier + + # Log the OTP attempt to terminal + print(f'[OTP Attempt] User: {username}, Attempts: {instance.attempts}') + + # Check if user is locked out + if instance.is_locked: + print(f'[OTP Lockout] User {username} is locked until {instance.lock_until}') + + # Log to file as well + logger.info(f'OTP attempt for user {username}, attempts: {instance.attempts}') + except Exception as e: + error_msg = f"Error handling OTP attempt: {str(e)}" + print(f'[OTP Error] {error_msg}') + logger.error(error_msg) + raise diff --git a/app/staffviews.py b/app/staffviews.py index 4d2a462..0d8d18e 100644 --- a/app/staffviews.py +++ b/app/staffviews.py @@ -25,6 +25,13 @@ from app.models import ( FEEDBACK_TYPE_CHOICES, ) import logging +from app.utils import ( + handle_file_upload, + cleanup_failed_upload, + FileUploadError, + ALLOWED_DOCUMENT_TYPES, + MAX_DOCUMENT_SIZE +) @login_required @@ -714,96 +721,9 @@ def manage_subject_files(request): logger.info(f"Teacher found: {teacher.name}") # Get all subjects taught by this teacher - subject_filter = request.GET.get("subject") - - subjects_query = ( - Routine.objects.filter(teacher=teacher, is_active=True) - .values_list("subject", flat=True) - .distinct() - ) - - logger.info(f"Found {len(subjects_query)} subject IDs: {list(subjects_query)}") - - # Apply subject filter if provided - try: - if subject_filter: - subjects = Subject.objects.filter( - id=subject_filter, id__in=subjects_query - ).order_by("course__name", "period_or_year", "name") - - logger.info( - f"Filtered by subject ID {subject_filter}, found {subjects.count()} subjects" - ) - - # If no subject found with the provided ID, return a helpful error - if not subjects.exists(): - logger.warning(f"No subjects found with ID {subject_filter}") - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse( - { - "success": False, - "message": f"Subject with ID {subject_filter} not found or you don't have permission to access it", - }, - status=404, - ) - else: - return render( - request, - "teacher/subject_files.html", - { - "subjects": [], - "teacher": teacher, - "selected_subject": None, - "error_message": f"Subject with ID {subject_filter} not found or you don't have permission to access it", - }, - ) - else: - subjects = Subject.objects.filter(id__in=subjects_query).order_by( - "course__name", "period_or_year", "name" - ) - - logger.info(f"No filter applied, found {subjects.count()} subjects") - - # If no subjects found at all, return a message - if not subjects.exists(): - logger.warning("No subjects found for this teacher") - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse( - { - "success": False, - "message": "You are not assigned to any subjects yet", - } - ) - else: - return render( - request, - "teacher/subject_files.html", - { - "subjects": [], - "teacher": teacher, - "selected_subject": None, - "error_message": "You are not assigned to any subjects yet", - }, - ) - except Exception as e: - logger.error(f"Error loading subjects: {e}", exc_info=True) - # Handle any unexpected errors - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse( - {"success": False, "message": f"Error loading subjects: {str(e)}"}, - status=500, - ) - else: - return render( - request, - "teacher/subject_files.html", - { - "subjects": [], - "teacher": teacher, - "selected_subject": None, - "error_message": f"Error loading subjects: {str(e)}", - }, - ) + subjects = Subject.objects.filter( + routine__teacher=teacher, routine__is_active=True + ).distinct() if request.method == "POST": subject_id = request.POST.get("subject_id") @@ -838,32 +758,53 @@ def manage_subject_files(request): } ) + # Handle file upload with security checks + try: + file_path = handle_file_upload( + file, + "subject_files", + ALLOWED_DOCUMENT_TYPES, + MAX_DOCUMENT_SIZE + ) + except FileUploadError as e: + logger.error(f"File upload validation failed: {e}") + return JsonResponse({"success": False, "message": str(e)}) + # Create new subject file - subject_file = SubjectFile.objects.create( - subject=subject, - title=title, - description=description, - file=file, - uploaded_by=teacher, - ) + try: + subject_file = SubjectFile.objects.create( + subject=subject, + title=title, + description=description, + file=file_path, + uploaded_by=teacher, + ) - logger.info(f"File uploaded successfully: {subject_file.id}") + logger.info(f"File uploaded successfully: {subject_file.id}") + + return JsonResponse( + { + "success": True, + "message": "File uploaded successfully", + "file": { + "id": subject_file.id, + "title": subject_file.title, + "description": subject_file.description, + "file_url": subject_file.file.url, + "uploaded_at": subject_file.uploaded_at.strftime( + "%b %d, %Y %H:%M" + ), + }, + } + ) + except Exception as e: + # Clean up the uploaded file if database operation fails + cleanup_failed_upload(file_path) + logger.error(f"Error creating subject file: {e}", exc_info=True) + return JsonResponse( + {"success": False, "message": f"Error creating subject file: {str(e)}"} + ) - return JsonResponse( - { - "success": True, - "message": "File uploaded successfully", - "file": { - "id": subject_file.id, - "title": subject_file.title, - "description": subject_file.description, - "file_url": subject_file.file.url, - "uploaded_at": subject_file.uploaded_at.strftime( - "%b %d, %Y %H:%M" - ), - }, - } - ) except Subject.DoesNotExist: logger.error(f"Subject {subject_id} not found") return JsonResponse({"success": False, "message": "Subject not found"}) @@ -952,27 +893,11 @@ def manage_subject_files(request): } ) except Exception as e: - # If there's an error with a specific subject, log it but continue - logger.error(f"Error processing subject {subject.id}: {e}", exc_info=True) + logger.error( + f"Error processing subject {subject.id}: {e}", exc_info=True + ) - logger.info(f"Rendering template with {len(subjects_data)} subjects") - - # Print to console for debugging - print(f"DEBUG: Teacher = {teacher.name}, Subjects count = {len(subjects_data)}") - if subjects_data: - print(f"DEBUG: First subject = {subjects_data[0]['name']}") - else: - print("DEBUG: No subjects data to render") - - return render( - request, - "teacher/subject_files.html", - { - "subjects": subjects_data, - "teacher": teacher, - "selected_subject": subject_filter, - }, - ) + return JsonResponse({"success": True, "subjects": subjects_data}) @login_required diff --git a/app/utils.py b/app/utils.py index c22b0fe..8e59a4b 100644 --- a/app/utils.py +++ b/app/utils.py @@ -2,21 +2,161 @@ import base64 import random import os +import magic # Core Django imports from django.core.mail import send_mail from django.template.loader import render_to_string from django.utils import timezone +from django.core.files.storage import default_storage +from django.core.files.base import ContentFile +from django.conf import settings +import logging +from django.core.exceptions import ValidationError # Third-party app imports import pyotp # Local app imports -from app.models import ResetToken, Staff, Student, TOTPSecret +from app.models import ResetToken, Staff, Student, TOTPSecret, OTPAttempt # OTP expiration time in seconds (5 minutes) OTP_EXPIRY = int(os.getenv('OTP_EXPIRY', 300)) +logger = logging.getLogger(__name__) + +class FileUploadError(Exception): + """Custom exception for file upload errors""" + pass + +def validate_file_type(file, allowed_types): + """ + Validate file type using python-magic + + Args: + file: Uploaded file object + allowed_types: List of allowed MIME types + + Returns: + bool: True if file type is allowed, False otherwise + """ + try: + # Read first 2048 bytes to determine file type + file.seek(0) + file_content = file.read(2048) + file.seek(0) # Reset file pointer + + mime_type = magic.from_buffer(file_content, mime=True) + return mime_type in allowed_types + except Exception as e: + logger.error(f"Error validating file type: {e}") + return False + +def validate_file_size(file, max_size_bytes): + """ + Validate file size + + Args: + file: Uploaded file object + max_size_bytes: Maximum allowed file size in bytes + + Returns: + bool: True if file size is within limit, False otherwise + """ + try: + return file.size <= max_size_bytes + except Exception as e: + logger.error(f"Error validating file size: {e}") + return False + +def sanitize_filename(filename): + """ + Sanitize filename to prevent directory traversal and other security issues + + Args: + filename: Original filename + + Returns: + str: Sanitized filename + """ + # Remove any path components + filename = os.path.basename(filename) + # Replace any non-alphanumeric characters (except ._-) with underscore + filename = ''.join(c if c.isalnum() or c in '._-' else '_' for c in filename) + return filename + +def handle_file_upload(file, upload_path, allowed_types, max_size_bytes): + """ + Handle file upload with security checks + + Args: + file: Uploaded file object + upload_path: Path where file should be uploaded + allowed_types: List of allowed MIME types + max_size_bytes: Maximum allowed file size in bytes + + Returns: + str: Path to uploaded file if successful + + Raises: + FileUploadError: If file validation fails + """ + try: + # Validate file type + if not validate_file_type(file, allowed_types): + raise FileUploadError(f"Invalid file type. Allowed types: {', '.join(allowed_types)}") + + # Validate file size + if not validate_file_size(file, max_size_bytes): + raise FileUploadError(f"File size too large. Maximum size: {max_size_bytes/1024/1024}MB") + + # Sanitize filename + filename = sanitize_filename(file.name) + + # Create full upload path + full_path = os.path.join(upload_path, filename) + + # Save file + path = default_storage.save(full_path, ContentFile(file.read())) + + return path + + except Exception as e: + logger.error(f"Error handling file upload: {e}") + raise FileUploadError(str(e)) + +def cleanup_failed_upload(file_path): + """ + Clean up failed file upload + + Args: + file_path: Path to the file that needs to be cleaned up + """ + try: + if default_storage.exists(file_path): + default_storage.delete(file_path) + except Exception as e: + logger.error(f"Error cleaning up failed upload: {e}") + +# Constants for file uploads +ALLOWED_DOCUMENT_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', + 'application/rtf' +] + +ALLOWED_IMAGE_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif' +] + +MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 # 10MB +MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB # -------------------------------------------------------------------- # OTP and Secret Key Generation @@ -52,18 +192,36 @@ def store_secret_key(identifier, secret_key): def verify_otp(identifier, otp): - """Verify OTP using TOTP""" + """Verify OTP using TOTP with improved security""" try: + # Check if identifier is locked out + attempt, _ = OTPAttempt.objects.get_or_create(identifier=identifier) + if attempt.is_locked_out(): + logger.warning(f"Identifier {identifier} is locked out from OTP attempts") + return False + # Get the most recent secret for this identifier secret = TOTPSecret.objects.filter(identifier=identifier).latest("created_at") # Check if secret is expired if secret.is_expired(): + attempt.increment_attempts() return False # Verify OTP totp = pyotp.TOTP(secret.secret_key, interval=OTP_EXPIRY) - return totp.verify(otp) + is_valid = totp.verify(otp) + + if is_valid: + # Reset attempts on successful verification + attempt.reset_attempts() + # Clean up expired tokens + cleanup_expired_tokens() + else: + # Increment failed attempts + attempt.increment_attempts() + + return is_valid except TOTPSecret.DoesNotExist: return False @@ -73,44 +231,79 @@ def verify_otp(identifier, otp): # -------------------------------------------------------------------- +def validate_phone_number(phone): + """Validate phone number format with improved validation""" + if not phone or not isinstance(phone, str): + raise ValidationError("Phone number must be a string") + + # Remove any non-digit characters + phone = ''.join(filter(str.isdigit, phone)) + + # Validate length and format + if len(phone) < 10 or len(phone) > 15: + raise ValidationError("Phone number must be between 10 and 15 digits") + + # Validate country code if present + if phone.startswith('0'): + raise ValidationError("Phone number cannot start with 0") + + # Additional validation for specific countries if needed + if phone.startswith('91'): # India + if len(phone) != 12: + raise ValidationError("Indian phone numbers must be 12 digits including country code") + + return True + def send_otp_sms(phone, otp): - """Send OTP via SMS using a service""" + """Send OTP via SMS with improved error handling""" try: - # Get user details from phone number + # Validate phone number first + validate_phone_number(phone) + + # Check if phone is locked out + attempt, _ = OTPAttempt.objects.get_or_create(identifier=phone) + if attempt.is_locked_out(): + logger.warning(f"Phone {phone} is locked out from OTP attempts") + return False + + # Get user name if available + user = None student = Student.objects.filter(phone=phone).first() staff = Staff.objects.filter(phone=phone).first() - user = student or staff - - # Get user's name - user_name = user.name if user else "User" - - # Calculate expiry time in minutes - expiry_minutes = OTP_EXPIRY // 60 - - # Render SMS message using template - message = render_to_string( - "login/sms_templates/otp_message.txt", - {"user_name": user_name, "otp": otp, "expiry_time": expiry_minutes}, - ) - - # This is a placeholder. You need to replace with actual SMS API implementation - # Example using a generic SMS API - # api_url = "https://api.example.com/send" - # payload = { - # "apikey": settings.SMS_API_KEY, - # "to": phone, - # "message": message - # } - # response = requests.post(api_url, json=payload) - # return response.status_code == 200 - - print("\nSMS Message Preview:") - print("-" * 50) - print(message) - print("-" * 50) + if student: + user = student + elif staff: + user = staff + + # Prepare context for template + context = { + 'user_name': user.name if user else 'User', + 'otp': otp, + 'expiry_time': OTP_EXPIRY // 60 # Convert seconds to minutes + } + + # Render message from template + message = render_to_string('login/sms_templates/otp_message.txt', context) + + # Your SMS service implementation here + # Example using Twilio: + # from twilio.rest import Client + # client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) + # message = client.messages.create( + # body=message, + # from_=settings.TWILIO_PHONE_NUMBER, + # to=f"+{phone}" + # ) + + # For now, just log the OTP + logger.info(f"OTP message would be sent to {phone}: {message}") return True + + except ValidationError as e: + logger.error(f"Invalid phone number format: {str(e)}") + return False except Exception as e: - print(f"Error sending OTP: {str(e)}") + logger.error(f"Failed to send SMS to {phone}: {str(e)}") return False @@ -176,19 +369,25 @@ def verify_reset_code(email, code): def store_reset_token(token, identifier): - """Store reset token in database""" - # Delete any existing tokens for this identifier - ResetToken.objects.filter(identifier=identifier).delete() - - # Create new token with expiration (15 minutes) - expires_at = timezone.now() + timezone.timedelta(minutes=15) - return ResetToken.objects.create( - token=token, identifier=identifier, expires_at=expires_at - ) + """Store reset token with improved session management""" + try: + # Clean up any existing tokens for this identifier + ResetToken.objects.filter(identifier=identifier).delete() + + # Create new token with expiration + expires_at = timezone.now() + timezone.timedelta(minutes=15) # 15 minutes expiration + return ResetToken.objects.create( + token=token, + identifier=identifier, + expires_at=expires_at + ) + except Exception as e: + logger.error(f"Error storing reset token: {str(e)}") + return None def verify_reset_token(token, identifier): - """Verify reset token from database""" + """Verify reset token with improved security""" try: reset_token = ResetToken.objects.get(token=token) @@ -196,7 +395,14 @@ def verify_reset_token(token, identifier): if reset_token.is_expired(): return False - return reset_token.identifier == identifier + # Verify identifier matches + if reset_token.identifier != identifier: + return False + + # Clean up expired tokens + cleanup_expired_tokens() + + return True except ResetToken.DoesNotExist: return False @@ -207,6 +413,18 @@ def verify_reset_token(token, identifier): def cleanup_expired_tokens(): - """Clean up expired TOTP secrets and reset tokens""" - TOTPSecret.objects.filter(expires_at__lt=timezone.now()).delete() - ResetToken.objects.filter(expires_at__lt=timezone.now()).delete() + """Clean up expired OTP secrets and reset tokens""" + try: + # Delete expired OTP secrets + expired_otps = TOTPSecret.objects.filter(expires_at__lt=timezone.now()) + expired_otps_count = expired_otps.count() + expired_otps.delete() + + # Delete expired reset tokens + expired_reset_tokens = ResetToken.objects.filter(expires_at__lt=timezone.now()) + expired_reset_count = expired_reset_tokens.count() + expired_reset_tokens.delete() + + logger.info(f"Cleaned up {expired_otps_count} expired OTPs and {expired_reset_count} expired reset tokens") + except Exception as e: + logger.error(f"Error cleaning up expired tokens: {str(e)}") diff --git a/app/views.py b/app/views.py index 3ea94e8..55be313 100644 --- a/app/views.py +++ b/app/views.py @@ -38,6 +38,16 @@ from django.db.models.base import transaction from dotenv import load_dotenv from app.firebase import save_fcm_token, FCMDevice from django.views.static import serve +from app.utils import ( + handle_file_upload, + cleanup_failed_upload, + FileUploadError, + ALLOWED_IMAGE_TYPES, + MAX_IMAGE_SIZE, + ALLOWED_DOCUMENT_TYPES, + MAX_DOCUMENT_SIZE +) +from django.views.decorators.csrf import csrf_exempt # Import views from studentviews.py from app.studentviews import ( @@ -218,107 +228,47 @@ def update_profile(request): HttpResponse: Redirects to appropriate dashboard with success/error message """ if request.method == "POST": - user = request.user - user_groups = user.groups.values_list("name", flat=True) + try: + user = request.user + user_groups = user.groups.values_list("name", flat=True) - # Handle profile image update - if "profile_image" in request.FILES: - try: - image = request.FILES["profile_image"] + # Handle profile image update + if "profile_image" in request.FILES: + try: + image = request.FILES["profile_image"] - # Validate image file type - allowed_types = ["image/jpeg", "image/png", "image/gif"] - if image.content_type not in allowed_types: - messages.error( - request, - "Invalid file type. Please upload a JPEG, PNG, or GIF image.", - ) + # Handle file upload with security checks + try: + file_path = handle_file_upload( + image, + "profile_images", + ALLOWED_IMAGE_TYPES, + MAX_IMAGE_SIZE + ) + except FileUploadError as e: + messages.error(request, str(e)) + return redirect("dashboard") + + # Update user's profile image + with transaction.atomic(): + if "Student" in user_groups: + user.image = file_path + user.save() + elif "Teacher" in user_groups: + user.image = file_path + user.save() + elif "Parent" in user_groups: + user.image = file_path + user.save() + + messages.success(request, "Profile picture updated successfully") + except Exception as e: + messages.error(request, f"Error updating profile picture: {str(e)}") return redirect("dashboard") - # Validate image size (max 5MB) - if image.size > 5 * 1024 * 1024: - messages.error( - request, - "Image size too large. Please upload an image smaller than 5MB.", - ) - return redirect("dashboard") - - if "Student" in user_groups: - student = Student.objects.get(id=user.id) - if student.image: - # Delete old image if updating - if os.path.isfile(student.image.path): - os.remove(student.image.path) - # Save new image - student.image = image - student.save() - messages.success(request, "Profile picture updated successfully.") - - elif "Teacher" in user_groups: - staff = Staff.objects.get(id=user.id) - if staff.image: - # Delete old image if updating - if os.path.isfile(staff.image.path): - os.remove(staff.image.path) - # Save new image - staff.image = image - staff.save() - messages.success(request, "Profile picture updated successfully.") - - elif "Parent" in user_groups: - parent = Parent.objects.get(id=user.id) - if parent.image: - # Delete old image if updating - if os.path.isfile(parent.image.path): - os.remove(parent.image.path) - # Save new image - parent.image = image - parent.save() - messages.success(request, "Profile picture updated successfully.") - - elif "HOD" in user_groups or any( - group.lower().startswith("hod") for group in user_groups - ): - # HOD is typically a Staff member with special privileges - staff = Staff.objects.get(id=user.id) - if staff.image: - # Delete old image if updating - if os.path.isfile(staff.image.path): - os.remove(staff.image.path) - # Save new image - staff.image = image - staff.save() - messages.success(request, "Profile picture updated successfully.") - - else: - messages.error( - request, - "User profile not found. Please make sure you are logged in as a student, staff, or parent member.", - ) - return redirect("dashboard") - - except (Student.DoesNotExist, Staff.DoesNotExist, Parent.DoesNotExist) as e: - messages.error(request, "User profile not found in the database.") - return redirect("dashboard") - except Exception as e: - messages.error(request, f"Error updating profile picture: {str(e)}") - return redirect("dashboard") - else: - messages.error(request, "No profile picture uploaded.") return redirect("dashboard") - - # Redirect based on user type - if "Student" in user_groups: - return redirect("studentDashboard") - elif "Teacher" in user_groups: - return redirect("teacherDashboard") - elif "Parent" in user_groups: - return redirect("parentDashboard") - elif "Admission Officer" in user_groups: - return redirect("admissionOfficerDashboard") - elif "HOD" in user_groups: - return redirect("hodDashboard") - else: + except Exception as e: + messages.error(request, f"An unexpected error occurred: {str(e)}") return redirect("dashboard") return redirect("dashboard") @@ -326,82 +276,43 @@ def update_profile(request): @login_required def change_password(request): + """Change user password.""" if request.method == "POST": - user = request.user - user_groups = user.groups.values_list("name", flat=True) - current_password = request.POST.get("current_password") - new_password = request.POST.get("new_password") - confirm_password = request.POST.get("confirm_password") - - # Check if this is an AJAX request - is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" - - # Determine redirect URL based on user role - redirect_url = "dashboard" - if "Student" in user_groups: - redirect_url = "studentDashboard" - elif "Teacher" in user_groups: - redirect_url = "teacherDashboard" - elif "Parent" in user_groups: - redirect_url = "parentDashboard" - elif "Admission Officer" in user_groups: - redirect_url = "admissionOfficerDashboard" - elif "HOD" in user_groups: - redirect_url = "hodDashboard" - - # Validate input - if not all([current_password, new_password, confirm_password]): - if is_ajax: - return JsonResponse( - {"success": False, "error": "All password fields are required."} - ) - messages.error(request, "All password fields are required.") - return redirect(redirect_url) - - if new_password != confirm_password: - if is_ajax: - return JsonResponse( - {"success": False, "error": "New passwords do not match."} - ) - messages.error(request, "New passwords do not match.") - return redirect(redirect_url) - - # Check current password - if not user.check_password(current_password): - if is_ajax: - return JsonResponse( - {"success": False, "error": "Current password is incorrect."} - ) - messages.error(request, "Current password is incorrect.") - return redirect(redirect_url) - - # Set new password try: - user.set_password(new_password) - user.save() + user = request.user + current_password = request.POST.get("current_password") + new_password = request.POST.get("new_password") + confirm_password = request.POST.get("confirm_password") - # For both AJAX and regular requests, we'll return success and indicate need for logout - if is_ajax: - return JsonResponse( - { - "success": True, - "redirect": "/login/", - "message": "Password changed successfully. Please login again.", - } - ) + # Validate input + if not all([current_password, new_password, confirm_password]): + messages.error(request, "All fields are required") + return redirect("dashboard") - messages.success( - request, "Password changed successfully. Please login again." - ) - return redirect("login") + if new_password != confirm_password: + messages.error(request, "New passwords do not match") + return redirect("dashboard") + # Check current password + if not user.check_password(current_password): + messages.error(request, "Current password is incorrect") + return redirect("dashboard") + + # Validate new password strength + if len(new_password) < 8: + messages.error(request, "Password must be at least 8 characters long") + return redirect("dashboard") + + # Update password + with transaction.atomic(): + user.set_password(new_password) + user.save() + + messages.success(request, "Password changed successfully") + return redirect("dashboard") except Exception as e: - if is_ajax: - return JsonResponse( - {"success": False, "error": f"Error changing password: {str(e)}"} - ) - messages.error(request, f"Error changing password: {str(e)}") - return redirect(redirect_url) + messages.error(request, f"An error occurred while changing password: {str(e)}") + return redirect("dashboard") return redirect("dashboard") @@ -409,85 +320,56 @@ def change_password(request): @login_required @require_GET def get_subjects(request): - """Get subjects for a course and period""" - course_id = request.GET.get("course_id") - period = request.GET.get("period", 1) - - if not course_id: - return JsonResponse({"success": False, "message": "Course ID is required"}) - + """Get subjects for a course.""" try: - subjects = Subject.objects.filter( - course_id=course_id, period_or_year=period - ).values("id", "name") + course_id = request.GET.get("course_id") + if not course_id: + return JsonResponse({"error": "Course ID is required"}, status=400) - return JsonResponse({"success": True, "subjects": list(subjects)}) + subjects = Subject.objects.filter(course_id=course_id).values( + "id", "name", "code", "period_or_year" + ) + return JsonResponse(list(subjects), safe=False) except Exception as e: - return JsonResponse({"success": False, "message": str(e)}) + return JsonResponse({"error": str(e)}, status=500) @login_required @require_GET def get_teachers(request): - """Get teachers for the institute""" + """Get teachers for a subject.""" try: - teachers = Staff.objects.filter( - Q(designation__icontains="Teacher") - | Q(designation__icontains="Staff") - | Q(designation__icontains="Principal") - | Q(designation__icontains="HOD") - | Q(designation__icontains="Admission Officer") - | Q(designation__isnull=True) - | Q(designation="") - ).values("id", "name") + subject_id = request.GET.get("subject_id") + if not subject_id: + return JsonResponse({"error": "Subject ID is required"}, status=400) - return JsonResponse({"success": True, "teachers": list(teachers)}) + teachers = Staff.objects.filter( + designation="Teacher", + routine__subject_id=subject_id + ).distinct().values("id", "name") + return JsonResponse(list(teachers), safe=False) except Exception as e: - return JsonResponse({"success": False, "message": str(e)}) + return JsonResponse({"error": str(e)}, status=500) @login_required @require_GET def get_course_duration(request): - """Get course duration details""" - course_id = request.GET.get("course_id") - - if not course_id: - return JsonResponse({"success": False, "message": "Course ID is required"}) - + """Get course duration information.""" try: + course_id = request.GET.get("course_id") + if not course_id: + return JsonResponse({"error": "Course ID is required"}, status=400) + course = Course.objects.get(id=course_id) - - # Calculate number of semesters or years - total_periods = course.duration - if course.duration_type == "Semester": - total_periods = course.duration * 2 # 2 semesters per year - - # Create a list of periods - periods = [] - for i in range(1, total_periods + 1): - if course.duration_type == "Semester": - periods.append({"value": i, "label": f"Semester {i}"}) - else: - periods.append({"value": i, "label": f"Year {i}"}) - - return JsonResponse( - { - "success": True, - "course": { - "id": course.id, - "name": course.name, - "duration": course.duration, - "duration_type": course.duration_type, - "total_periods": total_periods, - "periods": periods, - }, - } - ) + return JsonResponse({ + "duration": course.duration, + "duration_type": course.duration_type + }) except Course.DoesNotExist: - return JsonResponse({"success": False, "message": "Course not found"}) + return JsonResponse({"error": "Course not found"}, status=404) except Exception as e: - return JsonResponse({"success": False, "message": str(e)}) + return JsonResponse({"error": str(e)}, status=500) @login_required @@ -549,17 +431,60 @@ def add_notice(request): title = request.POST.get("title") message = request.POST.get("message") image = request.FILES.get("image") + file = request.FILES.get("file") if not title or not message: return JsonResponse( {"success": False, "error": "Title and message are required"} ) - notice = Notice.objects.create(title=title, message=message, image=image) + # Handle image upload if provided + image_path = None + if image: + try: + image_path = handle_file_upload( + image, + "notice_images", + ALLOWED_IMAGE_TYPES, + MAX_IMAGE_SIZE + ) + except FileUploadError as e: + return JsonResponse({"success": False, "error": str(e)}) - return JsonResponse( - {"success": True, "message": "Notice added successfully"} - ) + # Handle file upload if provided + file_path = None + if file: + try: + file_path = handle_file_upload( + file, + "notice_files", + ALLOWED_DOCUMENT_TYPES, + MAX_DOCUMENT_SIZE + ) + except FileUploadError as e: + # Clean up image if file upload fails + if image_path: + cleanup_failed_upload(image_path) + return JsonResponse({"success": False, "error": str(e)}) + + # Create notice with uploaded files + try: + notice = Notice.objects.create( + title=title, + message=message, + image=image_path, + file=file_path + ) + return JsonResponse( + {"success": True, "message": "Notice added successfully"} + ) + except Exception as e: + # Clean up uploaded files if database operation fails + if image_path: + cleanup_failed_upload(image_path) + if file_path: + cleanup_failed_upload(file_path) + return JsonResponse({"success": False, "error": str(e)}) except Exception as e: return JsonResponse({"success": False, "error": str(e)}) @@ -581,87 +506,51 @@ def delete_notice(request, notice_id): @login_required +@csrf_exempt @require_POST def save_fcm_token(request): + """Save FCM token to database""" try: - print("Received FCM token save request") + # Get token from request body data = json.loads(request.body) - token = data.get("token") - timestamp = data.get("timestamp") - vapid_key_used = data.get("vapidKeyUsed", False) - - # Log additional information for debugging - auth_header = request.headers.get("Authorization", "") - using_vapid_auth = auth_header.startswith("VAPID ") - - print(f"Auth method: {'VAPID' if using_vapid_auth else 'Cookie-based'}") - print(f"VAPID key used for token generation: {vapid_key_used}") - + token = data.get('token') + if not token: - print("No token provided in request") return JsonResponse({"error": "Token is required"}, status=400) - - user = request.user - print(f"User authenticated: {user.id} ({user.username})") - user_groups = user.groups.values_list("name", flat=True) - - print(f"FCM token saving for user: {user.id}, groups: {list(user_groups)}") - - # Determine user type based on group membership + + # Check if this is a fallback token + is_fallback = token.startswith("fcm-token-") or token.startswith("fallback-token-") + + # Determine user type if a user is authenticated user_type = "unknown" - if user.is_superuser: - user_type = "admin" - elif "Student" in user_groups: - user_type = "student" - elif "Teacher" in user_groups: - user_type = "teacher" - elif "Parent" in user_groups: - user_type = "parent" - - # Save token directly to the user model - user.fcm_token = token - user.save(update_fields=["fcm_token"]) - print(f"FCM token saved directly to user {user.name}") - - # Update or create FCMDevice - try: - # Check if this is a fallback token - is_fallback = token.startswith("fcm-token-") or token.startswith( - "fallback-token-" - ) - - # Create or update the device - device, created = FCMDevice.objects.update_or_create( - token=token, - defaults={ - "is_active": True, - "is_fallback": is_fallback, - "user_type": user_type, - }, - ) - - print( - f"FCMDevice {'created' if created else 'updated'} with ID: {device.id}, user_type: {user_type}" - ) - except Exception as e: - print(f"Error creating/updating FCMDevice: {str(e)}") - raise - - return JsonResponse( - { - "status": "success", - "device_id": device.id, - "created": created, - "auth_method": "vapid" if using_vapid_auth else "cookie", + if request.user.is_authenticated: + user_groups = request.user.groups.values_list("name", flat=True) + if "Student" in user_groups: + user_type = "student" + elif "Parent" in user_groups: + user_type = "parent" + elif "Teacher" in user_groups: + user_type = "teacher" + elif request.user.is_superuser: + user_type = "admin" + + # Use update_or_create to avoid unnecessary deletes + device, created = FCMDevice.objects.update_or_create( + token=token, + defaults={ + "is_fallback": is_fallback, + "is_active": True, "user_type": user_type, - } + }, ) + + return JsonResponse({"status": "success"}) + except json.JSONDecodeError: - print("Invalid JSON in request body") - return JsonResponse({"error": "Invalid JSON"}, status=400) + return JsonResponse({"error": "Invalid JSON data"}, status=400) except Exception as e: - print(f"Error in save_fcm_token: {str(e)}") - return JsonResponse({"error": str(e)}, status=500) + print(f"Error saving FCM token: {str(e)}") + return JsonResponse({"error": "Internal server error"}, status=500) def serve_firebase_sw(request): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e1a2cf2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "sms" +version = "0.1.0" +description = "A comprehensive Student Management System built with Django, featuring multi-role access, real-time notifications, and secure authentication." +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "django>=5.2", + "django-cacheops>=7.2", + "django-debug-toolbar>=5.1.0", + "django-jazzmin>=3.0.1", + "django-mathfilters>=1.0.0", + "django-model-utils>=5.0.0", + "django-redis>=5.4.0", + "djangorestframework>=3.16.0", + "dotenv>=0.9.9", + "faker>=37.1.0", + "firebase-admin>=6.7.0", + "gunicorn>=23.0.0", + "pillow>=11.2.1", + "pydantic>=2.11.3", + "pyotp>=2.9.0", + "python-magic>=0.4.27", + "pytz>=2025.2", + "redis>=5.2.1", + "requests>=2.32.3", + "tenacity>=9.1.2", + "whitenoise>=6.9.0", +] diff --git a/requirements.txt b/requirements.txt index 21afcce..7404301 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,49 +1,72 @@ +annotated-types==0.7.0 asgiref==3.8.1 -CacheControl==0.14.2 +cachecontrol==0.14.2 cachetools==5.5.2 certifi==2025.1.31 cffi==1.17.1 charset-normalizer==3.4.1 cryptography==44.0.2 -Django==5.2 +django==5.2 +django-cacheops==7.2 +django-debug-toolbar==5.1.0 django-jazzmin==3.0.1 django-mathfilters==1.0.0 django-model-utils==5.0.0 +django-query-profiler==0.9 +django-redis==5.4.0 djangorestframework==3.16.0 -Faker==37.1.0 +dotenv==0.9.9 +faker==37.1.0 firebase-admin==6.7.0 +funcy==2.0 google-api-core==2.24.2 -google-api-python-client==2.166.0 -google-auth==2.38.0 +google-api-python-client==2.167.0 +google-auth==2.39.0 google-auth-httplib2==0.2.0 google-cloud-core==2.4.3 -google-cloud-firestore==2.20.1 +google-cloud-firestore==2.20.2 google-cloud-storage==3.1.0 google-crc32c==1.7.1 google-resumable-media==2.7.2 -googleapis-common-protos==1.69.2 +googleapis-common-protos==1.70.0 grpcio==1.71.0 grpcio-status==1.71.0 gunicorn==23.0.0 +hiredis==3.1.0 httplib2==0.22.0 idna==3.10 +isort==6.0.1 +mmh3==5.1.0 +mo-dots==10.672.25036 +mo-future==7.672.25036 +mo-imports==7.672.25036 +mo-parsing==8.675.25037 +mo-sql-parsing==11.675.25037 msgpack==1.1.0 -packaging==24.2 -pillow==11.1.0 +packaging==25.0 +pillow==11.2.1 proto-plus==1.26.1 protobuf==5.29.4 pyasn1==0.6.1 -pyasn1_modules==0.4.2 +pyasn1-modules==0.4.2 pycparser==2.22 -PyJWT==2.10.1 +pydantic==2.11.3 +pydantic-core==2.33.1 +pyjwt==2.10.1 pyotp==2.9.0 pyparsing==3.2.3 python-dotenv==1.1.0 +python-magic==0.4.27 pytz==2025.2 +redis==5.2.1 requests==2.32.3 -rsa==4.9 +rsa==4.9.1 +setuptools==79.0.0 sqlparse==0.5.3 +tenacity==9.1.2 +typing-extensions==4.13.2 +typing-inspection==0.4.0 tzdata==2025.2 uritemplate==4.1.1 -urllib3==2.3.0 +urllib3==2.4.0 whitenoise==6.9.0 diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..6eae6a0 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,606 @@ +:root { + /* Colors */ + --primary: #0D6EFD; + --primary-light: #E7F1FF; + --primary-dark: #0B5ED7; + --secondary: #6C757D; + --success: #198754; + --info: #0DCAF0; + --warning: #FFC107; + --danger: #DC3545; + --light: #F8F9FA; + --dark: #212529; + --gray: #6C757D; + + /* Shadows */ + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 8px 15px rgba(0, 0, 0, 0.15); + + /* Transitions */ + --transition: all 0.3s ease; + + /* Border Radius */ + --border-radius-sm: 4px; + --border-radius-md: 6px; + --border-radius-lg: 12px; + --border-radius-xl: 16px; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; +} + +/* Base Styles */ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background-color: #f5f7fa; + color: var(--dark); + line-height: 1.5; +} + +/* Layout */ +.wrapper { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.main-header { + position: sticky; + top: 0; + z-index: 1000; +} + +.main-sidebar { + width: 250px; + background-color: white; + box-shadow: var(--shadow-md); + position: fixed; + top: 56px; + bottom: 0; + left: 0; + padding: var(--spacing-md); + overflow-y: auto; +} + +.main-content { + flex: 1; + margin-left: 250px; + padding: var(--spacing-lg); + margin-top: 56px; +} + +.main-footer { + background-color: white; + padding: var(--spacing-md); + box-shadow: var(--shadow-sm); + margin-top: auto; +} + +/* Navigation */ +.navbar { + padding: var(--spacing-sm) var(--spacing-lg); +} + +.navbar-brand { + font-weight: 600; + font-size: 1.25rem; +} + +.nav-link { + color: var(--dark); + font-weight: 500; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--border-radius-md); + transition: var(--transition); +} + +.nav-link:hover { + background-color: var(--primary-light); + color: var(--primary); +} + +.nav-link.active { + background-color: var(--primary); + color: white; +} + +/* Sidebar */ +.sidebar-nav .nav-link { + display: flex; + align-items: center; + padding: var(--spacing-sm) var(--spacing-md); + margin-bottom: var(--spacing-xs); + border-radius: var(--border-radius-md); +} + +.sidebar-nav .nav-link i { + width: 20px; + text-align: center; + margin-right: var(--spacing-sm); +} + +/* Cards */ +.card { + border: none; + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-md); + transition: var(--transition); +} + +.card:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} + +.card-header { + background-color: white; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; +} + +.card-body { + padding: var(--spacing-lg); +} + +/* Buttons */ +.btn { + padding: 8px 16px; + border-radius: var(--border-radius-md); + font-weight: 500; + transition: var(--transition); + display: inline-flex; + align-items: center; + gap: 8px; +} + +.btn i { + font-size: 0.875rem; +} + +.btn-primary { + background-color: var(--primary); + border-color: var(--primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-dark); + border-color: var(--primary-dark); + color: white; +} + +.btn-secondary { + background-color: var(--secondary); + border-color: var(--secondary); + color: white; +} + +.btn-secondary:hover { + background-color: #5C636A; + border-color: #565E64; + color: white; +} + +/* Forms */ +.form-control { + border-radius: var(--border-radius-md); + border: 1px solid #DEE2E6; + padding: 8px 12px; + transition: var(--transition); +} + +.form-control:focus { + border-color: var(--primary); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +.form-label { + font-weight: 500; + margin-bottom: 8px; + color: var(--dark); +} + +/* Tables */ +.table { + margin-bottom: 0; +} + +.table th { + background-color: var(--light); + color: var(--dark); + font-weight: 600; + padding: 12px; + border-bottom: 2px solid #DEE2E6; +} + +.table td { + padding: 12px; + vertical-align: middle; + border-bottom: 1px solid #DEE2E6; +} + +/* Badges */ +.badge { + padding: 4px 8px; + border-radius: var(--border-radius-sm); + font-weight: 500; + font-size: 0.75rem; +} + +/* Alerts */ +.alert { + border-radius: var(--border-radius-md); + padding: 12px 16px; + margin-bottom: var(--spacing-md); + border: none; +} + +/* Modals */ +.modal-content { + border-radius: var(--border-radius-lg); + border: none; + box-shadow: var(--shadow-lg); +} + +.modal-header { + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + color: white; + border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; + padding: var(--spacing-md) var(--spacing-lg); +} + +.modal-body { + padding: var(--spacing-lg); +} + +.modal-footer { + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid #E9ECEF; +} + +/* Responsive Design */ +@media (max-width: 992px) { + .main-sidebar { + width: 0; + transform: translateX(-100%); + transition: var(--transition); + } + + .main-sidebar.show { + width: 250px; + transform: translateX(0); + } + + .main-content { + margin-left: 0; + } +} + +@media (max-width: 768px) { + .main-content { + padding: var(--spacing-md); + } + + .card-body { + padding: var(--spacing-md); + } +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.fade-in { + animation: fadeIn 0.3s ease-in-out; +} + +/* Utility Classes */ +.shadow-sm { box-shadow: var(--shadow-sm); } +.shadow-md { box-shadow: var(--shadow-md); } +.shadow-lg { box-shadow: var(--shadow-lg); } + +.rounded-sm { border-radius: var(--border-radius-sm); } +.rounded-md { border-radius: var(--border-radius-md); } +.rounded-lg { border-radius: var(--border-radius-lg); } +.rounded-xl { border-radius: var(--border-radius-xl); } + +.mt-1 { margin-top: var(--spacing-xs); } +.mt-2 { margin-top: var(--spacing-sm); } +.mt-3 { margin-top: var(--spacing-md); } +.mt-4 { margin-top: var(--spacing-lg); } +.mt-5 { margin-top: var(--spacing-xl); } + +.mb-1 { margin-bottom: var(--spacing-xs); } +.mb-2 { margin-bottom: var(--spacing-sm); } +.mb-3 { margin-bottom: var(--spacing-md); } +.mb-4 { margin-bottom: var(--spacing-lg); } +.mb-5 { margin-bottom: var(--spacing-xl); } + +.p-1 { padding: var(--spacing-xs); } +.p-2 { padding: var(--spacing-sm); } +.p-3 { padding: var(--spacing-md); } +.p-4 { padding: var(--spacing-lg); } +.p-5 { padding: var(--spacing-xl); } + +/* Bottom Navigation */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: white; + box-shadow: 0 -2px 10px rgba(0,0,0,0.1); + z-index: 1000; + overflow-x: auto; + padding-bottom: 28px; +} + +.nav-container { + display: flex; + justify-content: space-around; + align-items: center; + padding: 10px 0 5px 0; + max-width: 1200px; + margin: 0 auto; + flex-wrap: nowrap; + min-width: max-content; +} + +.nav-item { + display: flex; + flex-direction: column; + align-items: center; + color: var(--gray); + text-decoration: none; + font-size: 0.9rem; + transition: var(--transition); + margin: 0 8px; + white-space: nowrap; +} + +.nav-btn { + display: flex; + flex-direction: column; + align-items: center; + color: var(--gray); + background: none; + border: none; + font-size: 0.9rem; + transition: var(--transition); + cursor: pointer; + margin: 0 8px; + padding: 0; + white-space: nowrap; +} + +.nav-item i, .nav-btn i { + font-size: 1.2rem; + margin-bottom: 4px; +} + +.nav-item.active, .nav-btn.active { + color: var(--primary); +} + +.nav-item:hover, .nav-btn:hover { + color: var(--primary); +} + +.nav-copyright { + width: 100%; + text-align: center; + position: absolute; + bottom: 0; + left: 0; + padding: 5px 0; + font-size: 0.85rem; + color: var(--gray); + background-color: var(--light); + border-top: 1px solid #ddd; + line-height: 18px; + box-shadow: 0 -1px 2px rgba(0,0,0,0.05); + z-index: 1001; +} + +.nav-copyright a { + color: var(--primary); + text-decoration: none; + font-weight: 600; +} + +/* Welcome Section */ +.welcome-card { + background: linear-gradient(135deg, var(--primary), var(--primary-light)); + border-radius: var(--border-radius-lg); + padding: clamp(1rem, 3vw, 1.5rem); + color: white; + box-shadow: var(--shadow-md); + margin-bottom: 1.5rem; + transition: var(--transition); +} + +.welcome-content { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: clamp(0.75rem, 2vw, 1rem); +} + +.user-info { + display: flex; + align-items: center; + gap: clamp(1rem, 2.5vw, 1.5rem); + flex: 1; + min-width: 280px; +} + +.profile-image { + width: clamp(60px, 10vw, 80px); + height: clamp(60px, 10vw, 80px); + border-radius: 50%; + border: 3px solid rgba(255, 255, 255, 0.3); + overflow: hidden; + background: rgba(255, 255, 255, 0.1); + flex-shrink: 0; +} + +.profile-picture { + width: 100%; + height: 100%; + object-fit: cover; +} + +.user-details { + flex: 1; + min-width: 200px; +} + +.user-details h2 { + color: white; + margin: 0 0 0.5rem 0; + font-size: clamp(1.25rem, 3vw, 1.75rem); + font-weight: 600; + line-height: 1.2; +} + +.user-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: clamp(0.5rem, 1.5vw, 1rem); +} + +.user-meta span { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: clamp(0.8rem, 2vw, 0.9rem); + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.1); + padding: 0.25rem 0.75rem; + border-radius: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.user-meta i { + font-size: clamp(0.8rem, 2vw, 0.9rem); + flex-shrink: 0; +} + +.user-actions { + display: flex; + gap: clamp(0.5rem, 1.5vw, 0.75rem); + margin-left: auto; +} + +.action-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: clamp(0.4rem, 1vw, 0.5rem) clamp(0.75rem, 2vw, 1rem); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: var(--border-radius-md); + background: rgba(255, 255, 255, 0.1); + color: white; + font-size: clamp(0.8rem, 2vw, 0.9rem); + transition: var(--transition); + white-space: nowrap; +} + +.action-btn:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-1px); +} + +/* Responsive Design for Bottom Nav and Welcome Section */ +@media (max-width: 768px) { + .nav-container { + padding: 8px 0; + justify-content: flex-start; + } + + .nav-item, .nav-btn { + font-size: 0.8rem; + } + + .nav-item i, .nav-btn i { + font-size: 1rem; + } + + .nav-copyright { + font-size: 0.75rem; + padding: 4px 0; + } + + .welcome-content { + flex-direction: column; + align-items: center; + text-align: center; + gap: 1.5rem; + } + + .user-info { + flex-direction: column; + text-align: center; + min-width: auto; + } + + .user-details { + min-width: auto; + } + + .user-meta { + justify-content: center; + } + + .user-actions { + width: 100%; + justify-content: center; + margin-left: 0; + } +} + +@media (max-width: 480px) { + .welcome-card { + border-radius: var(--border-radius-md); + } + + .user-meta { + flex-direction: column; + gap: 0.5rem; + width: 100%; + } + + .user-meta span { + width: 100%; + justify-content: center; + } + + .user-actions { + flex-direction: column; + } + + .action-btn { + width: 100%; + justify-content: center; + } +} + +@media (hover: none) { + .action-btn:hover { + transform: none; + } +} \ No newline at end of file diff --git a/static/firebase-messaging-sw.js b/static/firebase-messaging-sw.js new file mode 100644 index 0000000..85e1077 --- /dev/null +++ b/static/firebase-messaging-sw.js @@ -0,0 +1,163 @@ +// Version identifier for cache busting +const SW_VERSION = '1.0.3'; +console.log(`Firebase Messaging SW Version ${SW_VERSION} starting...`); + +// Import Firebase scripts +try { + importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-app-compat.js'); + importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-messaging-compat.js'); + console.log('Firebase scripts loaded successfully'); +} catch (e) { + console.error('Error loading Firebase scripts:', e); + throw e; // Re-throw to prevent further execution +} + +// Firebase configuration - this must match exactly with your main app configuration +const firebaseConfig = { + apiKey: "AIzaSyAYgYXsf4-iwSDEEBcNAJ3YJxz76C3STsU", + authDomain: "student-management-syste-2e040.firebaseapp.com", + projectId: "student-management-syste-2e040", + storageBucket: "student-management-syste-2e040.firebasestorage.app", + messagingSenderId: "800148418884", + appId: "1:800148418884:web:144b9c85bfeeec12252711", + measurementId: "G-BNRM6QEJQV" +}; + +// Handle any errors during initialization +try { + // Initialize Firebase + firebase.initializeApp(firebaseConfig); + console.log('Firebase initialized in service worker'); + + const messaging = firebase.messaging(); + console.log('Firebase messaging initialized'); + + // Handle background messages + messaging.onBackgroundMessage(function(payload) { + console.log('Received background message:', payload); + + // Generate a unique ID for this notification + const notificationId = payload.data?.notificationId || `notification-${Date.now()}`; + + // Check if we already have a notification with this ID + return self.registration.getNotifications({tag: notificationId}) + .then(function(notifications) { + // Only show if no matching notification exists + if (notifications.length === 0) { + const notificationTitle = payload.notification?.title || 'New Notification'; + const notificationOptions = { + body: payload.notification?.body || 'You have a new message.', + icon: '/static/img/logo.png', + badge: '/static/img/logo.png', + tag: notificationId, // Tag helps prevent duplicates + data: payload.data || {} + }; + + return self.registration.showNotification(notificationTitle, notificationOptions); + } else { + console.log('Prevented duplicate background notification', notificationId); + return Promise.resolve(); + } + }) + .catch(error => { + console.error('Error showing background notification:', error); + return Promise.resolve(); + }); + }); +} catch (error) { + console.error('Firebase messaging initialization error:', error); + throw error; // Re-throw to prevent further execution +} + +// Improve service worker lifecycle for better compatibility +self.addEventListener('install', function(event) { + console.log('Service Worker installed'); + self.skipWaiting(); +}); + +self.addEventListener('activate', function(event) { + console.log('Service Worker activated'); + event.waitUntil( + Promise.all([ + self.clients.claim(), + // Clean up old caches if needed + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + if (cacheName.startsWith('firebase-messaging-')) { + return caches.delete(cacheName); + } + }) + ); + }) + ]) + ); +}); + +// Add special handling for push events +self.addEventListener('push', function(event) { + console.log('Push event received in service worker', event); + + if (!event.data) { + console.log('Push event has no data'); + return; + } + + try { + const payload = event.data.json(); + console.log('Push data:', payload); + + // Generate a unique ID for this notification + const notificationId = payload.data?.notificationId || `push-${Date.now()}`; + + event.waitUntil( + // Check for existing notifications first + self.registration.getNotifications({tag: notificationId}) + .then(function(notifications) { + // Only show if no matching notification exists + if (notifications.length === 0) { + const title = payload.notification?.title || 'New Notification'; + const options = { + body: payload.notification?.body || 'You have a new message', + icon: '/static/img/logo.png', + badge: '/static/img/logo.png', + tag: notificationId, // Add tag to identify this notification + data: payload.data || {} + }; + + return self.registration.showNotification(title, options); + } else { + console.log('Prevented duplicate push notification', notificationId); + return Promise.resolve(); + } + }) + ); + } catch (error) { + console.error('Error handling push event:', error); + } +}); + +// Improve notification click handling for various environments +self.addEventListener('notificationclick', function(event) { + console.log('Notification clicked:', event.notification); + event.notification.close(); + + const urlToOpen = event.notification.data?.url || '/app/dashboard/'; + + event.waitUntil( + clients.matchAll({type: 'window', includeUncontrolled: true}).then(windowClients => { + // Check if there is already a window open with the target URL + for (let i = 0; i < windowClients.length; i++) { + const client = windowClients[i]; + // If so, focus on that window + if (client.url === urlToOpen && 'focus' in client) { + return client.focus(); + } + } + // If not, open a new window + if (clients.openWindow) { + return clients.openWindow(urlToOpen); + } + }) + ); +}); diff --git a/static/js/admin_dashboard.js b/static/js/admin_dashboard.js index c6cafa9..2a2aeb2 100644 --- a/static/js/admin_dashboard.js +++ b/static/js/admin_dashboard.js @@ -1,102 +1,120 @@ +// Function to get CSRF token +function getCSRFToken() { + const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + return token; +} + // Function to update leave requests function updateLeaveRequests() { + const csrfToken = getCSRFToken(); + // Fetch staff leave requests - fetch('/app/get-staff-leaves/') - .then(response => response.json()) - .then(data => { - const staffLeavesContainer = document.querySelector('#staff-leaves-container'); - if (data.staff_leaves.length === 0) { - staffLeavesContainer.innerHTML = ` -
Sorry, your browser sent a request that our server could not understand.
-The request could not be processed due to malformed syntax or invalid parameters.
-{{ traceback }}
+ Sorry, you don't have permission to access this page.
-Please check your credentials or contact the administrator if you believe this is an error.
-{{ traceback }}
+ Sorry, the page you're looking for doesn't exist.
-The page may have been moved, deleted, or never existed. Please check the URL and try again.
-{{ traceback }}
+ Sorry, something went wrong on our end.
-We're working to fix the issue. Please try again later or contact support if the problem persists.
-{{ traceback }}
+ Sorry, the HTTP version used in the request is not supported by this server.
-The server does not support the HTTP protocol version used in the request. Please try accessing the site using a different browser or updating your current one.
-{{ traceback }}
+ Student Management Dashboard
| Teacher | -Student Rating | -Parent Rating | -Total Feedbacks | -|||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Teacher | +Student Rating | +Parent Rating | +Total Feedbacks | +|||
|---|---|---|---|---|---|---|
|
+
+ {% if teacher.image %}
+
+
+
+
+ {% endif %}
+
+
+ {{ teacher.name }}+ {{ teacher.designation }} + |
-
- {% if teacher.image %}
-
+ {{ teacher.avg_student_rating|floatformat:1 }}
+
- |
-
-
- {{ teacher.avg_student_rating|floatformat:1 }}
-
-
- |
-
-
- {{ teacher.avg_parent_rating|floatformat:1 }}
- |
+
+
+ {{ teacher.avg_parent_rating|floatformat:1 }}
+
-
+ |
+ + {{ teacher.total_feedbacks }} | -- {{ teacher.total_feedbacks }} - | -
| - No teacher ratings available - | -||||||
| Date | Time | @@ -964,47 +1258,61 @@{{ meeting.duration }} minutes | {% if meeting.is_online %} - Online - {% if meeting.meeting_link %} - - - {% endif %} + + Online + {% if meeting.meeting_link %} + + + {% endif %} + {% else %} - In-person + + In-person + {% endif %} | - - {{ meeting.get_status_display }} + + {% if meeting.status == 'scheduled' %} + Scheduled + {% elif meeting.status == 'completed' %} + Completed + {% elif meeting.status == 'cancelled' %} + Cancelled + {% else %} + Rescheduled + {% endif %} |
- {% if meeting.status == 'scheduled' %}
-
+ {% if meeting.status == 'scheduled' %}
+
|
|---|
Scheduling meeting...
-Loading notes...
+Loading agenda...
Cancelling meeting...
-Rescheduling meeting...
-