Skip to content
Merged
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
167 changes: 150 additions & 17 deletions bases/rsptx/admin_server_api/routers/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
courses listed for them in ``editor_basecourse``.
"""

import json
from typing import Any

from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
Expand All @@ -18,17 +21,23 @@
from rsptx.configuration import settings
from rsptx.db.crud import (
delete_question_by_name,
fetch_assigned_question_ids,
fetch_all_course_attributes,
fetch_course,
fetch_editor_basecourses,
fetch_flagged_questions,
fetch_question,
fetch_question_by_id,
get_book_chapters,
update_question,
)
from rsptx.endpoint_validators import editor_role_required
from rsptx.logging import rslogger
from rsptx.response_helpers.core import get_webpack_static_imports, make_json_response
from rsptx.response_helpers.core import (
canonical_utcnow,
get_webpack_static_imports,
make_json_response,
)
from rsptx.templates import get_shared_templates

router = APIRouter(
Expand Down Expand Up @@ -63,12 +72,12 @@ async def manage_exercises(
):
"""
Display every question flagged for review in the base courses this user
edits, with controls to delete a question or clear its flag.
edits, with controls to edit or delete a question, or clear its flag.
"""
course = await fetch_course(user.course_name)
base_courses = await fetch_editor_basecourses(user.id)

questions = []
flagged_questions = []
# Chapter labels are what the questions table stores; map them to the
# human-readable chapter titles, per base course.
chapter_titles = {}
Expand All @@ -77,19 +86,25 @@ async def manage_exercises(
chapter.chapter_label: chapter.chapter_name
for chapter in await get_book_chapters(base_course)
}
for q in await fetch_flagged_questions(base_course):
questions.append(
{
"name": q.name,
"base_course": q.base_course,
"chapter": q.chapter,
"chapter_title": chapter_titles[base_course].get(q.chapter, ""),
"subchapter": q.subchapter,
"difficulty": q.difficulty,
"question_type": q.question_type,
"htmlsrc": q.htmlsrc,
}
)
flagged_questions.extend(await fetch_flagged_questions(base_course))

assigned_ids = await fetch_assigned_question_ids(q.id for q in flagged_questions)
questions = [
{
"id": q.id,
"name": q.name,
"base_course": q.base_course,
"chapter": q.chapter,
"chapter_title": chapter_titles[q.base_course].get(q.chapter, ""),
"subchapter": q.subchapter,
"difficulty": q.difficulty,
"question_type": q.question_type,
"htmlsrc": q.htmlsrc,
"assigned": q.id in assigned_ids,
"has_question_json": q.question_json is not None,
}
for q in flagged_questions
]

course_attrs = await fetch_all_course_attributes(course.id)
templates = get_shared_templates()
Expand All @@ -115,6 +130,10 @@ class QuestionRequest(BaseModel):
base_course: str


class QuestionEditRequest(BaseModel):
question_json: dict[str, Any]


async def _editable_question(user, body: QuestionRequest):
"""Resolve the question named in a request, but only if the caller edits the
base course it belongs to. Returns ``(question, error_response)``.
Expand All @@ -139,6 +158,102 @@ async def _editable_question(user, body: QuestionRequest):
return question, None


async def _editable_question_by_id(user, question_id: int):
"""Resolve a question id only when the caller edits its base course."""
question = await fetch_question_by_id(question_id)
if not question:
return None, make_json_response(
status=status.HTTP_404_NOT_FOUND,
detail={"status": "Error", "message": "Question not found."},
)

base_courses = await fetch_editor_basecourses(user.id)
if question.base_course not in base_courses:
return None, make_json_response(
status=status.HTTP_403_FORBIDDEN,
detail={"status": "Error", "message": "You do not edit that base course."},
)
return question, None


@router.get("/questions/{question_id}/edit", response_class=HTMLResponse)
@editor_role_required()
async def edit_question_page(
request: Request,
question_id: int,
user=Depends(auth_manager),
):
"""Display the focused editor for a question in the review queue."""
question, err = await _editable_question_by_id(user, question_id)
if err:
return err
if question.question_json is None:
return make_json_response(
status=status.HTTP_409_CONFLICT,
detail={
"status": "Error",
"message": "Legacy questions without question_json cannot be edited.",
},
)

course = await fetch_course(user.course_name)
course_attrs = await fetch_all_course_attributes(course.id)
context = {
"request": request,
"user": user,
"course": course,
"is_instructor": True,
"student_page": False,
"question": question,
"formatted_question_json": json.dumps(
question.question_json, indent=2, ensure_ascii=False
),
"wp_imports": _safe_webpack_imports(course),
"course_attrs": course_attrs,
"latex_preamble": course_attrs.get("latex_macros", ""),
"webwork_js_version": course_attrs.get("webwork_js_version", "2.20"),
"settings": settings,
}
return get_shared_templates().TemplateResponse(
"admin/editor/edit_question.html", context
)


