BETA

Government · Education · Django · NYS Public School

NYS Regents Chemistry
Proctoring & Score Management

A real-world dashboard for New York State public school administrators running the Physical Setting/Chemistry Regents exam — room assignments, accommodation-aware student check-in, per-part score entry with automatic raw-to-scale conversion, and school-level analytics with one-click SIRS export for state reporting.

DjangoModelComponent FormComponent · Pydantic Raw → Scale Conversion SIRS Export Accommodation Flags Performance Levels 1–5

Overview

The Physical Setting/Chemistry Regents is one of the most administratively complex standardized exams in New York State: mixed machine-scored and hand-scored parts, per-administration raw-to-scale conversion charts, five performance levels, accommodation-driven room splits, and mandatory score reporting to NYSED's SIRS repository. A typical school manages 50–200 students across 3–6 rooms per session.

This example builds three server-side Django components that replace a patchwork of spreadsheets. No JavaScript framework — HTMX handles all interactions, state lives on the server, and the conversion chart is stored in the database and applied automatically on save.

2026 exam transition: NYSED is transitioning the exam to "Physical Science: Chemistry" with a new 4-page data sheet (replacing the 12-page reference tables) beginning June 2026. The ConversionChart model uses a exam_format field so both formats can coexist during the overlap period.

Exam Structure

The Chemistry Regents has 85 total raw points across four parts, offered three times per year (January, June, August).

Part Max Raw Points Question Type Scoring
Part A3030 multiple choiceMachine-scanned answer sheet
Part B-12020 multiple choice (Reference Tables required)Machine-scanned answer sheet
Part B-215Constructed response, partial creditHand-scored by two raters
Part C25Extended constructed response, partial creditHand-scored by two raters

Raw scores are converted to a 0–100 scale score via a conversion chart released by NYSED on exam day. The chart varies each administration to account for difficulty differences. Scale scores map to five performance levels:

LevelScale ScoreMeaningDashboard indicator
Level 585–100Mastery — diploma mastery annotation eligibleMASTERY
Level 476–84Meets standardsLEVEL 4
Level 365–75Passing — satisfies Regents diploma requirementPASS
Level 255–64Safety net zone (primarily SWD students)SAFETY NET
Level 10–54Does not meet standardsFAIL

Architecture

NYSED releases exam materials + conversion chart (exam day) │ ▼ ExamSessionComponent ← proctor manages their room • room roster with OSIS numbers • accommodation flags (extended time, separate location) • real-time check-in / mark absent • seat count: pending / checked-in / absent │ ▼ ScoreEntryComponent ← score clerk enters raw scores • Part A (0–30) ─┐ • Part B-1 (0–20) ├─ auto total raw (0–85) • Part B-2 (0–15) │ → ConversionChart lookup • Part C (0–25) ─┘ → scale score + level badge • validation via ChemistryScoreSchema (Pydantic) │ ▼ ScoreAnalyticsComponent ← administrator / counselor • pass rate (≥65) · mastery rate (≥85) • Level 1–5 distribution bar • safety-net student list (55–64, SWD flag) • appeal roster (60–64, general ed) • SIRS export payload → state reporting

Data Models

regents/models.py python
from django.db import models
from django.contrib.auth import get_user_model

User = get_user_model()


class RegentsSession(models.Model):
    """A single administration of a Regents exam (e.g., June 2025 Chemistry)."""
    PERIOD_CHOICES = [("january", "January"), ("june", "June"), ("august", "August")]
    FORMAT_CHOICES = [("legacy", "Pre-2026 (12-page CRT)"), ("2026", "2026+ (4-page data sheet)")]

    period      = models.CharField(max_length=10, choices=PERIOD_CHOICES)
    year        = models.IntegerField()
    exam_date   = models.DateField()
    exam_format = models.CharField(max_length=10, choices=FORMAT_CHOICES, default="legacy")
    chart_loaded = models.BooleanField(default=False)  # True once NYSED chart is uploaded

    class Meta:
        unique_together = [("period", "year")]
        ordering = ["-year", "period"]

    def __str__(self):
        return f"{self.period.title()} {self.year} — Chemistry"


