diff --git a/.gitignore b/.gitignore index 79452bd..51b01c6 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,34 @@ gfpgan # Firebase credentials (NEVER commit these!) firebase-service-account.json -**/firebase-service-account.json \ No newline at end of file +**/firebase-service-account.json + + +# Markdown docs except README +*.md +!README.md + +# Local test/debug files +test_sync_endpoint.py + +# Local schema/docs +New_schema +data-1778222711514.csv +API_ENDPOINTS.md +ARCHITECTURE_RULES.md +DATABASE_SCHEMA.md +db-chat.txt +DBMSUIS_V7_utf8.sql +DBMSUIS_V7.sql +RABBITMQ_MIGRATION.md +setup_backend.bat + +# Python virtual env +.venv/ + +# Python cache +__pycache__/ +*.pyc + +# Environment files +.env \ No newline at end of file diff --git a/ClassLens_DB/ClassLens_DB/celery.py b/ClassLens_DB/ClassLens_DB/celery.py index 71231c6..a37f1b1 100644 --- a/ClassLens_DB/ClassLens_DB/celery.py +++ b/ClassLens_DB/ClassLens_DB/celery.py @@ -2,20 +2,29 @@ from celery import Celery import environ #type:ignore from pathlib import Path #type:ignore +from urllib.parse import quote_plus BASE_DIR = Path(__file__).resolve().parent.parent env = environ.Env() environ.Env.read_env(os.path.join(BASE_DIR, '.env')) -redis_url = env('REDIS_URL') +rabbitmq_url = env('RABBITMQ_URL') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ClassLens_DB.settings') +# Build database result backend URL with proper password encoding +db_user = env("DB_USER") +db_password = quote_plus(env("DB_PASSWORD")) +db_host = env("DB_HOST") +db_port = env("DB_PORT") +db_name = env("DB_NAME") +db_result_backend = f'db+postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}' + app = Celery('ClassLens_DB') app.config_from_object('django.conf:settings', namespace='CELERY') app.autodiscover_tasks() app.conf.update( - broker_url=redis_url, - result_backend=redis_url, + broker_url=rabbitmq_url, + result_backend=db_result_backend, broker_connection_retry_on_startup=True, ) \ No newline at end of file diff --git a/ClassLens_DB/ClassLens_DB/settings.py b/ClassLens_DB/ClassLens_DB/settings.py index 9549c20..3c00e49 100644 --- a/ClassLens_DB/ClassLens_DB/settings.py +++ b/ClassLens_DB/ClassLens_DB/settings.py @@ -13,6 +13,7 @@ from pathlib import Path import environ import os +from urllib.parse import quote_plus BASE_DIR = Path(__file__).resolve().parent.parent @@ -26,16 +27,20 @@ # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-x3zih9=91am0f(gkv&16y+n%7i1b91e-^mh&v*_r=_kz@y7t04" +SECRET_KEY = env("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = [ + '172.25.13.31', + '172.16.141.247', + '172.26.12.236', + '10.0.2.2', '14.139.121.110', 'localhost', '127.0.0.1', - '172.25.13.31' + '10.0.3.2', ] @@ -52,7 +57,6 @@ "DatabaseAdminApp", "rest_framework", 'corsheaders', - 'django_redis' ] MIDDLEWARE = [ @@ -105,11 +109,8 @@ CACHES = { "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": env('REDIS_URL'), - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - } + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "classlens-local-cache", } } @@ -164,8 +165,14 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -CELERY_BROKER_URL = env('REDIS_URL') -CELERY_RESULT_BACKEND = env('REDIS_URL') +CELERY_BROKER_URL = env('RABBITMQ_URL') +CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND', default='db+postgresql://{0}:{1}@{2}:{3}/{4}'.format( + env("DB_USER"), + quote_plus(env("DB_PASSWORD")), + env("DB_HOST"), + env("DB_PORT"), + env("DB_NAME") +)) CELERY_ACCEPT_CONTENT = ['json'] CELERY_TASK_SERIALIZER = 'json' @@ -182,4 +189,29 @@ EMAIL_USE_TLS = True # Use False if using SSL (port 465) EMAIL_HOST_USER = env('EMAIL_HOST_USER') # Your email address EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD') # Your email password/app password -DEFAULT_FROM_EMAIL = env('EMAIL_HOST_USER') \ No newline at end of file +DEFAULT_FROM_EMAIL = env('EMAIL_HOST_USER') + +# Django REST Framework Configuration +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'Home.authentication.CustomAdminAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.AllowAny', + ), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 100, +} + +# JWT Configuration +from datetime import timedelta + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=24), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, +} \ No newline at end of file diff --git a/ClassLens_DB/ClassLens_DB/urls.py b/ClassLens_DB/ClassLens_DB/urls.py index d134947..92e9395 100644 --- a/ClassLens_DB/ClassLens_DB/urls.py +++ b/ClassLens_DB/ClassLens_DB/urls.py @@ -21,9 +21,29 @@ from django.conf.urls.static import static from DatabaseAdminApp import urls as db_admin_urls from django.urls import include +from Home.views import health +from django.views.generic import RedirectView +from django.http import JsonResponse + + +def api_root(request): + return JsonResponse( + { + "message": "ClassLens API", + "routes": { + "health": "/api/health/", + "departments": "/api/getDepartments/", + "admin": "/api/admin/", + }, + } + ) urlpatterns = [ path("admin/", admin.site.urls), + path("health/",health,name="health_check"), + path("api", RedirectView.as_view(url="/api/", permanent=False)), + path("api/", api_root, name="api_root"), + path("api/health/", health, name="api_health"), path("api/", include(urls)), path("api/", include(db_admin_urls)), ] diff --git a/ClassLens_DB/DatabaseAdminApp/models.py b/ClassLens_DB/DatabaseAdminApp/models.py index 71a8362..4f3712d 100644 --- a/ClassLens_DB/DatabaseAdminApp/models.py +++ b/ClassLens_DB/DatabaseAdminApp/models.py @@ -1,3 +1,37 @@ from django.db import models -# Create your models here. + +class APIEnrollment(models.Model): + """Staging table for enrollment data - matches New_schema exactly.""" + prn = models.BigIntegerField() + subject_code = models.CharField(max_length=100) + division = models.CharField(max_length=20) + year = models.IntegerField() + + class Meta: + unique_together = ('prn', 'subject_code', 'division', 'year') + + def __str__(self): + return f"PRN {self.prn} - {self.subject_code} Div {self.division}" + + +class APIPaper(models.Model): + """Staging table for paper data - mirrors MSUIS API payloads. Matches New_schema exactly.""" + msuis_id = models.BigIntegerField(primary_key=True) + paper_name = models.CharField(max_length=500) + paper_code = models.CharField(max_length=100) + raw_payload = models.JSONField() + + def __str__(self): + return f"{self.paper_code} - {self.paper_name}" + + +class APIStudent(models.Model): + """Staging table for student data - mirrors MSUIS API payloads. Matches New_schema exactly.""" + prn = models.BigIntegerField(primary_key=True) + email_id = models.CharField(max_length=255, null=True, blank=True) + raw_payload = models.JSONField() + full_name = models.CharField(max_length=255, null=True, blank=True) + + def __str__(self): + return str(self.prn) diff --git a/ClassLens_DB/DatabaseAdminApp/serializers.py b/ClassLens_DB/DatabaseAdminApp/serializers.py index 7c2eb01..bd64ab6 100644 --- a/ClassLens_DB/DatabaseAdminApp/serializers.py +++ b/ClassLens_DB/DatabaseAdminApp/serializers.py @@ -1,9 +1,16 @@ # serializers.py +from django.db import transaction from rest_framework import serializers from Home.models import ( Department, Teacher, Student, Subject, SubjectFromDept, - StudentEnrollment, TeacherSubject, AdminUser + StudentEnrollment, TeacherSubject, AdminUser, Division +) +from Home.face_utils import extract_face_embedding +from .models import ( + APIStudent, + APIPaper, + APIEnrollment, ) class DepartmentSerializer(serializers.ModelSerializer): @@ -21,13 +28,50 @@ class Meta: class StudentSerializer(serializers.ModelSerializer): department_name = serializers.CharField(source='department.name', read_only=True) + division_name = serializers.CharField(source='division.name', read_only=True) + photo = serializers.ImageField(write_only=True, required=False, allow_null=True) class Meta: model = Student fields = ['id', 'prn', 'name', 'email', 'password_hash', 'year', 'department', - 'department_name', 'face_embedding', 'notification_token'] + 'department_name', 'division', 'division_name', 'face_embedding', 'notification_token', 'photo'] extra_kwargs = {'password_hash': {'write_only': True}, 'face_embedding': {'write_only': True}} + def _apply_face_photo(self, instance, photo): + if photo is None: + return + + embedding = extract_face_embedding(photo) + instance.face_embedding = [float(value) for value in embedding] + + def create(self, validated_data): + photo = validated_data.pop('photo', None) + try: + with transaction.atomic(): + student = super().create(validated_data) + + if photo is not None: + self._apply_face_photo(student, photo) + student.save(update_fields=['face_embedding']) + + return student + except ValueError as exc: + raise serializers.ValidationError({'photo': str(exc)}) from exc + + def update(self, instance, validated_data): + photo = validated_data.pop('photo', None) + try: + with transaction.atomic(): + student = super().update(instance, validated_data) + + if photo is not None: + self._apply_face_photo(student, photo) + student.save(update_fields=['face_embedding']) + + return student + except ValueError as exc: + raise serializers.ValidationError({'photo': str(exc)}) from exc + class SubjectSerializer(serializers.ModelSerializer): class Meta: model = Subject @@ -56,10 +100,11 @@ class Meta: class TeacherSubjectSerializer(serializers.ModelSerializer): teacher_name = serializers.CharField(source='teacher_id.name', read_only=True) subject_name = serializers.CharField(source='subject.name', read_only=True) + division_name = serializers.CharField(source='division.name', read_only=True) class Meta: model = TeacherSubject - fields = ['id', 'teacher_id', 'teacher_name', 'subject', 'subject_name'] + fields = ['id', 'teacher_id', 'teacher_name', 'subject', 'subject_name', 'division', 'division_name'] # class AdminUserSerializer(serializers.ModelSerializer): # password = serializers.CharField(write_only=True) @@ -93,4 +138,29 @@ def create(self, validated_data): ) user.set_password(validated_data['password']) user.save() - return user \ No newline at end of file + return user + + +class APIStudentSerializer(serializers.ModelSerializer): + class Meta: + model = APIStudent + fields = "__all__" + + +class APIPaperSerializer(serializers.ModelSerializer): + class Meta: + model = APIPaper + fields = "__all__" + + +class APIEnrollmentSerializer(serializers.ModelSerializer): + class Meta: + model = APIEnrollment + fields = "__all__" + + +class DivisionSerializer(serializers.ModelSerializer): + class Meta: + from Home.models import Division + model = Division + fields = ['id', 'department', 'year', 'name'] \ No newline at end of file diff --git a/ClassLens_DB/DatabaseAdminApp/settings.py b/ClassLens_DB/DatabaseAdminApp/settings.py index 64a635c..5181323 100644 --- a/ClassLens_DB/DatabaseAdminApp/settings.py +++ b/ClassLens_DB/DatabaseAdminApp/settings.py @@ -24,4 +24,5 @@ CORS_ALLOWED_ORIGINS = [ "http://localhost:3000", "http://127.0.0.1:3000", + "https://classlensfrontend.vercel.app" ] \ No newline at end of file diff --git a/ClassLens_DB/DatabaseAdminApp/tests.py b/ClassLens_DB/DatabaseAdminApp/tests.py index 7ce503c..9adbf67 100644 --- a/ClassLens_DB/DatabaseAdminApp/tests.py +++ b/ClassLens_DB/DatabaseAdminApp/tests.py @@ -1,3 +1,60 @@ +from io import BytesIO + from django.test import TestCase +from django.urls import reverse +from PIL import Image +from rest_framework.test import APIClient +from unittest.mock import patch + +from Home.models import Department, Student +from .serializers import StudentSerializer # Create your tests here. + + +class StudentFaceUpdateTests(TestCase): + def setUp(self): + self.client = APIClient() + self.department = Department.objects.create(name="Computer Science") + self.student = Student.objects.create( + prn=1001, + name="Student One", + email="student.one@example.com", + year=2, + department=self.department, + ) + + def _make_image_file(self, name="face.jpg", color=(255, 0, 0)): + image = Image.new("RGB", (8, 8), color) + buffer = BytesIO() + image.save(buffer, format="JPEG") + buffer.seek(0) + buffer.name = name + return buffer + + @patch("Home.views.extract_face_embedding", return_value=[0.1] * 512) + def test_register_student_accepts_photo_only_update(self, _mock_embedding): + response = self.client.post( + reverse("register_student"), + {"prn": self.student.prn, "photo": self._make_image_file()}, + format="multipart", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["message"], "Student face updated successfully") + self.student.refresh_from_db() + self.assertEqual(len(self.student.face_embedding), 512) + self.assertAlmostEqual(self.student.face_embedding[0], 0.1) + + @patch("DatabaseAdminApp.serializers.extract_face_embedding", return_value=[0.2] * 512) + def test_student_serializer_updates_face_from_photo(self, _mock_embedding): + serializer = StudentSerializer( + instance=self.student, + data={"photo": self._make_image_file()}, + partial=True, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + updated_student = serializer.save() + self.assertEqual(len(updated_student.face_embedding), 512) + self.assertAlmostEqual(updated_student.face_embedding[0], 0.2) diff --git a/ClassLens_DB/DatabaseAdminApp/urls.py b/ClassLens_DB/DatabaseAdminApp/urls.py index 8c00c77..75e7ebc 100644 --- a/ClassLens_DB/DatabaseAdminApp/urls.py +++ b/ClassLens_DB/DatabaseAdminApp/urls.py @@ -14,6 +14,11 @@ admin_login, get_dashboard_stats, AdminUserViewSet, + APIStudentViewSet, + APIPaperViewSet, + sync_msuis_payload, + sync_staging_to_core, + DivisionViewSet, ) router = DefaultRouter() @@ -28,11 +33,16 @@ r"student-enrollments", StudentEnrollmentViewSet, basename="student-enrollment" ) router.register(r"admin-users", AdminUserViewSet, basename="admin-user") +router.register(r"api-students", APIStudentViewSet, basename="api-student") +router.register(r"api-papers", APIPaperViewSet, basename="api-paper") +router.register(r"divisions", DivisionViewSet, basename="division") urlpatterns = [ # Authentication path("admin/login/", admin_login, name="admin-login"), path("admin/token/refresh/", TokenRefreshView.as_view(), name="token-refresh"), + path("admin/sync/msuis/", sync_msuis_payload, name="sync-msuis-payload"), + path("admin/sync/staging/", sync_staging_to_core, name="sync-staging-to-core"), # CRUD APIs path("admin/stats/", get_dashboard_stats, name="admin-stats"), path("admin/", include(router.urls)), diff --git a/ClassLens_DB/DatabaseAdminApp/views.py b/ClassLens_DB/DatabaseAdminApp/views.py index 3f214f1..7999292 100644 --- a/ClassLens_DB/DatabaseAdminApp/views.py +++ b/ClassLens_DB/DatabaseAdminApp/views.py @@ -5,20 +5,33 @@ from rest_framework_simplejwt.tokens import RefreshToken from django.contrib.auth.hashers import make_password from django.http import HttpResponse -import pandas as pd +try: + import pandas as pd +except Exception: + pd = None import io from .pagination import StudentPagination from Home.models import ( Department, Teacher, Student, Subject, SubjectFromDept, - StudentEnrollment, TeacherSubject, AdminUser, StudentAttendancePercentage + StudentEnrollment, TeacherSubject, AdminUser, StudentAttendancePercentage, Division +) +from .models import ( + APIStudent, + APIPaper, + APIEnrollment, ) from .serializers import ( DepartmentSerializer, TeacherSerializer, StudentSerializer, SubjectSerializer, SubjectFromDeptSerializer, StudentEnrollmentSerializer, - TeacherSubjectSerializer, AdminUserSerializer + TeacherSubjectSerializer, AdminUserSerializer, + APIStudentSerializer, APIPaperSerializer, + DivisionSerializer, ) +from django.db import transaction +from django.db.models import Max from rest_framework.permissions import BasePermission +import re class IsSuperUser(BasePermission): """ @@ -32,6 +45,78 @@ def has_permission(self, request, view): and getattr(request.user, "is_superuser", False) ) + +def _normalize_department_label(value): + if value is None: + return "" + normalized = str(value).strip().lower() + normalized = normalized.replace("&", "and") + normalized = re.sub(r"[().,/_-]", " ", normalized) + normalized = re.sub(r"\s+", " ", normalized) + return normalized.strip() + + +def _resolve_department(department_name): + normalized_name = _normalize_department_label(department_name) + if not normalized_name: + return None + + exact_match = Department.objects.filter(name__iexact=str(department_name).strip()).first() + if exact_match is not None: + return exact_match + + alias_map = { + "computer science and engineering": [ + "Bachelor in Computer Science and Engineering (B.E)", + "Computer Science and Engineering", + "CSE", + ], + "cse": [ + "Bachelor in Computer Science and Engineering (B.E)", + "Computer Science and Engineering", + "CSE", + ], + } + + for alias in alias_map.get(normalized_name, []): + department = Department.objects.filter(name__iexact=alias).first() + if department is not None: + return department + + candidate = Department.objects.all() + for department in candidate: + department_label = _normalize_department_label(department.name) + if normalized_name == department_label: + return department + if normalized_name in department_label or department_label in normalized_name: + return department + + tokens = [token for token in normalized_name.split(" ") if len(token) > 2] + if tokens: + query = candidate + for token in tokens: + query = query.filter(name__icontains=token) + department = query.first() + if department is not None: + return department + + return None + + +def _normalize_password_value(value, fallback): + if value is None: + return fallback + if pd is not None: + try: + if pd.isna(value): + return fallback + except Exception: + pass + if isinstance(value, str): + password = value.strip() + return password or fallback + return str(value) + @api_view(['POST']) @permission_classes([AllowAny]) def admin_login(request): @@ -147,9 +232,9 @@ def bulk_upload(self, request): try: department = Department.objects.get(name=row['department_name']) teacher_data = { - 'name': row['name'], - 'email': row['email'], - 'password_hash': make_password(row.get('password', 'default123')), + 'name': name, + 'email': email, + 'password_hash': make_password(_normalize_password_value(row.get('password'), 'default123')), 'department': department } Teacher.objects.create(**teacher_data) @@ -211,28 +296,59 @@ def bulk_upload(self, request): df = pd.read_excel(file) else: return Response({'error': 'Invalid file format'}, status=status.HTTP_400_BAD_REQUEST) + + df.columns = [str(column).strip() for column in df.columns] + + required_columns = {'prn', 'name', 'email', 'year', 'department_name'} + missing_columns = required_columns.difference(df.columns) + if missing_columns: + return Response( + {'error': f"Missing required columns: {', '.join(sorted(missing_columns))}"}, + status=status.HTTP_400_BAD_REQUEST, + ) created_count = 0 + updated_count = 0 errors = [] for index, row in df.iterrows(): try: - department = Department.objects.get(name=row['department_name']) - student_data = { - 'prn': int(row['prn']), - 'name': row['name'], - 'email': row['email'], - 'password_hash': make_password(row.get('password', 'student123')), - 'year': int(row['year']), - 'department': department + prn = int(row['prn']) + full_name = str(row['name']).strip() + email = str(row['email']).strip() + year = int(row['year']) + department_name = str(row['department_name']).strip() + if not department_name: + raise ValueError('department_name is required') + + department = _resolve_department(department_name) + + staging_defaults = { + 'full_name': full_name, + 'email_id': email, + 'raw_payload': { + 'department_name': department_name, + 'year': year, + 'name': full_name, + }, } - Student.objects.create(**student_data) - created_count += 1 + + _, created = APIStudent.objects.update_or_create( + prn=prn, + defaults=staging_defaults, + ) + if created: + created_count += 1 + else: + updated_count += 1 except Exception as e: errors.append(f"Row {index + 1}: {str(e)}") return Response({ - 'message': f'Successfully created {created_count} students', + 'message': f'Successfully processed {created_count + updated_count} staging students', + 'created_count': created_count, + 'updated_count': updated_count, + 'skipped_count': len(errors), 'errors': errors }, status=status.HTTP_201_CREATED) @@ -283,20 +399,45 @@ def bulk_upload(self, request): return Response({'error': 'Invalid file format'}, status=status.HTTP_400_BAD_REQUEST) created_count = 0 + updated_count = 0 errors = [] - + + current_max = APIPaper.objects.aggregate(max_id=Max("msuis_id")).get("max_id") or 0 + next_id = int(current_max) + 1 + for index, row in df.iterrows(): try: - Subject.objects.create( - code=row['code'], - name=row['name'] - ) - created_count += 1 + paper_code = str(row['code']).strip() + paper_name = str(row['name']).strip() + if not paper_code: + raise ValueError("code is required") + + existing = APIPaper.objects.filter(paper_code=paper_code).first() + if existing: + existing.paper_name = paper_name or existing.paper_name + existing.raw_payload = { + "code": paper_code, + "name": paper_name, + } + existing.save(update_fields=["paper_name", "raw_payload"]) + updated_count += 1 + else: + APIPaper.objects.create( + msuis_id=next_id, + subject_id=None, + paper_name=paper_name or paper_code, + paper_code=paper_code, + raw_payload={"code": paper_code, "name": paper_name}, + ) + next_id += 1 + created_count += 1 except Exception as e: errors.append(f"Row {index + 1}: {str(e)}") return Response({ - 'message': f'Successfully created {created_count} subjects', + 'message': f'Successfully processed {created_count + updated_count} staging subjects', + 'created_count': created_count, + 'updated_count': updated_count, 'errors': errors }, status=status.HTTP_201_CREATED) @@ -395,7 +536,9 @@ def download_template(self, request): """Download Excel template for bulk student enrollment upload""" data = { 'student_prn': [2021001, 2021001, 2021002], - 'subject_code': ['CS101', 'CS102', 'EE201'] + 'subject_code': ['CS101', 'CS102', 'EE201'], + 'year': [2, 2, 3], + 'division': ['A', 'A', 'B'], } df = pd.DataFrame(data) @@ -405,10 +548,11 @@ def download_template(self, request): # Add instructions instructions = pd.DataFrame({ 'Instructions': [ - '1. student_prn must exist in students table', - '2. subject_code must exist in subjects table', - '3. Each student-subject combination must be unique', - '4. One student can enroll in multiple subjects' + '1. student_prn must match staging students PRN', + '2. subject_code must match staging subjects code', + '3. year and division are required', + '4. Each student-subject-division-year combination must be unique', + '5. One student can enroll in multiple subjects' ] }) instructions.to_excel(writer, index=False, sheet_name='Instructions') @@ -425,7 +569,7 @@ def download_template(self, request): def bulk_upload(self, request): """ Bulk upload student enrollments from CSV/Excel - Expected columns: student_prn, subject_code + Expected columns: student_prn, subject_code, year, division """ file = request.FILES.get('file') if not file: @@ -439,31 +583,339 @@ def bulk_upload(self, request): else: return Response({'error': 'Invalid file format'}, status=status.HTTP_400_BAD_REQUEST) + df.columns = [str(column).strip() for column in df.columns] + + required_columns = { + 'student_prn', + 'subject_code', + 'year', + 'division', + } + missing_columns = required_columns.difference(df.columns) + if missing_columns: + return Response( + {'error': f"Missing required columns: {', '.join(sorted(missing_columns))}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + created_count = 0 + updated_count = 0 errors = [] for index, row in df.iterrows(): try: - subject = Subject.objects.get(code=row['subject_code']) - StudentEnrollment.objects.create( - student_prn=int(row['student_prn']), - subject=subject - ) - StudentAttendancePercentage.objects.create( - student=Student.objects.get(prn=int(row['student_prn'])), - subject=subject, - present_count=0, - attendancePercentage=0.0 + prn = int(row['student_prn']) + subject_code = str(row['subject_code']).strip() + year = int(row['year']) + division = str(row['division']).strip() + + if not subject_code: + raise ValueError('subject_code is required') + if not division: + raise ValueError('division is required') + + _, created = APIEnrollment.objects.update_or_create( + prn=prn, + subject_code=subject_code, + division=division, + year=year, + defaults={}, ) - created_count += 1 + if created: + created_count += 1 + else: + updated_count += 1 except Exception as e: errors.append(f"Row {index + 1}: {str(e)}") return Response({ - 'message': f'Successfully created {created_count} enrollments', + 'message': f'Successfully processed {created_count + updated_count} staging enrollments', + 'created_count': created_count, + 'updated_count': updated_count, 'errors': errors }, status=status.HTTP_201_CREATED) except Exception as e: return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class APIStudentViewSet(viewsets.ModelViewSet): + queryset = APIStudent.objects.all().order_by("prn") + serializer_class = APIStudentSerializer + permission_classes = [IsAuthenticated] + + +class APIPaperViewSet(viewsets.ModelViewSet): + queryset = APIPaper.objects.all().order_by("msuis_id") + serializer_class = APIPaperSerializer + permission_classes = [IsAuthenticated] + + +class DivisionViewSet(viewsets.ModelViewSet): + queryset = Division.objects.all().select_related('department') + serializer_class = DivisionSerializer + permission_classes = [IsAuthenticated] + + +def _full_name(student_record): + name_parts = [ + student_record.get("FirstName"), + student_record.get("MiddleName"), + student_record.get("LastName"), + ] + merged = " ".join([part.strip() for part in name_parts if isinstance(part, str) and part.strip()]) + return merged or student_record.get("NameAsPerMarksheet") or "Unknown" + + +def _to_bool(value): + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "y"} + return None + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def sync_msuis_payload(request): + """ + Sync student and paper data to staging tables for later promotion to core. + """ + payload = request.data or {} + + students = payload.get("students", []) + papers = payload.get("papers", []) + + counters = { + "students_synced": 0, + "papers_synced": 0, + } + + with transaction.atomic(): + for row in students: + prn = row.get("PRN") or row.get("Prn") or row.get("prn") + if prn is None: + continue + + # Build full_name from first/middle/last name components + first_name = row.get("FirstName") or "" + middle_name = row.get("MiddleName") or "" + last_name = row.get("LastName") or "" + full_name = " ".join([n.strip() for n in [first_name, middle_name, last_name] if n.strip()]) + if not full_name: + full_name = row.get("NameAsPerMarksheet") or f"PRN {prn}" + + APIStudent.objects.update_or_create( + prn=prn, + defaults={ + "full_name": full_name, + "email_id": row.get("EmailId"), + "raw_payload": row, + }, + ) + counters["students_synced"] += 1 + + for row in papers: + msuis_id = row.get("Id") or row.get("id") + if msuis_id is None: + continue + + paper_code = row.get("PaperCode") or row.get("Code") or f"PAPER-{msuis_id}" + paper_name = row.get("PaperName") or row.get("Name") or paper_code + + APIPaper.objects.update_or_create( + msuis_id=msuis_id, + defaults={ + "paper_name": paper_name, + "paper_code": paper_code, + "raw_payload": row, + }, + ) + counters["papers_synced"] += 1 + + return Response( + { + "message": "MSUIS payload synced to staging tables", + "counts": counters, + }, + status=status.HTTP_200_OK, + ) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def sync_staging_to_core(request): + """ + Process staging data into live Home_* tables. + Reads DatabaseAdminApp_* tables and writes to Home_* only. + """ + counters = { + "core_students_upserted": 0, + "core_subjects_upserted": 0, + "core_enrollments_upserted": 0, + "core_divisions_upserted": 0, + "students_skipped": 0, + "enrollments_skipped": 0, + } + + def _department_from_payload(raw_payload): + if not raw_payload: + return None + for key in ("department_name", "Department", "department", "FacultyName"): + value = raw_payload.get(key) + if value: + return str(value).strip() + return None + + def _year_from_payload(raw_payload): + if not raw_payload: + return None + for key in ("year", "Year", "YearOfStudy"): + value = raw_payload.get(key) + if value is None: + continue + try: + return int(value) + except Exception: + return None + return None + + def _student_display_name(api_student): + # APIStudent now stores `full_name` per new_schema + if getattr(api_student, 'full_name', None): + return api_student.full_name + raw_payload = api_student.raw_payload or {} + return raw_payload.get("name") or raw_payload.get("NameAsPerMarksheet") or f"PRN {api_student.prn}" + + api_students = list(APIStudent.objects.all()) + api_papers = list(APIPaper.objects.all()) + api_enrollments = list(APIEnrollment.objects.all()) + + year_by_prn = {} + for enrollment in api_enrollments: + if enrollment.prn is None: + continue + try: + year_value = int(enrollment.year) + except Exception: + continue + current = year_by_prn.get(enrollment.prn) + if current is None or year_value > current: + year_by_prn[enrollment.prn] = year_value + + with transaction.atomic(): + for paper in api_papers: + paper_code = (paper.paper_code or "").strip() + if not paper_code: + continue + paper_name = (paper.paper_name or paper_code).strip() + Subject.objects.update_or_create( + code=paper_code, + defaults={"name": paper_name}, + ) + counters["core_subjects_upserted"] += 1 + + for api_student in api_students: + prn = api_student.prn + if prn is None: + counters["students_skipped"] += 1 + continue + + raw_payload = api_student.raw_payload or {} + department_name = _department_from_payload(raw_payload) + department = None + if department_name: + department = _resolve_department(department_name) + if department is None: + department, _ = Department.objects.get_or_create(name=department_name) + + if department is None and getattr(api_student, 'faculty_id', None): + department = Department.objects.filter(id=api_student.faculty_id).first() + + if department is None: + counters["students_skipped"] += 1 + continue + + year_value = ( + year_by_prn.get(prn) + or _year_from_payload(raw_payload) + ) + if year_value is None: + year_value = 1 + + Student.objects.update_or_create( + prn=prn, + defaults={ + "name": _student_display_name(api_student), + "email": api_student.email_id or raw_payload.get("email") or f"{prn}@classlens.local", + "year": int(year_value), + "department": department, + }, + ) + counters["core_students_upserted"] += 1 + + for enrollment in api_enrollments: + if enrollment.prn is None or not enrollment.subject_code: + counters["enrollments_skipped"] += 1 + continue + + student = Student.objects.filter(prn=enrollment.prn).first() + if student is None: + counters["enrollments_skipped"] += 1 + continue + + subject_code = enrollment.subject_code.strip() + subject, created = Subject.objects.get_or_create( + code=subject_code, + defaults={"name": subject_code} + ) + if created: + counters["core_subjects_upserted"] += 1 + + department = None + if enrollment.department_name: + department = _resolve_department(enrollment.department_name) + if department is None: + department, _ = Department.objects.get_or_create(name=enrollment.department_name) + if department is None: + department = student.department + + division_obj = None + if ( + department + and enrollment.division + and enrollment.year is not None + ): + division_obj, created = Division.objects.get_or_create( + department=department, + year=int(enrollment.year), + name=str(enrollment.division).strip(), + ) + if created: + counters["core_divisions_upserted"] += 1 + + if division_obj is not None and student.division_id != division_obj.id: + student.division = division_obj + student.save(update_fields=["division"]) + + StudentEnrollment.objects.update_or_create( + student_prn=student.prn, + subject=subject, + ) + StudentAttendancePercentage.objects.get_or_create( + student=student, + subject=subject, + defaults={"present_count": 0, "attendancePercentage": 0.0}, + ) + counters["core_enrollments_upserted"] += 1 + + return Response( + { + "message": "Staging data processed into core tables", + "counts": counters, + }, + status=status.HTTP_200_OK, + ) diff --git a/ClassLens_DB/Home/admin.py b/ClassLens_DB/Home/admin.py index 6a81616..c5d0c3a 100644 --- a/ClassLens_DB/Home/admin.py +++ b/ClassLens_DB/Home/admin.py @@ -2,7 +2,7 @@ # Register your models here. -from .models import Student, Subject, Teacher, Department, ClassSession, SubjectFromDept, TeacherSubject, StudentEnrollment +from .models import Student, Subject, Teacher, Department, ClassSession, SubjectFromDept, TeacherSubject, StudentEnrollment, Division admin.site.register(Student) admin.site.register(Teacher) @@ -13,3 +13,4 @@ admin.site.register(Department) admin.site.register(SubjectFromDept) +admin.site.register(Division) diff --git a/ClassLens_DB/Home/face_utils.py b/ClassLens_DB/Home/face_utils.py new file mode 100644 index 0000000..4c7a17f --- /dev/null +++ b/ClassLens_DB/Home/face_utils.py @@ -0,0 +1,43 @@ +try: + from deepface import DeepFace +except Exception: + DeepFace = None + +try: + from PIL import Image +except Exception: + Image = None + +import numpy as np + + +def extract_face_embedding(photo): + if not photo: + raise ValueError("No photo uploaded") + + if DeepFace is None: + raise ValueError("Face embedding dependencies are unavailable") + + if Image is None: + raise ValueError("Face image processing dependencies are unavailable") + + try: + if hasattr(photo, "seek"): + photo.seek(0) + + image = Image.open(photo) + image = image.convert("RGB") + img_arr = np.array(image) + + image_embedding = DeepFace.represent( + img_path=img_arr, + model_name="Facenet512", + detector_backend="retinaface", + enforce_detection=True, + )[0]["embedding"] + + return [float(value) for value in image_embedding] + except ValueError as exc: + raise ValueError(str(exc)) from exc + except Exception as exc: + raise ValueError(f"Face Not Detected, Upload A New Image: {exc}") from exc \ No newline at end of file diff --git a/ClassLens_DB/Home/management/__init__.py b/ClassLens_DB/Home/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ClassLens_DB/Home/management/commands/__init__.py b/ClassLens_DB/Home/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ClassLens_DB/Home/models.py b/ClassLens_DB/Home/models.py index 3e26ac0..1c81276 100644 --- a/ClassLens_DB/Home/models.py +++ b/ClassLens_DB/Home/models.py @@ -1,9 +1,6 @@ from django.db import models -from pgvector.django import VectorField, IvfflatIndex, HnswIndex from django.contrib.auth.hashers import make_password, check_password - -from django.db import models -from django.db.models import F +from pgvector.django import VectorField class Department(models.Model): name = models.TextField(unique=True, null=False) @@ -32,31 +29,29 @@ class Student(models.Model): Department, on_delete=models.CASCADE, ) + division = models.ForeignKey( + 'Division', + on_delete=models.SET_NULL, + null=True, + blank=True, + ) face_embedding = VectorField(dimensions=512, null=True, blank=True) notification_token = models.TextField(null=True, blank=True) - class Meta: - indexes = [ - HnswIndex( - name='student_face_embedding_idx', - fields=['face_embedding'], - m=16, - ef_construction=200, - opclasses=['vector_cosine_ops'] - ), - ] def __str__(self): return f"{self.name} ({self.prn})" class Subject(models.Model): code = models.TextField(unique=True, null=False,default="") name = models.TextField(null=False) + department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True) + def __str__(self): return f"{self.name}" class SubjectFromDept(models.Model): department = models.ForeignKey(Department, on_delete=models.CASCADE) year = models.IntegerField(null=False) - subject = models.ManyToManyField(Subject,null=False) + subject = models.ManyToManyField(Subject) semester=models.IntegerField(null=False) class Meta: @@ -64,6 +59,23 @@ class Meta: def __str__(self): return f"{self.department} - {self.year}" + + +class Division(models.Model): + """ + Local teaching division metadata, e.g. BE CSE 4th year Sem 8 Division A. + """ + department = models.ForeignKey(Department, on_delete=models.CASCADE) + year = models.IntegerField(null=False) + name = models.CharField(max_length=20, null=False) + + class Meta: + unique_together = ("department", "year", "name") + + def __str__(self): + return ( + f"{self.year}th year Division {self.name}" + ) class StudentEnrollment(models.Model): student_prn = models.BigIntegerField(null=False) @@ -72,18 +84,20 @@ class StudentEnrollment(models.Model): class Meta: unique_together = ('student_prn', 'subject') - def _str_(self): + def __str__(self): return f"{self.student_prn} enrolled in {self.subject}" class TeacherSubject(models.Model): teacher_id = models.ForeignKey(Teacher, on_delete=models.CASCADE) subject = models.ForeignKey(Subject, on_delete=models.CASCADE) + division = models.ForeignKey(Division, on_delete=models.SET_NULL, null=True, blank=True) class Meta: - unique_together = ('teacher_id', 'subject') + unique_together = ('teacher_id', 'subject', 'division') - def _str_(self): - return f"{self.teacher.name} teaches {self.subject.name}" + def __str__(self): + division_name = self.division.name if self.division else 'All Divisions' + return f"{self.teacher_id.name} teaches {self.subject.name} ({division_name})" class ClassSession(models.Model): department = models.ForeignKey(Department, on_delete=models.CASCADE) diff --git a/ClassLens_DB/Home/tasks.py b/ClassLens_DB/Home/tasks.py index c31c9c0..b0cabef 100644 --- a/ClassLens_DB/Home/tasks.py +++ b/ClassLens_DB/Home/tasks.py @@ -1,50 +1,114 @@ from celery import shared_task -import matplotlib.pyplot as plt -import cv2 import os from rest_framework.response import Response -from deepface import DeepFace import uuid -import torch from django.conf import settings from django.http import request +from urllib.parse import urljoin import numpy as np from scipy.spatial.distance import cosine import json import sys, types -import torchvision.transforms.functional as F from django.db.models import F as DbF -import firebase_admin -from firebase_admin import credentials, messaging + +# Optional heavy dependencies — import lazily or fall back to None so management commands work without them +try: + import matplotlib.pyplot as plt +except Exception: + plt = None +try: + import cv2 +except Exception: + cv2 = None +try: + from deepface import DeepFace +except Exception: + DeepFace = None +try: + import torch +except Exception: + torch = None +try: + import torchvision.transforms.functional as F +except Exception: + F = None +try: + import firebase_admin + from firebase_admin import credentials, messaging +except Exception: + firebase_admin = None + credentials = None + messaging = None +try: + from gfpgan import GFPGANer +except Exception: + GFPGANer = None module_name = 'torchvision.transforms.functional_tensor' -if module_name not in sys.modules: - functional_tensor_module = types.ModuleType(module_name) - functional_tensor_module.rgb_to_grayscale = F.rgb_to_grayscale - sys.modules[module_name] = functional_tensor_module +if F is not None: + if module_name not in sys.modules: + functional_tensor_module = types.ModuleType(module_name) + functional_tensor_module.rgb_to_grayscale = F.rgb_to_grayscale + sys.modules[module_name] = functional_tensor_module -_original_torch_load = torch.load +if torch is not None: + _original_torch_load = torch.load -def patched_torch_load(f, *args, **kwargs): - if 'weights_only' not in kwargs: - kwargs['weights_only'] = False - return _original_torch_load(f, *args, **kwargs) + def patched_torch_load(f, *args, **kwargs): + if 'weights_only' not in kwargs: + kwargs['weights_only'] = False + return _original_torch_load(f, *args, **kwargs) -torch.load = patched_torch_load + torch.load = patched_torch_load -from gfpgan import GFPGANer from .models import Student, AttendanceRecord, ClassSession, StudentEnrollment, StudentAttendancePercentage +from django.utils import timezone + +# Defer GFPGAN restorer initialization until a task runs to avoid import-time file access +restorer = None + +def get_restorer(): + global restorer + if restorer is not None: + return restorer -restorer = GFPGANer( - model_path='GFPGANv1.4.pth', - upscale=2, - arch='clean', - channel_multiplier=2, - bg_upsampler=None -) + # If GFPGANer isn't available, skip initialization + if GFPGANer is None: + print("GFPGAN not available; skipping restorer initialization") + return None + + model_path = None + try: + from django.conf import settings as _settings + if hasattr(_settings, 'GFPGAN_MODEL_PATH') and _settings.GFPGAN_MODEL_PATH: + model_path = _settings.GFPGAN_MODEL_PATH + else: + # default to BASE_DIR/GFPGANv1.4.pth + model_path = str(_settings.BASE_DIR / 'GFPGANv1.4.pth') + except Exception: + model_path = 'GFPGANv1.4.pth' + + try: + restorer = GFPGANer( + model_path=model_path, + upscale=2, + arch='clean', + channel_multiplier=2, + bg_upsampler=None + ) + print(f"GFPGAN restorer initialized with model: {model_path}") + except Exception as e: + print(f"Warning: GFPGAN restorer failed to initialize: {e}") + restorer = None + + return restorer def initialize_firebase(): + if firebase_admin is None or credentials is None: + print("Firebase Admin SDK not available; skipping notifications") + return + if not firebase_admin._apps: cred_path = os.path.join(settings.BASE_DIR, 'firebase-service-account.json') if os.path.exists(cred_path): @@ -87,18 +151,26 @@ def send_attendance_notifications(student_records, subject_name, class_datetime) print(f"Failed to send notification to {student.name}: {e}") @shared_task -def evaluate_attendance(total_sessions,class_session_id:int,scheme, host): +def evaluate_attendance(total_sessions, class_session_id: int, scheme, host, division_id=None): session = ClassSession.objects.get(id=class_session_id) images=session.photos.all() image_urls=[] total_faces=0 - enrolled_prns = list(StudentEnrollment.objects.filter( + enrolled_prns_qs = StudentEnrollment.objects.filter( subject=session.subject - ).values_list('student_prn', flat=True)) + ).values_list('student_prn', flat=True) - all_students_qs = Student.objects.filter(prn__in=enrolled_prns) + all_students_qs = Student.objects.filter( + prn__in=enrolled_prns_qs, + year=session.year, + department=session.department, + ) + if division_id: + all_students_qs = all_students_qs.filter(division_id=division_id) + + enrolled_prns = list(all_students_qs.values_list('prn', flat=True)) student_obj_map = {s.prn: s for s in all_students_qs} @@ -143,13 +215,21 @@ def evaluate_attendance(total_sessions,class_session_id:int,scheme, host): facial_area = face_data['facial_area'] x, y, w, h = facial_area['x'], facial_area['y'], facial_area['w'], facial_area['h'] - _, restored_list, _ = restorer.enhance( - face_crop_bgr, - has_aligned=False, - only_center_face=True, - paste_back=False, - weight=0.1 - ) + _restorer = get_restorer() + if _restorer is not None: + try: + _, restored_list, _ = _restorer.enhance( + face_crop_bgr, + has_aligned=False, + only_center_face=True, + paste_back=False, + weight=0.1 + ) + except Exception: + restored_list = [] + else: + restored_list = [] + face_to_scan = restored_list[0] if restored_list else face_crop_bgr face_to_scan_rgb = cv2.cvtColor(face_to_scan, cv2.COLOR_BGR2RGB) @@ -163,8 +243,12 @@ def evaluate_attendance(total_sessions,class_session_id:int,scheme, host): align=True ) captured_embedding = embedding_result[0]['embedding'] - except ValueError: - cv2.rectangle(img_bgr, (x, y), (x + w, y + h), (0, 0, 255), 2) + except Exception: + if cv2 is not None: + try: + cv2.rectangle(img_bgr, (x, y), (x + w, y + h), (0, 0, 255), 2) + except Exception: + pass continue best_score = 1.0 @@ -188,8 +272,9 @@ def evaluate_attendance(total_sessions,class_session_id:int,scheme, host): filename = f"detected_{unique_id}.jpg" save_path = output_dir / filename cv2.imwrite(str(save_path), img_bgr) - - image_urls.append(f"{scheme}://{host}/media/images/{filename}") + + base_url = f"{scheme}://{host.rstrip('/')}" + image_urls.append(urljoin(f"{base_url}/", f"media/images/{filename}")) records_to_create = [] student_notification_list = [] @@ -203,7 +288,7 @@ def evaluate_attendance(total_sessions,class_session_id:int,scheme, host): class_session=session, student=student_obj, status=is_present, - marked_at=session.class_datetime + marked_at=timezone.now() ) ) @@ -226,11 +311,13 @@ def evaluate_attendance(total_sessions,class_session_id:int,scheme, host): session.subject.name, session.class_datetime ) + print(f"images url {image_urls[0]}") return { "num_faces": total_faces, "image_url": image_urls[0] if image_urls else None, "class_session_id": class_session_id, + "division_id": division_id, "present_count": len(present_student_prns), "absent_count": len(enrolled_prns) - len(present_student_prns), "subject": session.subject.name diff --git a/ClassLens_DB/Home/tests.py b/ClassLens_DB/Home/tests.py index 7ce503c..aadf281 100644 --- a/ClassLens_DB/Home/tests.py +++ b/ClassLens_DB/Home/tests.py @@ -1,3 +1,110 @@ from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient -# Create your tests here. +from .models import ( + AttendanceRecord, + ClassSession, + Department, + Division, + Student, + StudentEnrollment, + Subject, + Teacher, + TeacherSubject, +) + + +class TeacherClassSessionsEndpointTests(TestCase): + def setUp(self): + self.client = APIClient() + + self.department = Department.objects.create(name="Computer Science") + self.teacher = Teacher.objects.create( + name="Teacher One", + email="teacher.one@example.com", + department=self.department, + ) + self.subject = Subject.objects.create( + code="CS101", + name="Object Oriented Programming with Java-CSE", + department=self.department, + ) + self.division = Division.objects.create( + department=self.department, + year=2, + name="SFI", + ) + self.teacher_subject = TeacherSubject.objects.create( + teacher_id=self.teacher, + subject=self.subject, + division=self.division, + ) + + self.student_one = Student.objects.create( + prn=1001, + name="Student One", + email="student.one@example.com", + year=2, + department=self.department, + division=self.division, + ) + self.student_two = Student.objects.create( + prn=1002, + name="Student Two", + email="student.two@example.com", + year=2, + department=self.department, + division=self.division, + ) + + StudentEnrollment.objects.create(student_prn=self.student_one.prn, subject=self.subject) + StudentEnrollment.objects.create(student_prn=self.student_two.prn, subject=self.subject) + + self.class_session = ClassSession.objects.create( + department=self.department, + year=2, + subject=self.subject, + teacher=self.teacher, + class_datetime=timezone.now(), + ) + + AttendanceRecord.objects.create( + class_session=self.class_session, + student=self.student_one, + status=True, + ) + AttendanceRecord.objects.create( + class_session=self.class_session, + student=self.student_two, + status=False, + ) + + def test_get_teacher_class_sessions_returns_expected_payload(self): + response = self.client.get( + reverse("teacher_class_sessions"), + {"teacher_id": self.teacher.id, "limit": 5}, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("class_sessions", response.data) + self.assertEqual(len(response.data["class_sessions"]), 1) + + session_data = response.data["class_sessions"][0] + self.assertEqual(session_data["class_session_id"], self.class_session.id) + self.assertEqual(session_data["subject_name"], self.subject.name) + self.assertEqual(session_data["division_name"], self.division.name) + self.assertEqual(session_data["present_count"], 1) + self.assertEqual(session_data["absent_count"], 1) + self.assertEqual(session_data["total_count"], 2) + + def test_post_teacher_class_sessions_fallback_works(self): + response = self.client.post( + reverse("get_teacher_class_sessions"), + {"teacher_id": self.teacher.id, "limit": 5}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("class_sessions", response.data) diff --git a/ClassLens_DB/Home/urls.py b/ClassLens_DB/Home/urls.py index 16f4a2f..b78f5df 100644 --- a/ClassLens_DB/Home/urls.py +++ b/ClassLens_DB/Home/urls.py @@ -1,32 +1,54 @@ -from django.urls import path +from django.urls import path, re_path from django.urls import include -from Home.views import getDepartments,registerNewStudent,mark_attendance,teacher_profile,registerNewTeacher,validateStudent,validateTeacher,send_otp,verify_otp,set_password,get_subject_details,verify_email, verify_prn, get_student_attendance,attendance_status,teacher_subjects, get_present_absent_list,change_attendance,get_student_dashboard,update_notification_token,remove_notification_token +from Home.views import getDepartments,mark_attendance,teacher_profile,registerNewTeacher,validateStudent,validateTeacher,send_otp,verify_otp,set_password,get_subject_details,verify_email, verify_prn, get_student_attendance,attendance_status,teacher_subjects,teacher_class_sessions, get_present_absent_list,change_attendance,get_student_dashboard,update_notification_token,remove_notification_token, register_student, get_student_subject_attendance, update_face from django.conf import settings from django.conf.urls.static import static urlpatterns = [ - path("getDepartments/", getDepartments, name="get_departments"), - path("registerNewStudent", registerNewStudent, name="register_new_student"), - path("registerNewTeacher", registerNewTeacher, name="register_new_teacher"), - path("validateStudent", validateStudent, name="validate_student"), - path("validateTeacher", validateTeacher, name="validate_teacher"), - path("sendOtp", send_otp, name="send_otp"), - path("verifyOtp", verify_otp, name="verify_otp"), - path("setPassword", set_password, name="set_password"), - path("getSubjectDetails", get_subject_details, name="get_subject_details"), - path("verifyEmail", verify_email, name="verify_email"), - path("students/attendance/", get_student_attendance, name="get_student_attendance"), - path('verifyPRN',verify_prn, name='verify_prn'), - path('markAttendance',mark_attendance, name='mark_attendance'), - path('attendanceStatus//',attendance_status, name='attendance_status'), - path('getSubjects/',teacher_subjects, name='get_teacher_subjects'), - path('getPresentAbsentList/',get_present_absent_list, name='get_present_absent_list'), - path('changeAttendance/',change_attendance, name='change_attendance'), - path('teacherProfile//',teacher_profile, name='teacher_profile'), - path('student/dashboard/', get_student_dashboard, name='get_student_dashboard'), - path('student/notification-token/', update_notification_token, name='update_notification_token'), - path('student/notification-token/remove/', remove_notification_token, name='remove_notification_token'), + re_path(r"^getDepartments/?$", getDepartments, name="get_departments"), + re_path(r"^registerNewStudent/?$", register_student, name="register_new_student"), + re_path(r"^registerStudent/?$", register_student, name="register_student"), + re_path(r"^updateFace/?$", update_face, name="update_face"), + re_path(r"^registerNewTeacher/?$", registerNewTeacher, name="register_new_teacher"), + re_path(r"^validateStudent/?$", validateStudent, name="validate_student"), + re_path(r"^validateTeacher/?$", validateTeacher, name="validate_teacher"), + re_path(r"^sendOtp/?$", send_otp, name="send_otp"), + re_path(r"^verifyOtp/?$", verify_otp, name="verify_otp"), + re_path(r"^setPassword/?$", set_password, name="set_password"), + re_path(r"^getSubjectDetails/?$", get_subject_details, name="get_subject_details"), + re_path(r"^verifyEmail/?$", verify_email, name="verify_email"), + re_path(r"^students/attendance/?$", get_student_attendance, name="get_student_attendance"), + re_path( + r"^student/attendance/subject/(?P\d+)/?$", + get_student_subject_attendance, + name="get_student_subject_attendance", + ), + re_path(r"^verifyPRN/?$", verify_prn, name="verify_prn"), + re_path(r"^markAttendance/?$", mark_attendance, name="mark_attendance"), + re_path( + r"^attendanceStatus/(?P[^/]+)/?$", + attendance_status, + name="attendance_status", + ), + re_path(r"^teacher/subjects/?$", teacher_subjects, name="teacher_subjects"), + re_path(r"^getSubjects/?$", teacher_subjects, name="get_teacher_subjects"), + re_path(r"^teacher/class-sessions/?$", teacher_class_sessions, name="teacher_class_sessions"), + re_path(r"^getTeacherClassSessions/?$", teacher_class_sessions, name="get_teacher_class_sessions"), + re_path(r"^getPresentAbsentList/?$", get_present_absent_list, name="get_present_absent_list"), + re_path(r"^changeAttendance/?$", change_attendance, name="change_attendance"), + re_path( + r"^teacherProfile/(?P\d+)/?$", + teacher_profile, + name="teacher_profile", + ), + re_path(r"^student/dashboard/?$", get_student_dashboard, name="get_student_dashboard"), + re_path(r"^student/notification-token/?$", update_notification_token, name="update_notification_token"), + re_path( + r"^student/notification-token/remove/?$", + remove_notification_token, + name="remove_notification_token", + ), ] if settings.DEBUG: diff --git a/ClassLens_DB/Home/views.py b/ClassLens_DB/Home/views.py index 379caff..0b0a4d0 100644 --- a/ClassLens_DB/Home/views.py +++ b/ClassLens_DB/Home/views.py @@ -2,15 +2,12 @@ import string from django.db.models import F from rest_framework.decorators import api_view, parser_classes,permission_classes -from deepface import DeepFace -from PIL import Image -import numpy as np from rest_framework.response import Response -from .models import Department, Student, Teacher, SubjectFromDept, StudentAttendancePercentage,AttendanceRecord, StudentEnrollment,TeacherSubject, ClassSession, Subject,AttendancePhotos,AdminUser +from .models import Department, Student, Teacher, SubjectFromDept, StudentAttendancePercentage,AttendanceRecord, StudentEnrollment,TeacherSubject, ClassSession, Subject,AttendancePhotos,AdminUser, Division from django.db.models import Count, Q from .serializers import DepartmentSerializer,SubjectSerializer from rest_framework.parsers import MultiPartParser -from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.tokens import RefreshToken, AccessToken from django.contrib.auth.hashers import make_password, check_password from django.shortcuts import get_object_or_404 import traceback @@ -19,11 +16,20 @@ from django.core.cache import cache from django.core.mail import send_mail from django.conf import settings +from django.utils import timezone import environ import os from pathlib import Path -import cv2 -import matplotlib.pyplot as plt +try: + import cv2 +except Exception: + cv2 = None +try: + import matplotlib.pyplot as plt +except Exception: + plt = None + +from .face_utils import extract_face_embedding import uuid from .tasks import evaluate_attendance, send_attendance_notifications from django.core.files.storage import default_storage @@ -78,7 +84,7 @@ def registerNewTeacher(request, *args, **kwargs): except Exception as e: traceback.print_exc() return Response( - {"detail": "Method not allowed"}, status=status.HTTP_405_METHOD_NOT_ALLOWED + {"detail": f"Error registering teacher: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @api_view(["POST"]) @@ -94,13 +100,33 @@ def validateStudent(request, *args, **kwargs): try: student = Student.objects.get(prn=prn) + + # Check if password_hash exists before attempting to verify + if student.password_hash is None: + return Response( + {"detail": "Student account not fully registered. Please set a password first."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not check_password(password, student.password_hash): return Response( {"detail": "Invalid password"}, status=status.HTTP_400_BAD_REQUEST ) else: + # Issue a simple JWT-like token so frontend can authenticate subsequent requests + refresh = RefreshToken() + refresh['student_id'] = student.id + refresh['prn'] = student.prn + return Response( - {"message": "Student validated successfully", "student_id": student.id, 'student_name': student.name, 'prn': student.prn}, + { + "message": "Student validated successfully", + "student_id": student.id, + "student_name": student.name, + "prn": student.prn, + "access": str(refresh.access_token), + "refresh": str(refresh), + }, status=status.HTTP_200_OK, ) except Student.DoesNotExist: @@ -110,8 +136,8 @@ def validateStudent(request, *args, **kwargs): except Exception as e: traceback.print_exc() return Response( - {"detail": "Method not allowed"}, - status=status.HTTP_405_METHOD_NOT_ALLOWED, + {"detail": f"Error validating student: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @api_view(["POST"]) @@ -148,7 +174,8 @@ def validateTeacher(request, *args, **kwargs): except Exception as e: traceback.print_exc() return Response( - {"detail": "Method not allowed"}, status=status.HTTP_405_METHOD_NOT_ALLOWED + {"detail": f"Error validating teacher: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @api_view(["POST"]) @@ -164,12 +191,27 @@ def get_subject_details(request, *args, **kwargs): ) subjects = subject_from_dept.subject.all() subjects = SubjectSerializer(subjects, many=True).data - return Response({"subjects": subjects,"message":"subject details"}, status=status.HTTP_200_OK) + divisions = Division.objects.filter( + department=department, + year=year, + ).order_by("name") + divisions_data = [ + { + "id": division.id, + "name": division.name, + "year": division.year, + } + for division in divisions + ] + return Response( + {"subjects": subjects, "divisions": divisions_data, "message": "subject details"}, + status=status.HTTP_200_OK, + ) except Exception as e: traceback.print_exc() return Response( - {"detail": "Something went wrong"}, - status=status.HTTP_405_METHOD_NOT_ALLOWED, + {"detail": f"Error getting subject details: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @api_view(["POST"]) @@ -358,6 +400,44 @@ def verify_otp(request, *args, **kwargs): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) +def _update_student_password(prn, password, photo): + if prn is None or password is None: + return Response( + {"detail": "PRN and Password are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + student = Student.objects.filter(prn=prn).first() + if not student: + return Response( + {"detail": "No Student found with this prn"}, + status=status.HTTP_404_NOT_FOUND, + ) + + student.password_hash = make_password(password) + + if photo is not None: + try: + embedding = extract_face_embedding(photo) + student.face_embedding = [float(value) for value in embedding] + except ValueError as exc: + return Response( + {"error": str(exc)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + student.save() + print(f"✓ Student password set successfully for PRN {prn}") + print(f" Hash (first 20 chars): {student.password_hash[:20]}...") + + verify = Student.objects.get(prn=prn) + if verify.password_hash: + print(f"✓ Verified: Password hash persisted in database for PRN {prn}") + return Response({"message": "Student password set successfully"}, status=200) + + print(f"✗ ERROR: Password hash is None after save for PRN {prn}!") + return Response({"detail": "Failed to persist password"}, status=500) + @api_view(["POST"]) def set_password(request, *args, **kwargs): try: @@ -373,65 +453,118 @@ def set_password(request, *args, **kwargs): if teacher : teacher.password_hash = make_password(password) teacher.save() - print("Teacher password set successfully") - return Response({"message": "Teacher password set successfully"}, status=200) + print(f"✓ Teacher password set successfully for {email}") + print(f" Hash (first 20 chars): {teacher.password_hash[:20]}...") + + # Verify it was saved to DB + verify = Teacher.objects.get(email=email) + if verify.password_hash: + print(f"✓ Verified: Password hash persisted in database") + return Response({"message": "Teacher password set successfully"}, status=200) + else: + print(f"✗ ERROR: Password hash is None after save!") + return Response({"detail": "Failed to persist password"}, status=500) else : return Response({"detail": "No Teacher found with this email"}, status=status.HTTP_404_NOT_FOUND) elif request.data.get("prn"): - prn=request.data.get("prn") - if prn is None or password is None: - return Response( - {"detail": "PRN and Password are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - student = Student.objects.filter(prn=prn).first() - if student : - student.password_hash = make_password(password) - try : - student.face_embedding=registerNewStudent(request.FILES.get("photo")) - except ValueError as ve : - return Response({"error": "Face Not Detected, Upload A New Image"}, status=status.HTTP_400_BAD_REQUEST) - student.save() - print("Student password set successfully") - return Response({"message": "Student password set successfully"}, status=200) - else: - return Response({"detail": "No Student found with this prn"}, status=status.HTTP_404_NOT_FOUND) + prn = request.data.get("prn") + photo = request.FILES.get("photo") + return _update_student_password(prn, password, photo) except Exception as e: traceback.print_exc() + print(f"✗ Exception in set_password: {str(e)}") return Response( - {"detail": "An error occurred while updating the password"}, + {"detail": f"An error occurred while updating the password: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + +@api_view(["POST"]) +def register_student(request, *args, **kwargs): + prn = request.data.get("prn") + password = request.data.get("password") + photo = request.FILES.get("photo") + + if password is None and photo is not None: + student = Student.objects.filter(prn=prn).first() + if not student: + return Response( + {"detail": "No Student found with this prn"}, + status=status.HTTP_404_NOT_FOUND, + ) + + try: + embedding = extract_face_embedding(photo) + student.face_embedding = [float(value) for value in embedding] + student.save(update_fields=["face_embedding"]) + return Response({"message": "Student face updated successfully"}, status=200) + except ValueError as exc: + return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + return _update_student_password(prn, password, photo) def registerNewStudent(photo): + return extract_face_embedding(photo) - if not photo: - return Response( - {"error": "No photo uploaded"}, status=status.HTTP_400_BAD_REQUEST - ) +@api_view(["POST"]) +def update_face(request, *args, **kwargs): + """POST /api/updateFace/ - multipart/form-data: prn, photo + + Updates only the student's `face_embedding` without changing password or other fields. + """ try: - image = Image.open(photo) - image = image.convert("RGB") - img_arr = np.array(image) - image_embedding = DeepFace.represent( - img_path=img_arr, - model_name="Facenet512", - detector_backend="retinaface", - enforce_detection=True, - )[0]["embedding"] - - return image_embedding - except ValueError as ve: - return ValueError(ve) + # Prefer authenticated student identity if provided via Bearer token + prn = None + auth_header = request.META.get('HTTP_AUTHORIZATION') or request.headers.get('Authorization') + if auth_header: + parts = auth_header.split() + if len(parts) == 2 and parts[0].lower() == 'bearer': + token = parts[1] + try: + payload = AccessToken(token) + prn = payload.get('prn') or payload.get('student_prn') or payload.get('student_id') + # If student_id found, map to prn + if isinstance(prn, int) and not payload.get('prn'): + # prn may be student id - fetch actual prn + student_obj = Student.objects.filter(id=prn).first() + if student_obj: + prn = student_obj.prn + except Exception: + prn = None + + if prn is None: + prn = request.POST.get("prn") or request.data.get("prn") + photo = request.FILES.get("photo") + + if prn is None: + return Response({"detail": "prn is required"}, status=status.HTTP_400_BAD_REQUEST) + + if photo is None: + return Response({"detail": "photo file is required"}, status=status.HTTP_400_BAD_REQUEST) + + student = Student.objects.filter(prn=prn).first() + if not student: + return Response({"detail": "No Student found with this prn"}, status=status.HTTP_404_NOT_FOUND) + + try: + embedding = extract_face_embedding(photo) + student.face_embedding = [float(value) for value in embedding] + student.save(update_fields=["face_embedding"]) + return Response({"message": "Student face updated successfully"}, status=200) + except ValueError as exc: + return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + traceback.print_exc() + return Response({"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(["POST"]) def get_student_attendance(request, *args, **kwargs): try: subject_id = request.data.get("subject_id") + division_id = request.data.get("division_id") if subject_id is None: return Response( @@ -439,20 +572,40 @@ def get_student_attendance(request, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) - total_sessions = ClassSession.objects.filter(subject_id=subject_id).count() - - attendance_data = StudentAttendancePercentage.objects.filter( - subject_id=subject_id - ).select_related('student') + # Fetch enrolled student PRNs from StudentEnrollment + enrolled_prns = StudentEnrollment.objects.filter(subject_id=subject_id).values_list('student_prn', flat=True) + + # Get Student records and annotate them with real total and attended counts from AttendanceRecord + students = Student.objects.filter(prn__in=enrolled_prns).annotate( + real_total_classes=Count( + 'attendancerecord', + filter=Q(attendancerecord__class_session__subject_id=subject_id) + ), + real_attended_classes=Count( + 'attendancerecord', + filter=Q(attendancerecord__class_session__subject_id=subject_id, attendancerecord__status=True) + ) + ) + if division_id: + students = students.filter(division_id=division_id) result = [] - for record in attendance_data: + for student in students: + total = student.real_total_classes + attended = student.real_attended_classes + + percentage = 0.0 + if total > 0: + percentage = (attended * 100.0) / total + if percentage > 100.0: + percentage = 100.0 + result.append({ - "student_id": record.student.id, - "student_name": record.student.name, - "total_classes": total_sessions, - "attended_classes": record.present_count, - "attendance_percentage": record.attendancePercentage + "student_id": student.id, + "student_name": student.name, + "total_classes": total, + "attended_classes": attended, + "attendance_percentage": percentage }) return Response( @@ -467,6 +620,54 @@ def get_student_attendance(request, *args, **kwargs): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) +@api_view(["GET"]) +def get_student_subject_attendance(request, subject_id, *args, **kwargs): + try: + division_id = request.query_params.get("division_id") + year = request.query_params.get("year") + semester = request.query_params.get("semester") + + records = AttendanceRecord.objects.filter( + class_session__subject_id=subject_id + ).select_related( + "student", + "student__division", + "class_session", + ) + + if division_id: + records = records.filter(student__division_id=division_id) + if year: + records = records.filter(class_session__year=year) + # Division no longer stores semester; skip filtering by division.semester + + results = [] + for record in records: + results.append( + { + "class_session_id": record.class_session_id, + "student_id": record.student_id, + "student_name": record.student.name, + "student_prn": record.student.prn, + "status": record.status, + "marked_at": record.marked_at.isoformat(), + "class_datetime": record.class_session.class_datetime.isoformat(), + "division_id": record.student.division_id, + } + ) + + return Response( + {"attendance_records": results}, + status=status.HTTP_200_OK, + ) + + except Exception as e: + traceback.print_exc() + return Response( + {"detail": "Something went wrong"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + @api_view(["POST"]) @parser_classes([MultiPartParser]) def mark_attendance(request, *args, **kwargs): @@ -479,17 +680,57 @@ def mark_attendance(request, *args, **kwargs): teacher_id = request.data.get("teacherID") departmentName = request.data.get("departmentName") year = request.data.get("year") - - if not all([photos, subject_id, teacher_id, departmentName, year]): - return Response({"error": "Missing required fields (photo, subject_id, teacher_id, department_id, year)."}, status=400) + division_id = request.data.get("divisionID") + + # Debug log to see exactly what is received + print("=" * 60) + print("MARK ATTENDANCE REQUEST RECEIVED") + print(f" photos : {photos}") + print(f" subject_id : {subject_id!r}") + print(f" teacher_id : {teacher_id!r}") + print(f" department : {departmentName!r}") + print(f" year : {year!r}") + print(f" division_id : {division_id!r}") + print("=" * 60) + + missing = [] + if not photos: missing.append("photo") + if not subject_id: missing.append("subjectID") + if not teacher_id: missing.append("teacherID") + if not departmentName: missing.append("departmentName") + if not year: missing.append("year") + + if missing: + return Response({"error": f"Missing required fields: {', '.join(missing)}"}, status=400) + + if int(teacher_id) == 0: + return Response({"error": "Invalid teacher ID (0). Please log in again."}, status=400) try: + teacher_subject_qs = TeacherSubject.objects.filter( + teacher_id=teacher_id, + subject_id=subject_id, + ) + + if division_id: + teacher_subject_qs = teacher_subject_qs.filter(division_id=division_id) + + if not teacher_subject_qs.exists(): + return Response( + {"error": "Teacher is not mapped to this subject/division"}, + status=400, + ) + + resolved_division_id = int(division_id) if division_id else None + if resolved_division_id is None and teacher_subject_qs.count() == 1: + resolved_division_id = teacher_subject_qs.first().division_id + class_session = ClassSession.objects.create( department = get_object_or_404(Department, name=departmentName), year = year, subject = get_object_or_404(Subject, id=subject_id), teacher = get_object_or_404(Teacher, id=teacher_id), - class_datetime = datetime.now(), + class_datetime = timezone.now(), ) total_sessions=ClassSession.objects.filter( @@ -502,7 +743,13 @@ def mark_attendance(request, *args, **kwargs): photo=photo ) - task = evaluate_attendance.delay(total_sessions,class_session.id,request.scheme, request.get_host()) + task = evaluate_attendance.delay( + total_sessions, + class_session.id, + request.scheme, + request.get_host(), + resolved_division_id, + ) return Response({ "message": "Attendance processing started. You will be notified once it's done.", @@ -523,26 +770,34 @@ def mark_attendance(request, *args, **kwargs): # "task_id": task.id # }, status=202) -@api_view(["POST"]) -def teacher_subjects(request,*args, **kwargs): - teacher_id = request.data.get("teacher_id") +@api_view(["GET", "POST"]) +def teacher_subjects(request, *args, **kwargs): + teacher_id = ( + request.query_params.get("teacher_id") + if request.method == "GET" + else request.data.get("teacher_id") + ) if not teacher_id: return Response({"error": "Teacher ID is required"}, status=400) try: - subjects = TeacherSubject.objects.filter(teacher_id=teacher_id).values( - 'subject__id', - 'subject__code', - 'subject__name' + subjects = TeacherSubject.objects.filter(teacher_id=teacher_id).select_related( + "subject", + "division", ) clean_subjects = [ { - 'id': s['subject__id'], - 'code': s['subject__code'], - 'name': s['subject__name'], - 'strength': StudentEnrollment.objects.filter(subject_id=s['subject__id']).count() + "id": row.subject_id, + "code": row.subject.code, + "name": row.subject.name, + "division_id": row.division_id, + "division_name": row.division.name if row.division else None, + "strength": StudentEnrollment.objects.filter( + subject_id=row.subject_id, + student_prn__in=Student.objects.filter(division_id=row.division_id).values_list("prn", flat=True), + ).count() if row.division_id else StudentEnrollment.objects.filter(subject_id=row.subject_id).count(), } - for s in subjects + for row in subjects ] return Response({"subjects": clean_subjects}, status=200) except Exception as e: @@ -552,6 +807,97 @@ def teacher_subjects(request,*args, **kwargs): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + +@api_view(["GET", "POST"]) +def teacher_class_sessions(request, *args, **kwargs): + teacher_id = ( + request.query_params.get("teacher_id") + if request.method == "GET" + else request.data.get("teacher_id") + ) + limit = ( + request.query_params.get("limit") + if request.method == "GET" + else request.data.get("limit") + ) + + if not teacher_id: + return Response({"error": "Teacher ID is required"}, status=400) + + try: + teacher = get_object_or_404(Teacher, id=teacher_id) + + limit_value = 10 + if limit not in (None, ""): + limit_value = int(limit) + if limit_value <= 0: + limit_value = 10 + + sessions_qs = ( + ClassSession.objects.filter(teacher=teacher) + .select_related("subject", "teacher", "department") + .prefetch_related("attendancerecord_set__student__division") + .order_by("-class_datetime") + ) + + if limit_value: + sessions_qs = sessions_qs[:limit_value] + + class_sessions = [] + for session in sessions_qs: + attendance_records = list(session.attendancerecord_set.all()) + present_count = sum(1 for record in attendance_records if record.status) + total_count = len(attendance_records) + absent_count = total_count - present_count + + division_names = sorted( + { + record.student.division.name + for record in attendance_records + if record.student and record.student.division_id + } + ) + + division_name = division_names[0] if len(division_names) == 1 else None + if division_name is None: + teacher_subject = ( + TeacherSubject.objects.filter( + teacher_id=teacher, + subject_id=session.subject_id, + ) + .select_related("division") + .order_by("id") + .first() + ) + division_name = ( + teacher_subject.division.name + if teacher_subject and teacher_subject.division + else None + ) + + class_sessions.append( + { + "class_session_id": session.id, + "subject_name": session.subject.name, + "division_name": division_name or "All Divisions", + "class_datetime": session.class_datetime.isoformat(), + "present_count": present_count, + "absent_count": absent_count, + "total_count": total_count, + } + ) + + return Response({"class_sessions": class_sessions}, status=status.HTTP_200_OK) + + except ValueError: + return Response({"error": "limit must be an integer"}, status=400) + except Exception: + traceback.print_exc() + return Response( + {"detail": "Something went wrong"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + @api_view(["POST"]) def get_present_absent_list(request, *args, **kwargs): class_session_id = request.data.get("class_session_id") @@ -629,10 +975,16 @@ def teacher_profile(request,teacher_id, *args, **kwargs): return Response({"error": "Teacher ID is required"}, status=400) try: teacher = get_object_or_404(Teacher, id=teacher_id) - total_Subject=TeacherSubject.objects.filter(teacher_id_id=teacher_id).count() - total_Student=StudentEnrollment.objects.filter( - subject_id__in=TeacherSubject.objects.filter(teacher_id_id=teacher_id).values_list('subject_id', flat=True) - ).count() + teacher_subject_qs = TeacherSubject.objects.filter(teacher_id_id=teacher_id).select_related("division") + total_Subject = teacher_subject_qs.count() + total_Student = 0 + for teacher_subject in teacher_subject_qs: + enrollment_qs = StudentEnrollment.objects.filter(subject_id=teacher_subject.subject_id) + if teacher_subject.division_id: + enrollment_qs = enrollment_qs.filter( + student_prn__in=Student.objects.filter(division_id=teacher_subject.division_id).values_list("prn", flat=True) + ) + total_Student += enrollment_qs.count() department = teacher.department.name if teacher.department else None profile_data = { "name": teacher.name, @@ -649,6 +1001,34 @@ def teacher_profile(request,teacher_id, *args, **kwargs): {"detail": "Something went wrong"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + + +def _resolve_student_semester(student): + subject_ids = set( + StudentEnrollment.objects.filter(student_prn=student.prn).values_list("subject_id", flat=True) + ) + subject_mappings = ( + SubjectFromDept.objects.filter(department=student.department, year=student.year) + .prefetch_related("subject") + .order_by("semester") + ) + + best_semester = None + best_score = -1 + + for subject_mapping in subject_mappings: + mapped_subject_ids = set(subject_mapping.subject.values_list("id", flat=True)) + score = len(subject_ids.intersection(mapped_subject_ids)) + + if score > best_score: + best_score = score + best_semester = subject_mapping.semester + + if best_score > 0: + return best_semester + + first_mapping = subject_mappings.first() + return first_mapping.semester if first_mapping else None @api_view(['POST']) @permission_classes([AllowAny]) @@ -691,6 +1071,7 @@ def get_student_dashboard(request, *args, **kwargs): ) student = get_object_or_404(Student, id=student_id) + semester = _resolve_student_semester(student) enrollments = StudentEnrollment.objects.filter(student_prn=student.prn).select_related('subject') @@ -705,9 +1086,29 @@ def get_student_dashboard(request, *args, **kwargs): subject=subject ).first() - percentage=data.attendancePercentage + if data is None: + percentage = 0 + present_count = 0 + else: + percentage = data.attendancePercentage + present_count = data.present_count + + teacher = None + if student.division is not None: + teacher = TeacherSubject.objects.filter( + subject=subject, + division=student.division + ).select_related('teacher_id').first() + if teacher is None: + teacher = TeacherSubject.objects.filter( + subject=subject, + division__isnull=True + ).select_related('teacher_id').first() + else: + teacher = TeacherSubject.objects.filter( + subject=subject + ).select_related('teacher_id').first() - teacher = TeacherSubject.objects.filter(subject=subject).select_related('teacher_id').first() teacher_name = teacher.teacher_id.name if teacher else "N/A" subjects_data.append({ @@ -716,27 +1117,41 @@ def get_student_dashboard(request, *args, **kwargs): "code": subject.code, "teacher": teacher_name, "total": total_sessions, - "attended": data.present_count, + "attended": present_count, "percentage": round(float(percentage), 2) }) recent_records = AttendanceRecord.objects.filter( student=student - ).select_related('class_session__subject').order_by('-class_session__class_datetime')[:5] + ).select_related('class_session__subject').order_by('-marked_at')[:5] recent_activity = [] for record in recent_records: recent_activity.append({ "subject": record.class_session.subject.name, "status": "Present" if record.status else "Absent", - "date": record.class_session.class_datetime.isoformat() + "date": record.marked_at.isoformat() }) + # compute overall attendance across all subjects (weighted by total sessions) + total_classes_sum = sum(item.get('total', 0) for item in subjects_data) + attended_sum = sum(item.get('attended', 0) for item in subjects_data) + overall_percentage = None + if total_classes_sum > 0: + overall_percentage = round((attended_sum / total_classes_sum) * 100.0, 2) + return Response({ "student_name": student.name, "prn": student.prn, + "email": student.email, + "year": student.year, + "department_name": student.department.name if student.department else None, + "division_id": student.division_id, + "division_name": student.division.name if student.division else None, + "semester": semester, + "overall_attendance": overall_percentage, "subjects": subjects_data, - "recent_activity": recent_activity + "recent_activity": recent_activity, }, status=status.HTTP_200_OK) except Exception as e: @@ -808,4 +1223,11 @@ def remove_notification_token(request, *args, **kwargs): return Response( {"detail": "Something went wrong"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) \ No newline at end of file + ) + +@api_view(["GET"]) +def health(request,*args,**kwargs): + return Response( + {"message":"ok"}, + status=200 + ) \ No newline at end of file diff --git a/ClassLens_DB/requirements.txt b/ClassLens_DB/requirements.txt index b49df48..5a299bf 100644 Binary files a/ClassLens_DB/requirements.txt and b/ClassLens_DB/requirements.txt differ diff --git a/ClassLens_DB/scripts/fix_home_migrations.py b/ClassLens_DB/scripts/fix_home_migrations.py new file mode 100644 index 0000000..b9baeff --- /dev/null +++ b/ClassLens_DB/scripts/fix_home_migrations.py @@ -0,0 +1,22 @@ +import os +import django +import sys + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ClassLens_DB.settings') +try: + django.setup() +except Exception as e: + print('Django setup error:', e) + sys.exit(1) + +from django.db import connection + +with connection.cursor() as cur: + cur.execute("SELECT id, app, name FROM django_migrations WHERE app='Home'") + rows = cur.fetchall() + print('Home migrations in django_migrations:', rows) + if rows: + cur.execute("DELETE FROM django_migrations WHERE app='Home'") + print('Deleted Home migration entries from django_migrations') + else: + print('No Home migration entries found') diff --git a/ClassLens_DB/start_server.bat b/ClassLens_DB/start_server.bat new file mode 100644 index 0000000..423331a --- /dev/null +++ b/ClassLens_DB/start_server.bat @@ -0,0 +1,5 @@ +@echo off +cd C:\Users\Admin\Desktop\ClassLens\ClassLens_DB +call C:\Users\Admin\Desktop\ClassLens\venv\Scripts\activate.bat +waitress-serve --host=127.0.0.1 --port=8000 ClassLens_DB.wsgi:application +pause \ No newline at end of file diff --git a/README.md b/README.md index 32a62d8..fd587de 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ ClassLens/ - **Backend Framework**: Django, Django REST Framework - **Task Queue**: Celery -- **Message Broker / Cache**: Redis +- **Message Broker**: RabbitMQ - **Database**: PostgreSQL (with `pgvector` extension) - **Face Pipeline**: DeepFace, RetinaFace, GFPGAN - **Auth & OTP**: JWT authentication + Email OTP verification @@ -63,7 +63,7 @@ ClassLens/ - Python 3.10+ - PostgreSQL 13+ (local or managed, with pgvector installed) -- Redis server +- RabbitMQ server - Virtual environment (recommended) --- @@ -104,8 +104,8 @@ DB_PASSWORD=your_password DB_HOST=your_host DB_PORT=5432 -# Redis -REDIS_URL=redis://localhost:6379 +# RabbitMQ +RABBITMQ_URL=amqp://guest:guest@localhost:5672// # Email Configuration (SMTP) EMAIL_HOST=smtp.gmail.com # Use smtp-mail.outlook.com for Outlook @@ -156,21 +156,35 @@ You can now hit the API from: --- -## ⚡ Celery & Redis Setup +## ⚡ Celery & RabbitMQ Setup -Start Redis locally or via Docker: +Start RabbitMQ locally or via Docker: ```bash -redis-server # local OR -docker run -d --name classlens-redis -p 6379:6379 redis +# local server (default broker: 5672, management UI: 15672) +rabbitmq-server + +# OR Docker +docker run -d --name classlens-rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management ``` -Start Celery worker: +//// +RabbitMQ management UI: -```bash -celery -A ClassLens_DB.celery worker -l info -P gevent +- URL: http://localhost:15672/ +- Username: guest +- Password: guest + +Start Celery worker on Windows: + +```powershell +Set-Location "M:\ClassLens\classLenseBackend\ClassLens\ClassLens_DB" +$env:PYTHONPATH = "M:\ClassLens\classLenseBackend\ClassLens\ClassLens_DB" +& ".\.venv\Scripts\python.exe" -m celery -A ClassLens_DB.celery worker -l info -P solo ``` +Use `-P solo` on Windows. `gevent` is the source of the shutdown error you saw in this environment. + --- ## 🧬 GFPGAN Model Usage diff --git a/requirements.txt b/requirements.txt index 99241d8..e30c945 100644 Binary files a/requirements.txt and b/requirements.txt differ