Spaces:
Sleeping
Sleeping
Commit ·
ce13b1c
1
Parent(s): 8596974
spec pipeline updates + doc updates + viewer updates + schema updates + tool additions
Browse files- backend/app/preview.py +34 -7
- backend/app/rendering.py +14 -1
- backend/app/routes/chat.py +2 -1
- backend/app/routes/courses.py +16 -1
- backend/app/routes/preview.py +9 -6
- backend/app/routes/versions.py +5 -3
- backend/app/services/agent_tools.py +178 -0
- backend/app/services/curriculum.py +12 -2
- backend/app/services/schema.py +2 -0
- backend/app/templates/jinja_1_to_8.html +2 -6
- backend/app/templates/jinja_sem_5_6.html +85 -60
- docs/index.md +13 -3
- docs/schema.sql +1 -0
- frontend/auth/index.html +1 -0
- frontend/courses/courses.css +12 -0
- frontend/courses/courses.js +35 -3
- frontend/courses/index.html +7 -0
- frontend/form/form.css +118 -102
- frontend/form/form.js +1 -2
- frontend/form/index.html +1 -0
- frontend/images/SE1128.png +0 -0
- frontend/index.html +1 -0
- frontend/live-editor/index.html +10 -0
- frontend/live-editor/live-editor.css +19 -4
- frontend/live-editor/live-editor.js +9 -0
- frontend/preview/index.html +1 -0
- frontend/versions/index.html +1 -0
- frontend/versions/versions.js +1 -1
- tests/test_preview.py +313 -196
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":
|
| 76 |
-
"course_outcomes":
|
| 77 |
-
"units":
|
| 78 |
-
"lab_experiments":
|
| 79 |
-
"text_books":
|
| 80 |
-
"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 |
-
|
| 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(
|
| 237 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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">{{
|
| 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 =
|
| 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
|
| 16 |
-
<td class="center">{% if is_no_course %}{% elif
|
| 17 |
-
<td class="center">{% if is_no_course %}{% elif
|
| 18 |
-
<td class="center">{% if is_no_course %}{% elif
|
| 19 |
-
<td class="center">{% if is_no_course %}{% elif
|
| 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
|
| 30 |
-
{%
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 45 |
-
{%
|
| 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
|
| 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">{{
|
| 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
|
| 127 |
-
{%
|
| 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
|
| 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 |
-
{%
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
{%
|
| 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>{{
|
| 222 |
-
<th>{{
|
| 223 |
</tr>
|
| 224 |
-
{% for
|
| 225 |
-
|
| 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/…
|
| 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 =
|
| 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 =
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
padding: 0 16px;
|
| 7 |
}
|
| 8 |
|
| 9 |
.card {
|
| 10 |
background: var(--surface);
|
| 11 |
border: 1px solid var(--border);
|
| 12 |
border-radius: 12px;
|
| 13 |
-
padding:
|
| 14 |
-
box-shadow: 0 4px 24px rgba(0, 55, 123, 0.06);
|
| 15 |
}
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
font-size: 28px;
|
| 20 |
font-weight: 700;
|
| 21 |
-
|
| 22 |
}
|
| 23 |
|
| 24 |
.subtitle {
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
}
|
| 29 |
|
| 30 |
fieldset {
|
| 31 |
border: none;
|
| 32 |
-
|
| 33 |
-
margin: 0 0 24px;
|
| 34 |
}
|
| 35 |
|
| 36 |
legend {
|
| 37 |
-
font-size: 14px;
|
| 38 |
font-weight: 600;
|
| 39 |
-
|
| 40 |
-
margin-bottom:
|
| 41 |
-
padding-bottom:
|
| 42 |
-
border-bottom:
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
label {
|
| 46 |
display: block;
|
| 47 |
-
font-size:
|
| 48 |
font-weight: 500;
|
| 49 |
-
|
| 50 |
-
margin-bottom:
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
.optional {
|
| 54 |
-
font-weight: 400;
|
| 55 |
-
color: var(--muted);
|
| 56 |
-
font-size: 12px;
|
| 57 |
}
|
| 58 |
|
| 59 |
-
input
|
| 60 |
-
input[type="text"],
|
| 61 |
-
textarea {
|
| 62 |
width: 100%;
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
border-
|
| 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(--
|
| 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 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
.code-preview {
|
| 93 |
-
margin-top:
|
| 94 |
-
padding:
|
| 95 |
background: #f0f4f8;
|
| 96 |
border: 1px solid var(--border);
|
| 97 |
border-radius: 8px;
|
| 98 |
-
font-size:
|
| 99 |
color: var(--text);
|
| 100 |
}
|
| 101 |
|
|
@@ -105,15 +161,15 @@ textarea {
|
|
| 105 |
|
| 106 |
.code-preview strong {
|
| 107 |
display: block;
|
| 108 |
-
margin-bottom:
|
| 109 |
-
color: var(--
|
| 110 |
}
|
| 111 |
|
| 112 |
.badge {
|
| 113 |
display: inline-block;
|
| 114 |
padding: 2px 8px;
|
| 115 |
margin: 2px 4px 2px 0;
|
| 116 |
-
font-size:
|
| 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 |
-
|
| 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:
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
| 178 |
text-decoration: none;
|
| 179 |
}
|
| 180 |
|
| 181 |
.form-link:hover {
|
| 182 |
-
|
| 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 = "
|
| 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:
|
| 47 |
border: 1px solid #9ca3af;
|
| 48 |
-
border-radius:
|
| 49 |
background-color: #fff;
|
| 50 |
color: #111827;
|
| 51 |
font-size: 14px;
|
|
@@ -53,8 +53,7 @@ a {
|
|
| 53 |
|
| 54 |
select {
|
| 55 |
min-width: 150px;
|
| 56 |
-
|
| 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 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 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 "
|
| 195 |
|
| 196 |
|
| 197 |
-
def
|
| 198 |
html = templates.get_template("jinja_sample.html").render(
|
| 199 |
courses=[
|
| 200 |
-
_course(course_code="
|
| 201 |
-
_course(course_code="
|
| 202 |
-
_course(course_code="
|
| 203 |
-
_course(course_code="
|
| 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 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
|
| 219 |
-
def
|
| 220 |
html = templates.get_template("jinja_sample.html").render(
|
| 221 |
courses=[
|
| 222 |
-
_course(course_code="
|
|
|
|
| 223 |
],
|
| 224 |
-
semester=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|