class ConversionChart(models.Model):
    """Raw → scale score lookup table for one session. Uploaded on exam day."""
    session    = models.ForeignKey(RegentsSession, on_delete=models.CASCADE, related_name="chart_entries")
    raw_score  = models.PositiveSmallIntegerField()   # 0–85
    scale_score = models.PositiveSmallIntegerField()  # 0–100

    class Meta:
        unique_together = [("session", "raw_score")]


class ExamRoom(models.Model):
    """A room assigned to a Regents session — standard, extended time, or separate location."""
    ROOM_TYPES = [
        ("standard",          "Standard"),
        ("extended_time",     "Extended Time (1.5×)"),
        ("extended_time_2x",  "Extended Time (2×)"),
        ("separate_location", "Separate Location"),
    ]
    session     = models.ForeignKey(RegentsSession, on_delete=models.CASCADE, related_name="rooms")
    room_number = models.CharField(max_length=20)
    room_type   = models.CharField(max_length=20, choices=ROOM_TYPES, default="standard")
    capacity    = models.PositiveSmallIntegerField(default=30)
    proctor     = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="proctored_rooms")


class StudentEnrollment(models.Model):
    """A student registered for a session, assigned to a room with accommodation tracking."""
    ACCOMMODATION_CHOICES = [
        ("none",              "No accommodation"),
        ("extended_time",    "Extended time (1.5×)"),
        ("extended_time_2x", "Extended time (2×)"),
        ("separate_location","Separate location"),
        ("oral",             "Oral administration"),
        ("large_print",      "Large print"),
    ]
    STATUS_CHOICES = [("pending", "Pending"), ("checked_in", "Checked In"), ("absent", "Absent")]

    session       = models.ForeignKey(RegentsSession, on_delete=models.CASCADE)
    student       = models.ForeignKey(User, on_delete=models.CASCADE, related_name="enrollments")
    room          = models.ForeignKey(ExamRoom, on_delete=models.SET_NULL, null=True)
    accommodation = models.CharField(max_length=20, choices=ACCOMMODATION_CHOICES, default="none")
    is_swd        = models.BooleanField(default=False)  # student with disability (IEP/504)
    check_in_status = models.CharField(max_length=12, choices=STATUS_CHOICES, default="pending")
    checked_in_at   = models.DateTimeField(null=True, blank=True)


class RegentsScore(models.Model):
    """Scored results for one student's exam, stored part-by-part for analytics."""
    enrollment    = models.OneToOneField(StudentEnrollment, on_delete=models.CASCADE, related_name="score")
    part_a        = models.PositiveSmallIntegerField(null=True)  # 0–30
    part_b1       = models.PositiveSmallIntegerField(null=True)  # 0–20
    part_b2       = models.PositiveSmallIntegerField(null=True)  # 0–15
    part_c        = models.PositiveSmallIntegerField(null=True)  # 0–25
    total_raw     = models.PositiveSmallIntegerField(null=True)  # 0–85
    scale_score   = models.PositiveSmallIntegerField(null=True)  # 0–100
    performance_level = models.PositiveSmallIntegerField(null=True)  # 1–5
    entered_by    = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
    entered_at    = models.DateTimeField(auto_now_add=True, null=True)

ExamSessionComponent

Each proctor sees only their assigned room's roster. Students are listed with their OSIS number, accommodation flag, and current check-in status. The proctor taps a button per student — HTMX posts the event, the component updates state, and Django writes the timestamp. No page reload.

State & mount()

regents/components/exam_session.py python
from django.utils import timezone
from component_framework.core.registry import registry
from component_framework.adapters.django_model import DjangoModelComponent
from ..models import ExamRoom, StudentEnrollment


@registry.register("exam_session")
class ExamSessionComponent(DjangoModelComponent):
    """
    Proctor's room view for a Chemistry Regents administration.

    Params:
        room_id (int): PK of the ExamRoom this proctor is managing.
    """
    template_name = "regents/exam_session.html"
    model = ExamRoom

    def mount(self):
        room = (
            ExamRoom.objects
            .select_related("session", "proctor")
            .get(pk=self.params["room_id"])
        )
        enrollments = (
            StudentEnrollment.objects
            .filter(room=room)
            .select_related("student")
            .order_by("student__last_name", "student__first_name")
        )
        self.state.update({
            "room_id":      room.pk,
            "room_number":  room.room_number,
            "room_type":    room.get_room_type_display(),
            "proctor_name": room.proctor.get_full_name() if room.proctor else "—",
            "session_label": str(room.session),
            "capacity":     room.capacity,
            "students": [
                {
                    "id":            e.pk,
                    "name":          e.student.get_full_name(),
                    "osis":          e.student.username,   # OSIS stored as username
                    "accommodation": e.get_accommodation_display(),
                    "is_swd":        e.is_swd,
                    "status":        e.check_in_status,
                    "checked_in_at": (
                        e.checked_in_at.strftime("%H:%M") if e.checked_in_at else None
                    ),
                }
                for e in enrollments
            ],
        })
        self._refresh_counts()

    def _refresh_counts(self):
        students = self.state["students"]
        self.state["checked_in"] = sum(1 for s in students if s["status"] == "checked_in")
        self.state["absent"]     = sum(1 for s in students if s["status"] == "absent")
        self.state["pending"]    = sum(1 for s in students if s["status"] == "pending")

Events: check-in & absent

regents/components/exam_session.py (continued) python
    def on_check_in(self):
        """Mark a student as checked in and record the timestamp."""
        enrollment_id = self.payload["enrollment_id"]
        now = timezone.now()
        StudentEnrollment.objects.filter(pk=enrollment_id).update(
            check_in_status="checked_in",
            checked_in_at=now,
        )
        for s in self.state["students"]:
            if s["id"] == enrollment_id:
                s["status"] = "checked_in"
                s["checked_in_at"] = now.strftime("%H:%M")
        self._refresh_counts()

    def on_mark_absent(self):
        """Mark a student as absent (did not sit for the exam)."""
        enrollment_id = self.payload["enrollment_id"]
        StudentEnrollment.objects.filter(pk=enrollment_id).update(
            check_in_status="absent",
            checked_in_at=None,
        )
        for s in self.state["students"]:
            if s["id"] == enrollment_id:
                s["status"] = "absent"
                s["checked_in_at"] = None
        self._refresh_counts()

    def on_undo(self):
        """Revert a student's status back to pending (typo correction)."""
        enrollment_id = self.payload["enrollment_id"]
        StudentEnrollment.objects.filter(pk=enrollment_id).update(
            check_in_status="pending", checked_in_at=None,
        )
        for s in self.state["students"]:
            if s["id"] == enrollment_id:
                s["status"] = "pending"
                s["checked_in_at"] = None
        self._refresh_counts()

Template

templates/regents/exam_session.html html
<!-- Room header -->
<div class="room-header">
  <h2>Room {{ state.room_number }} — {{ state.room_type }}</h2>
  <p>Proctor: {{ state.proctor_name }} · {{ state.session_label }}</p>
  <div class="checkin-summary">
    <span class="badge badge--green">✓ {{ state.checked_in }} checked in</span>
    <span class="badge badge--amber">⏳ {{ state.pending }} pending</span>
    <span class="badge badge--red">✗ {{ state.absent }} absent</span>
  </div>
</div>

