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
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { EditView } from "./EditView";
import { ErrorDisplay } from "./ErrorDisplay";
import { ExerciseListView } from "./ExerciseListView";
import { ExerciseSuccessDialog } from "./ExerciseSuccessDialog";
import { AssignmentExercisesComponentProps, ViewMode } from "./types";
import { AssignmentExercisesComponentProps } from "./types";

export const AssignmentExercisesContainer = ({
startItemId,
Expand Down Expand Up @@ -100,7 +100,14 @@ export const AssignmentExercisesContainer = ({

{viewMode === "browse" && <ChooseExercises />}

{viewMode === "search" && <SmartSearchExercises />}
{viewMode === "search" && (
<SmartSearchExercises
setCurrentEditExercise={setCurrentEditExercise}
setViewMode={(mode: "list" | "browse" | "search" | "create" | "edit") =>
updateExerciseViewMode(mode)
}
/>
)}

{viewMode === "create" && (
<CreateView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,6 @@ export const AssignmentExercisesTable = ({
visible={copyModalVisible}
onHide={handleCopyModalHide}
exercise={selectedExerciseForCopy}
copyToAssignment={true}
setCurrentEditExercise={setCurrentEditExercise}
setViewMode={setViewMode}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ interface CopyExerciseModalProps {
visible: boolean;
onHide: () => void;
exercise: Exercise | null;
copyToAssignment?: boolean;
setCurrentEditExercise?: (exercise: Exercise | null) => void;
setViewMode?: (mode: "list" | "browse" | "search" | "create" | "edit") => void;
}
Expand All @@ -27,7 +26,6 @@ export const CopyExerciseModal = ({
visible,
onHide,
exercise,
copyToAssignment = false,
setCurrentEditExercise,
setViewMode
}: CopyExerciseModalProps) => {
Expand All @@ -44,6 +42,15 @@ export const CopyExerciseModal = ({
skip: !selectedAssignment?.id
});

// Determine if the exercise type supports direct editing
const canEditDirectly =
exercise &&
supportedExerciseTypesToEdit.includes(exercise.question_type) &&
!!exercise.question_json;

// Determine if we have the edit infrastructure available
const hasEditSupport = !!setCurrentEditExercise && !!setViewMode;

useEffect(() => {
if (exercise && visible) {
setNewName(exercise.name || "");
Expand Down Expand Up @@ -92,6 +99,7 @@ export const CopyExerciseModal = ({
try {
// Generate new HTML source with the new name if the exercise type is supported
let newHtmlSrc: string | undefined;

if (exercise.question_json && supportedExerciseTypesToEdit.includes(exercise.question_type)) {
try {
newHtmlSrc = regenerateHtmlSrc(exercise, newName.trim());
Expand All @@ -103,75 +111,116 @@ export const CopyExerciseModal = ({
const result = await copyQuestion({
original_question_id: exercise.question_id ?? exercise.id,
new_name: newName.trim(),
assignment_id: copyToAssignment ? selectedAssignment?.id : undefined,
copy_to_assignment: copyToAssignment,
assignment_id: selectedAssignment?.id,
copy_to_assignment: true,
htmlsrc: newHtmlSrc
}).unwrap();

if (setCurrentEditExercise && setViewMode && copyToAssignment && result.detail.question_id) {
const exercises = await refetchExercises();
const newExercise = exercises.data?.find(
(ex) => ex.question_id === result.detail.question_id
);
const canEdit =
newExercise &&
supportedExerciseTypesToEdit.includes(newExercise.question_type) &&
!!newExercise.question_json;

if (canEdit) {
setCurrentEditExercise(newExercise);
setViewMode("edit");
}
// Refetch the exercises list to pick up the newly added copy
const exercises = await refetchExercises();
const newExercise = exercises.data?.find(
(ex) => ex.question_id === result.detail.question_id
);

const canEdit =
newExercise &&
supportedExerciseTypesToEdit.includes(newExercise.question_type) &&
!!newExercise.question_json;

if (canEdit && hasEditSupport) {
toast.success("Exercise copied and added to assignment. Opening editor…");
setCurrentEditExercise!(newExercise);
setViewMode!("edit");
} else if (newExercise) {
toast.success("Exercise copied and added to your assignment. You are now the owner.", {
duration: 5000
});
} else {
toast.success("Exercise copied successfully! You are now the owner.");
}

toast.success("Exercise copied successfully!");
onHide();
handleClose();
} catch (error) {
toast.error("Error copying exercise");
}
};

const handleCancel = () => {
const handleClose = () => {
setNewName("");
setValidationMessage(null);
setIsValid(false);
onHide();
};

const getPrimaryButtonLabel = () => {
if (isCopying) return "Copying…";
if (canEditDirectly && hasEditSupport) return "Copy, Add & Edit";
return "Copy & Add to Assignment";
};

const getPrimaryButtonIcon = () => {
if (canEditDirectly && hasEditSupport) return "pi pi-pencil";
return "pi pi-plus";
};

return (
<Dialog
visible={visible}
onHide={handleCancel}
onHide={handleClose}
header="Copy Exercise"
style={{ width: "400px" }}
style={{ width: "480px" }}
modal
draggable={false}
resizable={false}
>
<div className="flex flex-column gap-3">
<Message
severity="info"
text="The copy will be added to your current assignment and you will become its owner."
className="w-full"
/>

<div>
<label htmlFor="exerciseName" className="block text-900 font-medium mb-2">
Exercise Name
New Exercise Name
</label>
<InputText
id="exerciseName"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Enter new exercise name"
placeholder="Enter a unique name for the copy"
className="w-full"
invalid={validationMessage !== null}
/>
{validationMessage && (
<Message severity="error" text={validationMessage} className="mt-2" />
<Message severity="error" text={validationMessage} className="mt-2 w-full" />
)}
</div>

<div className="flex justify-content-end gap-2 mt-3">
<Button label="Cancel" outlined onClick={handleCancel} disabled={isCopying} />
{canEditDirectly && hasEditSupport && (
<Message
severity="success"
text="The copy will open in the editor immediately after it is created."
className="w-full"
/>
)}

{!canEditDirectly && (
<Message
severity="warn"
text="This exercise type does not support the visual editor. The copy will be added to your assignment but must be edited via other means."
className="w-full"
/>
)}

<div className="flex justify-content-end gap-2 mt-2">
<Button label="Cancel" outlined onClick={handleClose} disabled={isCopying} />
<Button
label="Copy Exercise"
label={getPrimaryButtonLabel()}
icon={getPrimaryButtonIcon()}
onClick={handleCopy}
disabled={!isValid || isCopying || isValidating}
loading={isCopying}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,15 @@ import styles from "./SmartSearchExercises.module.css";
/**
* Smart exercise search component with fixed layout and enhanced UX
*/
export const SmartSearchExercises = () => {
interface SmartSearchExercisesProps {
setCurrentEditExercise?: (exercise: Exercise | null) => void;
setViewMode?: (mode: "list" | "browse" | "search" | "create" | "edit") => void;
}

export const SmartSearchExercises = ({
setCurrentEditExercise,
setViewMode
}: SmartSearchExercisesProps) => {
const dispatch = useDispatch();
const selectedExercises = useSelector(searchExercisesSelectors.getSelectedExercises);
const exerciseTypes = useSelector(datasetSelectors.getQuestionTypeOptions);
Expand Down Expand Up @@ -440,7 +448,8 @@ export const SmartSearchExercises = () => {
visible={copyModalVisible}
onHide={handleCopyModalHide}
exercise={selectedExerciseForCopy}
copyToAssignment={false}
setCurrentEditExercise={setCurrentEditExercise}
setViewMode={setViewMode}
/>
</div>
);
Expand Down
9 changes: 8 additions & 1 deletion bases/rsptx/assignment_server_api/routers/instructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1505,11 +1505,17 @@ async def validate_question_name(

@router.post("/copy_question")
@instructor_role_required()
@with_course()
async def copy_question_endpoint(
request: Request, request_data: CopyQuestionRequest, user=Depends(auth_manager)
request: Request,
request_data: CopyQuestionRequest,
user=Depends(auth_manager),
course=None,
):
"""
Copy a question with a new name and owner.
The user making the copy becomes the owner and author.
The base_course is updated to the current user's course base_course.
Optionally copy it to an assignment as well.
"""
try:
Expand All @@ -1523,6 +1529,7 @@ async def copy_question_endpoint(
new_owner=user.username,
assignment_id=assignment_id,
htmlsrc=request_data.htmlsrc,
new_base_course=course.base_course,
)

return make_json_response(
Expand Down
47 changes: 35 additions & 12 deletions components/rsptx/db/crud/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,7 @@ async def copy_question(
new_owner: str,
assignment_id: Optional[int] = None,
htmlsrc: Optional[str] = None,
new_base_course: Optional[str] = None,
) -> QuestionValidator:
"""
Copy a question to create a new one with the same content but different name and owner.
Expand All @@ -753,6 +754,7 @@ async def copy_question(
:param new_owner: str, the username of the new owner
:param assignment_id: Optional[int], the assignment ID if copying to an assignment
:param htmlsrc: Optional[str], the HTML source to use for the new question (if provided, overrides original)
:param new_base_course: Optional[str], the base course for the new question (if provided, overrides original)
:return: QuestionValidator, the newly created question
"""
async with async_session() as session:
Expand All @@ -769,13 +771,20 @@ async def copy_question(
# Use provided htmlsrc or fall back to original
question_htmlsrc = htmlsrc if htmlsrc is not None else original_question.htmlsrc

# Use provided base_course or fall back to original
question_base_course = (
new_base_course
if new_base_course is not None
else original_question.base_course
)

# Create new question with copied data
new_question = Question(
base_course=original_question.base_course,
base_course=question_base_course,
name=new_name,
chapter=original_question.chapter,
subchapter=original_question.subchapter,
author=original_question.author,
author=new_owner,
question=original_question.question,
timestamp=canonical_utcnow(),
question_type=original_question.question_type,
Expand All @@ -802,24 +811,25 @@ async def copy_question(
await session.flush()
await session.refresh(new_question)

# If assignment_id is provided, also copy the assignment question
# If assignment_id is provided, also add the new question to the assignment
if assignment_id:
# Get the original assignment question
# Try to get the original assignment question for copying settings
original_aq_query = select(AssignmentQuestion).where(
(AssignmentQuestion.question_id == original_question_id)
& (AssignmentQuestion.assignment_id == assignment_id)
)
original_aq_result = await session.execute(original_aq_query)
original_aq = original_aq_result.scalars().first()

if original_aq:
# Get the next sorting priority
max_priority_query = select(
func.max(AssignmentQuestion.sorting_priority)
).where(AssignmentQuestion.assignment_id == assignment_id)
max_priority_result = await session.execute(max_priority_query)
max_priority = max_priority_result.scalar() or 0
# Get the next sorting priority
max_priority_query = select(
func.max(AssignmentQuestion.sorting_priority)
).where(AssignmentQuestion.assignment_id == assignment_id)
max_priority_result = await session.execute(max_priority_query)
max_priority = max_priority_result.scalar() or 0

if original_aq:
# Copy settings from the original assignment question
new_assignment_question = AssignmentQuestion(
assignment_id=assignment_id,
question_id=new_question.id,
Expand All @@ -831,7 +841,20 @@ async def copy_question(
sorting_priority=max_priority + 1,
activities_required=original_aq.activities_required,
)
session.add(new_assignment_question)
else:
# Original question wasn't in this assignment; add with defaults
new_assignment_question = AssignmentQuestion(
assignment_id=assignment_id,
question_id=new_question.id,
points=1,
timed=False,
autograde="pct_correct",
which_to_grade="best_answer",
reading_assignment=False,
sorting_priority=max_priority + 1,
activities_required=0,
)
session.add(new_assignment_question)

await session.commit()
return QuestionValidator.from_orm(new_question)
Loading