Skip to content
Open
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
22 changes: 15 additions & 7 deletions bases/rsptx/admin_server_api/routers/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@
from rsptx.auth.session import auth_manager
from rsptx.configuration import settings
from rsptx.db.crud import (
delete_question_by_name,
fetch_all_course_attributes,
fetch_course,
fetch_editor_basecourses,
fetch_flagged_questions,
fetch_question,
get_book_chapters,
retire_question_by_name,
update_question,
)
from rsptx.endpoint_validators import editor_role_required
Expand Down Expand Up @@ -139,25 +139,33 @@ async def _editable_question(user, body: QuestionRequest):
return question, None


@router.post("/delete_question", response_class=JSONResponse)
@router.post("/retire_question", response_class=JSONResponse)
@editor_role_required()
async def delete_question(
async def retire_question(
request: Request,
body: QuestionRequest,
user=Depends(auth_manager),
):
"""Permanently remove a flagged question from the questions table."""
"""Take a flagged question out of circulation.

The row is kept on purpose. Courses that already assign the exercise carry
on unchanged -- deleting it would cascade through ``assignment_questions``
and pull the exercise out from under a course that may be mid-term. What
changes is that the exercise stops appearing in exercise search, so nobody
can build a *new* assignment around it. ``rsmanage questions purge``
deletes retired exercises later, once it can prove nothing depends on them.
"""
question, err = await _editable_question(user, body)
if err:
return err

try:
await delete_question_by_name(body.name, body.base_course)
await retire_question_by_name(body.name, body.base_course, user.username)
except Exception as e:
rslogger.error(f"Error deleting question {body.name}: {e}")
rslogger.error(f"Error retiring question {body.name}: {e}")
return make_json_response(
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"status": "Error", "message": f"Failed to delete: {e}"},
detail={"status": "Error", "message": f"Failed to retire: {e}"},
)

return make_json_response(detail={"status": "Success"})
Expand Down
159 changes: 159 additions & 0 deletions bases/rsptx/rsmanage/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@
create_library_book,
create_user_course_entry,
delete_course_completely,
delete_questions_by_id,
delete_user,
fetch_retired_questions,
find_questions_in_use,
retire_question_by_name,
unretire_question_by_name,
fetch_all_course_attributes,
fetch_assignments,
fetch_course,
Expand Down Expand Up @@ -1920,5 +1925,159 @@ async def telemetry(config, send):
await term_models()


# command group for managing the questions (exercise) table


@cli.group()
@click.pass_context
def questions(ctx):
"""subcommands for managing retired exercises"""
pass


@questions.command("listretired")
@click.pass_context
@click.option("--base-course", default=None, help="Limit to one base course")
async def questions_listretired(ctx, base_course):
"""
List exercises editors have retired, newest retirement last.
"""
retired = await fetch_retired_questions(canonical_utcnow(), base_course)
if not retired:
click.echo("No exercises are retired.")
return

for q in retired:
click.echo(
f"{q.retired_on:%Y-%m-%d} {q.base_course:30} {q.name:40} "
f"retired by {q.retired_by or 'unknown'}"
)
click.echo(f"\n{len(retired)} retired exercise(s).")


@questions.command("retire")
@click.pass_context
@click.argument("name")
@click.argument("base_course")
async def questions_retire(ctx, name, base_course):
"""
Retire exercise NAME in BASE_COURSE: take it out of exercise search while
leaving every course that already assigns it untouched.
"""
count = await retire_question_by_name(name, base_course, "rsmanage")
if count:
click.echo(f"Retired {name}. Courses already using it are unaffected.")
else:
click.echo(f"No searchable exercise named {name} in {base_course}.")
sys.exit(-1)


@questions.command("unretire")
@click.pass_context
@click.argument("name")
@click.argument("base_course")
async def questions_unretire(ctx, name, base_course):
"""
Put retired exercise NAME in BASE_COURSE back into circulation.
"""
count = await unretire_question_by_name(name, base_course)
if count:
click.echo(f"{name} is searchable again.")
else:
click.echo(f"No exercise named {name} in {base_course}.")
sys.exit(-1)