@router.post("/questions/{question_id}/edit", response_class=JSONResponse)
@editor_role_required()
async def edit_question(
request: Request,
question_id: int,
body: QuestionEditRequest,
user=Depends(auth_manager),
):
"""Save a question's Assignment Builder JSON without changing its identity."""
question, err = await _editable_question_by_id(user, question_id)
if err:
return err
if question.question_json is None:
return make_json_response(
status=status.HTTP_409_CONFLICT,
detail={
"status": "Error",
"message": "Legacy questions without question_json cannot be edited.",
},
)

question.question_json = body.question_json
question.timestamp = canonical_utcnow()
try:
await update_question(question)
except Exception as e:
rslogger.error(f"Error updating question {question.name}: {e}")
return make_json_response(
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"status": "Error", "message": f"Failed to update: {e}"},
)

return make_json_response(detail={"status": "Success"})


@router.post("/delete_question", response_class=JSONResponse)
@editor_role_required()
async def delete_question(
Expand All @@ -151,15 +266,33 @@ async def delete_question(
if err:
return err

if question.id in await fetch_assigned_question_ids([question.id]):
return make_json_response(
status=status.HTTP_409_CONFLICT,
detail={
"status": "Error",
"message": "Assigned questions cannot be deleted.",
},
)

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

if not deleted:
return make_json_response(
status=status.HTTP_409_CONFLICT,
detail={
"status": "Error",
"message": "The question was assigned or changed before deletion.",
},
)

return make_json_response(detail={"status": "Success"})


Expand Down
2 changes: 2 additions & 0 deletions components/rsptx/db/crud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@
fetch_question,
fetch_question_by_id,
fetch_questions_by_name,
fetch_assigned_question_ids,
fetch_flagged_questions,
fetch_questions_for_chapter_subchapter,
fetch_question_count_per_subchapter,
Expand Down Expand Up @@ -515,6 +516,7 @@
"fetch_question",
"fetch_questions_by_name",
"fetch_question_by_id",
"fetch_assigned_question_ids",
"fetch_flagged_questions",
"fetch_questions_for_chapter_subchapter",
"fetch_question_count_per_subchapter",
Expand Down
27 changes: 23 additions & 4 deletions components/rsptx/db/crud/question.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import re
import inspect
from typing import Dict, Iterable, List, Optional, Tuple
from typing import Dict, Iterable, List, Optional, Set, Tuple
from sqlalchemy import select, and_, or_, func, asc, desc, not_, update, delete
from sqlalchemy.exc import IntegrityError

Expand Down Expand Up @@ -137,17 +137,36 @@ async def fetch_flagged_questions(base_course: str) -> List[QuestionValidator]:
return [QuestionValidator.from_orm(x) for x in res.scalars().fetchall()]


async def fetch_assigned_question_ids(question_ids: Iterable[int]) -> Set[int]:
"""Return the question ids that are referenced by at least one assignment."""
ids = set(question_ids)
if not ids:
return set()

query = select(AssignmentQuestion.question_id).where(
AssignmentQuestion.question_id.in_(ids)
)
async with async_session() as session:
res = await session.execute(query)
return set(res.scalars().all())


async def delete_question_by_name(name: str, base_course: str) -> int:
"""
Delete a question identified by its name (div_id) within a base course.
``(base_course, name)`` is unique, so at most one row is removed.
Delete an unassigned question identified by its name (div_id) within a base
course. ``(base_course, name)`` is unique, so at most one row is removed.

:param name: str, the name (div_id) of the question
:param base_course: str, the base course the question belongs to
:return: int, the number of rows deleted
"""
assigned = (
select(AssignmentQuestion.id)
.where(AssignmentQuestion.question_id == Question.id)
.exists()
)
stmt = delete(Question).where(
(Question.name == name) & (Question.base_course == base_course)
(Question.name == name) & (Question.base_course == base_course) & ~assigned
)

async with async_session.begin() as session:
Expand Down
61 changes: 61 additions & 0 deletions components/rsptx/templates/admin/editor/edit_question.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{% extends "admin/_admin_base.html" %}
{% set needs_jquery = true %}

{% block title %}
Edit Question
{% endblock %}

{% block page_css %}
{% include 'common/static_assets.html' %}
<style>
.editorial-edit-form textarea {
min-height: 32rem;
width: 100%;
font-family: monospace;
}

.editorial-edit-form .form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
</style>
{% endblock %}

{% block content %}
{% include 'common/ebook_config.html' %}

<div class="admin-page">
<div class="page-header">
<h1>Edit Question</h1>
<p>
<strong>{{ question.name }}</strong> in
<strong>{{ question.base_course }}</strong>
</p>
</div>

<div id="alert-container"></div>

<form id="editorial-edit-form" class="editorial-edit-form" method="post"
data-question-id="{{ question.id }}">
<div class="form-group">
<label for="question-json">Question JSON</label>
<p class="help-block">
Edit the Assignment Builder representation of this question. The JSON
must be an object. The question identifier cannot be changed.
</p>
<textarea id="question-json" name="question_json" spellcheck="false"
required>{{ formatted_question_json }}</textarea>
</div>

<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a class="btn btn-default" href="/admin/editor/manage_exercises">Cancel</a>
</div>
</form>
</div>
{% endblock %}

{% block page_js %}
<script src="/staticAssets/js/admin/manage_exercises.js?v=1257-json-edit-1"></script>
{% endblock %}
Loading
Loading