Retire flagged exercises instead of deleting them - #1520
Open
oscarlevin wants to merge 1 commit into
Open
oscarlevin wants to merge 1 commit into
oscarlevin wants to merge 1 commit into
Conversation
The editorial page let an editor delete a flagged question outright. assignment_questions, question_tags and question_grades all reference questions.id with ON DELETE CASCADE, so that one click removed the exercise from every assignment in every course still using it, along with its grading configuration, while leaving the useinfo rows orphaned. A course could lose an assignment question mid-term with no warning to its instructor. Retiring stamps questions.retired_on instead. The row stays, so courses that already assign the exercise carry on untouched; what changes is that it drops out of the three instructor-facing search paths, so nobody can build a new assignment around it. The student-facing lookups (fetch_question, fetch_matching_questions, fetch_qualified_questions, the page-progress query) are deliberately left alone -- that is how a live course reaches the exercise. retired_on is a timestamp rather than a flag so the deletion can happen later, once it is safe: rsmanage questions purge deletes exercises that have been retired past a grace period and that nothing has referenced since a cutoff -- no assignment in a course whose term started in the window, no useinfo activity, no rows in any registered answer table, and not still compiled into the book source. There is no override flag; an exercise still tied to anything is kept and the report says why. It is a dry run unless given --apply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved purge safety races, unsafe negative retention values, and paths that can clear or bypass retirement remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Replaces hard deletion of flagged exercises with reversible retirement and adds controlled purge tooling.
Changes:
- Adds retirement metadata and migration support.
- Filters retired exercises from instructor-facing searches.
- Updates editor API, UI, and retirement behavior.
- Adds
rsmanage questionslifecycle and purge commands. - Extends retirement/search test coverage.
File summaries
| File | Summary |
|---|---|
test/bases/rsptx/admin_server_api/test_editor_routes.py |
Tests retirement and search exclusion; purge safety scenarios remain untested. |
migrations/versions/c3f7e2a8b491_add_retired_to_questions.py |
Adds retirement columns and a partial index. |
components/rsptx/templates/staticAssets/js/admin/manage_exercises.js |
Replaces the delete interaction with retirement. |
components/rsptx/templates/admin/editor/manage_exercises.html |
Updates editorial controls and wording. |
components/rsptx/db/models.py |
Adds retirement fields; ordinary edits must preserve them. |
components/rsptx/db/crud/question.py |
Implements retirement, filtering, and purge checks; unresolved purge race and mutation/update lifecycle issues remain. |
components/rsptx/db/crud/__init__.py |
Exports the new question CRUD functions. |
bases/rsptx/rsmanage/core.py |
Adds retirement and purge commands; retention validation, transactional safety, and coverage require changes. |
bases/rsptx/admin_server_api/routers/editor.py |
Replaces the editor delete endpoint with retirement. |
Review details
Suppressed comments (5)
bases/rsptx/rsmanage/core.py:2037
- Both retention windows accept negative values.
--retired-for-days -1includes recently retired (even future-dated) rows, while a negative--unused-for-yearsmovesactive_sinceinto the future and suppresses current-use checks, effectively providing an unsafe override despite the command's contract. Reject negative values before calculating these cutoffs.
retired_before = now - datetime.timedelta(days=retired_for_days)
active_since = now - datetime.timedelta(days=round(unused_for_years * 365.25))
bases/rsptx/rsmanage/core.py:2051
- This adds a destructive
--applypath and four independent retention blockers, but the only rsmanage test remains a smoke import and the new tests cover only the editor endpoint/search. Please add tests for the dry-run/apply boundary and each blocker before relying on this purge to protect active course data.
in_use = await find_questions_in_use([q.id for q in candidates], active_since)
components/rsptx/db/crud/question.py:143
- The
NOT_RETIREDpredicate makes a second retire request a no-op for the whole update, so it does not clearreview_flagon an already-retired question. An instructor can flag a retired question through the existing instructor route; it then reappears on the editorial page, and clicking Retire leaves it flagged. Make the idempotent update clearreview_flagwhile preserving the original retirement timestamp and editor.
stmt = (
update(Question)
.where(
(Question.name == name)
& (Question.base_course == base_course)
& NOT_RETIRED
)
.values(retired_on=canonical_utcnow(), retired_by=retired_by, review_flag=False)
components/rsptx/db/crud/question.py:110
- Filtering the three search queries does not enforce the retirement invariant on assignment mutations.
new_assignment_questionstill accepts any caller-suppliedquestion_id, andcopy_question_endpointaccepts anyoriginal_question_id; both paths can be reached with an existing assignment's retired ID and respectively attach it or create a new assignable copy. Validateretired_on IS NULLin those mutation paths so a stale client/API call cannot create a new assignment around a retired exercise.
# Every instructor-facing exercise search hangs this on its where clause.
# Retired exercises stay readable by name/id -- the courses that already assign
# them keep working -- they simply stop being discoverable in new assignments.
NOT_RETIRED = Question.retired_on.is_(None)
components/rsptx/db/models.py:623
QuestionValidatornow includes these nullable columns, butupdate_question()writes every field. The instructor update route constructs a fresh validator withoutretired_on/retired_by, so editing a retired exercise writes both asNULL, makes it searchable again, and loses the audit actor. Preserve these lifecycle fields when updating ordinary question content, or make that update partial.
retired_on = Column(DateTime)
retired_by = Column(String(512)) # username of the editor who retired it
- Files reviewed: 9/9 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| click.echo("Dry run -- nothing deleted. Re-run with --apply to delete.") | ||
| return | ||
|
|
||
| deleted = await delete_questions_by_id([q.id for q in purge]) |
Comment on lines
+340
to
+342
| stmt = delete(Question).where(Question.id.in_(question_ids)) | ||
| async with async_session.begin() as session: | ||
| res = await session.execute(stmt) |
Comment on lines
+125
to
+129
| The review flag is cleared in the same statement. Retiring settles the | ||
| review, and doing it here rather than through ``update_question`` keeps it | ||
| atomic: ``update_question`` rewrites every column from whatever the caller | ||
| read earlier, which for a row this function just changed would put | ||
| ``retired_on`` straight back to NULL. |
Comment on lines
+233
to
+237
| async def find_questions_in_use( | ||
| question_ids: List[int], active_since: datetime | ||
| ) -> Dict[int, List[str]]: | ||
| """ | ||
| Work out which of ``question_ids`` are still in use, and why. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
The editorial page gives an editor two choices for a flagged question: clear the flag, or delete it.
delete_questionruns a realDELETE FROM questions, and three tables referencequestions.idwithON DELETE CASCADE:assignment_questions(models.py:697)question_tags(models.py:633)question_grades(models.py:931)So one click removes the exercise from every assignment in every course still using it, along with its points and grading configuration.
useinfo.div_idhas no foreign key, so the student answer log survives as orphaned rows pointing at a question that no longer exists.A course can lose an assignment question mid-term, and neither the instructor nor the students get any warning. An editor triaging a flagged exercise has no way to see who depends on it.
What this changes
Retiring instead of deleting.
questions.retired_onis stamped (withretired_by), the row stays, and the exercise drops out of the three instructor-facing search paths:search_exercises— the assignment builder's exercise searchfetch_questions_by_search_criteria—/search_questionsfetch_questions_for_chapter_subchapter— the book-browsing chooser (fetch_chooser_data)Courses that already assign the exercise are untouched. What changes is that nobody can build a new assignment around it.
Deliberately not filtered, because these are how a live course reaches the exercise:
fetch_question,fetch_question_by_id,fetch_matching_questions(selectquestion),fetch_qualified_questions(practice), and the page-progress query incrud/book.py. Retiring is invisible to students.The editorial page's Delete button becomes Retire; there is no hard-delete path left in the UI.
Why a timestamp rather than a boolean
So the deletion can still happen later, once it is provably safe.
rsmanage questions purgedeletes exercises that cleared two windows: retired longer than--retired-for-days(default 365), and untouched for--unused-for-years(default 3) by any of four signals:assignment_questionsrow in a course whoseterm_start_datefalls in the windowuseinforow for thediv_id— catches reading-page exercises that were never formally assignedrunestone_component_dict, so a new@register_answer_tabletype is covered automaticallyfrom_source = True— still compiled into the book, so a rebuild would just recreate itEvery check is batched over the whole candidate set rather than run per question;
useinfois far too large to probe a row at a time.There is no override flag. An exercise still tied to anything is kept and the report says why. It is a dry run unless given
--apply.Migration
c3f7e2a8b491addsretired_on(nullableDateTime) andretired_by(String(512)), plus a partial index on the retired rows only — every search path testsretired_on IS NULL, which matches nearly every row, so a plain b-tree index would never be chosen; the partial index is small and is what the purge scans.Single head after this, on top of
f3b8d5c2a710.Testing
test_editor_routes.pyis updated for the new endpoint and addstest_retired_question_drops_out_of_search, which asserts the exercise is returned bysearch_exercisesbefore retiring and absent after.Also exercised by hand in a full docker compose stack: retire from the editorial page, confirm the exercise disappears from both exercise search and the book chooser while the existing assignment using it keeps working.
Note for deployers
Polylith bundles a copy of
components/into each project wheel, so this change needsadmin(endpoint + template),assignment(all three search filters run there), andrsmanage(the purge command) rebuilt. It also touchescomponents/rsptx/templates/staticAssets/js/admin/manage_exercises.js, which the proxy serves from a copy baked into its own image — socaddy/nginxneeds rebuilding too, or the Retire button throwsretireQuestion is not definedagainst a cached script.book_serverdoes not need rebuilding; none of the filtered functions are called from it.🤖 Generated with Claude Code