<!-- Student roster -->
<table class="roster-table">
  <thead><tr>
    <th>Student</th><th>OSIS</th><th>Accommodation</th><th>Status</th><th>Actions</th>
  </tr></thead>
  <tbody>
  {% for student in state.students %}
    <tr class="row--{{ student.status }}">
      <td>
        {{ student.name }}
        {% if student.is_swd %}<span class="swd-flag" title="Student with Disability">SWD</span>{% endif %}
      </td>
      <td class="osis">{{ student.osis }}</td>
      <td>{{ student.accommodation }}</td>
      <td>
        {% if student.status == "checked_in" %}✓ {{ student.checked_in_at }}
        {% elif student.status == "absent" %}✗ Absent
        {% else %}—{% endif %}
      </td>
      <td class="actions">
        {% if student.status == "pending" %}
          <button hx-post="/components/exam_session"
                  hx-vals='{"event":"check_in","payload":{"enrollment_id":{{ student.id }}},"state":"{{ serialized_state }}"}'
                  hx-target="#exam-session-{{ state.room_id }}"
                  hx-swap="outerHTML">Check In</button>
          <button hx-post="/components/exam_session"
                  hx-vals='{"event":"mark_absent","payload":{"enrollment_id":{{ student.id }}},"state":"{{ serialized_state }}"}'
                  hx-target="#exam-session-{{ state.room_id }}"
                  hx-swap="outerHTML">Absent</button>
        {% else %}
          <button hx-post="/components/exam_session"
                  hx-vals='{"event":"undo","payload":{"enrollment_id":{{ student.id }}},"state":"{{ serialized_state }}"}'
                  hx-target="#exam-session-{{ state.room_id }}"
                  hx-swap="outerHTML">Undo</button>
        {% endif %}
      </td>
    </tr>
  {% endfor %}
  </tbody>
</table>
Proctor rule: NYSED requires that no teacher proctor a section of their own subject. The ExamRoom.proctor FK should be validated at assignment time to enforce this — or simply note it in the admin UI. The component itself is read-only about who the proctor is.

ScoreEntryComponent

After scanning is complete (Parts A and B-1) and hand-scoring is done (B-2 and C), a score clerk enters raw scores per part. The component validates each part's range, sums to a total raw score, looks up the administration's conversion chart in the database, and immediately shows the scale score and performance level badge.

Pydantic Schema

regents/components/score_entry.py python
from pydantic import BaseModel, Field, model_validator
from component_framework.core.registry import registry
from component_framework.core.form import FormComponent
from ..models import StudentEnrollment, RegentsScore, ConversionChart


class ChemistryScoreSchema(BaseModel):
    """
    Validates raw scores for each part of the Chemistry Regents.

    Part maxima: A=30, B-1=20, B-2=15, C=25.  Total must not exceed 85.
    """
    part_a:  int = Field(ge=0, le=30, description="Part A  (0–30)")
    part_b1: int = Field(ge=0, le=20, description="Part B-1 (0–20)")
    part_b2: int = Field(ge=0, le=15, description="Part B-2 (0–15)")
    part_c:  int = Field(ge=0, le=25, description="Part C   (0–25)")

    @model_validator(mode="after")
    def total_within_range(self) -> "ChemistryScoreSchema":
        total = self.part_a + self.part_b1 + self.part_b2 + self.part_c
        if total > 85:
            raise ValueError(f"Total raw score {total} exceeds maximum of 85.")
        return self

mount(), on_preview(), on_submit(), and conversion

