diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index e275e285d..b99c250de 100644 --- a/FusionIIIT/applications/examination/api/views.py +++ b/FusionIIIT/applications/examination/api/views.py @@ -4777,6 +4777,80 @@ def _credits_text(value): return str(int(number)) if number == int(number) else '{:.1f}'.format(number) +# Mirrors Fusion-client/src/lib/credits.js — keep the two in step. The document +# is an official record, so the server decides what it says; the client renders +# `standing_text` rather than recomputing it. +DEGREE_CREDIT_REQUIREMENT = 148 +SWAYAM_CREDIT_CAP = 6 +_NON_EARNING_GRADES = {"F", "I", "X", "AU", "CD", "\u2014"} +_GRADE_POINTS = { + "O": 1.0, "A+": 1.0, "A": 0.9, "B+": 0.8, "B": 0.7, + "C+": 0.6, "C": 0.5, "D+": 0.4, "D": 0.3, "F": 0.2, "S": 0.0, +} + + +def _grade_points(grade): + key = str(grade or "").strip() + if key in _GRADE_POINTS: + return _GRADE_POINTS[key] + try: + return float(key) / 10 + except (TypeError, ValueError): + return -1 + + +def _degree_standing_text(semesters, programme): + """The one line under the header: done, or how much is left. + + Undergraduate-only, because the 148-credit requirement and the swayam cap + are undergraduate rules. Returns "" for every other programme. + """ + if not re.search(r"bachelor|b\.?tech", str(programme or ""), re.I): + return "" + + swayam_total = 0.0 + credited = {} + for sem in semesters: + if sem.get("is_registered_only"): + continue + for c in sem.get("courses", []): + if str(c.get("grade") or "").strip() in _NON_EARNING_GRADES or not c.get("grade"): + continue + credits = float(c.get("credits") or 0) + code = str(c.get("code") or "").strip().upper() + if code.startswith("SW"): + swayam_total += credits + if c.get("superseded"): + continue + kept = credited.get(code) + if not kept or _grade_points(c.get("grade")) > _grade_points(kept[0]): + credited[code] = (c.get("grade"), credits) + + earned = sum(credits for _, credits in credited.values()) + above_cap = max(0.0, swayam_total - SWAYAM_CREDIT_CAP) + remaining = DEGREE_CREDIT_REQUIREMENT - (earned - above_cap) + + if remaining <= 0: + return "This student has successfully completed the degree requirements." + shown = int(remaining) if float(remaining).is_integer() else round(remaining, 1) + return f"Remaining credits required for the degree: {shown}" + + +def _full_academic_year(raw): + """"2024-25" and "2024-2025" both render as "2024-2025"; anything else is + passed through untouched rather than guessed at.""" + text = (raw or "").strip() + parts = text.split("-") + if len(parts) != 2: + return text + start, end = parts[0].strip(), parts[1].strip() + if not (start.isdigit() and end.isdigit() and len(start) == 4): + return text + if len(end) == 2: + end = str((int(start) // 100) * 100 + int(end)) + return f"{start}-{end}" + + def _build_grade_validation_semesters(student): """One student's full semester-by-semester grade history for Grade Validation -- shared by GradeValidationView's get_all_grades action and export_all_zip action @@ -4899,7 +4973,12 @@ def _is_summer_key(key): courses = [] sem_credits_earned = Decimal('0') + # Recorded per grade row; one semester should carry one value, but the + # column is nullable and free text, so take the first that is set. + academic_year = "" for g in semesters_map[key]: + if not academic_year: + academic_year = _full_academic_year(g.academic_year) course = g.course_id cid = course.id grade = g.grade or "" @@ -4942,6 +5021,7 @@ def _is_summer_key(key): "semester_type": s_type, "is_summer": is_summer, "label": label, + "academic_year": academic_year, "courses": courses, "semester_credits": float(sem_credits_earned), "total_credits": float(cumulative_credits), @@ -4964,7 +5044,11 @@ def _is_summer_key(key): reg_courses = [] reg_credits_total = Decimal('0') + # Registrations carry the starting year as an int, not the span. + academic_year = "" for r in reg_map[key]: + if not academic_year and r.working_year: + academic_year = f"{r.working_year}-{r.working_year + 1}" credit = Decimal(str(r.course_id.credit)) if r.course_id.credit is not None else Decimal('0') reg_credits_total += credit reg_courses.append({ @@ -4980,6 +5064,7 @@ def _is_summer_key(key): "is_summer": is_summer, "is_registered_only": True, "label": label, + "academic_year": academic_year, "courses": reg_courses, "semester_credits": float(reg_credits_total), "total_credits": float(cumulative_credits), @@ -5003,11 +5088,21 @@ def _is_summer_key(key): except Exception: pass + # The furthest the student has actually been graded in. Registered-but-ungraded + # semesters do not count, and the label already reads "Summer Semester 4" for a + # summer term the database stores as semester 8 + "Summer Semester". + last_graded_semester = "" + for sem in semesters_data: + if not sem.get("is_registered_only"): + last_graded_semester = sem.get("label") or "" + student_info = { "roll_no": student.id.user.username, "name": f"{student.id.user.first_name} {student.id.user.last_name}".strip(), "programme": programme_full, "discipline": discipline_full, + "last_graded_semester": last_graded_semester, + "standing_text": _degree_standing_text(semesters_data, programme_full), } return student_info, semesters_data @@ -5167,25 +5262,52 @@ def _build_pdf_bytes(stu_info, semesters): [Paragraph("Student Name", small), Paragraph(stu_info["name"], small), Paragraph("Discipline", small), Paragraph(stu_info["discipline"], small)], ] - col_w = [W * 0.14, W * 0.36, W * 0.14, W * 0.36] - ht = RLTable(header_data, colWidths=col_w, repeatRows=0) - ht.setStyle(RLTableStyle([ + # Same extra rows the single-student PDF prints, so a batch ZIP + # and an individual download never disagree. + header_style = [ ("BOX", (0, 0), (-1, -1), 0.5, RL_COLORS.black), ("LINEBELOW", (0, 0), (-1, 0), 0.5, RL_COLORS.black), ("FONTSIZE", (0, 0), (-1, -1), 8), ("PADDING", (0, 0), (-1, -1), 3), ("VALIGN", (0, 0), (-1, -1), "TOP"), - ])) + ] + + last_graded = stu_info.get("last_graded_semester") or "" + if last_graded: + header_data.append([ + Paragraph("Last Graded Semester", small), + Paragraph(last_graded, small), "", "", + ]) + row = len(header_data) - 1 + header_style.append(("SPAN", (1, row), (-1, row))) + + standing_text = stu_info.get("standing_text") or "" + if standing_text: + standing_style = ParagraphStyle( + "standing", parent=bold_small, alignment=1, + textColor=RL_COLORS.HexColor("#c00000"), + ) + header_data.append([Paragraph(standing_text, standing_style), "", "", ""]) + row = len(header_data) - 1 + header_style.append(("SPAN", (0, row), (-1, row))) + + col_w = [W * 0.14, W * 0.36, W * 0.14, W * 0.36] + ht = RLTable(header_data, colWidths=col_w, repeatRows=0) + ht.setStyle(RLTableStyle(header_style)) story.append(ht) # ── Per-semester blocks ────────────────────────────────────── for sem in semesters: story.append(Spacer(1, 6)) - # Heading row + # Heading row: label on the left, academic year on the right + year_style = ParagraphStyle( + "sem_year", parent=heading_style, alignment=2, + ) ht2 = RLTable( - [[Paragraph(sem["label"], heading_style)]], - colWidths=[W], + [[Paragraph(sem["label"], heading_style), + Paragraph(sem.get("academic_year") or "", year_style)]], + colWidths=[W * 0.5, W * 0.5], ) ht2.setStyle(RLTableStyle([ ("BOX", (0, 0), (-1, -1), 0.5, RL_COLORS.black),