Skip to content

Commit e69cff3

Browse files
committed
Merge branch 'morozov-av-issue-1167'
2 parents 377080d + 0398948 commit e69cff3

6 files changed

Lines changed: 141 additions & 47 deletions

File tree

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesContainer.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { EditView } from "./EditView";
1818
import { ErrorDisplay } from "./ErrorDisplay";
1919
import { ExerciseListView } from "./ExerciseListView";
2020
import { ExerciseSuccessDialog } from "./ExerciseSuccessDialog";
21-
import { AssignmentExercisesComponentProps, ViewMode } from "./types";
21+
import { AssignmentExercisesComponentProps } from "./types";
2222

2323
export const AssignmentExercisesContainer = ({
2424
startItemId,
@@ -100,7 +100,14 @@ export const AssignmentExercisesContainer = ({
100100

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

103-
{viewMode === "search" && <SmartSearchExercises />}
103+
{viewMode === "search" && (
104+
<SmartSearchExercises
105+
setCurrentEditExercise={setCurrentEditExercise}
106+
setViewMode={(mode: "list" | "browse" | "search" | "create" | "edit") =>
107+
updateExerciseViewMode(mode)
108+
}
109+
/>
110+
)}
104111

105112
{viewMode === "create" && (
106113
<CreateView

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesTable.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,6 @@ export const AssignmentExercisesTable = ({
291291
visible={copyModalVisible}
292292
onHide={handleCopyModalHide}
293293
exercise={selectedExerciseForCopy}
294-
copyToAssignment={true}
295294
setCurrentEditExercise={setCurrentEditExercise}
296295
setViewMode={setViewMode}
297296
/>

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CopyExercise/CopyExerciseModal.tsx

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ interface CopyExerciseModalProps {
1818
visible: boolean;
1919
onHide: () => void;
2020
exercise: Exercise | null;
21-
copyToAssignment?: boolean;
2221
setCurrentEditExercise?: (exercise: Exercise | null) => void;
2322
setViewMode?: (mode: "list" | "browse" | "search" | "create" | "edit") => void;
2423
}
@@ -27,7 +26,6 @@ export const CopyExerciseModal = ({
2726
visible,
2827
onHide,
2928
exercise,
30-
copyToAssignment = false,
3129
setCurrentEditExercise,
3230
setViewMode
3331
}: CopyExerciseModalProps) => {
@@ -44,6 +42,15 @@ export const CopyExerciseModal = ({
4442
skip: !selectedAssignment?.id
4543
});
4644

45+
// Determine if the exercise type supports direct editing
46+
const canEditDirectly =
47+
exercise &&
48+
supportedExerciseTypesToEdit.includes(exercise.question_type) &&
49+
!!exercise.question_json;
50+
51+
// Determine if we have the edit infrastructure available
52+
const hasEditSupport = !!setCurrentEditExercise && !!setViewMode;
53+
4754
useEffect(() => {
4855
if (exercise && visible) {
4956
setNewName(exercise.name || "");
@@ -92,6 +99,7 @@ export const CopyExerciseModal = ({
9299
try {
93100
// Generate new HTML source with the new name if the exercise type is supported
94101
let newHtmlSrc: string | undefined;
102+
95103
if (exercise.question_json && supportedExerciseTypesToEdit.includes(exercise.question_type)) {
96104
try {
97105
newHtmlSrc = regenerateHtmlSrc(exercise, newName.trim());
@@ -103,75 +111,116 @@ export const CopyExerciseModal = ({
103111
const result = await copyQuestion({
104112
original_question_id: exercise.question_id ?? exercise.id,
105113
new_name: newName.trim(),
106-
assignment_id: copyToAssignment ? selectedAssignment?.id : undefined,
107-
copy_to_assignment: copyToAssignment,
114+
assignment_id: selectedAssignment?.id,
115+
copy_to_assignment: true,
108116
htmlsrc: newHtmlSrc
109117
}).unwrap();
110118

111-
if (setCurrentEditExercise && setViewMode && copyToAssignment && result.detail.question_id) {
112-
const exercises = await refetchExercises();
113-
const newExercise = exercises.data?.find(
114-
(ex) => ex.question_id === result.detail.question_id
115-
);
116-
const canEdit =
117-
newExercise &&
118-
supportedExerciseTypesToEdit.includes(newExercise.question_type) &&
119-
!!newExercise.question_json;
120-
121-
if (canEdit) {
122-
setCurrentEditExercise(newExercise);
123-
setViewMode("edit");
124-
}
119+
// Refetch the exercises list to pick up the newly added copy
120+
const exercises = await refetchExercises();
121+
const newExercise = exercises.data?.find(
122+
(ex) => ex.question_id === result.detail.question_id
123+
);
124+
125+
const canEdit =
126+
newExercise &&
127+
supportedExerciseTypesToEdit.includes(newExercise.question_type) &&
128+
!!newExercise.question_json;
129+
130+
if (canEdit && hasEditSupport) {
131+
toast.success("Exercise copied and added to assignment. Opening editor…");
132+
setCurrentEditExercise!(newExercise);
133+
setViewMode!("edit");
134+
} else if (newExercise) {
135+
toast.success("Exercise copied and added to your assignment. You are now the owner.", {
136+
duration: 5000
137+
});
138+
} else {
139+
toast.success("Exercise copied successfully! You are now the owner.");
125140
}
126141

127-
toast.success("Exercise copied successfully!");
128-
onHide();
142+
handleClose();
129143
} catch (error) {
130144
toast.error("Error copying exercise");
131145
}
132146
};
133147

134-
const handleCancel = () => {
148+
const handleClose = () => {
135149
setNewName("");
136150
setValidationMessage(null);
137151
setIsValid(false);
138152
onHide();
139153
};
140154

155+
const getPrimaryButtonLabel = () => {
156+
if (isCopying) return "Copying…";
157+
if (canEditDirectly && hasEditSupport) return "Copy, Add & Edit";
158+
return "Copy & Add to Assignment";
159+
};
160+
161+
const getPrimaryButtonIcon = () => {
162+
if (canEditDirectly && hasEditSupport) return "pi pi-pencil";
163+
return "pi pi-plus";
164+
};
165+
141166
return (
142167
<Dialog
143168
visible={visible}
144-
onHide={handleCancel}
169+
onHide={handleClose}
145170
header="Copy Exercise"
146-
style={{ width: "400px" }}
171+
style={{ width: "480px" }}
147172
modal
148173
draggable={false}
149174
resizable={false}
150175
>
151176
<div className="flex flex-column gap-3">
177+
<Message
178+
severity="info"
179+
text="The copy will be added to your current assignment and you will become its owner."
180+
className="w-full"
181+
/>
182+
152183
<div>
153184
<label htmlFor="exerciseName" className="block text-900 font-medium mb-2">
154-
Exercise Name
185+
New Exercise Name
155186
</label>
156187
<InputText
157188
id="exerciseName"
158189
value={newName}
159190
onChange={(e) => setNewName(e.target.value)}
160-
placeholder="Enter new exercise name"
191+
placeholder="Enter a unique name for the copy"
161192
className="w-full"
162193
invalid={validationMessage !== null}
163194
/>
164195
{validationMessage && (
165-
<Message severity="error" text={validationMessage} className="mt-2" />
196+
<Message severity="error" text={validationMessage} className="mt-2 w-full" />
166197
)}
167198
</div>
168199

169-
<div className="flex justify-content-end gap-2 mt-3">
170-
<Button label="Cancel" outlined onClick={handleCancel} disabled={isCopying} />
200+
{canEditDirectly && hasEditSupport && (
201+
<Message
202+
severity="success"
203+
text="The copy will open in the editor immediately after it is created."
204+
className="w-full"
205+
/>
206+
)}
207+
208+
{!canEditDirectly && (
209+
<Message
210+
severity="warn"
211+
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."
212+
className="w-full"
213+
/>
214+
)}
215+
216+
<div className="flex justify-content-end gap-2 mt-2">
217+
<Button label="Cancel" outlined onClick={handleClose} disabled={isCopying} />
171218
<Button
172-
label="Copy Exercise"
219+
label={getPrimaryButtonLabel()}
220+
icon={getPrimaryButtonIcon()}
173221
onClick={handleCopy}
174222
disabled={!isValid || isCopying || isValidating}
223+
loading={isCopying}
175224
/>
176225
</div>
177226
</div>

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/SearchExercises/SmartSearchExercises.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,15 @@ import styles from "./SmartSearchExercises.module.css";
2929
/**
3030
* Smart exercise search component with fixed layout and enhanced UX
3131
*/
32-
export const SmartSearchExercises = () => {
32+
interface SmartSearchExercisesProps {
33+
setCurrentEditExercise?: (exercise: Exercise | null) => void;
34+
setViewMode?: (mode: "list" | "browse" | "search" | "create" | "edit") => void;
35+
}
36+
37+
export const SmartSearchExercises = ({
38+
setCurrentEditExercise,
39+
setViewMode
40+
}: SmartSearchExercisesProps) => {
3341
const dispatch = useDispatch();
3442
const selectedExercises = useSelector(searchExercisesSelectors.getSelectedExercises);
3543
const exerciseTypes = useSelector(datasetSelectors.getQuestionTypeOptions);
@@ -440,7 +448,8 @@ export const SmartSearchExercises = () => {
440448
visible={copyModalVisible}
441449
onHide={handleCopyModalHide}
442450
exercise={selectedExerciseForCopy}
443-
copyToAssignment={false}
451+
setCurrentEditExercise={setCurrentEditExercise}
452+
setViewMode={setViewMode}
444453
/>
445454
</div>
446455
);

bases/rsptx/assignment_server_api/routers/instructor.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1505,11 +1505,17 @@ async def validate_question_name(
15051505

15061506
@router.post("/copy_question")
15071507
@instructor_role_required()
1508+
@with_course()
15081509
async def copy_question_endpoint(
1509-
request: Request, request_data: CopyQuestionRequest, user=Depends(auth_manager)
1510+
request: Request,
1511+
request_data: CopyQuestionRequest,
1512+
user=Depends(auth_manager),
1513+
course=None,
15101514
):
15111515
"""
15121516
Copy a question with a new name and owner.
1517+
The user making the copy becomes the owner and author.
1518+
The base_course is updated to the current user's course base_course.
15131519
Optionally copy it to an assignment as well.
15141520
"""
15151521
try:
@@ -1523,6 +1529,7 @@ async def copy_question_endpoint(
15231529
new_owner=user.username,
15241530
assignment_id=assignment_id,
15251531
htmlsrc=request_data.htmlsrc,
1532+
new_base_course=course.base_course,
15261533
)
15271534

15281535
return make_json_response(

components/rsptx/db/crud/question.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,7 @@ async def copy_question(
746746
new_owner: str,
747747
assignment_id: Optional[int] = None,
748748
htmlsrc: Optional[str] = None,
749+
new_base_course: Optional[str] = None,
749750
) -> QuestionValidator:
750751
"""
751752
Copy a question to create a new one with the same content but different name and owner.
@@ -755,6 +756,7 @@ async def copy_question(
755756
:param new_owner: str, the username of the new owner
756757
:param assignment_id: Optional[int], the assignment ID if copying to an assignment
757758
:param htmlsrc: Optional[str], the HTML source to use for the new question (if provided, overrides original)
759+
:param new_base_course: Optional[str], the base course for the new question (if provided, overrides original)
758760
:return: QuestionValidator, the newly created question
759761
"""
760762
async with async_session() as session:
@@ -771,13 +773,20 @@ async def copy_question(
771773
# Use provided htmlsrc or fall back to original
772774
question_htmlsrc = htmlsrc if htmlsrc is not None else original_question.htmlsrc
773775

776+
# Use provided base_course or fall back to original
777+
question_base_course = (
778+
new_base_course
779+
if new_base_course is not None
780+
else original_question.base_course
781+
)
782+
774783
# Create new question with copied data
775784
new_question = Question(
776-
base_course=original_question.base_course,
785+
base_course=question_base_course,
777786
name=new_name,
778787
chapter=original_question.chapter,
779788
subchapter=original_question.subchapter,
780-
author=original_question.author,
789+
author=new_owner,
781790
question=original_question.question,
782791
timestamp=canonical_utcnow(),
783792
question_type=original_question.question_type,
@@ -804,24 +813,25 @@ async def copy_question(
804813
await session.flush()
805814
await session.refresh(new_question)
806815

807-
# If assignment_id is provided, also copy the assignment question
816+
# If assignment_id is provided, also add the new question to the assignment
808817
if assignment_id:
809-
# Get the original assignment question
818+
# Try to get the original assignment question for copying settings
810819
original_aq_query = select(AssignmentQuestion).where(
811820
(AssignmentQuestion.question_id == original_question_id)
812821
& (AssignmentQuestion.assignment_id == assignment_id)
813822
)
814823
original_aq_result = await session.execute(original_aq_query)
815824
original_aq = original_aq_result.scalars().first()
816825

817-
if original_aq:
818-
# Get the next sorting priority
819-
max_priority_query = select(
820-
func.max(AssignmentQuestion.sorting_priority)
821-
).where(AssignmentQuestion.assignment_id == assignment_id)
822-
max_priority_result = await session.execute(max_priority_query)
823-
max_priority = max_priority_result.scalar() or 0
826+
# Get the next sorting priority
827+
max_priority_query = select(
828+
func.max(AssignmentQuestion.sorting_priority)
829+
).where(AssignmentQuestion.assignment_id == assignment_id)
830+
max_priority_result = await session.execute(max_priority_query)
831+
max_priority = max_priority_result.scalar() or 0
824832

833+
if original_aq:
834+
# Copy settings from the original assignment question
825835
new_assignment_question = AssignmentQuestion(
826836
assignment_id=assignment_id,
827837
question_id=new_question.id,
@@ -833,7 +843,20 @@ async def copy_question(
833843
sorting_priority=max_priority + 1,
834844
activities_required=original_aq.activities_required,
835845
)
836-
session.add(new_assignment_question)
846+
else:
847+
# Original question wasn't in this assignment; add with defaults
848+
new_assignment_question = AssignmentQuestion(
849+
assignment_id=assignment_id,
850+
question_id=new_question.id,
851+
points=1,
852+
timed=False,
853+
autograde="pct_correct",
854+
which_to_grade="best_answer",
855+
reading_assignment=False,
856+
sorting_priority=max_priority + 1,
857+
activities_required=0,
858+
)
859+
session.add(new_assignment_question)
837860

838861
await session.commit()
839862
return QuestionValidator.from_orm(new_question)

0 commit comments

Comments
 (0)