regents/components/score_entry.py (continued) python
@registry.register("score_entry")
class ScoreEntryComponent(FormComponent):
    """Per-student score entry with automatic raw-to-scale conversion."""
    template_name = "regents/score_entry.html"
    schema = ChemistryScoreSchema

    def mount(self):
        enrollment = (
            StudentEnrollment.objects
            .select_related("student", "room__session")
            .get(pk=self.params["enrollment_id"])
        )
        session = enrollment.room.session
        self.state.update({
            "enrollment_id":  enrollment.pk,
            "student_name":   enrollment.student.get_full_name(),
            "osis":           enrollment.student.username,
            "session_label":  str(session),
            "is_swd":         enrollment.is_swd,
            "accommodation":  enrollment.get_accommodation_display(),
            "chart_loaded":   session.chart_loaded,
            "part_a": None, "part_b1": None,
            "part_b2": None, "part_c": None,
            "total_raw": None, "scale_score": None,
            "performance_level": None, "entry_status": "unsaved",
        })
        # Load existing scores if already entered
        try:
            score = RegentsScore.objects.get(enrollment=enrollment)
            self.state.update({
                "part_a": score.part_a, "part_b1": score.part_b1,
                "part_b2": score.part_b2, "part_c": score.part_c,
                "total_raw": score.total_raw, "scale_score": score.scale_score,
                "performance_level": score.performance_level,
                "entry_status": "saved",
            })
        except RegentsScore.DoesNotExist:
            pass

    def on_preview(self):
        """Calculate scale score from typed values without writing to the database."""
        part_a  = int(self.payload.get("part_a", 0))
        part_b1 = int(self.payload.get("part_b1", 0))
        part_b2 = int(self.payload.get("part_b2", 0))
        part_c  = int(self.payload.get("part_c", 0))
        total_raw = part_a + part_b1 + part_b2 + part_c
        enrollment = StudentEnrollment.objects.select_related("room__session").get(
            pk=self.state["enrollment_id"]
        )
        scale = self._to_scale(enrollment.room.session, total_raw)
        self.state.update({
            "part_a": part_a, "part_b1": part_b1,
            "part_b2": part_b2, "part_c": part_c,
            "total_raw": total_raw, "scale_score": scale,
            "performance_level": self._level(scale),
            "entry_status": "previewing",
        })

    def on_submit(self):
        """Validate, compute scale score, and persist to RegentsScore."""
        data = self.validated_data   # raises FormValidationError on bad input
        total_raw = data.part_a + data.part_b1 + data.part_b2 + data.part_c
        enrollment = StudentEnrollment.objects.select_related("room__session").get(
            pk=self.state["enrollment_id"]
        )
        scale = self._to_scale(enrollment.room.session, total_raw)
        level = self._level(scale)
        RegentsScore.objects.update_or_create(
            enrollment=enrollment,
            defaults={
                "part_a": data.part_a, "part_b1": data.part_b1,
                "part_b2": data.part_b2, "part_c": data.part_c,
                "total_raw": total_raw, "scale_score": scale,
                "performance_level": level,
            }
        )
        self.state.update({
            "part_a": data.part_a, "part_b1": data.part_b1,
            "part_b2": data.part_b2, "part_c": data.part_c,
            "total_raw": total_raw, "scale_score": scale,
            "performance_level": level, "entry_status": "saved",
        })

    # ── helpers ────────────────────────────────────────────────────────────

    def _to_scale(self, session, raw: int) -> int:
        """Look up the administration-specific conversion chart."""
        try:
            entry = ConversionChart.objects.get(session=session, raw_score=raw)
            return entry.scale_score
        except ConversionChart.DoesNotExist:
            # Fallback linear approximation if chart not yet uploaded
            return min(100, round((raw / 85) * 100))

    def _level(self, scale: int) -> int:
        """Map scale score to NYSED 1-5 performance level."""
        if scale >= 85: return 5  # Mastery
        if scale >= 76: return 4  # Meets standards
        if scale >= 65: return 3  # Passing
        if scale >= 55: return 2  # Safety net zone
        return 1                     # Does not meet standards

Template — score entry form

score_entry.html — form fields

<form id="score-{{ state.enrollment_id }}">
  <h3>{{ state.student_name }}</h3>
  <p>OSIS: {{ state.osis }}
     · {{ state.session_label }}</p>

  <!-- Part scores -->
  <label>Part A (0–30)
    <input type="number" name="part_a"
           min="0" max="30"
           value="{{ state.part_a|default:'' }}">
  </label>
  <label>Part B-1 (0–20)
    <input type="number" name="part_b1"
           min="0" max="20"
           value="{{ state.part_b1|default:'' }}">
  </label>
  <label>Part B-2 (0–15)
    <input type="number" name="part_b2"
           min="0" max="15"
           value="{{ state.part_b2|default:'' }}">
  </label>
  <label>Part C (0–25)
    <input type="number" name="part_c"
           min="0" max="25"
           value="{{ state.part_c|default:'' }}">
  </label>

  <button hx-post="/components/score_entry"
          hx-include="#score-{{ state.enrollment_id }}"
          hx-vals='{"event":"preview","state":"{{ serialized_state }}"}'
          hx-target="closest form"
          hx-swap="outerHTML">
    Preview
  </button>
  <button hx-post="/components/score_entry"
          hx-include="#score-{{ state.enrollment_id }}"
          hx-vals='{"event":"submit","state":"{{ serialized_state }}"}'
          hx-target="closest form"
          hx-swap="outerHTML">
    Save Scores
  </button>
