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.
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 A | 30 | 30 multiple choice | Machine-scanned answer sheet |
| Part B-1 | 20 | 20 multiple choice (Reference Tables required) | Machine-scanned answer sheet |
| Part B-2 | 15 | Constructed response, partial credit | Hand-scored by two raters |
| Part C | 25 | Extended constructed response, partial credit | Hand-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:
| Level | Scale Score | Meaning | Dashboard indicator |
|---|---|---|---|
| Level 5 | 85–100 | Mastery — diploma mastery annotation eligible | MASTERY |
| Level 4 | 76–84 | Meets standards | LEVEL 4 |
| Level 3 | 65–75 | Passing — satisfies Regents diploma requirement | PASS |
| Level 2 | 55–64 | Safety net zone (primarily SWD students) | SAFETY NET |
| Level 1 | 0–54 | Does not meet standards | FAIL |
Architecture
Data Models
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()
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
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
<!-- 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>
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
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
@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 %}
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.
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
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.
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
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
- Install with Django extras:
pip install "component-framework[django]" - Add to
INSTALLED_APPS:"component_framework"and"regents" - Run migrations:
python manage.py migrate - 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
ConversionChartrows (raw 0–85 → scale 0–100). - Register rooms and enroll students. Accommodation flags drive automatic room assignment to extended-time or separate-location rooms.
- Wire up URLs in
urls.py:
from component_framework.adapters.django_views import ComponentView
path("components/<str:name>/", ComponentView.as_view()) - Set the global renderer in
apps.py:
Component.renderer = DjangoRenderer() - Include HTMX in your base template and load each component
via the
{% live_component %}template tag.
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 |