Skip to content

feat(gradebook): external assessment foundation - model, serialization, and inline grade entry#8470

Open
LWS49 wants to merge 1 commit into
lws49/feat-gradebook-exportfrom
lws49/feat-ext-assessments-pr1-foundation
Open

feat(gradebook): external assessment foundation - model, serialization, and inline grade entry#8470
LWS49 wants to merge 1 commit into
lws49/feat-gradebook-exportfrom
lws49/feat-ext-assessments-pr1-foundation

Conversation

@LWS49

@LWS49 LWS49 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the foundation for external assessments, the only gradebook columns a professor can create, on top of the weighted gradebook view (#8436). This PR introduces the data model (external assessment + external grade), serializes externals into the gradebook payload so they render as columns in the "All assessments" view, and makes those cells inline-editable with an optimistic save. Grades store at decimal(5,2) so two-decimal imports (Canvas, SoftMark) round-trip without silent rounding, and each external carries optional floor/cap bound flags. The management panel and CSV import wizard are deferred to later PRs in this stack; this PR is model, read serialization, and single-cell write only. The grade-write endpoint (#grades) is also tightened from teaching assistant to manager.

Design decisions

  • External grade/max columns use decimal(5,2) while native grade columns stay decimal(4,1) - externals are fed by Canvas/SoftMark which emit two decimals, whereas native grading is Coursemology-native where one decimal is fine. Widening every grade column would be a larger, separate change.
  • Floor and cap flags are applied only at weighted-compute time (in effectiveGrade), never mutating the stored grade - the raw "All assessments" view always shows the true entered value, so toggling a flag re-interprets rather than rewrites data.
  • Inline cells use the spreadsheet model (commit on blur/Enter, cancel on Esc) with an optimistic update and rollback-plus-toast on failure - this matches the Excel/Sheets idiom for a grid; entry validation and richer save feedback arrive in the validation PR.

Regression prevention

Covers: the external assessment and external grade models (validations, bounds, two-decimal precision), the synthetic-category contribution integration, the gradebook serializer's external payload, inline grade entry (optimistic apply and rollback on failure), the setExternalGrade operation, and external assessment column-id building. Migrations (20260615 create external tables, 20260616 link externals to contributions, 20260622 bounds, 20260623 decimal(5,2) precision) are reversible. The #grades authorization change is asserted in the gradebook controller spec. No change to existing gradebook behavior when a course has no externals. Model and controller specs run on CI.


♻️ Recreated after #8462 was auto-closed as "merged" when the base branch (feat-gradebook-export) was briefly force-pushed to a commit that contained this branch. Same head, base, and content; the base has been restored.

@LWS49 LWS49 force-pushed the lws49/feat-ext-assessments-pr1-foundation branch from cbf61fa to af0e2bd Compare July 2, 2026 12:15
@LWS49 LWS49 force-pushed the lws49/feat-gradebook-export branch from 177439e to c563f33 Compare July 2, 2026 14:36
@LWS49 LWS49 force-pushed the lws49/feat-ext-assessments-pr1-foundation branch from af0e2bd to b6b0aaf Compare July 2, 2026 14:42
@LWS49 LWS49 force-pushed the lws49/feat-gradebook-export branch from c563f33 to e963677 Compare July 2, 2026 15:23
@LWS49 LWS49 force-pushed the lws49/feat-ext-assessments-pr1-foundation branch from b6b0aaf to bd9256e Compare July 2, 2026 15:26
@LWS49 LWS49 force-pushed the lws49/feat-gradebook-export branch from e963677 to dd2eeeb Compare July 9, 2026 04:57
@LWS49 LWS49 force-pushed the lws49/feat-ext-assessments-pr1-foundation branch from bd9256e to d6d17fa Compare July 9, 2026 05:08
@adi-herwana-nus adi-herwana-nus requested a review from Copilot July 9, 2026 06:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR lays the groundwork for External Assessments in the gradebook (professor-created columns distinct from native Course::Assessments), including backend data modelling, gradebook JSON serialization for rendering externals as columns, and inline grade entry with an optimistic save/rollback flow. It also integrates externals into the weighted gradebook structure via a synthetic category/tab representation and external weight contributions.

Changes:

  • Added external-assessment backend models + migration/schema updates (external assessments, per-student external grades, and gradebook contribution weights).
  • Extended gradebook JSON payload to emit externals as synthetic category/tabs/assessment leaves, and emit external grade “submissions”.
  • Implemented frontend rendering + inline editing for external-grade cells, plus bounded-grade handling in weighted computations.

Reviewed changes

Copilot reviewed 41 out of 41 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
spec/models/course/gradebook/external_contribution_spec.rb Tests ExternalContribution validations, dependent destroy, and bulk upsert behavior.
spec/models/course/external_assessment_spec.rb Tests ExternalAssessment associations, validations, defaults, and transactional creation.
spec/models/course/external_assessment_grade_spec.rb Tests ExternalAssessmentGrade uniqueness and numeric/null grade validation.
spec/factories/course_gradebook_external_contributions.rb Adds factory for ExternalContribution.
spec/factories/course_external_assessments.rb Adds factories for ExternalAssessment and ExternalAssessmentGrade.
spec/controllers/course/gradebook_controller_spec.rb Verifies gradebook index payload includes externals and that weights persist to ExternalContribution.
spec/controllers/course/external_assessments_controller_spec.rb Verifies external-grade write endpoint behavior + authorization.
db/schema.rb Reflects new external assessment/grade/contribution tables and keys.
db/migrate/20260624000000_create_course_external_assessment_tables.rb Migration squashing external assessment tables + contributions with decimal(5,2).
config/routes.rb Adds nested gradebook route for external grade writes.
config/locales/en/activerecord/errors.yml Adds translated validation error for “exactly one contributor” rule.
config/locales/zh/activerecord/errors.yml Same as above (ZH).
config/locales/ko/activerecord/errors.yml Same as above (KO).
client/locales/en.json Adds UI strings for external badge and grade save toast/errors.
client/locales/zh.json Same as above (ZH).
client/locales/ko.json Same as above (KO).
client/app/types/course/gradebook.ts Extends gradebook types for externals and optional submissionId.
client/app/lib/components/table/utils.ts Prepends UTF-8 BOM for CSV download to improve Excel UTF-8 detection.
client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts Adds sortDescFirst support when provided by column template.
client/app/lib/components/table/builder/ColumnTemplate.ts Adds descFirst sorting option to template type.
client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx Minor styling adjustment (border via sx).
client/app/bundles/course/gradebook/store.ts Adds reducer/action to upsert external grades into submissions.
client/app/bundles/course/gradebook/selectors.ts Adds selector for external assessments.
client/app/bundles/course/gradebook/operations.ts Adds optimistic setExternalGrade operation calling new API endpoint.
client/app/bundles/course/gradebook/computeWeighted.ts Applies external grade floor/cap at compute time and uses bounded ratio for percent display.
client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx Uses bounded ratio for percent breakdown display.
client/app/bundles/course/gradebook/components/GradebookTable.tsx Renders external columns distinctly and adds inline-edit external grade cell + toast feedback.
client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts Allows negative assessment IDs in column-id parsing.
client/app/bundles/course/gradebook/tests/store.test.ts Tests external grade upsert/clear reducer behavior.
client/app/bundles/course/gradebook/tests/computeWeighted.test.ts Tests floor/cap grade bounding behavior in equal/custom/breakdown flows.
client/app/bundles/course/gradebook/tests/buildAssessmentColumnIds.test.ts Tests round-tripping of negative assessment IDs in column IDs.
client/app/api/course/Gradebook.ts Adds setExternalGrade API call.
app/views/course/gradebook/index.json.jbuilder Emits synthetic external category/tabs/assessments and external grade submissions.
app/views/course/external_assessments/update_grade.json.jbuilder Serializes external-grade write response for optimistic reconcile.
app/models/course/gradebook/external_contribution.rb New model: external weight contributions + bulk update implementation.
app/models/course/external_assessment.rb New model: external assessment + synthetic IDs and transactional creation helper.
app/models/course/external_assessment_grade.rb New model: per-student external grade storage.
app/models/course.rb Adds associations for external assessments and their contributions.
app/models/course_user.rb Adds association for external assessment grades.
app/controllers/course/gradebook_controller.rb Loads externals for index and persists external + tab weights atomically.
app/controllers/course/external_assessments_controller.rb New endpoint to upsert/clear an external grade for a student.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +224 to +227
const [localValue, setLocalValue] = useState<number | null | undefined>(
value,
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, will update accordingly.

Comment on lines +283 to +293
<span
onClick={() => {
setText(localValue == null ? '' : String(localValue));
setEditing(true);
}}
role="button"
style={{ cursor: 'pointer', display: 'inline-block', minWidth: 24 }}
tabIndex={0}
>
{localValue == null ? '—' : localValue}
</span>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, will update accordingly.

Comment on lines 349 to 353
const submissionIds: Partial<Record<number, number>> = {};
assessments.forEach((a) => {
const sub = subs?.get(a.id);
const sub = subs.find((s) => s.assessmentId === a.id);
if (sub != null) {
grades[a.id] = sub.grade;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, reverted.

Comment thread app/models/course/gradebook/external_contribution.rb
interface SortingProps<D> {
sort?: (datumA: D, datumB: D) => number;
undefinedPriority?: false | 'first' | 'last';
descFirst?: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to be unused. I thought we removed this from an earlier PR?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad. I removed descFirst from the tables, but forgot to remove them from the components setup. removing it from both ColumnTemplate.ts and columnsBuilder.ts, there should be no more remnants of descFirst now.


const DEFAULT_CSV_FILENAME = 'data' as const;

// Prepend UTF-8 BOM so Excel on macOS/Windows detects UTF-8 encoding instead

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember being against this functionality when reviewing a previous PR, but I see that our backend actually has some code handling BOM logic, so I'm inclined to keep it in for now to keep things moving.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, noted. will keep this then.

@LWS49 LWS49 force-pushed the lws49/feat-ext-assessments-pr1-foundation branch 3 times, most recently from a431979 to f2b0246 Compare July 9, 2026 10:03
…ation, inline grade entry)

Foundation layer for external assessments: data model + migrations, gradebook read serialization, and inline grade entry (update_grade). Management UI (panel, add/edit/delete), weight integration, and CSV import are introduced in later PRs of the stack.
@LWS49 LWS49 force-pushed the lws49/feat-ext-assessments-pr1-foundation branch from f2b0246 to 251439f Compare July 9, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants