Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
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
Expand Up @@ -132,6 +132,18 @@ export const AssignmentBuilder = () => {
}
};

const handlePeerAsyncChange = async (assignment: Assignment, peer_async_visible: boolean) => {
try {
await updateAssignment({
...assignment,
peer_async_visible
});
toast.success(`Async peer ${peer_async_visible ? "enabled" : "disabled"}`);
} catch (error) {
toast.error("Failed to update async peer setting");
}
};

const handleVisibilityChange = async (
assignment: Assignment,
data: { visible: boolean; visible_on: string | null; hidden_on: string | null }
Expand Down Expand Up @@ -199,6 +211,7 @@ export const AssignmentBuilder = () => {
onDuplicate={handleDuplicate}
onReleasedChange={handleReleasedChange}
onEnforceDueChange={handleEnforceDueChange}
onPeerAsyncChange={handlePeerAsyncChange}
onVisibilityChange={handleVisibilityChange}
onRemove={onRemove}
/>
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 } = 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 && (
<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, 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 @@ -2,6 +2,7 @@ import { useCallback, useState } from "react";

import { SearchInput } from "@components/ui/SearchInput";
import { Button } from "primereact/button";
import { Checkbox } from "primereact/checkbox";
import { Column } from "primereact/column";
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
import { DataTable, DataTableSortEvent } from "primereact/datatable";
Expand All @@ -25,6 +26,7 @@ interface AssignmentListProps {
onDuplicate: (assignment: Assignment) => void;
onReleasedChange: (assignment: Assignment, released: boolean) => void;
onEnforceDueChange: (assignment: Assignment, enforce_due: boolean) => void;
onPeerAsyncChange: (assignment: Assignment, peer_async_visible: boolean) => void;
onVisibilityChange: (
assignment: Assignment,
data: { visible: boolean; visible_on: string | null; hidden_on: string | null }
Expand All @@ -41,6 +43,7 @@ export const AssignmentList = ({
onDuplicate,
onReleasedChange,
onEnforceDueChange,
onPeerAsyncChange,
onVisibilityChange,
onRemove
}: AssignmentListProps) => {
Expand Down Expand Up @@ -120,6 +123,17 @@ export const AssignmentList = ({
>
{rowData.kind || "Unknown"}
</span>
{rowData.kind === "Peer" && (
<label className={styles.asyncPeerGroup}>
<span className={styles.asyncPeerText}>Async Peer</span>
<Checkbox
checked={rowData.peer_async_visible}
onChange={(e) => onPeerAsyncChange(rowData, !!e.checked)}
tooltip={rowData.peer_async_visible ? "Disable async peer" : "Enable async peer"}
tooltipOptions={{ position: "top" }}
/>
</label>
)}
</div>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ export const assignmentExerciseApi = createApi({
body
})
}),
hasApiKey: build.query<boolean, void>({
query: () => ({
method: "GET",
url: "/assignment/instructor/has_api_key"
}),
transformResponse: (response: DetailResponse<{ has_api_key: boolean }>) =>
response.detail.has_api_key
}),
copyQuestion: build.mutation<
DetailResponse<{ status: string; question_id: number; message: string }>,
{
Expand Down Expand Up @@ -218,5 +226,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
11 changes: 11 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,17 @@ 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."""
tokens = await fetch_all_api_tokens(course.id)
return make_json_response(
status=status.HTTP_200_OK, detail={"has_api_key": len(tokens) > 0}
)


@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 @@ -742,7 +742,14 @@ 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()
question_use_llm = bool(aq.use_llm) if aq else False
llm_enabled = _llm_enabled() and question_use_llm
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"),
)
Loading