Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions bases/rsptx/admin_server_api/routers/instructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ async def get_course_settings(
"enable_compare_me": course_attrs.get("enable_compare_me", "false"),
"show_points": course_attrs.get("show_points") == "true",
"groupsize": course_attrs.get("groupsize", "3"),
"enable_async_llm_modes": course_attrs.get("enable_async_llm_modes", "false"),
}

return templates.TemplateResponse("admin/instructor/course_settings.html", context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,34 @@
}

.typeColumn {
width: 120px;
width: 160px;
}

.typeCell {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 0.5rem;
}

.asyncPeerGroup {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.2rem;
padding-top: 0.25rem;
cursor: pointer;
user-select: none;
}

.asyncPeerText {
font-size: 0.7rem;
color: var(--surface-500);
white-space: nowrap;
}

.asyncPeerGroup:hover .asyncPeerText {
color: var(--surface-700);
}

.typeTag {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { EditableCellFactory } from "@components/ui/EditableTable/EditableCellFactory";
import { TableSelectionOverlay } from "@components/ui/EditableTable/TableOverlay";
import { ExerciseTypeTag } from "@components/ui/ExerciseTypeTag";
import { useReorderAssignmentExercisesMutation } from "@store/assignmentExercise/assignmentExercise.logic.api";
import { useToastContext } from "@components/ui/ToastContext";
import {
useHasApiKeyQuery,
useReorderAssignmentExercisesMutation,
useUpdateAssignmentQuestionsMutation
} from "@store/assignmentExercise/assignmentExercise.logic.api";
import { Button } from "primereact/button";
import { Column } from "primereact/column";
import { DataTable, DataTableSelectionMultipleChangeEvent } from "primereact/datatable";
import { Dropdown } from "primereact/dropdown";
import { OverlayPanel } from "primereact/overlaypanel";
import { Tooltip } from "primereact/tooltip";
import { useRef, useState } from "react";

import { useExercisesSelector } from "@/hooks/useExercisesSelector";

import { difficultyOptions } from "@/config/exerciseTypes";
import { useJwtUser } from "@/hooks/useJwtUser";
import { useSelectedAssignment } from "@/hooks/useSelectedAssignment";
import { DraggingExerciseColumns } from "@/types/components/editableTableCell";
import { Exercise, supportedExerciseTypesToEdit } from "@/types/exercises";

Expand All @@ -19,6 +30,68 @@ import { ExercisePreviewModal } from "../components/ExercisePreview/ExercisePrev

import { SetCurrentEditExercise, ViewModeSetter, MouseUpHandler } from "./types";

const AsyncModeHeader = ({ hasApiKey }: { hasApiKey: boolean }) => {
const { showToast } = useToastContext();
const [updateExercises] = useUpdateAssignmentQuestionsMutation();
const { assignmentExercises = [] } = useExercisesSelector();
const overlayRef = useRef<OverlayPanel>(null);
const [value, setValue] = useState("Standard");

const handleSubmit = async () => {
const exercises = assignmentExercises.map((ex) => ({
...ex,
question_json: JSON.stringify(ex.question_json),
use_llm: value === "LLM"
}));
const { error } = await updateExercises(exercises);
if (!error) {
overlayRef.current?.hide();
showToast({ severity: "success", summary: "Success", detail: "Exercises updated successfully" });
} else {
showToast({ severity: "error", summary: "Error", detail: "Failed to update exercises" });
}
};

return (
<div className="flex align-items-center gap-2">
<span>Async Mode</span>
<Button
className="icon-button-sm"
tooltip='Edit "Async Mode" for all exercises'
rounded
text
severity="secondary"
size="small"
icon="pi pi-pencil"
onClick={(e) => overlayRef.current?.toggle(e)}
/>
<OverlayPanel closeIcon ref={overlayRef} style={{ width: "17rem" }}>
<div className="p-1 flex gap-2 flex-column align-items-center justify-content-around">
<div><span>Edit "Async Mode" for all exercises</span></div>
<div style={{ width: "100%" }}>
<Dropdown
style={{ width: "100%" }}
value={value}
onChange={(e) => setValue(e.value)}
options={[
{ label: "Standard", value: "Standard" },
{ label: "LLM", value: "LLM", disabled: !hasApiKey }
]}
optionLabel="label"
optionDisabled="disabled"
scrollHeight="auto"
/>
</div>
<div className="flex flex-row justify-content-around align-items-center w-full">
<Button size="small" severity="danger" onClick={() => overlayRef.current?.hide()}>Cancel</Button>
<Button size="small" onClick={handleSubmit}>Submit</Button>
</div>
</div>
</OverlayPanel>
</div>
);
};

interface AssignmentExercisesTableProps {
assignmentExercises: Exercise[];
selectedExercises: Exercise[];
Expand Down Expand Up @@ -48,6 +121,11 @@ export const AssignmentExercisesTable = ({
}: AssignmentExercisesTableProps) => {
const { username } = useJwtUser();
const [reorderExercises] = useReorderAssignmentExercisesMutation();
const [updateAssignmentQuestions] = useUpdateAssignmentQuestionsMutation();
const { selectedAssignment } = useSelectedAssignment();
const { data: { hasApiKey = false, asyncLlmModesEnabled = false } = {} } = useHasApiKeyQuery();
const isPeerAsync =
selectedAssignment?.kind === "Peer" && selectedAssignment?.peer_async_visible === true;
const dataTableRef = useRef<DataTable<Exercise[]>>(null);
const [copyModalVisible, setCopyModalVisible] = useState(false);
const [selectedExerciseForCopy, setSelectedExerciseForCopy] = useState<Exercise | null>(null);
Expand Down Expand Up @@ -276,6 +354,32 @@ export const AssignmentExercisesTable = ({
/>
)}
/>
{isPeerAsync && asyncLlmModesEnabled && (
<Column
resizeable={false}
style={{ width: "12rem" }}
header={() => <AsyncModeHeader hasApiKey={hasApiKey} />}
bodyStyle={{ padding: 0 }}
body={(data: Exercise) => (
<div className="editable-table-cell" style={{ position: "relative" }}>
<Dropdown
className="editable-table-dropdown"
value={data.use_llm && hasApiKey ? "LLM" : "Standard"}
onChange={(e) => updateAssignmentQuestions([{ ...data, question_json: JSON.stringify(data.question_json), use_llm: e.value === "LLM" }])}
options={[
Comment on lines +365 to +369
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

The per-row Async Mode dropdown calls updateAssignmentQuestions([{ ...data, use_llm: ... }]) but does not JSON-stringify question_json. The batch update endpoint expects question_json as a Pydantic Json (string), and other update paths in this codebase stringify question_json before sending. As-is, this update is likely to fail validation when question_json is an object. Recommend normalizing the payload (e.g., ensure question_json is a JSON string) and handling/displaying mutation errors.

Copilot uses AI. Check for mistakes.
{ label: "Standard", value: "Standard" },
{ label: "LLM", value: "LLM", disabled: !hasApiKey }
]}
optionLabel="label"
optionDisabled="disabled"
scrollHeight="auto"
tooltip={!hasApiKey ? "Add an API key to enable LLM mode" : undefined}
tooltipOptions={{ showOnDisabled: true }}
/>
</div>
)}
/>
)}
<Column resizeable={false} rowReorder style={{ width: "3rem" }} />
</DataTable>
<TableSelectionOverlay
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,18 @@ export const assignmentExerciseApi = createApi({
body
})
}),
hasApiKey: build.query<{ hasApiKey: boolean; asyncLlmModesEnabled: boolean }, void>({
query: () => ({
method: "GET",
url: "/assignment/instructor/has_api_key"
}),
transformResponse: (
response: DetailResponse<{ has_api_key: boolean; async_llm_modes_enabled: boolean }>
) => ({
hasApiKey: response.detail.has_api_key,
asyncLlmModesEnabled: response.detail.async_llm_modes_enabled
})
}),
copyQuestion: build.mutation<
DetailResponse<{ status: string; question_id: number; message: string }>,
{
Expand Down Expand Up @@ -218,5 +230,6 @@ export const {
useReorderAssignmentExercisesMutation,
useUpdateAssignmentExercisesMutation,
useValidateQuestionNameMutation,
useCopyQuestionMutation
useCopyQuestionMutation,
useHasApiKeyQuery
} = assignmentExerciseApi;
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type Exercise = {
reading_assignment: boolean;
sorting_priority: number;
activities_required: number;
use_llm: boolean;
qnumber: string;
name: string;
subchapter: string;
Expand Down
19 changes: 19 additions & 0 deletions bases/rsptx/assignment_server_api/routers/instructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,25 @@ async def add_api_token(
)


@router.get("/has_api_key")
@instructor_role_required()
@with_course()
async def has_api_key(request: Request, user=Depends(auth_manager), course=None):
"""Return whether the course has at least one API token configured and whether async LLM modes are enabled."""
tokens = await fetch_all_api_tokens(course.id)
course_attrs = await fetch_all_course_attributes(course.id)
async_llm_modes_enabled = (
course_attrs.get("enable_async_llm_modes", "false") == "true"
)
return make_json_response(
status=status.HTTP_200_OK,
detail={
"has_api_key": len(tokens) > 0,
"async_llm_modes_enabled": async_llm_modes_enabled,
},
)


@router.get("/add_token")
@instructor_role_required()
@with_course()
Expand Down
3 changes: 1 addition & 2 deletions bases/rsptx/book_server_api/routers/assessment.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,7 @@ async def getpollresults(request: Request, course: str, div_id: str):
my_vote = int(user_res.split(":")[0])
my_comment = user_res.split(":")[1]
else:
if user_res.isnumeric():
my_vote = int(user_res)
my_vote = int(user_res) if user_res.isnumeric() else -1
my_comment = ""
else:
my_vote = -1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,32 @@ def dashboard():
is_last=done,
lti=is_lti,
has_vote1=has_vote1,
peer_async_visible=assignment.peer_async_visible or False,
**course_attrs,
)


@auth.requires(
lambda: verifyInstructorStatus(auth.user.course_id, auth.user),
requires_login=True,
)
def toggle_async():
response.headers["content-type"] = "application/json"
assignment_id = request.vars.assignment_id
if not assignment_id:
return json.dumps({"ok": False, "error": "missing assignment_id"})
assignment = db(db.assignments.id == assignment_id).select().first()
if not assignment:
return json.dumps({"ok": False, "error": "assignment not found"})
course = db(db.courses.course_name == auth.user.course_name).select().first()
if not course or assignment.course != course.id:
return json.dumps({"ok": False, "error": "assignment does not belong to your course"})
new_value = not (assignment.peer_async_visible or False)
db(db.assignments.id == assignment_id).update(peer_async_visible=new_value)
db.commit()
return json.dumps({"peer_async_visible": new_value})
Comment on lines +155 to +169
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

toggle_async updates an assignment solely by assignment_id without verifying the assignment belongs to the instructor’s current course (or even that the assignment exists). As written, an instructor could toggle peer_async_visible for any assignment ID they can guess, and a missing/invalid ID will raise an exception when accessing assignment.peer_async_visible. Recommend validating assignment_id, returning a JSON error for missing/unknown assignments, and checking assignment.course == auth.user.course_id before updating.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The @auth.requires decorator handles the security function to make sure that the instructor is an instructor, but you are correct it does not check that the assignment belongs to the instructors current course.



def extra():
assignment_id = request.vars.assignment_id
current_question, done, idx = _get_current_question(assignment_id, False)
Expand Down Expand Up @@ -174,7 +196,9 @@ def _get_current_question(assignment_id, get_next):
idx = 0
db(db.assignments.id == assignment_id).update(current_index=idx)
elif get_next is True:
idx = assignment.current_index + 1
all_questions = _get_assignment_questions(assignment_id)
total_questions = len(all_questions)
idx = min(assignment.current_index + 1, max(total_questions - 1, 0))
db(db.assignments.id == assignment_id).update(current_index=idx)
else:
idx = assignment.current_index
Expand Down Expand Up @@ -743,7 +767,18 @@ def peer_async():
if "latex_macros" not in course_attrs:
course_attrs["latex_macros"] = ""

llm_enabled = _llm_enabled()
aq = None
if current_question:
aq = db(
(db.assignment_questions.assignment_id == assignment_id)
& (db.assignment_questions.question_id == current_question.id)
).select().first()
async_llm_modes_enabled = course_attrs.get("enable_async_llm_modes", "false") == "true"
if async_llm_modes_enabled:
question_use_llm = bool(aq.use_llm) if aq else False
llm_enabled = _llm_enabled() and question_use_llm
else:
llm_enabled = _llm_enabled()
try:
db.useinfo.insert(
course_id=auth.user.course_name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,6 @@
Field(
"activities_required", type="integer"
), # specifies how many activities in a sub chapter a student must perform in order to receive credit
Field("use_llm", type="boolean", default=False),
migrate=bookserver_owned("assignment_questions"),
)
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ <h3>Question {{ =current_qnum }} of {{ =num_questions }}</h3>
</div>

<div id="pi-assignment-navigation">
{{ if current_qnum < num_questions: }}
<button
type="submit"
id="nextq"
Expand All @@ -70,6 +71,19 @@ <h3>Question {{ =current_qnum }} of {{ =num_questions }}</h3>
>
Next Question
</button>
{{ else: }}
<div id="asyncBtnArea" style="display:inline-block;">
<button
type="button"
id="toggleAsyncBtn"
class="btn btn-info"
onclick="showAsyncConfirm()"
style="margin-right: 4px;{{ if peer_async_visible: }} background-color:#a3d4ec; border-color:#a3d4ec; color:#fff;{{ pass }}"
>
{{ if peer_async_visible: }}Undo After-Class Release{{ else: }}Release After-Class PI{{ pass }}
</button>
</div>
{{ pass }}
<button
type="submit"
id="restart"
Expand Down Expand Up @@ -349,8 +363,33 @@ <h3>Question {{ =current_qnum }} of {{ =num_questions }}</h3>
var mess_count = 0;
var answerCount = 0;
var done = {{=is_last }}
if (done) {
document.getElementById("nextq").disabled = true;

var asyncReleased = {{=peer_async_visible}};

function showAsyncConfirm() {
var area = document.getElementById("asyncBtnArea");
var msg = asyncReleased
? "Undo the after-class PI release?"
: "Release after-class PI questions to students?";
area.innerHTML = `
<span style="margin-right:6px; font-weight:bold;">${msg}</span>
<button type="button" class="btn btn-sm btn-default" onclick="confirmToggleAsync()" style="margin-right:4px;">Yes</button>
<button type="button" class="btn btn-sm btn-default" onclick="cancelAsyncConfirm()">Cancel</button>
`;
}

function cancelAsyncConfirm() {
var area = document.getElementById("asyncBtnArea");
var label = asyncReleased ? "Undo After-Class Release" : "Release After-Class PI";
var extraStyle = asyncReleased ? 'style="background-color:#a3d4ec; border-color:#a3d4ec; color:#fff; margin-right:4px;"' : 'style="margin-right:4px;"';
area.innerHTML = `<button type="button" id="toggleAsyncBtn" class="btn btn-info" onclick="showAsyncConfirm()" ${extraStyle}>${label}</button>`;
}

async function confirmToggleAsync() {
var resp = await fetch("/runestone/peer/toggle_async?assignment_id={{=assignment_id}}", { method: "POST" });
var data = await resp.json();
asyncReleased = data.peer_async_visible;
cancelAsyncConfirm();
}
</script>
{{ end }}
Loading
Loading