</form>

score_entry.html — result panel

<!-- Result panel (shown after preview/save) -->
{% if state.total_raw is not None %}
<div class="score-result">

  <div class="score-row">
    <span>Total raw:</span>
    <strong>{{ state.total_raw }} / 85</strong>
  </div>

  <div class="score-row">
    <span>Scale score:</span>
    <strong class="scale-score">{{ state.scale_score }}</strong>
  </div>

  <div class="score-row">
    <span>Performance level:</span>
    <span class="level-badge level-{{ state.performance_level }}">
      Level {{ state.performance_level }}
      {% if state.performance_level == 5 %} — Mastery
      {% elif state.performance_level == 3 %} — Pass
      {% elif state.performance_level == 2 %} — Safety Net
      {% elif state.performance_level == 1 %} — Fail
      {% endif %}
    </span>
  </div>

  {% if state.is_swd and state.performance_level == 2 %}
  <p class="safety-net-note">
    ⚑ SWD Safety Net: score 55–64 may satisfy
    local diploma testing requirement.
  </p>
  {% endif %}

  {% if state.entry_status == "saved" %}
  <p class="saved-badge">✓ Saved</p>
  {% endif %}

</div>
{% endif %}
Conversion chart timing: NYSED releases the raw-to-scale conversion chart on exam day, after the morning session ends. The component handles a missing chart gracefully with a linear fallback, but chart_loaded is surfaced in the template so clerks see a clear warning if they're entering scores before the official chart is uploaded. Upload the chart via a simple Django admin action that bulk-creates ConversionChart rows from NYSED's published CSV.

ScoreAnalyticsComponent

The analytics view gives administrators a school-level picture of each session: pass rate, mastery rate, Level 1–5 distribution, and a safety-net student list. The on_export_sirs event generates the state-reporting payload in the format required by NYSED's SIRS repository.

regents/components/score_analytics.py python
from django.db.models import Avg, Count
from component_framework.core.registry import registry
from component_framework.adapters.django_model import DjangoModelComponent
from ..models import RegentsSession, RegentsScore


@registry.register("score_analytics")
class ScoreAnalyticsComponent(DjangoModelComponent):
    """
    School-level analytics for a Chemistry Regents session.

    Surfaces pass/mastery rates, Level 1-5 distribution, and the safety-net
    student roster. The on_export_sirs event generates a SIRS-compatible
    payload for upload to NYSED's Student Information Repository System.
    """
    template_name = "regents/score_analytics.html"
    model = RegentsSession

    def mount(self):
        session_id = self.params.get("session_id")
        session = RegentsSession.objects.get(pk=session_id)
        self.state.update({
            "session_id":    session_id,
            "session_label": str(session),
            "sessions": [
                {"id": s.pk, "label": str(s)}
                for s in RegentsSession.objects.order_by("-year", "period")[:10]
            ],
            "sirs_ready": False,
        })
        self._compute(session)

    def _compute(self, session):
        scores = RegentsScore.objects.filter(
            enrollment__room__session=session
        ).select_related("enrollment__student", "enrollment")

        total = scores.count()
        if not total:
            self.state["no_data"] = True
            return

        agg = scores.aggregate(avg=Avg("scale_score"))
        passing   = scores.filter(scale_score__gte=65).count()
        mastery   = scores.filter(scale_score__gte=85).count()
        safety_net = scores.filter(
            scale_score__gte=55, scale_score__lt=65
        )

        appeal_eligible = scores.filter(  # general ed, 60–64 (within 5 of passing)
            scale_score__gte=60, scale_score__lt=65,
            enrollment__is_swd=False,
        )

        level_dist = {
            i: scores.filter(performance_level=i).count() for i in range(1, 6)
        }

        self.state.update({
            "no_data":          False,
            "total":            total,
            "average_scale":    round(agg["avg"] or 0, 1),
            "pass_count":       passing,
            "pass_rate":        round(passing / total * 100, 1),
            "mastery_count":    mastery,
            "mastery_rate":     round(mastery / total * 100, 1),
            "safety_net_students": [
                {
                    "name":    s.enrollment.student.get_full_name(),
                    "osis":    s.enrollment.student.username,
                    "scale":   s.scale_score,
                    "is_swd":  s.enrollment.is_swd,
                }
                for s in safety_net.select_related("enrollment__student")
            ],
            "appeal_count":     appeal_eligible.count(),
            "level_dist": {
                str(k): {"count": v, "pct": round(v / total * 100, 1)}
                for k, v in level_dist.items()
            },
        })

    def on_filter_session(self):
        """Switch the analytics view to a different session."""
        session_id = self.payload["session_id"]
        session = RegentsSession.objects.get(pk=session_id)
        self.state["session_id"]    = session_id
        self.state["session_label"] = str(session)
        self.state["sirs_ready"]    = False
        self._compute(session)

