Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
staticfiles
media/

# Flask stuff:
instance/
Expand Down
3 changes: 3 additions & 0 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ COPY --from=builder /usr/local /usr/local

COPY --from=builder /app /app

# Media uploads land here; make the named volume writable by nonroot.
RUN mkdir -p /app/media && chown -R nonroot:nonroot /app/media

WORKDIR /app

USER nonroot
Expand Down
58 changes: 32 additions & 26 deletions apps/spider/crawlers/gc_offerings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
from bs4 import BeautifulSoup
from django.db import transaction

from apps.web.models import Course
from apps.web.models import Course, CourseOffering, Instructor

OFFERINGS_URL = (
"https://gc.sjtu.edu.cn/academics/courses/present-course-offerings/"
)
OFFERINGS_URL = "https://gc.sjtu.edu.cn/academics/courses/present-course-offerings/"
HEADING_RE = re.compile(
r"Courses\s+Offered\s+in\s+(Spring|Summer|Fall)\s+(20\d{2})",
re.IGNORECASE,
Expand Down Expand Up @@ -84,9 +82,7 @@ def _coalesce_course_metadata(offerings):

for (_, course_code), items in by_code.items():
for field in fields:
populated = {
item[field] for item in items if item[field] not in (None, "")
}
populated = {item[field] for item in items if item[field] not in (None, "")}
if len(populated) > 1:
raise GCOfferingsParseError(
f"conflicting {field} values for {course_code}: "
Expand All @@ -97,11 +93,7 @@ def _coalesce_course_metadata(offerings):
item[field] = value

crosslisted_codes = sorted(
{
code
for item in items
for code in item.get("crosslisted_codes", [])
}
{code for item in items for code in item.get("crosslisted_codes", [])}
)
for item in items:
item["crosslisted_codes"] = crosslisted_codes
Expand Down Expand Up @@ -207,21 +199,35 @@ def _parse_instructors(value):
def import_gc_courses(offerings):
if not offerings:
raise ValueError("refusing to import an empty course list")
imported_codes = set()
courses_by_code = {}

for item in offerings:
if item["course_code"] in imported_codes:
continue
Course.objects.update_or_create(
course_code=item["course_code"],
defaults={
"course_title": item["course_title"],
"department": item["department"],
"number": item["number"],
"course_credits": item["course_credits"],
"url": item["url"],
},
course = courses_by_code.get(item["course_code"])
if course is None:
course, _ = Course.objects.update_or_create(
course_code=item["course_code"],
defaults={
"course_title": item["course_title"],
"department": item["department"],
"number": item["number"],
"course_credits": item["course_credits"],
"url": item["url"],
},
)
courses_by_code[item["course_code"]] = course

instructors = [
Instructor.objects.get_or_create(name=name)[0]
for name in item["instructors"]
]
# Sections are numbered by row order within the GC page table, not by
# the registrar's section numbers (the page has no such column).
offering, _ = CourseOffering.objects.get_or_create(
course=course,
term=item["term"],
section=item["section"],
defaults={"period": ""},
)
imported_codes.add(item["course_code"])
offering.instructors.set(instructors)

return len(imported_codes)
return len(courses_by_code)
65 changes: 58 additions & 7 deletions apps/spider/tests/test_gc_offerings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import_gc_courses,
parse_gc_offerings,
)
from apps.web.models import Course, CourseOffering
from apps.web.models import Course, CourseOffering, Instructor


SAMPLE_HTML = """
Expand Down Expand Up @@ -114,12 +114,12 @@ def test_parse_gc_offerings_reads_all_semester_tables():
reason="project migrations use PostgreSQL-only ArrayField columns",
)
@pytest.mark.django_db
def test_import_gc_courses_updates_courses_without_changing_offerings():
def test_import_gc_courses_updates_courses_and_creates_offerings():
rows = parse_gc_offerings(SAMPLE_HTML)
stale_course = Course.objects.create(
course_code="OLD1000J", course_title="Old", department="OLD", number=1000
)
CourseOffering.objects.create(
stale_offering = CourseOffering.objects.create(
course=stale_course, term="26SU", section=1, period=""
)

Expand All @@ -128,7 +128,58 @@ def test_import_gc_courses_updates_courses_without_changing_offerings():
physics = Course.objects.get(course_code="PHYS1500J")
assert physics.course_title == "Physics I"
assert physics.course_credits == 4
assert not physics.courseoffering_set.exists()
assert CourseOffering.objects.filter(
course=stale_course, term="26SU"
).exists()
# Unrelated offerings are untouched.
assert CourseOffering.objects.get(pk=stale_offering.pk).course == stale_course

offerings = list(physics.courseoffering_set.order_by("section"))
assert [(o.term, o.section) for o in offerings] == [("26SU", 1), ("26SU", 2)]
assert [o.instructors_string() for o in offerings] == [
"Richard Grumitt",
"Mesli Abdelmadjid",
]

# Crosslisted code imports under its primary code only.
assert Course.objects.filter(course_code="VK335").count() == 0

# A row with no instructor ("–") still gets an offering, without instructors.
materials = Course.objects.get(course_code="MSE3350J")
assert materials.courseoffering_set.get().instructors.count() == 0


@pytest.mark.skipif(
"postgresql" not in settings.DATABASES["default"]["ENGINE"],
reason="project migrations use PostgreSQL-only ArrayField columns",
)
@pytest.mark.django_db
def test_import_gc_courses_is_idempotent():
rows = parse_gc_offerings(SAMPLE_HTML)

assert import_gc_courses(rows) == 5
assert import_gc_courses(rows) == 5

assert CourseOffering.objects.count() == 6
assert Instructor.objects.count() == 6 # 6 unique names across 6 rows
physics = Course.objects.get(course_code="PHYS1500J")
assert physics.courseoffering_set.count() == 2


@pytest.mark.skipif(
"postgresql" not in settings.DATABASES["default"]["ENGINE"],
reason="project migrations use PostgreSQL-only ArrayField columns",
)
@pytest.mark.django_db
def test_import_gc_courses_syncs_changed_instructors():
rows = parse_gc_offerings(SAMPLE_HTML)
assert import_gc_courses(rows) == 5

physics_rows = [row for row in rows if row["course_code"] == "PHYS1500J"]
physics_rows[0]["instructors"] = ["New Professor"]
assert import_gc_courses(rows) == 5

physics = Course.objects.get(course_code="PHYS1500J")
section_one = physics.courseoffering_set.get(term="26SU", section=1)
assert [i.name for i in section_one.instructors.all()] == ["New Professor"]
section_two = physics.courseoffering_set.get(term="26SU", section=2)
assert [i.name for i in section_two.instructors.all()] == ["Mesli Abdelmadjid"]
# Instructor rows are never deleted, only unbound.
assert Instructor.objects.filter(name="Richard Grumitt").exists()
48 changes: 47 additions & 1 deletion apps/web/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django import forms
from django.contrib import admin
from django.core.exceptions import PermissionDenied
from django.db import models
from django.core.management import call_command
from django.core.management.base import CommandError
from django.shortcuts import render
Expand All @@ -20,6 +21,8 @@
Review,
ReviewVote,
Student,
Syllabus,
SyllabusFile,
Vote,
)

Expand Down Expand Up @@ -89,7 +92,9 @@ def import_legacy_reviews_view(self, request):
if form.is_valid():
upload = form.cleaned_data["csv_file"]
if upload.size > self.max_upload_size:
form.add_error("csv_file", "CSV files must be no larger than 1 MiB.")
form.add_error(
"csv_file", "CSV files must be no larger than 1 MiB."
)
else:
try:
csv_text = upload.read().decode("utf-8-sig")
Expand Down Expand Up @@ -150,3 +155,44 @@ def _run_import(self, csv_text, expected_count, *, execute):
admin.site.register(ReviewVote)
admin.site.register(Vote)
admin.site.register(Student)


@admin.register(SyllabusFile)
class SyllabusFileAdmin(admin.ModelAdmin):
list_display = ("id", "original_filename", "sha256", "size", "created_at")
search_fields = ("original_filename", "sha256")
readonly_fields = ("sha256", "original_filename", "size", "created_at")
fields = ("file", "sha256", "original_filename", "size", "content_type")


@admin.register(Syllabus)
class SyllabusAdmin(admin.ModelAdmin):
list_display = (
"id",
"course",
"instructor",
"status",
"is_primary",
"uploaded_by",
"created_at",
)
list_filter = ("status", "is_primary")
search_fields = ("course__course_code", "course__course_title", "instructor__name")
actions = ("reject_syllabi",)

def formfield_for_dbfield(self, db_field, **kwargs):
form_field = super().formfield_for_dbfield(db_field, **kwargs)
if isinstance(db_field, models.TextField) and db_field.name == "summary_md":
form_field.widget = forms.Textarea(attrs={"rows": 10, "cols": 80})
return form_field

def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
if obj.is_primary:
Syllabus.objects.filter(
course=obj.course, instructor=obj.instructor
).exclude(pk=obj.pk).update(is_primary=False)

@admin.action(description="Reject selected syllabi (mark failed, unset primary)")
def reject_syllabi(self, request, queryset):
queryset.update(status=Syllabus.Status.FAILED, is_primary=False)
127 changes: 127 additions & 0 deletions apps/web/migrations/0014_syllabusfile_syllabus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Generated by Django 6.0.5 on 2026-09-05 05:51

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("web", "0013_expand_review_term"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="SyllabusFile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("file", models.FileField(upload_to="syllabi/")),
("sha256", models.CharField(db_index=True, max_length=64, unique=True)),
(
"content_type",
models.CharField(blank=True, default="", max_length=100),
),
(
"original_filename",
models.CharField(blank=True, default="", max_length=255),
),
("size", models.PositiveBigIntegerField(default=0)),
("extracted_text", models.TextField(blank=True, default="")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name="Syllabus",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("processing", "Processing"),
("analyzed", "Analyzed"),
("failed", "Failed"),
],
db_index=True,
default="pending",
max_length=16,
),
),
("summary_md", models.TextField(blank=True, default="")),
("verdict", models.JSONField(blank=True, null=True)),
("comparison", models.JSONField(blank=True, null=True)),
("is_primary", models.BooleanField(default=False)),
("error_message", models.TextField(blank=True, default="")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"course",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="syllabi",
to="web.course",
),
),
(
"instructor",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="syllabi",
to="web.instructor",
),
),
(
"uploaded_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="uploaded_syllabi",
to=settings.AUTH_USER_MODEL,
),
),
(
"file",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="syllabi",
to="web.syllabusfile",
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(
fields=["course", "instructor", "status"],
name="web_syllabu_course__a2f772_idx",
)
],
"constraints": [
models.UniqueConstraint(
fields=("course", "instructor", "file"),
name="unique_course_instructor_syllabus_file",
)
],
},
),
]
Loading
Loading