Lonelyguyse1 commited on
Commit
ce13b1c
·
1 Parent(s): 8596974

spec pipeline updates + doc updates + viewer updates + schema updates + tool additions

Browse files
backend/app/preview.py CHANGED
@@ -2,6 +2,7 @@ import json
2
 
3
  from app.services.books import parse_books
4
  from app.services.deterministic import compute_course_type, compute_hours, compute_program
 
5
 
6
 
7
  def _lines(value) -> list[str]:
@@ -56,7 +57,16 @@ def build_course_preview(row: dict) -> dict:
56
 
57
  practical_hours = int(hours["practical_hours"] or 0)
58
  status = str(row.get("status") or content.get("status") or "")
 
 
 
 
 
 
 
 
59
  return {
 
60
  "course_code": str(row.get("course_code") or content.get("course_code") or ""),
61
  "course_title": str(row.get("course_title") or content.get("course_title") or ""),
62
  "program": str(program),
@@ -67,15 +77,32 @@ def build_course_preview(row: dict) -> dict:
67
  "credits": str(hours["credits"]),
68
  "semester": str(row.get("semester") or content.get("semester") or ""),
69
  "course_type": str(course_type),
 
70
  "status": status,
71
- "render_detail": status != "summary_only",
72
  "tools_languages": _text(row.get("tools_languages"), content.get("tools_languages"), submission.get("preferred_tools")),
73
  "desirable_knowledge": _text(row.get("desirable_knowledge"), content.get("desirable_knowledge")),
74
  "prelude": row.get("prelude") or content.get("prelude") or "",
75
- "objectives": _lines(row.get("objectives") or content.get("objectives"))[:4],
76
- "course_outcomes": _lines(row.get("course_outcomes") or content.get("course_outcomes"))[:4],
77
- "units": row.get("units") or content.get("units") or [],
78
- "lab_experiments": _lines(row.get("lab_experiments") or content.get("lab_experiments"))[:10] if practical_hours else [],
79
- "text_books": parse_books(row.get("text_books") or content.get("text_books")) or parse_books(submission.get("text_books")),
80
- "reference_books": parse_books(row.get("reference_books") or content.get("reference_books")) or parse_books(submission.get("reference_books")),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  }
 
2
 
3
  from app.services.books import parse_books
4
  from app.services.deterministic import compute_course_type, compute_hours, compute_program
5
+ from app.supabase import supabase
6
 
7
 
8
  def _lines(value) -> list[str]:
 
57
 
58
  practical_hours = int(hours["practical_hours"] or 0)
59
  status = str(row.get("status") or content.get("status") or "")
60
+
61
+ objectives = _lines(row.get("objectives") or content.get("objectives"))[:4]
62
+ course_outcomes = _lines(row.get("course_outcomes") or content.get("course_outcomes"))[:4]
63
+ units = row.get("units") or content.get("units") or []
64
+ lab_experiments = _lines(row.get("lab_experiments") or content.get("lab_experiments"))[:10] if practical_hours else []
65
+ text_books = parse_books(row.get("text_books") or content.get("text_books")) or parse_books(submission.get("text_books"))
66
+ reference_books = parse_books(row.get("reference_books") or content.get("reference_books")) or parse_books(submission.get("reference_books"))
67
+ has_content = any([objectives, course_outcomes, units, lab_experiments, text_books, reference_books])
68
  return {
69
+ "refined_id": row.get("id"),
70
  "course_code": str(row.get("course_code") or content.get("course_code") or ""),
71
  "course_title": str(row.get("course_title") or content.get("course_title") or ""),
72
  "program": str(program),
 
77
  "credits": str(hours["credits"]),
78
  "semester": str(row.get("semester") or content.get("semester") or ""),
79
  "course_type": str(course_type),
80
+ "is_elective": bool(row.get("is_elective") or content.get("is_elective") or False),
81
  "status": status,
82
+ "render_detail": status != "summary_only" and has_content,
83
  "tools_languages": _text(row.get("tools_languages"), content.get("tools_languages"), submission.get("preferred_tools")),
84
  "desirable_knowledge": _text(row.get("desirable_knowledge"), content.get("desirable_knowledge")),
85
  "prelude": row.get("prelude") or content.get("prelude") or "",
86
+ "objectives": objectives,
87
+ "course_outcomes": course_outcomes,
88
+ "units": units,
89
+ "lab_experiments": lab_experiments,
90
+ "text_books": text_books,
91
+ "reference_books": reference_books,
92
+ }
93
+
94
+
95
+ def build_specialization_context(academic_year: str = "") -> dict:
96
+ """Load specialization tracks and their elective assignments for template rendering.
97
+
98
+ academic_year is accepted for forward compatibility (multiple batches) but the
99
+ current single-batch deployment returns every track. Filtering by an empty or
100
+ mismatched year would hide all tracks, so we load them all and let the caller
101
+ scope by semester inside the template.
102
+ """
103
+ specs = supabase.table("specialization_definitions").select("*").order("semester").order("letter").execute().data
104
+ assignments = supabase.table("course_specialization_assignments").select("*").execute().data
105
+ return {
106
+ "specializations": specs,
107
+ "specialization_assignments": assignments,
108
  }
backend/app/rendering.py CHANGED
@@ -2,6 +2,7 @@ import re
2
  from pathlib import Path
3
 
4
  from jinja2 import Environment, FileSystemLoader, select_autoescape
 
5
  from markupsafe import Markup, escape
6
 
7
  APP_DIR = Path(__file__).resolve().parent
@@ -22,7 +23,7 @@ def _find_frontend_dir() -> Path:
22
 
23
 
24
  FRONTEND_DIR = _find_frontend_dir()
25
- templates = Environment(loader=FileSystemLoader(APP_DIR / "templates"), autoescape=select_autoescape(["html", "xml"]))
26
  URL_RE = re.compile(r"https?://[^\s<>()]+")
27
  YEAR_RE = re.compile(r"\d{4}")
28
 
@@ -71,3 +72,15 @@ def course_code_for_year(value: str, semester, curriculum_year: str) -> str:
71
  templates.filters["linkify"] = linkify
72
  templates.filters["course_code_for_year"] = course_code_for_year
73
  templates.globals["batch_label"] = batch_label
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from pathlib import Path
3
 
4
  from jinja2 import Environment, FileSystemLoader, select_autoescape
5
+ from jinja2.ext import do
6
  from markupsafe import Markup, escape
7
 
8
  APP_DIR = Path(__file__).resolve().parent
 
23
 
24
 
25
  FRONTEND_DIR = _find_frontend_dir()
26
+ templates = Environment(loader=FileSystemLoader(APP_DIR / "templates"), autoescape=select_autoescape(["html", "xml"]), extensions=[do])
27
  URL_RE = re.compile(r"https?://[^\s<>()]+")
28
  YEAR_RE = re.compile(r"\d{4}")
29
 
 
72
  templates.filters["linkify"] = linkify
73
  templates.filters["course_code_for_year"] = course_code_for_year
74
  templates.globals["batch_label"] = batch_label
75
+
76
+ SEMESTER_NAMES = {
77
+ "1": "I",
78
+ "2": "II",
79
+ "3": "III",
80
+ "4": "IV",
81
+ "5": "V",
82
+ "6": "VI",
83
+ "7": "VII",
84
+ "8": "VIII",
85
+ }
86
+ templates.globals["SEMESTER_NAMES"] = SEMESTER_NAMES
backend/app/routes/chat.py CHANGED
@@ -135,12 +135,13 @@ When the user asks to change the active course, call create_course_draft with th
135
  When the user asks for changes across multiple courses or an uploaded document, inspect the curriculum or attachment text, then call create_document_draft with the affected courses.
136
  When the user asks what changed, call diff_course_json or read the relevant draft before answering.
137
  For broad document requests, use get_curriculum_json to inspect the whole syllabus before proposing edits.
 
138
  To fetch a public URL, call fetch_url and use the returned text.
139
  To search the web for current information, call web_search with a query.
140
  Never apply a draft, never claim a draft was applied, and never claim the refined database was changed.
141
  After creating a draft, tell the user to review the diff in the Review panel before applying it.
142
  After calling a tool, summarize the result for the user in natural language. Do not call another tool — stop and respond to the user.
143
- Do not change deterministic fields such as program, hours, credits, or course type.
144
 
145
  Course data access — prefer granular tools over full JSON:
146
  - get_course_codes: lightweight IDs (refined_id, course_code, title, semester) — use for lists/lookups
 
135
  When the user asks for changes across multiple courses or an uploaded document, inspect the curriculum or attachment text, then call create_document_draft with the affected courses.
136
  When the user asks what changed, call diff_course_json or read the relevant draft before answering.
137
  For broad document requests, use get_curriculum_json to inspect the whole syllabus before proposing edits.
138
+ For specialization management, call list_specializations to discover tracks, define_specialization to create one, and assign_elective_to_tracks / get_course_assignments to categorize electives.
139
  To fetch a public URL, call fetch_url and use the returned text.
140
  To search the web for current information, call web_search with a query.
141
  Never apply a draft, never claim a draft was applied, and never claim the refined database was changed.
142
  After creating a draft, tell the user to review the diff in the Review panel before applying it.
143
  After calling a tool, summarize the result for the user in natural language. Do not call another tool — stop and respond to the user.
144
+ To change deterministic fields (program, hours, credits, course_type), call update_deterministic_fields. This creates a draft that is blocked until the user explicitly approves it in the Review panel. Confirm with the user before changing these fields.
145
 
146
  Course data access — prefer granular tools over full JSON:
147
  - get_course_codes: lightweight IDs (refined_id, course_code, title, semester) — use for lists/lookups
backend/app/routes/courses.py CHANGED
@@ -16,11 +16,26 @@ def list_courses():
16
  rows = attach_submissions(rows)
17
  except APIError as exc:
18
  raise database_http_exception(exc) from exc
19
- courses = [build_course_preview(row) | {"id": row["id"]} for row in rows]
20
  courses.sort(key=lambda item: (int(item.get("semester") or 0), item.get("course_code") or "", item.get("course_title") or ""))
21
  return {"courses": courses}
22
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  @router.delete("/courses/{refined_id}")
25
  def delete_course(refined_id: int):
26
  try:
 
16
  rows = attach_submissions(rows)
17
  except APIError as exc:
18
  raise database_http_exception(exc) from exc
19
+ courses = [build_course_preview(row) | {"id": row["id"], "visible": row.get("visible", True)} for row in rows]
20
  courses.sort(key=lambda item: (int(item.get("semester") or 0), item.get("course_code") or "", item.get("course_title") or ""))
21
  return {"courses": courses}
22
 
23
 
24
+ @router.patch("/courses/{refined_id}/visible")
25
+ def set_visibility(refined_id: int, body: dict):
26
+ visible = body.get("visible")
27
+ if not isinstance(visible, bool):
28
+ raise HTTPException(status_code=400, detail="visible must be a boolean")
29
+ try:
30
+ row = first_row(supabase.table("refined_submissions").select("id").eq("id", refined_id).neq("status", "archived"))
31
+ if not row:
32
+ raise HTTPException(status_code=404, detail="Course not found")
33
+ result = supabase.table("refined_submissions").update({"visible": visible}).eq("id", refined_id).execute()
34
+ except APIError as exc:
35
+ raise database_http_exception(exc) from exc
36
+ return {"message": "Visibility updated", "course": result.data[0] if result.data else None}
37
+
38
+
39
  @router.delete("/courses/{refined_id}")
40
  def delete_course(refined_id: int):
41
  try:
backend/app/routes/preview.py CHANGED
@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Query
2
  from fastapi.responses import HTMLResponse, Response
3
  from weasyprint import HTML
4
 
5
- from app.preview import build_course_preview
6
  from app.rendering import FRONTEND_DIR, templates
7
  from app.services.curriculum import attach_submissions, ordered_courses, selected_curriculum_year
8
  from app.supabase import first_row, supabase
@@ -12,13 +12,13 @@ router = APIRouter()
12
 
13
  @router.get("/preview/semester/{sem}/courses")
14
  def list_courses(sem: int):
15
- result = supabase.table("refined_submissions").select("id").neq("status", "archived").eq("semester", sem).order("id").execute()
16
  return {"course_ids": [row["id"] for row in result.data]}
17
 
18
 
19
  @router.get("/preview/courses")
20
  def list_all_courses():
21
- result = supabase.table("refined_submissions").select("id,semester,course_code").neq("status", "archived").execute()
22
  rows = sorted(result.data, key=lambda row: (int(row.get("semester") or 0), str(row.get("course_code") or ""), int(row.get("id") or 0)))
23
  return {"course_ids": [row["id"] for row in rows]}
24
 
@@ -39,7 +39,7 @@ def preview_course(refined_id: int, curriculum_year: str | None = Query(None)):
39
 
40
  @router.get("/preview/html")
41
  def preview_all_html(curriculum_year: str | None = Query(None)):
42
- result = supabase.table("refined_submissions").select("*").neq("status", "archived").execute()
43
  courses = ordered_courses(result.data)
44
  html = templates.get_template("jinja_sample.html").render(
45
  courses=courses,
@@ -47,13 +47,14 @@ def preview_all_html(curriculum_year: str | None = Query(None)):
47
  curriculum_year=selected_curriculum_year(curriculum_year),
48
  asset_root="/",
49
  show_summaries=True,
 
50
  )
51
  return HTMLResponse(html, headers={"Cache-Control": "no-store"})
52
 
53
 
54
  @router.get("/preview/pdf")
55
  def download_all_pdf(download: bool = Query(False), curriculum_year: str | None = Query(None)):
56
- result = supabase.table("refined_submissions").select("*").neq("status", "archived").execute()
57
  courses = ordered_courses(result.data)