SIRS export event

regents/components/score_analytics.py (continued) python
    def on_export_sirs(self):
        """
        Build a SIRS-compatible score payload.

        NYSED's Student Information Repository System expects actual scale
        scores — no rounding up, no substitution. Schools report the score
        the student earned, regardless of passing status.
        """
        session = RegentsSession.objects.get(pk=self.state["session_id"])
        scores = RegentsScore.objects.filter(
            enrollment__room__session=session
        ).select_related("enrollment__student")

        self.state["sirs_export"] = [
            {
                "osis":             s.enrollment.student.username,
                "last_name":        s.enrollment.student.last_name,
                "first_name":       s.enrollment.student.first_name,
                "exam_code":        "CHRE",  # NYSED exam code for Chemistry Regents
                "scale_score":      s.scale_score,
                "performance_level": s.performance_level,
                "period":           session.period,
                "year":             session.year,
            }
            for s in scores
        ]
        self.state["sirs_ready"] = True

Testing

All three components are pure Python — no HTTP, no browser, no Django test client required for unit tests. Use ComponentTestCase and inject fake state directly.

tests/test_regents_components.py python
import pytest
from unittest.mock import patch, MagicMock
from component_framework.testing import ComponentTestCase

from regents.components.score_entry import ScoreEntryComponent, ChemistryScoreSchema
from regents.components.score_analytics import ScoreAnalyticsComponent


# ── Schema validation tests (no DB) ─────────────────────────────────────

class TestChemistryScoreSchema:

    def test_valid_scores_accepted(self):
        s = ChemistryScoreSchema(part_a=28, part_b1=18, part_b2=12, part_c=20)
        assert s.part_a + s.part_b1 + s.part_b2 + s.part_c == 78

    def test_part_a_exceeds_max_raises(self):
        with pytest.raises(Exception, match="less than or equal to 30"):
            ChemistryScoreSchema(part_a=31, part_b1=0, part_b2=0, part_c=0)

    def test_total_over_85_raises(self):
        with pytest.raises(Exception, match="exceeds maximum of 85"):
            # A=30, B1=20, B2=15, C=25 = 90 → should fail
            ChemistryScoreSchema(part_a=30, part_b1=20, part_b2=15, part_c=25)

    def test_zero_scores_valid(self):
        s = ChemistryScoreSchema(part_a=0, part_b1=0, part_b2=0, part_c=0)
        assert s.part_a == 0

    def test_maximum_total_valid(self):
        # 30+20+15+19 = 84 — valid (model_validator checks >85 strictly)
        s = ChemistryScoreSchema(part_a=30, part_b1=20, part_b2=15, part_c=19)
        assert s.part_a + s.part_b1 + s.part_b2 + s.part_c == 84