def _purge_blockers_to_report(question, reasons):
"""Format one kept exercise for the report."""
return f" keep {question.base_course}/{question.name}: " + ", ".join(reasons)


@questions.command("purge")
@click.pass_context
@click.option(
"--retired-for-days",
default=365,
show_default=True,
help="Only consider exercises retired at least this long ago",
)
@click.option(
"--unused-for-years",
default=3.0,
show_default=True,
help="An exercise counts as unused if nothing has touched it in this many years",
)
@click.option("--base-course", default=None, help="Limit to one base course")
@click.option(
"--apply",
"apply_changes",
is_flag=True,
help="Actually delete. Without this the command only reports what it would do.",
)
async def questions_purge(
ctx, retired_for_days, unused_for_years, base_course, apply_changes
):
"""
Permanently delete retired exercises that nothing depends on any more.

An exercise is deleted only when it cleared both windows: it was retired at
least --retired-for-days ago, and for the last --unused-for-years nothing
has referenced it -- no assignment in a course whose term started in that
window, no student activity, no graded answers -- and it is no longer part
of the book source.

There is deliberately no way to override those checks. Deletion cascades
into assignment_questions, question_tags and question_grades, so an
exercise that is still tied to anything is simply kept, and the report says
why. If you want one gone regardless, remove whatever still references it
first. The default is a dry run; pass --apply to go through with it.
"""
now = canonical_utcnow()
retired_before = now - datetime.timedelta(days=retired_for_days)
active_since = now - datetime.timedelta(days=round(unused_for_years * 365.25))

candidates = await fetch_retired_questions(retired_before, base_course)
if not candidates:
click.echo(
f"No exercises have been retired since before {retired_before:%Y-%m-%d}."
)
return

click.echo(
f"{len(candidates)} exercise(s) retired before {retired_before:%Y-%m-%d}; "
f"checking for use since {active_since:%Y-%m-%d}."
)

in_use = await find_questions_in_use([q.id for q in candidates], active_since)

purge = []
keep = []
for q in candidates:
reasons = in_use.get(q.id, [])
if reasons:
keep.append((q, reasons))
else:
purge.append(q)

for q, reasons in keep:
click.echo(_purge_blockers_to_report(q, reasons))
for q in purge:
click.echo(
f" PURGE {q.base_course}/{q.name} (retired {q.retired_on:%Y-%m-%d})"
)

click.echo(f"\n{len(purge)} to delete, {len(keep)} kept.")

if not purge:
return

if not apply_changes:
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])
click.echo(f"Deleted {deleted} exercise(s).")


if __name__ == "__main__":
cli(_anyio_backend="asyncio")
18 changes: 18 additions & 0 deletions components/rsptx/db/crud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,15 @@
from .question import (
count_matching_questions,
delete_question_by_name,
delete_questions_by_id,
find_questions_in_use,
IN_USE_ACTIVITY,
IN_USE_ANSWERS,
IN_USE_ASSIGNED,
IN_USE_FROM_SOURCE,
retire_question_by_name,
unretire_question_by_name,
fetch_retired_questions,
create_question_grade_entry,
create_question,
create_user_experiment_entry,
Expand Down Expand Up @@ -500,6 +509,15 @@
__all__ += [
"count_matching_questions",
"delete_question_by_name",
"delete_questions_by_id",
"find_questions_in_use",
"IN_USE_ACTIVITY",
"IN_USE_ANSWERS",
"IN_USE_ASSIGNED",
"IN_USE_FROM_SOURCE",
"retire_question_by_name",
"unretire_question_by_name",
"fetch_retired_questions",
"create_question",
"create_question_grade_entry",
"create_user_experiment_entry",
Expand Down
Loading
Loading