58
  html = templates.get_template("jinja_sample.html").render(
59
  courses=courses,
@@ -61,6 +62,7 @@ def download_all_pdf(download: bool = Query(False), curriculum_year: str | None
61
  curriculum_year=selected_curriculum_year(curriculum_year),
62
  asset_root="",
63
  show_summaries=True,
 
64
  )
65
  pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf()
66
  return pdf_response(pdf, "curriculum-preview.pdf", download)
@@ -68,7 +70,7 @@ def download_all_pdf(download: bool = Query(False), curriculum_year: str | None
68
 
69
  @router.get("/preview/semester/{sem}/pdf")
70
  def download_pdf(sem: int, download: bool = Query(False), curriculum_year: str | None = Query(None)):
71
- result = supabase.table("refined_submissions").select("*").neq("status", "archived").eq("semester", sem).order("id").execute()
72
  courses = ordered_courses(result.data)
73
  html = templates.get_template("jinja_sample.html").render(
74
  courses=courses,
@@ -76,6 +78,7 @@ def download_pdf(sem: int, download: bool = Query(False), curriculum_year: str |
76
  curriculum_year=selected_curriculum_year(curriculum_year),
77
  asset_root="",
78
  show_summaries=True,
 
79
  )
80
  pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf()
81
  return pdf_response(pdf, f"semester-{sem}.pdf", download)
 
2
  from fastapi.responses import HTMLResponse, Response
3
  from weasyprint import HTML
4
 
5
+ from app.preview import build_course_preview, build_specialization_context
6
  from app.rendering import FRONTEND_DIR, templates
7
  from app.services.curriculum import attach_submissions, ordered_courses, selected_curriculum_year
8
  from app.supabase import first_row, supabase
 
12
 
13
  @router.get("/preview/semester/{sem}/courses")
14
  def list_courses(sem: int):
15
+ result = supabase.table("refined_submissions").select("id").neq("status", "archived").eq("visible", True).eq("semester", sem).order("id").execute()
16
  return {"course_ids": [row["id"] for row in result.data]}
17
 
18
 
19
  @router.get("/preview/courses")
20
  def list_all_courses():
21
+ result = supabase.table("refined_submissions").select("id,semester,course_code").neq("status", "archived").eq("visible", True).execute()
22
  rows = sorted(result.data, key=lambda row: (int(row.get("semester") or 0), str(row.get("course_code") or ""), int(row.get("id") or 0)))
23
  return {"course_ids": [row["id"] for row in rows]}
24
 
 
39
 
40
  @router.get("/preview/html")
41
  def preview_all_html(curriculum_year: str | None = Query(None)):
42
+ result = supabase.table("refined_submissions").select("*").neq("status", "archived").eq("visible", True).execute()
43
  courses = ordered_courses(result.data)
44
  html = templates.get_template("jinja_sample.html").render(
45
  courses=courses,
 
47
  curriculum_year=selected_curriculum_year(curriculum_year),
48
  asset_root="/",
49
  show_summaries=True,
50
+ **build_specialization_context(selected_curriculum_year(curriculum_year)),
51
  )
52
  return HTMLResponse(html, headers={"Cache-Control": "no-store"})
53
 
54
 
55
  @router.get("/preview/pdf")
56
  def download_all_pdf(download: bool = Query(False), curriculum_year: str | None = Query(None)):
57
+ result = supabase.table("refined_submissions").select("*").neq("status", "archived").eq("visible", True).execute()
58
  courses = ordered_courses(result.data)
59
  html = templates.get_template("jinja_sample.html").render(
60
  courses=courses,
 
62
  curriculum_year=selected_curriculum_year(curriculum_year),
63
  asset_root="",
64
  show_summaries=True,
65
+ **build_specialization_context(selected_curriculum_year(curriculum_year)),
66
  )
67
  pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf()
68
  return pdf_response(pdf, "curriculum-preview.pdf", download)
 
70
 
71
  @router.get("/preview/semester/{sem}/pdf")
72
  def download_pdf(sem: int, download: bool = Query(False), curriculum_year: str | None = Query(None)):
73
+ result = supabase.table("refined_submissions").select("*").neq("status", "archived").eq("visible", True).eq("semester", sem).order("id").execute()
74
  courses = ordered_courses(result.data)
75
  html = templates.get_template("jinja_sample.html").render(
76
  courses=courses,
 
78
  curriculum_year=selected_curriculum_year(curriculum_year),
79
  asset_root="",
80
  show_summaries=True,
81
+ **build_specialization_context(selected_curriculum_year(curriculum_year)),
82
  )
83
  pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf()
84
  return pdf_response(pdf, f"semester-{sem}.pdf", download)
backend/app/routes/versions.py CHANGED
@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Query
2
  from fastapi.responses import HTMLResponse
3
  from postgrest.exceptions import APIError
4
 
5
- from app.preview import build_course_preview
6
  from app.rendering import templates
7
  from app.services.curriculum import attach_submissions, DEFAULT_CURRICULUM_YEAR, selected_curriculum_year, update_refined_fields
8
  from app.services.diffing import diff_course
@@ -218,6 +218,7 @@ def preview_version(version_id: int, diff: bool = Query(False), curriculum_year:
218
  curriculum_year=selected_curriculum_year(curriculum_year),
219
  asset_root="/",
220
  show_summaries=True,
 
221
  )
222
  return HTMLResponse(html, headers={"Cache-Control": "no-store"})
223
 
@@ -233,8 +234,9 @@ def preview_version(version_id: int, diff: bool = Query(False), curriculum_year:
233
  if current_course:
234
  base = dict(current_course)
235
  proposed = dict(v_course)
236
- course_diff = _build_course_diff(base, proposed)
237
- course_diffs.append({"base": base, "proposed": proposed, "course_diff": course_diff})
 
238
 
239
  html = templates.get_template("jinja_diff.html").render(
240
  course_diffs=course_diffs,
 
2
  from fastapi.responses import HTMLResponse
3
  from postgrest.exceptions import APIError
4
 
5
+ from app.preview import build_course_preview, build_specialization_context
6
  from app.rendering import templates
7
  from app.services.curriculum import attach_submissions, DEFAULT_CURRICULUM_YEAR, selected_curriculum_year, update_refined_fields
8
  from app.services.diffing import diff_course
 
218
  curriculum_year=selected_curriculum_year(curriculum_year),
219
  asset_root="/",
220
  show_summaries=True,
221
+ **build_specialization_context(selected_curriculum_year(curriculum_year)),
222
  )
223
  return HTMLResponse(html, headers={"Cache-Control": "no-store"})
224
 
 
234
  if current_course:
235
  base = dict(current_course)
236
  proposed = dict(v_course)
237
+ course_diff = _build_course_diff(proposed, base)
238
+ if any(v is not None for v in course_diff.values()):
239
+ course_diffs.append({"base": base, "proposed": proposed, "course_diff": course_diff})
240
 
241
  html = templates.get_template("jinja_diff.html").render(
242
  course_diffs=course_diffs,
backend/app/services/agent_tools.py CHANGED
@@ -438,6 +438,109 @@ def _create_curriculum_version(arguments: dict) -> dict:
438
  return {"version": version}
439
 
440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  def _signal_done(arguments: dict) -> dict:
442
  summary = str(arguments.get("summary") or "").strip()
443
  if not summary:
@@ -636,6 +739,81 @@ TOOLS: dict[str, AgentTool] = {
636
  },
637
  _create_curriculum_version,
638
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
  "signal_done": AgentTool(
640
  "signal_done",
641
  "Signal that the agent has completed the user's request. Provide a concise summary of what was accomplished. This ends the agent's turn.",
 
438
  return {"version": version}
439
 
440
 
441
+ def _define_specialization(arguments: dict) -> dict:
442
+ semester = _require_int(arguments, "semester")
443
+ name = str(arguments.get("name") or "").strip()
444
+ if not name:
445
+ raise ValueError("name is required")
446
+ letter = str(arguments.get("letter") or "").strip().upper()
447
+ if not letter:
448
+ existing = supabase.table("specialization_definitions").select("letter").eq("semester", semester).execute().data
449
+ used = {row["letter"] for row in existing}
450
+ for candidate in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
451
+ if candidate not in used:
452
+ letter = candidate
453
+ break
454
+ if not letter:
455
+ raise ValueError("No free specialization letter available for this semester")
456
+ academic_year = str(arguments.get("academic_year") or "").strip()
457
+ key = str(arguments.get("key") or "").strip().upper()
458
+ if not key:
459
+ key = name.split("(")[-1].rstrip(")") if "(" in name else name[:3].upper()
460
+ row = supabase.table("specialization_definitions").insert(
461
+ {"semester": semester, "letter": letter, "name": name, "key": key, "academic_year": academic_year}
462
+ ).execute().data[0]
463
+ return {"specialization": row}
464
+
465
+
466
+ def _list_specializations(arguments: dict) -> dict:
467
+ query = supabase.table("specialization_definitions").select("*")
468
+ if arguments.get("semester") is not None:
469
+ query = query.eq("semester", int(arguments["semester"]))
470
+ return {"specializations": query.order("semester").order("letter").execute().data}
471
+
472
+
473
+ def _assign_elective_to_tracks(arguments: dict) -> dict:
474
+ refined_id = _require_int(arguments, "refined_id")
475
+ spec_ids = arguments.get("specialization_ids")
476
+ if not isinstance(spec_ids, list) or not spec_ids:
477
+ raise ValueError("specialization_ids must be a non-empty array")
478
+ created = 0
479
+ for spec_id in spec_ids:
480
+ existing = (
481
+ supabase.table("course_specialization_assignments")
482
+ .select("id")
483
+ .eq("refined_id", refined_id)
484
+ .eq("specialization_id", int(spec_id))
485
+ .execute()
486
+ .data
487
+ )
488
+ if not existing:
489
+ supabase.table("course_specialization_assignments").insert(
490
+ {"refined_id": refined_id, "specialization_id": int(spec_id)}
491
+ ).execute()
492
+ created += 1
493
+ supabase.table("refined_submissions").update({"is_elective": True}).eq("id", refined_id).execute()
494
+ return {"assignments_created": created}
495
+
496
+
497
+ def _remove_elective_from_tracks(arguments: dict) -> dict:
498
+ refined_id = _require_int(arguments, "refined_id")
499
+ spec_ids = arguments.get("specialization_ids")
500
+ if not isinstance(spec_ids, list) or not spec_ids:
501
+ raise ValueError("specialization_ids must be a non-empty array")
502
+ removed = 0
503
+ for spec_id in spec_ids:
504
+ result = (
505
+ supabase.table("course_specialization_assignments")
506
+ .delete()
507
+ .eq("refined_id", refined_id)
508
+ .eq("specialization_id", int(spec_id))
509
+ .execute()
510
+ )
511
+ removed += len(result.data or [])
512
+ return {"assignments_removed": removed}
513
+
514
+
515
+ def _get_course_assignments(arguments: dict) -> dict:
516
+ refined_id = _require_int(arguments, "refined_id")
517
+ assignments = (
518
+ supabase.table("course_specialization_assignments")
519
+ .select("*, specialization_definitions(*)")
520
+ .eq("refined_id", refined_id)
521
+ .execute()
522
+ .data
523
+ )
524
+ return {"refined_id": refined_id, "assignments": assignments}
525
+
526
+
527
+ def _update_deterministic_fields(arguments: dict) -> dict:
528
+ from app.services.diffing import PROTECTED_FIELDS
529
+
530
+ refined_id = _require_int(arguments, "refined_id")
531
+ fields = _require_dict(arguments, "fields")
532
+ protected = {key: value for key, value in fields.items() if key in PROTECTED_FIELDS}
533
+ if not protected:
534
+ raise ValueError("No deterministic fields provided. Use create_course_draft for other fields.")
535
+ record = draft_record(refined_id, protected, str(arguments.get("reason") or "Explicit user request to change deterministic fields"))
536
+ record["status"] = "blocked"
537
+ draft = supabase.table("agent_drafts").insert(record).execute().data[0]
538
+ return {
539
+ "draft": draft,
540
+ "warning": "This draft modifies deterministic fields (program, hours, credits, course_type). The user must explicitly approve it in the Review panel before it is applied.",
541
+ }
542
+
543
+
544
  def _signal_done(arguments: dict) -> dict:
545
  summary = str(arguments.get("summary") or "").strip()
546
  if not summary:
 
739
  },
740
  _create_curriculum_version,
741
  ),
742
+ "define_specialization": AgentTool(
743
+ "define_specialization",
744
+ "Create a specialization track (e.g. Machine Intelligence and Data Science) for a given semester. The letter (A, B, C...) is auto-assigned if omitted. Used to set up the elective specialization brackets.",
745
+ {
746
+ **OBJECT,
747
+ "properties": {
748
+ "semester": {"type": "integer", "minimum": 1, "maximum": 8, "description": "Semester the specialization applies to (5 or 6 for electives)"},
749
+ "name": {"type": "string", "description": "Full specialization name, e.g. 'Machine Intelligence and Data Science (MIDS)'"},
750
+ "letter": {"type": "string", "description": "Optional letter label (A, B, C). Auto-assigned if omitted."},
751
+ "key": {"type": "string", "description": "Optional short key (SCC, MIDS, CSCS). Derived from name if omitted."},
752
+ "academic_year": {"type": "string", "description": "Optional academic year batch, e.g. 2025-26"},
753
+ },
754
+ "required": ["semester", "name"],
755
+ },
756
+ _define_specialization,
757
+ ),
758
+ "list_specializations": AgentTool(
759
+ "list_specializations",
760
+ "List specialization track definitions, optionally filtered by semester. Use to discover which tracks exist before assigning electives.",
761
+ {
762
+ **OBJECT,
763
+ "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}},
764
+ },
765
+ _list_specializations,
766
+ ),
767
+ "assign_elective_to_tracks": AgentTool(
768
+ "assign_elective_to_tracks",
769
+ "Categorize an elective course into one or more specialization tracks by their specialization_id. Also marks the course as an elective. Use after determining (e.g. via AI analysis) which tracks the course belongs to.",
770
+ {
771
+ **OBJECT,
772
+ "properties": {
773
+ "refined_id": {"type": "integer"},
774
+ "specialization_ids": {"type": "array", "items": {"type": "integer"}, "description": "Specialization track IDs to assign the elective to"},
775
+ },
776
+ "required": ["refined_id", "specialization_ids"],
777
+ },
778
+ _assign_elective_to_tracks,
779
+ ),
780
+ "remove_elective_from_tracks": AgentTool(
781
+ "remove_elective_from_tracks",
782
+ "Remove an elective course from one or more specialization tracks.",
783
+ {
784
+ **OBJECT,
785
+ "properties": {
786
+ "refined_id": {"type": "integer"},
787
+ "specialization_ids": {"type": "array", "items": {"type": "integer"}, "description": "Specialization track IDs to remove the elective from"},
788
+ },
789
+ "required": ["refined_id", "specialization_ids"],
790
+ },
791
+ _remove_elective_from_tracks,
792
+ ),
793
+ "get_course_assignments": AgentTool(
794
+ "get_course_assignments",
795
+ "Return which specialization tracks a course currently belongs to, including the track definitions.",
796
+ {
797
+ **OBJECT,
798
+ "properties": {"refined_id": {"type": "integer"}},
799
+ "required": ["refined_id"],
800
+ },
801
+ _get_course_assignments,
802
+ ),
803
+ "update_deterministic_fields": AgentTool(
804
+ "update_deterministic_fields",
805
+ "Create a reviewable draft that changes deterministic/protected fields (program, lecture_hours, tutorial_hours, practical_hours, self_study, credits, course_type). This is the ONLY way to modify those fields. The resulting draft is blocked until the user explicitly approves it in the Review panel. Always confirm with the user before calling this.",
806
+ {
807
+ **OBJECT,
808
+ "properties": {
809
+ "refined_id": {"type": "integer"},
810
+ "fields": {"type": "object", "description": "Protected fields to change, e.g. {\"credits\": 5, \"lecture_hours\": 4}"},
811
+ "reason": {"type": "string"},
812
+ },
813
+ "required": ["refined_id", "fields"],
814
+ },
815
+ _update_deterministic_fields,
816
+ ),
817
  "signal_done": AgentTool(
818
  "signal_done",
819
  "Signal that the agent has completed the user's request. Provide a concise summary of what was accomplished. This ends the agent's turn.",
backend/app/services/curriculum.py CHANGED
@@ -82,7 +82,17 @@ def ordered_courses(rows: list[dict]) -> list[dict]:
82
  return [build_course_preview(row) for row in rows]
83
 
84
 
85
- def course_sort_key(row: dict) -> tuple[int, int, int]:
 
 
 
 
 
 
 
 
 
 
86
  semester = int(row.get("semester") or 0)
87
  code = str(row.get("course_code") or "").replace(" ", "").upper()
88
  order = SOURCE_ORDER.get(code)
@@ -90,7 +100,7 @@ def course_sort_key(row: dict) -> tuple[int, int, int]:
90
  order = elective_order(code, "AA", "AB")
91
  if order is None and semester == 6:
92
  order = elective_order(code, "BA", "BB")
93
- return semester, order or 900, int(row.get("id") or 0)
94
 
95
 
96
  def elective_order(code: str, first_group: str, second_group: str) -> int | None:
 
82
  return [build_course_preview(row) for row in rows]
83
 
84
 
85
+ def course_credits(row: dict) -> int:
86
+ value = row.get("credits")
87
+ if value not in (None, ""):
88
+ return int(value)
89
+ category = str((row.get("_submission") or {}).get("credit_category") or "").strip()
90
+ if category.isdigit():
91
+ return int(category)
92
+ return 0
93
+
94
+
95
+ def course_sort_key(row: dict) -> tuple[int, int, int, int]:
96
  semester = int(row.get("semester") or 0)
97
  code = str(row.get("course_code") or "").replace(" ", "").upper()
98
  order = SOURCE_ORDER.get(code)
 
100
  order = elective_order(code, "AA", "AB")
101
  if order is None and semester == 6:
102
  order = elective_order(code, "BA", "BB")
103
+ return semester, -course_credits(row), order or 900, int(row.get("id") or 0)
104
 
105
 
106
  def elective_order(code: str, first_group: str, second_group: str) -> int | None:
backend/app/services/schema.py CHANGED
@@ -13,6 +13,8 @@ REQUIRED_TABLES = (
13
  "chat_sessions",
14
  "chat_messages",
15
  "chat_attachments",
 
 
16
  )
17
 
18
 
 
13
  "chat_sessions",
14
  "chat_messages",
15
  "chat_attachments",
16
+ "specialization_definitions",
17
+ "course_specialization_assignments",
18
  )
19
 
20
 
backend/app/templates/jinja_1_to_8.html CHANGED
@@ -1,7 +1,3 @@
1
- {% macro semester_name(value) -%}
2
- {{ {"1": "I", "2": "II", "3": "III", "4": "IV", "7": "VII", "8": "VIII"}.get(value|string, value) }}
3
- {%- endmacro %}
4
-
5
  {% macro course_row(course, number) -%}
6
  <tr>
7
  <td class="center">{{ number }}</td>
@@ -53,7 +49,7 @@
53
  {% endfor %}
54
 
55
  {% if totals.count %}
56
- <h2 class="semester-title">{{ semester_name(summary_semester) }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
57
  <table class="meta summary-table">
58
  <colgroup>
59
  <col class="summary-number" />
@@ -185,7 +181,7 @@
185
  {% endfor %}
186
 
187
  {% if totals.count %}
188
- <h2 class="semester-title">{{ semester_name(summary_semester) }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
189
  <table class="meta summary-table final-year-table">
190
  <colgroup>
191
  <col class="final-number" />
 
 
 
 
 
1
  {% macro course_row(course, number) -%}
2
  <tr>
3
  <td class="center">{{ number }}</td>
 
49
  {% endfor %}
50
 
51
  {% if totals.count %}
52
+ <h2 class="semester-title">{{ SEMESTER_NAMES[summary_semester|string] }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
53
  <table class="meta summary-table">
54
  <colgroup>
55
  <col class="summary-number" />
 
181
  {% endfor %}
182
 
183
  {% if totals.count %}
184
+ <h2 class="semester-title">{{ SEMESTER_NAMES[summary_semester|string] }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
185
  <table class="meta summary-table final-year-table">
186
  <colgroup>
187
  <col class="final-number" />
backend/app/templates/jinja_sem_5_6.html CHANGED
@@ -1,22 +1,18 @@
1
- {% macro semester_name(value) -%}
2
- {{ {"5": "V", "6": "VI"}.get(value|string, value) }}
3
- {%- endmacro %}
4
-
5
  {% macro course_row(course, number) -%}
6
  {% set code = course.course_code|string|upper|replace(" ", "") %}
7
  {% set title = course.course_title|string|lower %}
8
  {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
9
  {% set is_no_course = "no course offered" in title %}
10
- {% set is_elective = ("AA" in code or "AB" in code or "BA" in code or "BB" in code) and not is_placeholder and not is_no_course %}
11
  <tr>
12
  <td class="center">{{ number }}</td>
13
  <td>{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}</td>
14
  <td>{{ course.course_title }}</td>
15
- <td class="center">{% if is_no_course %}{% elif is_elective %}4{% else %}{{ course.lecture_hours }}{% endif %}</td>
16
- <td class="center">{% if is_no_course %}{% elif is_elective %}0{% else %}{{ course.tutorial_hours }}{% endif %}</td>
17
- <td class="center">{% if is_no_course %}{% elif is_elective %}0{% else %}{{ course.practical_hours }}{% endif %}</td>
18
- <td class="center">{% if is_no_course %}{% elif is_elective %}4{% else %}{{ course.self_study }}{% endif %}</td>
19
- <td class="center">{% if is_no_course %}{% elif is_elective %}4{% else %}{{ course.credits }}{% endif %}</td>
20
  <td>{{ course.tools_languages }}</td>
21
  <td>{% if is_no_course %}{% elif is_elective or is_placeholder %}Elective Course{% else %}{{ course.course_type }}{% endif %}</td>
22
  </tr>
@@ -26,10 +22,39 @@
26
  {% if total != credit_total %}{{ credit_total }}/{{ total }}{% else %}{{ total }}{% endif %}
27
  {%- endmacro %}
28
 
29
- {% macro specialization_codes(codes, summary_semester, curriculum_year) -%}
30
- {% for code in codes %}
31
- <p>{{ ("UE00CS" ~ code) | course_code_for_year(summary_semester, curriculum_year) }}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  {% endfor %}
 
 
 
 
 
 
33
  {%- endmacro %}
34
 
35
  {% set requested_semester = semester|string if semester is defined and semester else "" %}
@@ -39,11 +64,9 @@
39
  {% for summary_course in course_list %}
40
  {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
41
  {% set title = summary_course.course_title|string|lower %}
42
- {% set course_type = summary_course.course_type|string|lower %}
43
  {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
44
- {% set has_elective_code = "AA" in code or "AB" in code or "BA" in code or "BB" in code %}
45
- {% set is_actual_elective = has_elective_code and not is_placeholder %}
46
- {% if summary_course.semester|string == summary_semester|string and not is_actual_elective %}
47
  {% set credit_value = summary_course.credits|int %}
48
  {% set totals.count = totals.count + 1 %}
49
  {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %}
@@ -56,23 +79,24 @@
56
  {% endif %}
57
  {% endif %}
58
  {% endfor %}
 
 
 
 
 
 
59
 
60
  {% set elective_a = namespace(count=0) %}
61
  {% set elective_b = namespace(count=0) %}
62
  {% for summary_course in course_list %}
63
  {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
64
  {% set title = summary_course.course_title|string|lower %}
65
- {% set course_type = summary_course.course_type|string|lower %}
66
  {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
67
- {% set is_elective = not is_placeholder %}
68
- {% if summary_course.semester|string == summary_semester|string and is_elective %}
69
- {% if summary_semester == 5 and "AA" in code %}
70
  {% set elective_a.count = elective_a.count + 1 %}
71
- {% elif summary_semester == 5 and "AB" in code %}
72
- {% set elective_b.count = elective_b.count + 1 %}
73
- {% elif summary_semester == 6 and "BA" in code %}
74
- {% set elective_a.count = elective_a.count + 1 %}
75
- {% elif summary_semester == 6 and "BB" in code %}
76
  {% set elective_b.count = elective_b.count + 1 %}
77
  {% endif %}
78
  {% endif %}
@@ -84,7 +108,7 @@
84
  <img class="seal" src="{{ asset_root | default('') }}images/image2.png" alt="PES University logo" />
85
  <img class="wordmark" src="{{ asset_root | default('') }}images/image1.png" alt="PES University" />
86
  </header>
87
- <h2 class="semester-title">{{ semester_name(summary_semester) }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
88
 
89
  {% if totals.count %}
90
  <table class="meta summary-table">
@@ -121,15 +145,30 @@
121
  {% for summary_course in course_list %}
122
  {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
123
  {% set title = summary_course.course_title|string|lower %}
124
- {% set course_type = summary_course.course_type|string|lower %}
125
  {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
126
- {% set has_elective_code = "AA" in code or "AB" in code or "BA" in code or "BB" in code %}
127
- {% set is_actual_elective = has_elective_code and not is_placeholder %}
128
- {% if summary_course.semester|string == summary_semester|string and not is_actual_elective %}
129
  {{ course_row(summary_course, row.number) }}
130
  {% set row.number = row.number + 1 %}
131
  {% endif %}
132
  {% endfor %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  <tr>
134
  <th colspan="3" class="center">Total</th>
135
  <td class="center">{{ total_cell(totals.lecture, totals.lecture_credit) }}</td>
@@ -145,6 +184,8 @@
145
  {% endif %}
146
 
147
  {% for group in ["a", "b"] %}
 
 
148
  <table class="meta summary-table">
149
  <colgroup>
150
  <col class="summary-number" />
@@ -172,38 +213,27 @@
172
  {% for summary_course in course_list %}
173
  {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
174
  {% set title = summary_course.course_title|string|lower %}
175
- {% set course_type = summary_course.course_type|string|lower %}
176
  {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
177
- {% set is_elective = not is_placeholder %}
178
  {% set is_group_a = (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %}
179
  {% set is_group_b = (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %}
180
- {% if summary_course.semester|string == summary_semester|string and is_elective and ((group == "a" and is_group_a) or (group == "b" and is_group_b)) %}
181
  {{ course_row(summary_course, row.number) }}
182
  {% set row.number = row.number + 1 %}
183
  {% endif %}
184
  {% endfor %}
185
  </tbody>
186
  </table>
 
187
  {% endfor %}
188
 
189
- {% if summary_semester in [5, 6] %}
190
- {% if summary_semester == 5 %}
191
- {% set first_label = "Elective-I" %}
192
- {% set second_label = "Elective-II" %}
193
- {% set specializations = [
194
- {"letter": "A", "name": "System and Core Computing (SCC)", "first": ["342AA1", "342AA8", "342AA9"], "second": ["343AB1", "343AB2", "343AB3", "343AB4", "343AB8"]},
195
- {"letter": "B", "name": "Machine Intelligence and Data Science (MIDS)", "first": ["342AA2", "342AA5", "342AA6", "342AA8"], "second": ["343AB1", "342AB2", "342AB7"]},
196
- {"letter": "C", "name": "Cyber Security and Connected Systems (CSCS)", "first": ["342AA3", "342AA4", "342AA5", "342AA7"], "second": ["343AB5", "342AB6", "342AB7"]},
197
- ] %}
198
- {% else %}
199
- {% set first_label = "Elective-III" %}
200
- {% set second_label = "Elective-IV" %}
201
- {% set specializations = [
202
- {"letter": "A", "name": "System and Core Computing (SCC)", "first": ["342BA1", "342BA2", "342BA3"], "second": ["343BB1", "343BB3", "343BB5", "343BB10"]},
203
- {"letter": "B", "name": "Machine Intelligence and Data Science (MIDS)", "first": ["342BA2", "342BA3", "342BA4", "342BA7", "342BA10"], "second": ["343BB2", "343BB4", "343BB7", "343BB9", "343BB10"]},
204
- {"letter": "C", "name": "Cyber Security and Connected Systems (CSCS)", "first": ["342BA5", "342BA6", "342BA7", "342BA8"], "second": ["343BB6", "343BB7", "343BB8", "343BB9", "343BB11", "343BB12"]},
205
- ] %}
206
  {% endif %}
 
 
207
  <table class="meta summary-table specialization-table">
208
  <colgroup>
209
  <col class="summary-number" />
@@ -218,16 +248,11 @@
218
  <tr>
219
  <th class="center">Sl. No.</th>
220
  <th>Specialization</th>
221
- <th>{{ first_label }}</th>
222
- <th>{{ second_label }}</th>
223
  </tr>
224
- {% for specialization in specializations %}
225
- <tr>
226
- <td class="center">{{ specialization.letter }}</td>
227
- <td>{{ specialization.name }}</td>
228
- <td>{{ specialization_codes(specialization.first, summary_semester, curriculum_year) }}</td>
229
- <td>{{ specialization_codes(specialization.second, summary_semester, curriculum_year) }}</td>
230
- </tr>
231
  {% endfor %}
232
  </tbody>
233
  </table>
 
 
 
 
 
1
  {% macro course_row(course, number) -%}
2
  {% set code = course.course_code|string|upper|replace(" ", "") %}
3
  {% set title = course.course_title|string|lower %}
4
  {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
5
  {% set is_no_course = "no course offered" in title %}
6
+ {% set is_elective = course.is_elective %}
7
  <tr>
8
  <td class="center">{{ number }}</td>
9
  <td>{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}</td>
10
  <td>{{ course.course_title }}</td>
11
+ <td class="center">{% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.lecture_hours }}{% endif %}</td>
12
+ <td class="center">{% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.tutorial_hours }}{% endif %}</td>
13
+ <td class="center">{% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.practical_hours }}{% endif %}</td>
14
+ <td class="center">{% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.self_study }}{% endif %}</td>
15
+ <td class="center">{% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.credits }}{% endif %}</td>
16
  <td>{{ course.tools_languages }}</td>
17
  <td>{% if is_no_course %}{% elif is_elective or is_placeholder %}Elective Course{% else %}{{ course.course_type }}{% endif %}</td>
18
  </tr>
 
22
  {% if total != credit_total %}{{ credit_total }}/{{ total }}{% else %}{{ total }}{% endif %}
23
  {%- endmacro %}
24
 
25
+ {% macro spec_codes_for(spec, summary_semester) -%}
26
+ {% set first = [] %}
27
+ {% set second = [] %}
28
+ {% for assign in specialization_assignments|default([]) %}
29
+ {% if assign.specialization_id == spec.id %}
30
+ {% for c in course_list %}
31
+ {% set code = c.course_code|string|upper|replace(" ", "") %}
32
+ {% set title = c.course_title|string|lower %}
33
+ {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
34
+ {% set is_no_course = "no course offered" in title %}
35
+ {% if c.refined_id|string == assign.refined_id|string
36
+ and c.semester|string == summary_semester|string
37
+ and c.is_elective
38
+ and not is_placeholder
39
+ and not is_no_course %}
40
+ {% set adjusted = c.course_code | course_code_for_year(c.semester, curriculum_year) %}
41
+ {% if (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %}
42
+ {% do first.append(adjusted) %}
43
+ {% elif (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %}
44
+ {% do second.append(adjusted) %}
45
+ {% else %}
46
+ {% do first.append(adjusted) %}
47
+ {% endif %}
48
+ {% endif %}
49
+ {% endfor %}
50
+ {% endif %}
51
  {% endfor %}
52
+ <tr>
53
+ <td class="center">{{ spec.letter }}</td>
54
+ <td>{{ spec.name }}</td>
55
+ <td>{% for code in first %}<p>{{ code }}</p>{% endfor %}</td>
56
+ <td>{% for code in second %}<p>{{ code }}</p>{% endfor %}</td>
57
+ </tr>
58
  {%- endmacro %}
59
 
60
  {% set requested_semester = semester|string if semester is defined and semester else "" %}
 
64
  {% for summary_course in course_list %}
65
  {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
66
  {% set title = summary_course.course_title|string|lower %}
 
67
  {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
68
+ {% set is_no_course = "no course offered" in title %}
69
+ {% if summary_course.semester|string == summary_semester|string and not summary_course.is_elective and not is_placeholder and not is_no_course %}
 
70
  {% set credit_value = summary_course.credits|int %}
71
  {% set totals.count = totals.count + 1 %}
72
  {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %}
 
79
  {% endif %}
80
  {% endif %}
81
  {% endfor %}
82
+ {% set placeholder_names = ["Elective I", "Elective II"] if summary_semester == 5 else ["Elective III", "Elective IV"] %}
83
+ {% set has_real_courses = totals.count > 0 %}
84
+ {% if has_real_courses %}
85
+ {% set totals.count = totals.count + placeholder_names|length %}
86
+ {% set totals.credits = totals.credits + placeholder_names|length * 4 %}
87
+ {% endif %}
88
 
89
  {% set elective_a = namespace(count=0) %}
90
  {% set elective_b = namespace(count=0) %}
91
  {% for summary_course in course_list %}
92
  {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
93
  {% set title = summary_course.course_title|string|lower %}
 
94
  {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
95
+ {% set is_no_course = "no course offered" in title %}
96
+ {% if summary_course.semester|string == summary_semester|string and summary_course.is_elective and not is_placeholder and not is_no_course %}
97
+ {% if (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %}
98
  {% set elective_a.count = elective_a.count + 1 %}
99
+ {% elif (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %}
 
 
 
 
100
  {% set elective_b.count = elective_b.count + 1 %}
101
  {% endif %}
102
  {% endif %}
 
108
  <img class="seal" src="{{ asset_root | default('') }}images/image2.png" alt="PES University logo" />
109
  <img class="wordmark" src="{{ asset_root | default('') }}images/image1.png" alt="PES University" />
110
  </header>
111
+ <h2 class="semester-title">{{ SEMESTER_NAMES[summary_semester|string] }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
112
 
113
  {% if totals.count %}
114
  <table class="meta summary-table">
 
145
  {% for summary_course in course_list %}
146
  {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
147
  {% set title = summary_course.course_title|string|lower %}
 
148
  {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
149
+ {% set is_no_course = "no course offered" in title %}
150
+ {% if summary_course.semester|string == summary_semester|string and not summary_course.is_elective and not is_placeholder and not is_no_course %}
 
151
  {{ course_row(summary_course, row.number) }}
152
  {% set row.number = row.number + 1 %}
153
  {% endif %}
154
  {% endfor %}
155
+ {% if has_real_courses %}
156
+ {% for pname in placeholder_names %}
157
+ <tr>
158
+ <td class="center">{{ row.number }}</td>
159
+ <td></td>
160
+ <td>{{ pname }}</td>
161
+ <td class="center"></td>
162
+ <td class="center"></td>
163
+ <td class="center"></td>
164
+ <td class="center"></td>
165
+ <td class="center">4</td>
166
+ <td></td>
167
+ <td>Elective Course</td>
168
+ </tr>
169
+ {% set row.number = row.number + 1 %}
170
+ {% endfor %}
171
+ {% endif %}
172
  <tr>
173
  <th colspan="3" class="center">Total</th>
174
  <td class="center">{{ total_cell(totals.lecture, totals.lecture_credit) }}</td>
 
184
  {% endif %}
185
 
186
  {% for group in ["a", "b"] %}
187
+ {% set group_count = elective_a.count if group == "a" else elective_b.count %}
188
+ {% if group_count %}
189
  <table class="meta summary-table">
190
  <colgroup>
191
  <col class="summary-number" />
 
213
  {% for summary_course in course_list %}
214
  {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
215
  {% set title = summary_course.course_title|string|lower %}
 
216
  {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
217
+ {% set is_no_course = "no course offered" in title %}
218
  {% set is_group_a = (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %}
219
  {% set is_group_b = (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %}
220
+ {% if summary_course.semester|string == summary_semester|string and summary_course.is_elective and not is_placeholder and not is_no_course and ((group == "a" and is_group_a) or (group == "b" and is_group_b)) %}
221
  {{ course_row(summary_course, row.number) }}
222
  {% set row.number = row.number + 1 %}
223
  {% endif %}
224
  {% endfor %}
225
  </tbody>
226
  </table>
227
+ {% endif %}
228
  {% endfor %}
229
 
230
+ {% set semester_specs = [] %}
231
+ {% for spec in specializations|default([]) %}
232
+ {% if spec.semester|string == summary_semester|string %}
233
+ {% do semester_specs.append(spec) %}
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  {% endif %}
235
+ {% endfor %}
236
+ {% if semester_specs %}
237
  <table class="meta summary-table specialization-table">
238
  <colgroup>
239
  <col class="summary-number" />
 
248
  <tr>
249
  <th class="center">Sl. No.</th>
250
  <th>Specialization</th>
251
+ <th>{{ "Elective-I" if summary_semester == 5 else "Elective-III" }}</th>
252
+ <th>{{ "Elective-II" if summary_semester == 5 else "Elective-IV" }}</th>
253
  </tr>
254
+ {% for spec in semester_specs %}
255
+ {{ spec_codes_for(spec, summary_semester) }}
 
 
 
 
 
256
  {% endfor %}
257
  </tbody>
258
  </table>
docs/index.md CHANGED
@@ -163,6 +163,12 @@ The templates compose like this:
163
  WeasyPrint turns the rendered HTML into PDF for the `/preview/pdf` and
164
  `/preview/semester/{sem}/pdf` endpoints.
165
 
 
 
 
 
 
 
166
  ### 4. Specialization System (dynamic)
167
 
168
  Specialization brackets and elective membership are fully data-driven.
@@ -323,8 +329,12 @@ Run `docs/schema.sql` in the Supabase SQL editor. Required tables:
323
 
324
  Key columns on `refined_submissions`: `course_code, course_title, semester,
325
  credit_category, program, lecture_hours, tutorial_hours, practical_hours, self_study,
326
- credits, course_type, is_elective, units (jsonb), objectives/text_books/… (arrays),
327
- status`.
 
 
 
 
328
 
329
  Verify with `GET /api/health/schema`.
330
 
@@ -431,5 +441,5 @@ Either is fine; keep it isolated so it does not block the submission response.
431
  Templates live in `backend/app/templates/`. They receive context built in
432
  `services/preview.py` + the route. Prefer adding data to `build_course_preview` /
433
  `build_specialization_context` over hardcoding anything in HTML. The `do` extension
434
- is enabled, so mutable `{% set x = [] %}{% do x.append(...) %}` works. Always read
435
  list context through `|default([])` so a missing key never crashes rendering.
 
163
  WeasyPrint turns the rendered HTML into PDF for the `/preview/pdf` and
164
  `/preview/semester/{sem}/pdf` endpoints.
165
 
166
+ Course ordering within a semester is set by `course_sort_key` in
167
+ `services/curriculum.py`: courses sort by credits descending (5 before 4 before 2
168
+ before 0), then by the explicit `SOURCE_ORDER` position (or the `elective_order`
169
+ suffix rule for semesters 5/6), then by database id. Courses with `visible = false`
170
+ are excluded from all rendered output.
171
+
172
  ### 4. Specialization System (dynamic)
173
 
174
  Specialization brackets and elective membership are fully data-driven.
 
329
 
330
  Key columns on `refined_submissions`: `course_code, course_title, semester,
331
  credit_category, program, lecture_hours, tutorial_hours, practical_hours, self_study,
332
+ credits, course_type, is_elective, visible, units (jsonb), objectives/text_books/…
333
+ (arrays), status`.
334
+
335
+ `visible` (default `true`) controls whether a course renders in preview/PDF output.
336
+ Toggle it from the course management page. Hidden courses stay in the database and
337
+ remain editable but are excluded from every rendered document.
338
 
339
  Verify with `GET /api/health/schema`.
340
 
 
441
  Templates live in `backend/app/templates/`. They receive context built in
442
  `services/preview.py` + the route. Prefer adding data to `build_course_preview` /
443
  `build_specialization_context` over hardcoding anything in HTML. The `do` extension
444
+ is enabled, so mutable {% raw %}`{% set x = [] %}{% do x.append(...) %}`{% endraw %} works. Always read
445
  list context through `|default([])` so a missing key never crashes rendering.
docs/schema.sql CHANGED
@@ -34,6 +34,7 @@ create table if not exists refined_submissions (
34
  credits int not null default 0,
35
  course_type text not null default '',
36
  is_elective boolean not null default false,
 
37
  units jsonb not null default '[]'::jsonb,
38
  objectives text not null default '',
39
  course_outcomes text not null default '',
 
34
  credits int not null default 0,
35
  course_type text not null default '',
36
  is_elective boolean not null default false,
37
+ visible boolean not null default true,
38
  units jsonb not null default '[]'::jsonb,
39
  objectives text not null default '',
40
  course_outcomes text not null default '',
frontend/auth/index.html CHANGED
@@ -3,6 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
6
  <title>Sign In - PESU Curriculum Automation</title>
7
  <link rel="stylesheet" href="../shared.css" />
8
  <link rel="stylesheet" href="auth.css" />
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="../images/SE1128.png" />
7
  <title>Sign In - PESU Curriculum Automation</title>
8
  <link rel="stylesheet" href="../shared.css" />
9
  <link rel="stylesheet" href="auth.css" />
frontend/courses/courses.css CHANGED
@@ -99,6 +99,18 @@ th {
99
  background: #f8fafc;
100
  }
101
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  .details {
103
  display: grid;
104
  gap: 8px;
 
99
  background: #f8fafc;
100
  }
101
 
102
+ td.center {
103
+ text-align: center;
104
+ }
105
+
106
+ .hidden-row {
107
+ opacity: 0.5;
108
+ }
109
+
110
+ .hidden-row td:not(.center) {
111
+ text-decoration: line-through;
112
+ }
113
+
114
  .details {
115
  display: grid;
116
  gap: 8px;
frontend/courses/courses.js CHANGED
@@ -1,4 +1,5 @@
1
  const semester = document.getElementById("semester");
 
2
  const search = document.getElementById("search");
3
  const statusText = document.getElementById("status");
4
  const table = document.getElementById("course-table");
@@ -19,6 +20,8 @@ function cell(text) {
19
  function courseMatches(course) {
20
  const query = search.value.trim().toLowerCase();
21
  if (semester.value && String(course.semester) !== semester.value) return false;
 
 
22
  if (!query) return true;
23
  return `${course.course_code} ${course.course_title}`.toLowerCase().includes(query);
24
  }
@@ -26,7 +29,7 @@ function courseMatches(course) {
26
  function detailsRow(course) {
27
  const row = document.createElement("tr");
28
  const td = document.createElement("td");
29
- td.colSpan = 5;
30
  const details = document.createElement("div");
31
  details.className = "details";
32
  details.append(
@@ -54,7 +57,7 @@ function render() {
54
  if (!visible.length) {
55
  const row = document.createElement("tr");
56
  const td = document.createElement("td");
57
- td.colSpan = 5;
58
  td.className = "empty";
59
  td.textContent = "No courses found.";
60
  row.appendChild(td);
@@ -65,9 +68,20 @@ function render() {
65
 
66
  visible.forEach((course) => {
67
  const row = document.createElement("tr");
68
- row.className = "course-row";
69
  row.append(cell(course.semester), cell(course.course_code), cell(course.course_title), cell(course.credits));
70
 
 
 
 
 
 
 
 
 
 
 
 
71
  const action = document.createElement("td");
72
  const remove = document.createElement("button");
73
  remove.type = "button";
@@ -90,6 +104,23 @@ function render() {
90
  setStatus(`${visible.length} course${visible.length === 1 ? "" : "s"}.`, "ready");
91
  }
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  async function deleteCourse(course) {
94
  if (!confirm(`Delete ${course.course_code || course.course_title}?`)) return;
95
  setStatus("Deleting course...");
@@ -110,6 +141,7 @@ async function loadCourses() {
110
  }
111
 
112
  semester.addEventListener("change", render);
 
113
  search.addEventListener("input", render);
114
 
115
  loadCourses().catch((error) => {
 
1
  const semester = document.getElementById("semester");
2
+ const visibility = document.getElementById("visibility");
3
  const search = document.getElementById("search");
4
  const statusText = document.getElementById("status");
5
  const table = document.getElementById("course-table");
 
20
  function courseMatches(course) {
21
  const query = search.value.trim().toLowerCase();
22
  if (semester.value && String(course.semester) !== semester.value) return false;
23
+ if (visibility.value === "visible" && course.visible === false) return false;
24
+ if (visibility.value === "hidden" && course.visible !== false) return false;
25
  if (!query) return true;
26
  return `${course.course_code} ${course.course_title}`.toLowerCase().includes(query);
27
  }
 
29
  function detailsRow(course) {
30
  const row = document.createElement("tr");
31
  const td = document.createElement("td");
32
+ td.colSpan = 6;
33
  const details = document.createElement("div");
34
  details.className = "details";
35
  details.append(
 
57
  if (!visible.length) {
58
  const row = document.createElement("tr");
59
  const td = document.createElement("td");
60
+ td.colSpan = 6;
61
  td.className = "empty";
62
  td.textContent = "No courses found.";
63
  row.appendChild(td);
 
68
 
69
  visible.forEach((course) => {
70
  const row = document.createElement("tr");
71
+ row.className = course.visible === false ? "course-row hidden-row" : "course-row";
72
  row.append(cell(course.semester), cell(course.course_code), cell(course.course_title), cell(course.credits));
73
 
74
+ const visibleCell = document.createElement("td");
75
+ visibleCell.className = "center";
76
+ const toggle = document.createElement("input");
77
+ toggle.type = "checkbox";
78
+ toggle.checked = course.visible !== false;
79
+ toggle.title = "Include in rendered documents";
80
+ toggle.addEventListener("click", (event) => event.stopPropagation());
81
+ toggle.addEventListener("change", () => setVisibility(course, toggle));
82
+ visibleCell.appendChild(toggle);
83
+ row.appendChild(visibleCell);
84
+
85
  const action = document.createElement("td");
86
  const remove = document.createElement("button");
87
  remove.type = "button";
 
104
  setStatus(`${visible.length} course${visible.length === 1 ? "" : "s"}.`, "ready");
105
  }
106
 
107
+ async function setVisibility(course, toggle) {
108
+ const next = toggle.checked;
109
+ setStatus("Updating visibility...");
110
+ const response = await fetch(`/api/courses/${course.id}/visible`, {
111
+ method: "PATCH",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ visible: next }),
114
+ });
115
+ if (!response.ok) {
116
+ toggle.checked = !next;
117
+ setStatus("Unable to update visibility.", "error");
118
+ return;
119
+ }
120
+ course.visible = next;
121
+ render();
122
+ }
123
+
124
  async function deleteCourse(course) {
125
  if (!confirm(`Delete ${course.course_code || course.course_title}?`)) return;
126
  setStatus("Deleting course...");
 
141
  }
142
 
143
  semester.addEventListener("change", render);
144
+ visibility.addEventListener("change", render);
145
  search.addEventListener("input", render);
146
 
147
  loadCourses().catch((error) => {
frontend/courses/index.html CHANGED
@@ -3,6 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
6
  <title>Course Management</title>
7
  <link rel="stylesheet" href="courses.css" />
8
  <script src="courses.js" defer></script>
@@ -33,6 +34,11 @@
33
  <option value="7">Semester 7</option>
34
  <option value="8">Semester 8</option>
35
  </select>
 
 
 
 
 
36
  <input id="search" type="search" aria-label="Search courses" />
37
  <span class="status-line" id="status"></span>
38
  </div>
@@ -45,6 +51,7 @@
45
  <th>Code</th>
46
  <th>Course</th>
47
  <th>Credits</th>
 
48
  <th></th>
49
  </tr>
50
  </thead>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="../images/SE1128.png" />
7
  <title>Course Management</title>
8
  <link rel="stylesheet" href="courses.css" />
9
  <script src="courses.js" defer></script>
 
34
  <option value="7">Semester 7</option>
35
  <option value="8">Semester 8</option>
36
  </select>
37
+ <select id="visibility" aria-label="Visibility">
38
+ <option value="">All courses</option>
39
+ <option value="visible">Visible only</option>
40
+ <option value="hidden">Hidden only</option>
41
+ </select>
42
  <input id="search" type="search" aria-label="Search courses" />
43
  <span class="status-line" id="status"></span>
44
  </div>
 
51
  <th>Code</th>
52
  <th>Course</th>
53
  <th>Credits</th>
54
+ <th>Visible</th>
55
  <th></th>
56
  </tr>
57
  </thead>
frontend/form/form.css CHANGED
@@ -1,101 +1,157 @@
1
  @import "../shared.css";
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  .container {
4
- max-width: 720px;
5
- margin: 40px auto;
6
- padding: 0 16px;
7
  }
8
 
9
  .card {
10
  background: var(--surface);
11
  border: 1px solid var(--border);
12
  border-radius: 12px;
13
- padding: 32px;
14
- box-shadow: 0 4px 24px rgba(0, 55, 123, 0.06);
15
  }
16
 
17
- .card h1 {
18
- margin: 0 0 8px;
19
- font-size: 28px;
20
  font-weight: 700;
21
- color: var(--pesu-blue);
22
  }
23
 
24
  .subtitle {
25
- margin: 0 0 24px;
26
- color: var(--muted);
27
- font-size: 14px;
28
  }
29
 
30
  fieldset {
31
  border: none;
32
- padding: 0;
33
- margin: 0 0 24px;
34
  }
35
 
36
  legend {
37
- font-size: 14px;
38
  font-weight: 600;
39
- color: var(--pesu-blue);
40
- margin-bottom: 12px;
41
- padding-bottom: 4px;
42
- border-bottom: 2px solid var(--pesu-blue);
 
43
  }
44
 
45
  label {
46
  display: block;
47
- font-size: 13px;
48
  font-weight: 500;
49
- color: var(--text);
50
- margin-bottom: 6px;
51
- }
52
-
53
- .optional {
54
- font-weight: 400;
55
- color: var(--muted);
56
- font-size: 12px;
57
  }
58
 
59
- input[type="email"],
60
- input[type="text"],
61
- textarea {
62
  width: 100%;
63
- box-sizing: border-box;
64
- border: 1px solid var(--border-strong);
65
- border-radius: 6px;
66
- padding: 10px 12px;
67
- font: 14px/1.4 Arial, sans-serif;
68
- color: var(--text);
69
- background: var(--surface);
70
- transition: border-color 0.15s, box-shadow 0.15s;
71
  }
72
 
73
- input:focus,
74
- textarea:focus {
75
  outline: none;
76
- border-color: var(--pesu-blue);
77
- box-shadow: 0 0 0 3px rgba(0, 55, 123, 0.12);
78
  }
79
 
80
  textarea {
81
  resize: vertical;
82
- min-height: 100px;
83
  }
84
 
85
  .hint {
86
  display: block;
87
- margin-top: 4px;
88
- font-size: 12px;
89
- color: var(--muted);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  }
91
 
92
  .code-preview {
93
- margin-top: 8px;
94
- padding: 12px;
95
  background: #f0f4f8;
96
  border: 1px solid var(--border);
97
  border-radius: 8px;
98
- font-size: 13px;
99
  color: var(--text);
100
  }
101
 
@@ -105,15 +161,15 @@ textarea {
105
 
106
  .code-preview strong {
107
  display: block;
108
- margin-bottom: 8px;
109
- color: var(--pesu-blue);
110
  }
111
 
112
  .badge {
113
  display: inline-block;
114
  padding: 2px 8px;
115
  margin: 2px 4px 2px 0;
116
- font-size: 11px;
117
  font-weight: 600;
118
  border-radius: 4px;
119
  background: var(--pesu-blue);
@@ -124,60 +180,20 @@ textarea {
124
  background: var(--pesu-orange);
125
  }
126
 
127
- button[type="submit"] {
128
- width: 100%;
129
- padding: 14px;
130
- font-size: 15px;
131
- font-weight: 600;
132
- color: white;
133
- background: var(--pesu-blue);
134
- border: none;
135
- border-radius: 8px;
136
- cursor: pointer;
137
- transition: background 0.15s;
138
- }
139
-
140
- button[type="submit"]:hover:not(:disabled) {
141
- background: #002d66;
142
- }
143
-
144
- button[type="submit"]:disabled {
145
- opacity: 0.6;
146
- cursor: not-allowed;
147
- }
148
-
149
- #result {
150
- margin-top: 16px;
151
- padding: 14px;
152
- border-radius: 8px;
153
- font-size: 14px;
154
- text-align: center;
155
- }
156
-
157
- #result.hidden {
158
  display: none;
159
  }
160
 
161
- #result.error {
162
- background: var(--error-bg);
163
- color: var(--error);
164
- border: 1px solid var(--error);
165
- }
166
-
167
- #result.success {
168
- background: var(--success-bg);
169
- color: var(--success);
170
- border: 1px solid var(--success);
171
- }
172
-
173
  .form-link {
174
- display: inline-block;
175
- margin-top: 16px;
176
- font-size: 14px;
177
- color: var(--pesu-blue);
 
 
178
  text-decoration: none;
179
  }
180
 
181
  .form-link:hover {
182
- text-decoration: underline;
183
- }
 
1
  @import "../shared.css";
2
 
3
+ *, *::before, *::after {
4
+ box-sizing: border-box;
5
+ margin: 0;
6
+ padding: 0;
7
+ }
8
+
9
+ :root {
10
+ --bg: var(--page-bg);
11
+ --text: var(--pesu-blue);
12
+ --text-secondary: #5a6b8a;
13
+ --accent: var(--pesu-orange);
14
+ --accent-hover: #e05e00;
15
+ --optional: #8a9bb5;
16
+ }
17
+
18
+ body {
19
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
20
+ background: var(--bg);
21
+ color: var(--text);
22
+ line-height: 1.6;
23
+ min-height: 100dvh;
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ padding: 1rem;
28
+ }
29
+
30
  .container {
31
+ width: 100%;
32
+ max-width: 640px;
 
33
  }
34
 
35
  .card {
36
  background: var(--surface);
37
  border: 1px solid var(--border);
38
  border-radius: 12px;
39
+ padding: 2rem;
 
40
  }
41
 
42
+ h1 {
43
+ font-size: 1.5rem;
 
44
  font-weight: 700;
45
+ margin-bottom: 0.25rem;
46
  }
47
 
48
  .subtitle {
49
+ color: var(--text-secondary);
50
+ font-size: 0.875rem;
51
+ margin-bottom: 1.5rem;
52
  }
53
 
54
  fieldset {
55
  border: none;
56
+ margin-bottom: 1.5rem;
 
57
  }
58
 
59
  legend {
 
60
  font-weight: 600;
61
+ font-size: 1.125rem;
62
+ margin-bottom: 1rem;
63
+ padding-bottom: 0.5rem;
64
+ border-bottom: 1px solid var(--border);
65
+ width: 100%;
66
  }
67
 
68
  label {
69
  display: block;
70
+ font-size: 0.875rem;
71
  font-weight: 500;
72
+ margin-top: 1rem;
73
+ margin-bottom: 0.25rem;
 
 
 
 
 
 
74
  }
75
 
76
+ input, select, textarea {
 
 
77
  width: 100%;
78
+ font-family: inherit;
79
+ background-color: var(--surface);
80
+ transition: border-color 0.15s;
 
 
 
 
 
81
  }
82
 
83
+ input:focus, select:focus, textarea:focus {
 
84
  outline: none;
85
+ border-color: var(--accent);
 
86
  }
87
 
88
  textarea {
89
  resize: vertical;
 
90
  }
91
 
92
  .hint {
93
  display: block;
94
+ font-size: 0.75rem;
95
+ color: var(--text-secondary);
96
+ margin-top: 0.25rem;
97
+ }
98
+
99
+ .optional {
100
+ color: var(--optional);
101
+ font-weight: 400;
102
+ font-size: 0.75rem;
103
+ }
104
+
105
+ button {
106
+ width: 100%;
107
+ padding: 0.625rem;
108
+ border: none;
109
+ border-radius: 6px;
110
+ background: var(--accent);
111
+ color: var(--bg);
112
+ font-size: 0.875rem;
113
+ font-weight: 600;
114
+ cursor: pointer;
115
+ margin-top: 1rem;
116
+ transition: background 0.15s;
117
+ }
118
+
119
+ button:hover {
120
+ background: var(--accent-hover);
121
+ }
122
+
123
+ button:disabled {
124
+ opacity: 0.5;
125
+ cursor: not-allowed;
126
+ }
127
+
128
+ #result {
129
+ margin-top: 1rem;
130
+ padding: 1rem;
131
+ border-radius: 6px;
132
+ font-size: 0.875rem;
133
+ white-space: pre-wrap;
134
+ }
135
+
136
+ #result.success {
137
+ background: var(--success-bg);
138
+ color: var(--success);
139
+ border: 1px solid var(--success);
140
+ }
141
+
142
+ #result.error {
143
+ background: var(--error-bg);
144
+ color: var(--error);
145
+ border: 1px solid var(--error);
146
  }
147
 
148
  .code-preview {
149
+ margin-top: 0.5rem;
150
+ padding: 0.75rem;
151
  background: #f0f4f8;
152
  border: 1px solid var(--border);
153
  border-radius: 8px;
154
+ font-size: 0.8125rem;
155
  color: var(--text);
156
  }
157
 
 
161
 
162
  .code-preview strong {
163
  display: block;
164
+ margin-bottom: 0.5rem;
165
+ color: var(--accent);
166
  }
167
 
168
  .badge {
169
  display: inline-block;
170
  padding: 2px 8px;
171
  margin: 2px 4px 2px 0;
172
+ font-size: 0.6875rem;
173
  font-weight: 600;
174
  border-radius: 4px;
175
  background: var(--pesu-blue);
 
180
  background: var(--pesu-orange);
181
  }
182
 
183
+ .hidden {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  display: none;
185
  }
186
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  .form-link {
188
+ display: flex;
189
+ align-items: center;
190
+ justify-content: center;
191
+ margin-top: 8px;
192
+ color: var(--text);
193
+ font-size: 13px;
194
  text-decoration: none;
195
  }
196
 
197
  .form-link:hover {
198
+ color: var(--accent);
199
+ }
frontend/form/form.js CHANGED
@@ -27,8 +27,7 @@ function parseCode(code) {
27
  const offering = DEPT_OFFERING[dept] || "CS";
28
  const target = DEPT_TARGET[dept] || "CSE";
29
  let credit = "4";
30
- if (suffix === "A" || suffix === "B") credit = "4";
31
- else if (suffix === "A*" || suffix === "B*") credit = "0";
32
  else if (suffix.endsWith("XX") || suffix.endsWith("AX") || suffix.endsWith("BX") || ["AXX","ABX","BAX","BBX"].includes(suffix)) credit = "5";
33
  const isLateral = suffix.includes("*");
34
  return { year, dept, semester, offering, target, credit, isLateral, baseCode: c };
 
27
  const offering = DEPT_OFFERING[dept] || "CS";
28
  const target = DEPT_TARGET[dept] || "CSE";
29
  let credit = "4";
30
+ if (suffix === "A*" || suffix === "B*") credit = "0";
 
31
  else if (suffix.endsWith("XX") || suffix.endsWith("AX") || suffix.endsWith("BX") || ["AXX","ABX","BAX","BBX"].includes(suffix)) credit = "5";
32
  const isLateral = suffix.includes("*");
33
  return { year, dept, semester, offering, target, credit, isLateral, baseCode: c };
frontend/form/index.html CHANGED
@@ -3,6 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
6
  <title>Course Submission - PESU</title>
7
  <link rel="stylesheet" href="form.css" />
8
  <script src="form.js" defer></script>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="../images/SE1128.png" />
7
  <title>Course Submission - PESU</title>
8
  <link rel="stylesheet" href="form.css" />
9
  <script src="form.js" defer></script>
frontend/images/SE1128.png ADDED
frontend/index.html CHANGED
@@ -3,6 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
6
  <title>PESU Curriculum Automation</title>
7
  <link rel="stylesheet" href="home/home.css" />
8
  </head>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="images/SE1128.png" />
7
  <title>PESU Curriculum Automation</title>
8
  <link rel="stylesheet" href="home/home.css" />
9
  </head>
frontend/live-editor/index.html CHANGED
@@ -3,6 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
6
  <title>Live Editor - PESU</title>
7
  <link rel="stylesheet" href="live-editor.css" />
8
  </head>
@@ -34,6 +35,15 @@
34
  <button id="restore-version" type="button">Restore</button>
35
  </div>
36
 
 
 
 
 
 
 
 
 
 
37
  <div class="toolbar-group auth-controls">
38
  <button id="logout-btn" type="button" class="icon-btn" title="Sign Out">
39
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="../images/SE1128.png" />
7
  <title>Live Editor - PESU</title>
8
  <link rel="stylesheet" href="live-editor.css" />
9
  </head>
 
35
  <button id="restore-version" type="button">Restore</button>
36
  </div>
37
 
38
+ <div class="toolbar-group layout-controls">
39
+ <button id="toggle-pane" type="button" class="icon-btn" title="Expand chat / collapse preview">
40
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
41
+ <rect x="3" y="4" width="18" height="16" rx="2" />
42
+ <line x1="14" y1="4" x2="14" y2="20" />
43
+ </svg>
44
+ </button>
45
+ </div>
46
+
47
  <div class="toolbar-group auth-controls">
48
  <button id="logout-btn" type="button" class="icon-btn" title="Sign Out">
49
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
frontend/live-editor/live-editor.css CHANGED
@@ -43,9 +43,9 @@ select,
43
  input,
44
  button,
45
  a {
46
- height: 34px;
47
  border: 1px solid #9ca3af;
48
- border-radius: 4px;
49
  background-color: #fff;
50
  color: #111827;
51
  font-size: 14px;
@@ -53,8 +53,7 @@ a {
53
 
54
  select {
55
  min-width: 150px;
56
- max-width: 280px;
57
- background-color: #f8fafc;
58
  appearance: none;
59
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
60
  background-repeat: no-repeat;
@@ -62,6 +61,11 @@ select {
62
  background-size: 14px;
63
  padding-right: 28px;
64
  cursor: pointer;
 
 
 
 
 
65
  }
66
 
67
  button {
@@ -216,6 +220,11 @@ button:disabled {
216
  gap: 12px;
217
  min-height: 0;
218
  padding: 12px;
 
 
 
 
 
219
  }
220
 
221
  .preview-pane,
@@ -335,6 +344,12 @@ iframe {
335
  color: #00377b;
336
  }
337
 
 
 
 
 
 
 
338
  .chat-log {
339
  min-height: 0;
340
  overflow: auto;
 
43
  input,
44
  button,
45
  a {
46
+ height: 36px;
47
  border: 1px solid #9ca3af;
48
+ border-radius: 6px;
49
  background-color: #fff;
50
  color: #111827;
51
  font-size: 14px;
 
53
 
54
  select {
55
  min-width: 150px;
56
+ background-color: var(--surface);
 
57
  appearance: none;
58
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
59
  background-repeat: no-repeat;
 
61
  background-size: 14px;
62
  padding-right: 28px;
63
  cursor: pointer;
64
+ transition: border-color 0.15s;
65
+ }
66
+
67
+ select:hover {
68
+ border-color: #6b7280;
69
  }
70
 
71
  button {
 
220
  gap: 12px;
221
  min-height: 0;
222
  padding: 12px;
223
+ transition: grid-template-columns 0.18s ease;
224
+ }
225
+
226
+ .workspace.chat-focus {
227
+ grid-template-columns: minmax(0, 1fr) minmax(360px, 1.5fr);
228
  }
229
 
230
  .preview-pane,
 
344
  color: #00377b;
345
  }
346
 
347
+ .icon-btn.active {
348
+ background: #00377b;
349
+ border-color: #00377b;
350
+ color: #fff;
351
+ }
352
+
353
  .chat-log {
354
  min-height: 0;
355
  overflow: auto;
frontend/live-editor/live-editor.js CHANGED
@@ -38,6 +38,7 @@ const reviewSummary = document.getElementById("review-summary");
38
  const diffView = document.getElementById("diff-view");
39
  const previewDraft = document.getElementById("preview-draft");
40
  const applyDraft = document.getElementById("apply-draft");
 
41
  const logoutBtn = document.getElementById("logout-btn");
42
  let activeCourseId = "";
43
  let activeDraftId = "";
@@ -765,6 +766,14 @@ async function loadSemester(sem) {
765
  chatTab.addEventListener("click", () => setTab("chat"));
766
  fieldsTab.addEventListener("click", () => setTab("fields"));
767
  reviewTab.addEventListener("click", () => setTab("review"));
 
 
 
 
 
 
 
 
768
  viewer.addEventListener("load", () => loading.classList.remove("active"));
769
  preview.addEventListener("click", () => {
770
  if (viewer.src && viewer.src !== "about:blank") viewer.src = viewer.src;
 
38
  const diffView = document.getElementById("diff-view");
39
  const previewDraft = document.getElementById("preview-draft");
40
  const applyDraft = document.getElementById("apply-draft");
41
+ const togglePane = document.getElementById("toggle-pane");
42
  const logoutBtn = document.getElementById("logout-btn");
43
  let activeCourseId = "";
44
  let activeDraftId = "";
 
766
  chatTab.addEventListener("click", () => setTab("chat"));
767
  fieldsTab.addEventListener("click", () => setTab("fields"));
768
  reviewTab.addEventListener("click", () => setTab("review"));
769
+ if (togglePane) {
770
+ togglePane.addEventListener("click", () => {
771
+ const workspace = document.querySelector(".workspace");
772
+ const focused = workspace.classList.toggle("chat-focus");
773
+ togglePane.title = focused ? "Collapse chat / expand preview" : "Expand chat / collapse preview";
774
+ togglePane.classList.toggle("active", focused);
775
+ });
776
+ }
777
  viewer.addEventListener("load", () => loading.classList.remove("active"));
778
  preview.addEventListener("click", () => {
779
  if (viewer.src && viewer.src !== "about:blank") viewer.src = viewer.src;
frontend/preview/index.html CHANGED
@@ -3,6 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width,initial-scale=1.0" />
 
6
  <title>Curriculum Preview</title>
7
  <link rel="stylesheet" href="preview.css" />
8
  </head>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width,initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="../images/SE1128.png" />
7
  <title>Curriculum Preview</title>
8
  <link rel="stylesheet" href="preview.css" />
9
  </head>
frontend/versions/index.html CHANGED
@@ -3,6 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
6
  <title>Curriculum Versions</title>
7
  <link rel="stylesheet" href="versions.css" />
8
  <script src="/shared/auth-guard.js"></script>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="../images/SE1128.png" />
7
  <title>Curriculum Versions</title>
8
  <link rel="stylesheet" href="versions.css" />
9
  <script src="/shared/auth-guard.js"></script>
frontend/versions/versions.js CHANGED
@@ -166,7 +166,7 @@ async function loadVersions() {
166
  function loadVersion(versionId) {
167
  activeVersionId = String(versionId);
168
  versionList.querySelectorAll(".tree-item").forEach((el) => el.classList.toggle("active", el.dataset.versionId === String(versionId)));
169
- viewer.src = `/api/versions/${versionId}/preview?diff=1`;
170
  openEditor.href = `/live-editor/?version=${versionId}`;
171
  openEditor.hidden = false;
172
  setStatus(`Viewing diff for snapshot ${versionId}`);
 
166
  function loadVersion(versionId) {
167
  activeVersionId = String(versionId);
168
  versionList.querySelectorAll(".tree-item").forEach((el) => el.classList.toggle("active", el.dataset.versionId === String(versionId)));
169
+ viewer.src = `/api/versions/${versionId}/preview?diff=1&_t=${Date.now()}`;
170
  openEditor.href = `/live-editor/?version=${versionId}`;
171
  openEditor.hidden = false;
172
  setStatus(`Viewing diff for snapshot ${versionId}`);
tests/test_preview.py CHANGED
@@ -1,8 +1,46 @@
1
  from app.preview import build_course_preview
2
  from app.rendering import templates
3
- from app.services.curriculum import ordered_courses
4
 
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  def test_preview_uses_submission_deterministic_fields():
7
  row = {
8
  "course_title": "Data Structures",
@@ -12,7 +50,6 @@ def test_preview_uses_submission_deterministic_fields():
12
  "lecture_hours": 0,
13
  "_submission": {"credit_category": "5", "target_department": "CSE"},
14
  }
15
-
16
  course = build_course_preview(row)
17
 
18
  assert course["program"] == "B. TECH"
@@ -35,7 +72,6 @@ def test_preview_keeps_refined_source_values_when_present():
35
  "credits": 8,
36
  "_submission": {"credit_category": "4", "target_department": "CSE"},
37
  }
38
-
39
  course = build_course_preview(row)
40
 
41
  assert course["practical_hours"] == "16"
@@ -43,15 +79,76 @@ def test_preview_keeps_refined_source_values_when_present():
43
  assert course["course_type"] == "Internship"
44
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  def test_preview_suppresses_labs_for_theory_course():
47
  row = {
48
  "course_title": "Theory",
49
  "lab_experiments": ["Lab that should not render"],
50
  "_submission": {"credit_category": "4", "target_department": "CSE"},
51
  }
52
-
53
  course = build_course_preview(row)
54
-
55
  assert course["lab_experiments"] == []
56
 
57
 
@@ -61,9 +158,7 @@ def test_preview_parses_numbered_books():
61
  "text_books": ["1. First Book, 2024. 2. Second Book, 2025."],
62
  "_submission": {"credit_category": "4", "target_department": "CSE"},
63
  }
64
-
65
  course = build_course_preview(row)
66
-
67
  assert course["text_books"] == ["First Book, 2024.", "Second Book, 2025."]
68
 
69
 
@@ -78,37 +173,75 @@ Outcome should not render
78
  """,
79
  "_submission": {"credit_category": "4", "target_department": "CSE"},
80
  }
81
-
82
  course = build_course_preview(row)
83
-
84
  assert course["reference_books"] == ["First Reference, 2024.", "Second Reference, 2025."]
85
 
86
 
87
- def _course(**values):
88
- course = {
89
- "course_code": "",
90
- "course_title": "",
91
- "program": "B. TECH",
92
- "lecture_hours": "4",
93
- "tutorial_hours": "0",
94
- "practical_hours": "0",
95
- "self_study": "4",
96
- "credits": "4",
97
- "semester": "5",
98
- "course_type": "Core Course",
99
- "tools_languages": "",
100
- "desirable_knowledge": "",
101
- "prelude": "",
102
- "objectives": [],
103
- "course_outcomes": [],
104
- "units": [],
105
- "lab_experiments": [],
106
- "text_books": [],
107
- "reference_books": [],
108
- }
109
- course.update(values)
110
- return course
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
  def test_curriculum_template_renders_regular_semester_summary():
114
  html = templates.get_template("jinja_sample.html").render(
@@ -121,7 +254,6 @@ def test_curriculum_template_renders_regular_semester_summary():
121
  asset_root="",
122
  show_summaries=True,
123
  )
124
-
125
  assert "III SEMESTER (2024-28 BATCH)" in html
126
  assert "UE24CS251A" in html
127
  assert "Digital Design" in html
@@ -141,30 +273,86 @@ def test_curriculum_template_clubs_semester_one_and_two_summary():
141
  show_summaries=True,
142
  )
143
  early = html.split("III SEMESTER", 1)[0]
144
-
145
  assert "I SEMESTER (2025-29 BATCH)" in early
146
  assert "II SEMESTER (2025-29 BATCH)" in early
147
  assert early.count('class="summary-page"') == 1
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  def test_curriculum_template_renders_semester_five_elective_summary():
151
  html = templates.get_template("jinja_sample.html").render(
152
  courses=[
153
- _course(course_code="UE23CS351A", course_title="Database Management System", semester="5", lecture_hours="4", practical_hours="2", self_study="5", credits="5", course_type="Core Course-Lab Integrated"),
154
- _course(course_code="UE23CS342AAX", course_title="Elective I", semester="5", course_type="Elective Course"),
155
- _course(course_code="UE23CS342AA1", course_title="Advanced Algorithms", semester="5", course_type="Elective Course", tools_languages="C++"),
156
- _course(course_code="UE23CS343AB1", course_title="Image Processing", semester="5", course_type="Elective Course", tools_languages="Python"),
157
  ],
158
  semester=5,
159
  curriculum_year="2025-2026",
160
  asset_root="",
161
  show_summaries=True,
 
 
 
 
 
 
 
 
162
  )
163
-
164
  assert "V SEMESTER (2023-27 BATCH)" in html
165
  assert "Elective-I" in html
166
  assert "Elective-II" in html
167
- assert "Elective I" in html
168
  assert "Advanced Algorithms" in html
169
  assert "Image Processing" in html
170
  assert "ELECTIVES TO BE OPTED FOR SPECIALIZATION" in html
@@ -175,192 +363,121 @@ def test_curriculum_template_renders_semester_five_elective_summary():
175
  def test_curriculum_template_renders_semester_six_elective_groups():
176
  html = templates.get_template("jinja_sample.html").render(
177
  courses=[
178
- _course(course_code="UE23CS351B", course_title="Cloud Computing", semester="6", practical_hours="2", self_study="5", credits="5", course_type="Core Course-Lab Integrated"),
179
- _course(course_code="UE23CS342BAX", course_title="Elective III", semester="6", course_type="Elective Course"),
180
- _course(course_code="UE23CS343BBX", course_title="Elective IV", semester="6", course_type="Elective Course"),
181
- _course(course_code="UE23CS342BA1", course_title="Supply Chain Management for Engineers", semester="6", course_type="Elective Course"),
182
- _course(course_code="UE23CS343BB1", course_title="Heterogeneous Parallelism", semester="6", course_type="Elective Course"),
183
  ],
184
  semester=6,
185
  curriculum_year="2025-2026",
186
  asset_root="",
187
  show_summaries=True,
 
 
 
 
 
 
 
 
188
  )
189
-
190
  assert "VI SEMESTER (2023-27 BATCH)" in html
191
  assert "Elective-III" in html
192
  assert "Elective-IV" in html
193
  assert "ELECTIVES TO BE OPTED FOR SPECIALIZATION" in html
194
- assert "UE23CS343BB12" in html
195
 
196
 
197
- def test_curriculum_template_renders_final_year_summary():
198
  html = templates.get_template("jinja_sample.html").render(
199
  courses=[
200
- _course(course_code="UE22CS441A", course_title="Capstone Project Phase-III", semester="7", lecture_hours="0", practical_hours="16", self_study="4", credits="4", course_type="Project Work"),
201
- _course(course_code="UZ22UZ422A", course_title="Technical writing", semester="7", lecture_hours="0", tutorial_hours="2", self_study="2", credits="2", course_type="Special Topic"),
202
- _course(course_code="UE22AM421AXX", course_title="Special Topic", semester="7", lecture_hours="0", tutorial_hours="2", self_study="2", credits="2", course_type="Special Topic"),
203
- _course(course_code="UE22CS421B", course_title="Capstone Project Phase-IV", semester="8", lecture_hours="0", practical_hours="8", self_study="4", credits="4", course_type="Project Work"),
204
- _course(course_code="UE22CS461XB", course_title="Internship", semester="8", lecture_hours="0", practical_hours="16", self_study="8", credits="8", course_type="Internship"),
205
  ],
206
- semester="",
207
  curriculum_year="2025-2026",
208
  asset_root="",
209
  show_summaries=True,
210
  )
 
 
 
 
211
 
212
- assert "VII SEMESTER (2022-26 BATCH)" in html
213
- assert "VIII SEMESTER (2022-26 BATCH)" in html
214
- assert "Technical writing" in html
215
- assert "Internship" in html
216
- assert "AI Tools / Tools / Languages" not in html.split("VII SEMESTER", 1)[1].split("</article>", 1)[0]
 
 
 
 
 
 
 
 
 
 
217
 
218
 
219
- def test_summary_only_courses_do_not_render_detail_pages():
220
  html = templates.get_template("jinja_sample.html").render(
221
  courses=[
222
- _course(course_code="UZ24UZ221A", course_title="CIE L1", semester="3", credits="2", lecture_hours="2", self_study="2", course_type="Special Topic", status="summary_only", render_detail=False),
 
223
  ],
224
- semester=3,
225
  curriculum_year="2025-2026",
226
  asset_root="",
227
  show_summaries=True,
228
  )
 
 
229
 
230
- assert "CIE L1" in html
231
- assert "Course Objectives:" not in html
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
- def test_ordered_courses_preserves_refined_order_within_semester():
235
- rows = [
236
- {"id": 6, "semester": 5, "course_code": "UE23CS320A", "course_title": "Capstone Project", "credits": 2},
237
- {"id": 1, "semester": 5, "course_code": "UE23CS351A", "course_title": "Database Management System", "credits": 5},
238
- ]
239
 
240
- courses = ordered_courses(rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
- assert [course["course_title"] for course in courses] == ["Database Management System", "Capstone Project"]
243
-
244
-
245
- def test_preview_html_endpoint_returns_html(monkeypatch):
246
- from app.routes import preview
247
-
248
- # Mock the supabase chain
249
- class MockExecute:
250
- def execute(self):
251
- return type("Result", (), {"data": [
252
- {"id": 1, "course_code": "CS101", "course_title": "Test Course", "semester": "1", "credits": "4", "status": "refined", "lecture_hours": "4", "tutorial_hours": "0", "practical_hours": "0", "self_study": "4"}
253
- ]})()
254
-
255
- class MockSelect:
256
- def neq(self, *args, **kwargs):
257
- return self
258
- def execute(self):
259
- return MockExecute().execute()
260
-
261
- class MockTable:
262
- def select(self, *args, **kwargs):
263
- return MockSelect()
264
- def neq(self, *args, **kwargs):
265
- return self
266
-
267
- def mock_table(name):
268
- return MockTable()
269
-
270
- monkeypatch.setattr(preview.supabase, "table", mock_table)
271
-
272
- # Mock ordered_courses to return a simple dict with all required fields
273
- def mock_ordered_courses(rows):
274
- return [{
275
- "course_code": "CS101",
276
- "course_title": "Test Course",
277
- "semester": "1",
278
- "credits": "4",
279
- "lecture_hours": "4",
280
- "tutorial_hours": "0",
281
- "practical_hours": "0",
282
- "self_study": "4",
283
- "course_type": "Core Course",
284
- "program": "B. TECH",
285
- "tools_languages": "",
286
- "desirable_knowledge": "",
287
- "prelude": "",
288
- "objectives": [],
289
- "course_outcomes": [],
290
- "units": [],
291
- "lab_experiments": [],
292
- "text_books": [],
293
- "reference_books": [],
294
- "render_detail": True,
295
- }]
296
-
297
- monkeypatch.setattr(preview, "ordered_courses", mock_ordered_courses)
298
-
299
- # Call the endpoint function
300
- resp = preview.preview_all_html(None)
301
-
302
- # Should return HTMLResponse
303
- from fastapi.responses import HTMLResponse
304
- assert isinstance(resp, HTMLResponse)
305
- # HTML should contain the course title
306
- assert "Test Course" in resp.body.decode()
307
-
308
-
309
- def test_preview_pdf_endpoint_returns_pdf(monkeypatch):
310
- from app.routes import preview
311
-
312
- class MockExecute:
313
- def execute(self):
314
- return type("Result", (), {"data": [
315
- {"id": 1, "course_code": "CS101", "course_title": "Test Course", "semester": "1", "credits": "4", "status": "refined", "lecture_hours": "4", "tutorial_hours": "0", "practical_hours": "0", "self_study": "4"}
316
- ]})()
317
-
318
- class MockSelect:
319
- def neq(self, *args, **kwargs):
320
- return self
321
- def execute(self):
322
- return MockExecute().execute()
323
-
324
- class MockTable:
325
- def select(self, *args, **kwargs):
326
- return MockSelect()
327
-
328
- def mock_table(name):
329
- return MockTable()
330
-
331
- monkeypatch.setattr(preview.supabase, "table", mock_table)
332
-
333
- def mock_ordered_courses(rows):
334
- return [{
335
- "course_code": "CS101",
336
- "course_title": "Test Course",
337
- "semester": "1",
338
- "credits": "4",
339
- "lecture_hours": "4",
340
- "tutorial_hours": "0",
341
- "practical_hours": "0",
342
- "self_study": "4",
343
- "course_type": "Core Course",
344
- "program": "B. TECH",
345
- "tools_languages": "",
346
- "desirable_knowledge": "",
347
- "prelude": "",
348
- "objectives": [],
349
- "course_outcomes": [],
350
- "units": [],
351
- "lab_experiments": [],
352
- "text_books": [],
353
- "reference_books": [],
354
- "render_detail": True,
355
- }]
356
-
357
- monkeypatch.setattr(preview, "ordered_courses", mock_ordered_courses)
358
-
359
- # WeasyPrint will fail without fonts, so just check it attempts to generate
360
- try:
361
- resp = preview.download_all_pdf(False, "")
362
- assert hasattr(resp, "media_type")
363
- assert resp.media_type == "application/pdf"
364
- except Exception:
365
- # WeasyPrint may fail in test env without fonts - that's OK
366
- pass
 
1
  from app.preview import build_course_preview
2
  from app.rendering import templates
3
+ from app.services.curriculum import course_credits, ordered_courses
4
 
5
 
6
+ # ---------------------------------------------------------------------------
7
+ # _course helper
8
+ # ---------------------------------------------------------------------------
9
+
10
+ def _course(**values):
11
+ course = {
12
+ "course_code": "",
13
+ "course_title": "",
14
+ "program": "B. TECH",
15
+ "lecture_hours": "4",
16
+ "tutorial_hours": "0",
17
+ "practical_hours": "0",
18
+ "self_study": "4",
19
+ "credits": "4",
20
+ "semester": "5",
21
+ "course_type": "Core Course",
22
+ "tools_languages": "",
23
+ "desirable_knowledge": "",
24
+ "prelude": "",
25
+ "objectives": [],
26
+ "course_outcomes": [],
27
+ "units": [],
28
+ "lab_experiments": [],
29
+ "text_books": [],
30
+ "reference_books": [],
31
+ "is_elective": False,
32
+ "status": "refined",
33
+ "render_detail": True,
34
+ "refined_id": 1,
35
+ }
36
+ course.update(values)
37
+ return course
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # build_course_preview: deterministic fields
42
+ # ---------------------------------------------------------------------------
43
+
44
  def test_preview_uses_submission_deterministic_fields():
45
  row = {
46
  "course_title": "Data Structures",
 
50
  "lecture_hours": 0,
51
  "_submission": {"credit_category": "5", "target_department": "CSE"},
52
  }
 
53
  course = build_course_preview(row)
54
 
55
  assert course["program"] == "B. TECH"
 
72
  "credits": 8,
73
  "_submission": {"credit_category": "4", "target_department": "CSE"},
74
  }
 
75
  course = build_course_preview(row)
76
 
77
  assert course["practical_hours"] == "16"
 
79
  assert course["course_type"] == "Internship"
80
 
81
 
82
+ # ---------------------------------------------------------------------------
83
+ # build_course_preview: render_detail / has_content
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def test_render_detail_false_when_no_content():
87
+ row = {
88
+ "course_title": "Empty Course",
89
+ "semester": 3,
90
+ "_submission": {"credit_category": "4", "target_department": "CSE"},
91
+ }
92
+ course = build_course_preview(row)
93
+ assert course["render_detail"] is False
94
+
95
+
96
+ def test_render_detail_true_when_has_objectives():
97
+ row = {
98
+ "course_title": "Course With Content",
99
+ "semester": 3,
100
+ "objectives": ["Objective 1"],
101
+ "_submission": {"credit_category": "4", "target_department": "CSE"},
102
+ }
103
+ course = build_course_preview(row)
104
+ assert course["render_detail"] is True
105
+
106
+
107
+ def test_render_detail_true_when_has_text_books():
108
+ row = {
109
+ "course_title": "Course With Books",
110
+ "semester": 3,
111
+ "text_books": "1. Some Book",
112
+ "_submission": {"credit_category": "4", "target_department": "CSE"},
113
+ }
114
+ course = build_course_preview(row)
115
+ assert course["render_detail"] is True
116
+
117
+
118
+ def test_render_detail_true_when_has_units():
119
+ row = {
120
+ "course_title": "Course With Units",
121
+ "semester": 3,
122
+ "units": [{"title": "Unit 1", "content": "Content", "hours": 4}],
123
+ "_submission": {"credit_category": "4", "target_department": "CSE"},
124
+ }
125
+ course = build_course_preview(row)
126
+ assert course["render_detail"] is True
127
+
128
+
129
+ def test_render_detail_false_when_summary_only_even_with_content():
130
+ row = {
131
+ "course_title": "Hidden Course",
132
+ "semester": 3,
133
+ "status": "summary_only",
134
+ "objectives": ["Objective 1"],
135
+ "_submission": {"credit_category": "4", "target_department": "CSE"},
136
+ }
137
+ course = build_course_preview(row)
138
+ assert course["render_detail"] is False
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # build_course_preview: books / experiments
143
+ # ---------------------------------------------------------------------------
144
+
145
  def test_preview_suppresses_labs_for_theory_course():
146
  row = {
147
  "course_title": "Theory",
148
  "lab_experiments": ["Lab that should not render"],
149
  "_submission": {"credit_category": "4", "target_department": "CSE"},
150
  }
 
151
  course = build_course_preview(row)
 
152
  assert course["lab_experiments"] == []
153
 
154
 
 
158
  "text_books": ["1. First Book, 2024. 2. Second Book, 2025."],
159
  "_submission": {"credit_category": "4", "target_department": "CSE"},
160
  }
 
161
  course = build_course_preview(row)
 
162
  assert course["text_books"] == ["First Book, 2024.", "Second Book, 2025."]
163
 
164
 
 
173
  """,
174
  "_submission": {"credit_category": "4", "target_department": "CSE"},
175
  }
 
176
  course = build_course_preview(row)
 
177
  assert course["reference_books"] == ["First Reference, 2024.", "Second Reference, 2025."]
178
 
179
 
180
+ # ---------------------------------------------------------------------------
181
+ # course_credits helper
182
+ # ---------------------------------------------------------------------------
183
+
184
+ def test_course_credits_reads_from_credits_column():
185
+ assert course_credits({"credits": 5}) == 5
186
+
187
+
188
+ def test_course_credits_falls_back_to_submission_category():
189
+ assert course_credits({"_submission": {"credit_category": "2"}}) == 2
190
+
191
+
192
+ def test_course_credits_returns_zero_for_empty_row():
193
+ assert course_credits({}) == 0
194
+
195
+
196
+ def test_course_credits_prefers_credits_over_category():
197
+ assert course_credits({"credits": 4, "_submission": {"credit_category": "2"}}) == 4
198
+
 
 
 
 
 
199
 
200
+ # ---------------------------------------------------------------------------
201
+ # ordered_courses: credit sort
202
+ # ---------------------------------------------------------------------------
203
+
204
+ def test_ordered_courses_sorts_by_credits_descending():
205
+ rows = [
206
+ {"id": 1, "semester": 3, "course_code": "UE99XX111A", "course_title": "Two Credit", "credits": 2},
207
+ {"id": 2, "semester": 3, "course_code": "UE99XX222A", "course_title": "Five Credit", "credits": 5},
208
+ {"id": 3, "semester": 3, "course_code": "UE99XX333A", "course_title": "Four Credit", "credits": 4},
209
+ ]
210
+ courses = ordered_courses(rows)
211
+ assert [c["course_title"] for c in courses] == ["Five Credit", "Four Credit", "Two Credit"]
212
+
213
+
214
+ def test_ordered_courses_reads_credits_from_submission_category():
215
+ rows = [
216
+ {"id": 1, "semester": 4, "course_code": "UE99XX111A", "course_title": "Theory", "_submission": {"credit_category": "2"}},
217
+ {"id": 2, "semester": 4, "course_code": "UE99XX222A", "course_title": "Lab Integrated", "_submission": {"credit_category": "5"}},
218
+ ]
219
+ courses = ordered_courses(rows)
220
+ assert [c["course_title"] for c in courses] == ["Lab Integrated", "Theory"]
221
+
222
+
223
+ def test_ordered_courses_preserves_order_within_same_credits():
224
+ rows = [
225
+ {"id": 6, "semester": 5, "course_code": "UE23CS320A", "course_title": "Capstone Project", "credits": 2},
226
+ {"id": 1, "semester": 5, "course_code": "UE23CS351A", "course_title": "Database Management System", "credits": 5},
227
+ ]
228
+ courses = ordered_courses(rows)
229
+ assert [c["course_title"] for c in courses] == ["Database Management System", "Capstone Project"]
230
+
231
+
232
+ def test_ordered_courses_separates_semesters():
233
+ rows = [
234
+ {"id": 1, "semester": 3, "course_code": "UE99XX111A", "course_title": "Sem 3 Course", "credits": 4},
235
+ {"id": 2, "semester": 1, "course_code": "UE99XX222A", "course_title": "Sem 1 Course", "credits": 5},
236
+ ]
237
+ courses = ordered_courses(rows)
238
+ assert courses[0]["semester"] == "1"
239
+ assert courses[1]["semester"] == "3"
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Template: semester summary tables
244
+ # ---------------------------------------------------------------------------
245
 
246
  def test_curriculum_template_renders_regular_semester_summary():
247
  html = templates.get_template("jinja_sample.html").render(
 
254
  asset_root="",
255
  show_summaries=True,
256
  )
 
257
  assert "III SEMESTER (2024-28 BATCH)" in html
258
  assert "UE24CS251A" in html
259
  assert "Digital Design" in html
 
273
  show_summaries=True,
274
  )
275
  early = html.split("III SEMESTER", 1)[0]
 
276
  assert "I SEMESTER (2025-29 BATCH)" in early
277
  assert "II SEMESTER (2025-29 BATCH)" in early
278
  assert early.count('class="summary-page"') == 1
279
 
280
 
281
+ def test_curriculum_template_renders_final_year_summary():
282
+ html = templates.get_template("jinja_sample.html").render(
283
+ courses=[
284
+ _course(course_code="UE22CS441A", course_title="Capstone Project Phase-III", semester="7", lecture_hours="0", practical_hours="16", self_study="4", credits="4", course_type="Project Work"),
285
+ _course(course_code="UZ22UZ422A", course_title="Technical writing", semester="7", lecture_hours="0", tutorial_hours="2", self_study="2", credits="2", course_type="Special Topic"),
286
+ _course(course_code="UE22CS421B", course_title="Capstone Project Phase-IV", semester="8", lecture_hours="0", practical_hours="8", self_study="4", credits="4", course_type="Project Work"),
287
+ _course(course_code="UE22CS461XB", course_title="Internship", semester="8", lecture_hours="0", practical_hours="16", self_study="8", credits="8", course_type="Internship"),
288
+ ],
289
+ semester="",
290
+ curriculum_year="2025-2026",
291
+ asset_root="",
292
+ show_summaries=True,
293
+ )
294
+ assert "VII SEMESTER (2022-26 BATCH)" in html
295
+ assert "VIII SEMESTER (2022-26 BATCH)" in html
296
+ assert "Technical writing" in html
297
+ assert "Internship" in html
298
+
299
+
300
+ def test_summary_only_courses_do_not_render_detail_pages():
301
+ html = templates.get_template("jinja_sample.html").render(
302
+ courses=[
303
+ _course(course_code="UZ24UZ221A", course_title="CIE L1", semester="3", credits="2", lecture_hours="2", self_study="2", course_type="Special Topic", status="summary_only", render_detail=False),
304
+ ],
305
+ semester=3,
306
+ curriculum_year="2025-2026",
307
+ asset_root="",
308
+ show_summaries=True,
309
+ )
310
+ assert "CIE L1" in html
311
+ assert "Course Objectives:" not in html
312
+
313
+
314
+ def test_empty_content_course_skips_detail_page():
315
+ html = templates.get_template("jinja_sample.html").render(
316
+ courses=[
317
+ _course(course_code="UE99XX111A", course_title="Bare Course", semester="3", credits="2", render_detail=False),
318
+ ],
319
+ semester=3,
320
+ curriculum_year="2025-2026",
321
+ asset_root="",
322
+ show_summaries=True,
323
+ )
324
+ assert "Bare Course" in html
325
+ assert "Course Objectives:" not in html
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Template: semester 5/6 elective summaries + placeholders
330
+ # ---------------------------------------------------------------------------
331
+
332
  def test_curriculum_template_renders_semester_five_elective_summary():
333
  html = templates.get_template("jinja_sample.html").render(
334
  courses=[
335
+ _course(refined_id=1, course_code="UE23CS351A", course_title="Database Management System", semester="5", lecture_hours="4", practical_hours="2", self_study="5", credits="5", course_type="Core Course-Lab Integrated", is_elective=False),
336
+ _course(refined_id=2, course_code="UE23CS342AAX", course_title="Elective I", semester="5", course_type="Elective Course", is_elective=True),
337
+ _course(refined_id=3, course_code="UE23CS342AA1", course_title="Advanced Algorithms", semester="5", course_type="Elective Course", tools_languages="C++", is_elective=True),
338
+ _course(refined_id=4, course_code="UE23CS343AB1", course_title="Image Processing", semester="5", course_type="Elective Course", tools_languages="Python", is_elective=True),
339
  ],
340
  semester=5,
341
  curriculum_year="2025-2026",
342
  asset_root="",
343
  show_summaries=True,
344
+ specializations=[
345
+ {"id": 1, "semester": 5, "letter": "A", "name": "System and Core Computing (SCC)", "key": "SCC", "academic_year": ""},
346
+ {"id": 2, "semester": 5, "letter": "B", "name": "Machine Intelligence and Data Science (MIDS)", "key": "MIDS", "academic_year": ""},
347
+ {"id": 3, "semester": 5, "letter": "C", "name": "Cyber Security and Connected Systems (CSCS)", "key": "CSCS", "academic_year": ""},
348
+ ],
349
+ specialization_assignments=[
350
+ {"refined_id": 3, "specialization_id": 1},
351
+ ],
352
  )
 
353
  assert "V SEMESTER (2023-27 BATCH)" in html
354
  assert "Elective-I" in html
355
  assert "Elective-II" in html
 
356
  assert "Advanced Algorithms" in html
357
  assert "Image Processing" in html
358
  assert "ELECTIVES TO BE OPTED FOR SPECIALIZATION" in html
 
363
  def test_curriculum_template_renders_semester_six_elective_groups():
364
  html = templates.get_template("jinja_sample.html").render(
365
  courses=[
366
+ _course(refined_id=1, course_code="UE23CS351B", course_title="Cloud Computing", semester="6", practical_hours="2", self_study="5", credits="5", course_type="Core Course-Lab Integrated", is_elective=False),
367
+ _course(refined_id=2, course_code="UE23CS342BAX", course_title="Elective III", semester="6", course_type="Elective Course", is_elective=True),
368
+ _course(refined_id=3, course_code="UE23CS343BBX", course_title="Elective IV", semester="6", course_type="Elective Course", is_elective=True),
369
+ _course(refined_id=4, course_code="UE23CS342BA1", course_title="Supply Chain Management for Engineers", semester="6", course_type="Elective Course", is_elective=True),
370
+ _course(refined_id=5, course_code="UE23CS343BB1", course_title="Heterogeneous Parallelism", semester="6", course_type="Elective Course", is_elective=True),
371
  ],
372
  semester=6,
373
  curriculum_year="2025-2026",
374
  asset_root="",
375
  show_summaries=True,
376
+ specializations=[
377
+ {"id": 1, "semester": 6, "letter": "A", "name": "System and Core Computing (SCC)", "key": "SCC", "academic_year": ""},
378
+ {"id": 2, "semester": 6, "letter": "B", "name": "Machine Intelligence and Data Science (MIDS)", "key": "MIDS", "academic_year": ""},
379
+ {"id": 3, "semester": 6, "letter": "C", "name": "Cyber Security and Connected Systems (CSCS)", "key": "CSCS", "academic_year": ""},
380
+ ],
381
+ specialization_assignments=[
382
+ {"refined_id": 5, "specialization_id": 3},
383
+ ],
384
  )
 
385
  assert "VI SEMESTER (2023-27 BATCH)" in html
386
  assert "Elective-III" in html
387
  assert "Elective-IV" in html
388
  assert "ELECTIVES TO BE OPTED FOR SPECIALIZATION" in html
389
+ assert "UE23CS343BB1" in html
390
 
391
 
392
+ def test_sem5_core_table_excludes_electives_and_placeholders():
393
  html = templates.get_template("jinja_sample.html").render(
394
  courses=[
395
+ _course(refined_id=1, course_code="UE23CS351A", course_title="DBMS", semester="5", credits="4", is_elective=False),
396
+ _course(refined_id=2, course_code="UE23CS342AAX", course_title="Elective I", semester="5", is_elective=True),
397
+ _course(refined_id=3, course_code="UE23CS342AA1", course_title="Advanced Algorithms", semester="5", is_elective=True),
398
+ _course(refined_id=4, course_code="UE23CS342AA2", course_title="Cloud Architecture", semester="5", is_elective=True),
 
399
  ],
400
+ semester=5,
401
  curriculum_year="2025-2026",
402
  asset_root="",
403
  show_summaries=True,
404
  )
405
+ core_section = html.split("Elective-I", 1)[0]
406
+ assert "DBMS" in core_section
407
+ assert "Advanced Algorithms" not in core_section
408
+ assert "Cloud Architecture" not in core_section
409
 
410
+
411
+ def test_sem5_core_table_excludes_no_course_offered():
412
+ html = templates.get_template("jinja_sample.html").render(
413
+ courses=[
414
+ _course(refined_id=1, course_code="UE23CS351A", course_title="DBMS", semester="5", credits="4", is_elective=False),
415
+ _course(refined_id=2, course_code="UE23CS999A", course_title="No Course Offered", semester="5", credits="0", is_elective=False, render_detail=False),
416
+ ],
417
+ semester=5,
418
+ curriculum_year="2025-2026",
419
+ asset_root="",
420
+ show_summaries=True,
421
+ )
422
+ sem_section = html.split("V SEMESTER", 1)[1] if "V SEMESTER" in html else ""
423
+ assert "DBMS" in sem_section
424
+ assert "No Course Offered" not in sem_section
425
 
426
 
427
+ def test_sem5_summary_shows_placeholder_electives():
428
  html = templates.get_template("jinja_sample.html").render(
429
  courses=[
430
+ _course(refined_id=1, course_code="UE23CS351A", course_title="DBMS", semester="5", credits="4", is_elective=False),
431
+ _course(refined_id=2, course_code="UE23CS342AA1", course_title="Advanced Algorithms", semester="5", credits="4", is_elective=True),
432
  ],
433
+ semester=5,
434
  curriculum_year="2025-2026",
435
  asset_root="",
436
  show_summaries=True,
437
  )
438
+ assert "Elective I" in html
439
+ assert "Elective II" in html
440
 
 
 
441
 
442
+ def test_sem6_summary_shows_placeholder_electives():
443
+ html = templates.get_template("jinja_sample.html").render(
444
+ courses=[
445
+ _course(refined_id=1, course_code="UE23CS351B", course_title="Cloud Computing", semester="6", credits="4", is_elective=False),
446
+ _course(refined_id=3, course_code="UE23CS343BB1", course_title="Heterogeneous Parallelism", semester="6", credits="4", is_elective=True),
447
+ ],
448
+ semester=6,
449
+ curriculum_year="2025-2026",
450
+ asset_root="",
451
+ show_summaries=True,
452
+ )
453
+ assert "Elective III" in html
454
+ assert "Elective IV" in html
455
 
 
 
 
 
 
456
 
457
+ def test_sem5_summary_total_includes_placeholder_credits():
458
+ html = templates.get_template("jinja_sample.html").render(
459
+ courses=[
460
+ _course(refined_id=1, course_code="UE23CS351A", course_title="DBMS", semester="5", credits="4", is_elective=False),
461
+ _course(refined_id=2, course_code="UE23CS342AA1", course_title="Advanced Algorithms", semester="5", credits="4", is_elective=True),
462
+ ],
463
+ semester=5,
464
+ curriculum_year="2025-2026",
465
+ asset_root="",
466
+ show_summaries=True,
467
+ )
468
+ sem5_section = html.split("V SEMESTER", 1)[1].split("</tbody>", 1)[0]
469
+ assert ">12<" in sem5_section
470
+
471
 
472
+ def test_no_summary_page_for_empty_semester():
473
+ html = templates.get_template("jinja_sample.html").render(
474
+ courses=[
475
+ _course(course_code="UE99XX111A", course_title="Sem 3 Only", semester="3", credits="4"),
476
+ ],
477
+ semester="",
478
+ curriculum_year="2025-2026",
479
+ asset_root="",
480
+ show_summaries=True,
481
+ )
482
+ assert "V SEMESTER" not in html
483
+ assert "VI SEMESTER" not in html