# ── Performance level helper (isolated) ─────────────────────────────────

class TestPerformanceLevels:

    def _level(self, scale):
        # Mirror component logic directly
        if scale >= 85: return 5
        if scale >= 76: return 4
        if scale >= 65: return 3
        if scale >= 55: return 2
        return 1

    @pytest.mark.parametrize("scale,expected", [
        (100, 5), (85, 5),   # mastery
        (84,  4), (76, 4),   # meets standards
        (75,  3), (65, 3),   # passing
        (64,  2), (55, 2),   # safety net
        (54,  1), (0,  1),   # failing
    ])
    def test_level_boundaries(self, scale, expected):
        assert self._level(scale) == expected


# ── ScoreEntryComponent (DB mocked) ─────────────────────────────────────

class TestScoreEntryComponent:

    def _make_component(self, initial_state):
        comp = ScoreEntryComponent(enrollment_id=1)
        comp.state = initial_state
        return comp

    def test_level_mastery(self):
        comp = ScoreEntryComponent()
        assert comp._level(91) == 5

    def test_level_safety_net_boundary(self):
        comp = ScoreEntryComponent()
        assert comp._level(55) == 2
        assert comp._level(64) == 2
        assert comp._level(65) == 3

    def test_conversion_fallback_when_no_chart(self):
        comp = ScoreEntryComponent()
        mock_session = MagicMock()
        with patch("regents.components.score_entry.ConversionChart.objects.get",
                    side_effect=Exception("DoesNotExist")):
            scale = comp._to_scale(mock_session, 51)
        # Linear fallback: round(51/85 * 100) = 60
        assert scale == 60

    def test_conversion_uses_chart_when_available(self):
        comp = ScoreEntryComponent()
        mock_session = MagicMock()
        mock_entry = MagicMock(scale_score=67)
        with patch("regents.components.score_entry.ConversionChart.objects.get",
                    return_value=mock_entry):
            scale = comp._to_scale(mock_session, 42)
        assert scale == 67
Test strategy: Schema validation and level-mapping logic need zero database access — test them as plain Python. DB-touching methods (mount, on_submit, on_export_sirs) are best covered with pytest-django fixtures using a real SQLite test database, or mocked with unittest.mock.patch for fast CI.

Running the Example

  1. Install with Django extras:
    pip install "component-framework[django]"
  2. Add to INSTALLED_APPS: "component_framework" and "regents"
  3. Run migrations: python manage.py migrate
  4. Create a session and load a conversion chart via Django admin or a management command. NYSED publishes the chart as a PDF on exam day — parse it into 86 ConversionChart rows (raw 0–85 → scale 0–100).
  5. Register rooms and enroll students. Accommodation flags drive automatic room assignment to extended-time or separate-location rooms.
  6. Wire up URLs in urls.py:
    from component_framework.adapters.django_views import ComponentView
    path("components/<str:name>/", ComponentView.as_view())
  7. Set the global renderer in apps.py:
    Component.renderer = DjangoRenderer()
  8. Include HTMX in your base template and load each component via the {% live_component %} template tag.
SIRS reporting: The on_export_sirs event returns a JSON list in the component state. Wire a download view that reads this list and streams it as a CSV — NYSED's ASAP platform accepts CSV uploads for score entry. Always report the actual scale score earned; do not round up or substitute for administrative convenience.

Compared to the spreadsheet approach

Capability Spreadsheet + Email component-framework
Check-in tracking Paper roster, reconciled manually Live per-room view, timestamped check-ins
Score entry validation None — clerks enter anything Pydantic schema catches out-of-range values before save
Raw → scale conversion Manual lookup, transcription errors common Automatic via DB conversion chart
Performance level Computed separately in a second sheet Derived automatically on save
Safety-net flagging Manual filter, often missed Surfaced automatically; SWD flag shown inline
SIRS export Re-keyed into ASAP web form One-click JSON payload, download as CSV
Multi-room coordination Email/phone between rooms Each room is an independent component instance
Audit trail None entered_by + entered_at on every score row