11from django .db .models .query_utils import Q
2- from django .http import HttpResponse
3- from django .shortcuts import get_object_or_404 , HttpResponse
4- from django .http import HttpResponse
5- from django .http import JsonResponse
2+ from django .http import HttpResponse , JsonResponse
3+ from django .shortcuts import get_object_or_404
64from django .db import transaction
75from decimal import Decimal , ROUND_HALF_UP
86from applications .academic_procedures .models import (course_registration , course_replacement )
1816from io import StringIO , BytesIO
1917from django .contrib .auth import get_user_model
2018from rest_framework .views import APIView
21- from django .db .models import IntegerField , Sum
19+ from django .db .models import IntegerField , Sum , Case , When
2220from django .db .models .functions import Cast
2321from rest_framework .parsers import MultiPartParser , FormParser
2422from openpyxl import Workbook
3432from reportlab .lib .units import inch
3533from django .core .exceptions import ObjectDoesNotExist
3634from collections import defaultdict
37- from django .db .models import Case , When , IntegerField
35+ import re
36+
37+
38+ def _safe_filename (name : str , extension : str = "" ) -> str :
39+ """Strip/replace characters unsafe in HTTP Content-Disposition filenames."""
40+ safe = re .sub (r'[^\w\s\-.]' , '_' , str (name )).strip ()
41+ safe = re .sub (r'\s+' , '_' , safe )
42+ safe = safe [:100 ]
43+ return f"{ safe } { extension } " if extension else safe
44+
3845
3946grade_conversion = {
4047 "O" : 1.0 , "A+" : 1.0 , "A" : 0.9 , "B+" : 0.8 , "B" : 0.7 ,
@@ -78,21 +85,9 @@ def format_semester_display(semester_no, semester_type=None, semester_label=None
7885 return str (semester_no )
7986
8087def round_from_last_decimal (number , decimal_places = 1 ):
88+ """Round a number to `decimal_places` using ROUND_HALF_UP."""
8189 d = Decimal (str (number ))
8290 return Decimal (d ).quantize (Decimal ('0.1' ), rounding = ROUND_HALF_UP )
83- # d = Decimal(str(number))
84- # current_places = abs(d.as_tuple().exponent)
85-
86- # # Keep rounding from the last decimal place until we reach the desired one
87- # while current_places > decimal_places:
88- # quantize_str = '0.' + '0' * (current_places - 1) + '1'
89- # d = d.quantize(Decimal(quantize_str), rounding=ROUND_HALF_UP)
90- # current_places -= 1
91-
92- # # Final rounding to target place
93- # final_quantize = '0.' + '0' * (decimal_places - 1) + '1'
94- # return float(d.quantize(Decimal(final_quantize), rounding=ROUND_HALF_UP))
95-
9691
9792def calculate_spi_for_student (student , selected_semester , semester_type ):
9893 semester_unit = Decimal ('0' )
@@ -217,22 +212,26 @@ def calculate_cpi_for_student(student, selected_semester, semester_type):
217212def parse_academic_year (academic_year , semester_type ):
218213 """
219214 Parse academic_year string (e.g., "2024-25") and determine the working_year based on semester type.
220- For Odd Semester, working_year = first part (e.g., 2024).
221- For Even Semester, working_year = second part prefixed by '20' (e.g., 2025 if academic_year is "2024-25").
215+ For Odd Semester, working_year = first part (e.g., 2024).
216+ For Even/Summer, working_year = second part, expanded to 4 digits when only 2 are given
217+ (e.g., "25" → 2025 for "2024-25").
222218 The session is set to the academic_year string.
223219 """
224220 parts = academic_year .split ("-" )
225221 if len (parts ) != 2 :
226222 raise ValueError ("Invalid academic year format. Expected format like '2024-25'." )
227223 first_year = parts [0 ].strip ()
228224 second_year = parts [1 ].strip ()
225+ if not first_year .isdigit () or not second_year .isdigit ():
226+ raise ValueError ("Academic year parts must be numeric." )
229227 if semester_type == "Odd Semester" :
230228 working_year = int (first_year )
231- elif semester_type == "Even Semester" :
232- working_year = int ("20" + second_year )
233229 else :
234- # For any other semester type (e.g., Summer Semester) use the first year by default.
235- working_year = int ("20" + second_year )
230+ if len (second_year ) == 2 :
231+ century = (int (first_year ) // 100 ) * 100
232+ working_year = century + int (second_year )
233+ else :
234+ working_year = int (second_year )
236235 session = academic_year # Use the complete academic year string as session.
237236 return working_year , session
238237
@@ -430,9 +429,10 @@ def download_template(request):
430429 # Get course information from the first matched registration.
431430 course_obj = course_info .first ().course_id
432431 response = HttpResponse (content_type = "text/csv" )
433- course_name_clean = course_obj .name .replace (' ' , '_' ).replace ('/' , '-' )[:50 ]
434- semester_type_clean = semester_type .replace (' ' , '_' )
435- filename = f"{ course_name_clean } _{ semester_type_clean } _{ session_year } .csv"
432+ filename = _safe_filename (
433+ f"{ course_obj .name } _{ semester_type } _{ session_year } " ,
434+ extension = ".csv"
435+ )
436436 response ['Content-Disposition' ] = f'attachment; filename="{ filename } "'
437437
438438 writer = csv .writer (response )
@@ -441,7 +441,10 @@ def download_template(request):
441441 # Write a CSV row for each student registration.
442442 for entry in course_info :
443443 student_entry = entry .student_id
444- student_user = User .objects .get (username = student_entry .id_id )
444+ try :
445+ student_user = User .objects .get (username = student_entry .id_id )
446+ except User .DoesNotExist :
447+ student_user = None
445448 branch_acronym = ""
446449 if student_entry .batch_id :
447450 try :
@@ -454,9 +457,13 @@ def download_template(request):
454457 if entry .course_slot_id and entry .course_slot_id .semester :
455458 semester_no = entry .course_slot_id .semester .semester_no
456459
460+ full_name = (
461+ f"{ student_user .first_name } { student_user .last_name } " .strip ()
462+ if student_user else student_entry .id_id
463+ )
457464 writer .writerow ([
458465 student_entry .id_id ,
459- f" { student_user . first_name } { student_user . last_name } " ,
466+ full_name ,
460467 branch_acronym ,
461468 "" ,
462469 "" ,
@@ -588,7 +595,6 @@ def post(self, request):
588595 )
589596
590597
591- from django .db import transaction
592598class UploadGradesAPI (APIView ):
593599 permission_classes = [IsAuthenticated ]
594600 parser_classes = [MultiPartParser , FormParser ]
@@ -985,29 +991,43 @@ def post(self, request):
985991 status = status .HTTP_400_BAD_REQUEST ,
986992 )
987993
988- # Update or create grades
989- for student_id , semester_id , course_id , grade ,remark in zip (
990- student_ids , semester_ids , course_ids , grades ,remarks
991- ):
994+ for student_id , course_id , grade in zip (student_ids , course_ids , grades ):
992995 try :
993- grade_of_student = Student_grades .objects .get (
994- course_id = course_id , roll_no = student_id , semester = semester_id
996+ course_obj = Courses .objects .get (id = course_id )
997+ except Courses .DoesNotExist :
998+ return Response (
999+ {"error" : f"Course ID { course_id } does not exist." },
1000+ status = status .HTTP_400_BAD_REQUEST ,
9951001 )
996- grade_of_student .remarks = remark
997- grade_of_student .grade = grade
998- grade_of_student .verified = True
999- if allow_resubmission .upper () == "YES" :
1000- grade_of_student .reSubmit = True
1001- grade_of_student .save ()
1002- except Student_grades .DoesNotExist :
1003- # Create a new hidden grade if the student grade doesn't exist
1004- hidden_grades .objects .create (
1005- course_id = course_id ,
1006- student_id = student_id ,
1007- semester_id = semester_id ,
1008- grade = grade ,
1002+ if not is_valid_grade (grade , course_obj .code ):
1003+ allowed_list = ", " .join (sorted (ALLOWED_GRADES ))
1004+ return Response (
1005+ {"error" : f"Invalid grade '{ grade } ' for course '{ course_obj .code } '. Allowed: { allowed_list } ." },
1006+ status = status .HTTP_400_BAD_REQUEST ,
10091007 )
10101008
1009+ with transaction .atomic ():
1010+ for student_id , semester_id , course_id , grade , remark in zip (
1011+ student_ids , semester_ids , course_ids , grades , remarks
1012+ ):
1013+ try :
1014+ grade_of_student = Student_grades .objects .get (
1015+ course_id = course_id , roll_no = student_id , semester = semester_id
1016+ )
1017+ grade_of_student .remarks = remark
1018+ grade_of_student .grade = grade
1019+ grade_of_student .verified = True
1020+ if allow_resubmission .upper () == "YES" :
1021+ grade_of_student .reSubmit = True
1022+ grade_of_student .save ()
1023+ except Student_grades .DoesNotExist :
1024+ hidden_grades .objects .create (
1025+ course_id = course_id ,
1026+ student_id = student_id ,
1027+ semester_id = semester_id ,
1028+ grade = grade ,
1029+ )
1030+
10111031 # Generate CSV file as the response
10121032 response = HttpResponse (content_type = "text/csv" )
10131033 response ["Content-Disposition" ] = 'attachment; filename="grades.csv"'
@@ -1075,8 +1095,10 @@ def post(self, request):
10751095 student = Student .objects .get (id_id = student_id )
10761096 cpi , tu , _ = calculate_cpi_for_student (student , semester_number , semester_type )
10771097 spi , su , _ = calculate_spi_for_student (student , semester_number , semester_type )
1078- except :
1079- return Response ({"error" : "Student ID does not exist." }, status = status .HTTP_400_BAD_REQUEST )
1098+ except Student .DoesNotExist :
1099+ return Response ({"error" : "Student ID does not exist." }, status = status .HTTP_404_NOT_FOUND )
1100+ except Exception as e :
1101+ return Response ({"error" : f"Error computing grades: { str (e )} " }, status = status .HTTP_500_INTERNAL_SERVER_ERROR )
10801102
10811103 course_grades = {}
10821104 courses_registered = Student_grades .objects .filter (roll_no = student_id , semester = semester_number , semester_type = semester_type )
@@ -1411,7 +1433,7 @@ def post(self, request):
14111433 try :
14121434 student_user = User .objects .get (username = student .id_id )
14131435 student_name = f"{ student_user .first_name } { student_user .last_name } " .strip () or student_user .username
1414- except :
1436+ except Exception :
14151437 student_name = student .id_id
14161438
14171439 ws .cell (row = row_idx , column = 3 ).value = student_name
@@ -1946,10 +1968,10 @@ def post(self, request):
19461968 continue
19471969
19481970 # c) VALID GRADE?
1949- if grade not in ALLOWED_GRADES :
1971+ if not is_valid_grade ( grade , course . code ) :
19501972 allowed = ", " .join (sorted (ALLOWED_GRADES ))
19511973 errors .append (
1952- f"Row { idx } : Invalid grade '{ grade } '. Allowed: { allowed } ."
1974+ f"Row { idx } : Invalid grade '{ grade } ' for course ' { course . code } ' . Allowed: { allowed } ."
19531975 )
19541976 continue
19551977
@@ -2145,7 +2167,7 @@ def post(self, request):
21452167
21462168 # prepare PDF response
21472169 response = HttpResponse (content_type = "application/pdf" )
2148- response ["Content-Disposition" ] = f'attachment; filename="{ course_info .code } _grades.pdf"'
2170+ response ["Content-Disposition" ] = f'attachment; filename="{ _safe_filename ( course_info .code ) } _grades.pdf"'
21492171
21502172 # Create PDF metadata to fix "(anonymous)" title issue
21512173 pdf_title = f"Course Grades - { course_info .code } - { course_info .name } "
@@ -2470,13 +2492,14 @@ def generate_student_result_pdf(self, request):
24702492
24712493 response = HttpResponse (pdf_data , content_type = 'application/pdf' )
24722494 # Create filename with formatted semester for clarity
2473- semester_suffix = formatted_semester .replace (' ' , '_' ).replace (':' , '' ).lower ()
2474- filename = f"result_{ student_info .get ('rollNumber' , student_info .get ('roll_number' , 'student' ))} _{ semester_suffix } .pdf"
2495+ semester_suffix = re .sub (r'\s+' , '_' , formatted_semester .replace (':' , '' )).lower ()
2496+ roll = student_info .get ('rollNumber' , student_info .get ('roll_number' , 'student' ))
2497+ filename = _safe_filename (f"result_{ roll } _{ semester_suffix } " , extension = ".pdf" )
24752498 response ['Content-Disposition' ] = f'attachment; filename="{ filename } "'
24762499 response ['Content-Length' ] = len (pdf_data )
2477-
2500+
24782501 return response
2479-
2502+
24802503 except Exception as e :
24812504 return JsonResponse ({'error' : f'PDF generation failed: { str (e )} ' }, status = 500 )
24822505
@@ -3700,7 +3723,8 @@ def post(self, request):
37003723
37013724 # Choose prefix based on document type
37023725 prefix = "transcript_" if is_transcript else "result_"
3703- filename = f"{ prefix } { student_info .get ('rollNumber' , student_info .get ('roll_number' , 'student' ))} _{ semester_suffix } .pdf"
3726+ roll = student_info .get ('rollNumber' , student_info .get ('roll_number' , 'student' ))
3727+ filename = _safe_filename (f"{ prefix } { roll } _{ semester_suffix } " , extension = ".pdf" )
37043728 response ['Content-Disposition' ] = f'attachment; filename="{ filename } "'
37053729 response ['Content-Length' ] = len (pdf_data )
37063730
@@ -3747,14 +3771,9 @@ def post(self, request):
37473771
37483772 course_grades = {}
37493773 courses_registered = Student_grades .objects .filter (
3750- roll_no = student_id , semester = semester_number , semester_type = semester_type
3774+ roll_no = student_id , semester = semester_number , semester_type = semester_type ,
37513775 ).select_related ('course_id' )
37523776
3753- if not courses_registered .exists ():
3754- courses_registered = Student_grades .objects .filter (
3755- roll_no = student_id , semester = semester_number
3756- ).select_related ('course_id' )
3757-
37583777 academic_year = None
37593778 if courses_registered .exists ():
37603779 academic_year = courses_registered .first ().academic_year
@@ -3765,6 +3784,7 @@ def post(self, request):
37653784 student_regs = course_registration .objects .filter (
37663785 student_id = student ,
37673786 semester_id__semester_no = semester_number ,
3787+ semester_type = semester_type ,
37683788 ).select_related ('course_id' )
37693789
37703790 substituted_ids = set (
0 commit comments