diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/ActiveCodeExercise.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/ActiveCodeExercise.tsx index 89f94f6f4..774ec4f0b 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/ActiveCodeExercise.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/ActiveCodeExercise.tsx @@ -1,6 +1,8 @@ import { ExerciseComponentProps } from "@components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/types/ExerciseTypes"; -import { FC } from "react"; +import { FC, useCallback } from "react"; +import { useFetchDatafilesQuery } from "@/store/datafile/datafile.logic.api"; +import { ExistingDataFile, SelectedDataFile } from "@/types/datafile"; import { CreateExerciseFormType } from "@/types/exercises"; import { createExerciseId } from "@/utils/exercise"; import { generateActiveCodePreview } from "@/utils/preview/activeCode"; @@ -14,6 +16,7 @@ import { validateCommonFields } from "../../utils/validation"; import { ActiveCodeExerciseSettings } from "./ActiveCodeExerciseSettings"; import { ActiveCodePreview } from "./ActiveCodePreview"; +import { DataFilesEditor } from "./components/DataFilesEditor"; import { InstructionsEditor } from "./components/InstructionsEditor"; import { LanguageSelector } from "./components/LanguageSelector"; import { PrefixCodeEditor } from "./components/PrefixCodeEditor"; @@ -24,6 +27,7 @@ import { SuffixCodeEditor } from "./components/SuffixCodeEditor"; // Define the steps for ActiveCode exercise const ACTIVE_CODE_STEPS = [ { label: "Language" }, + { label: "Data Files" }, { label: "Instructions" }, { label: "Hidden Prefix" }, { label: "Starter Code" }, @@ -51,22 +55,10 @@ const getDefaultFormData = (): Partial => ({ suffix_code: "", instructions: "", language: "", - stdin: "" + stdin: "", + selectedExistingDataFiles: [] }); -// Create a wrapper for generateActiveCodePreview to match the expected type -const generatePreview = (data: Partial): string => { - return generateActiveCodePreview( - data.instructions || "", - data.language || "python", - data.prefix_code || "", - data.starter_code || "", - data.suffix_code || "", - data.name || "", - data.stdin || "" - ); -}; - export const ActiveCodeExercise: FC = ({ initialData, onSave, @@ -75,6 +67,35 @@ export const ActiveCodeExercise: FC = ({ onFormReset, isEdit = false }) => { + const { data: allDatafiles = [] } = useFetchDatafilesQuery(); + + const generatePreview = useCallback( + (data: Partial): string => { + const selectedFileAcids: SelectedDataFile[] = data.selectedExistingDataFiles || []; + const selectedDatafilesInfo = selectedFileAcids + .map((acid) => { + const existingFile = allDatafiles.find((df: ExistingDataFile) => df.acid === acid); + return { + acid, + filename: existingFile?.filename + }; + }) + .filter((df) => df.acid); + + return generateActiveCodePreview( + data.instructions || "", + data.language || "python", + data.prefix_code || "", + data.starter_code || "", + data.suffix_code || "", + data.name || "", + data.stdin || "", + selectedDatafilesInfo + ); + }, + [allDatafiles] + ); + const { formData, activeStep, @@ -135,7 +156,17 @@ export const ActiveCodeExercise: FC = ({ ); - case 1: // Instructions + case 1: // Data Files + return ( + + updateFormData("selectedExistingDataFiles", files) + } + /> + ); + + case 2: // Instructions return ( = ({ /> ); - case 2: // Hidden Prefix Code + case 3: // Hidden Prefix Code return ( = ({ /> ); - case 3: // Starter Code + case 4: // Starter Code return ( = ({ /> ); - case 4: // Hidden Suffix Code + case 5: // Hidden Suffix Code return ( = ({ /> ); - case 5: // Standard Input + case 6: // Standard Input return ( = ({ /> ); - case 6: // Settings + case 7: // Settings return ; - case 7: // Preview + case 8: // Preview return ( = ({ suffix_code={formData.suffix_code || ""} name={formData.name || ""} stdin={formData.stdin || ""} + selectedExistingDataFiles={formData.selectedExistingDataFiles || []} /> ); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/ActiveCodePreview.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/ActiveCodePreview.tsx index 7aae4cac1..62911aa54 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/ActiveCodePreview.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/ActiveCodePreview.tsx @@ -1,6 +1,8 @@ import { ExercisePreview } from "@components/routes/AssignmentBuilder/components/exercises/components/ExercisePreview/ExercisePreview"; import { FC } from "react"; +import { useFetchDatafilesQuery } from "@/store/datafile/datafile.logic.api"; +import { ExistingDataFile, SelectedDataFile } from "@/types/datafile"; import { generateActiveCodePreview } from "@/utils/preview/activeCode"; interface ActiveCodePreviewProps { @@ -11,6 +13,7 @@ interface ActiveCodePreviewProps { suffix_code: string; name: string; stdin?: string; + selectedExistingDataFiles?: SelectedDataFile[]; } export const ActiveCodePreview: FC = ({ @@ -20,8 +23,23 @@ export const ActiveCodePreview: FC = ({ starter_code, suffix_code, name, - stdin + stdin, + selectedExistingDataFiles = [] }) => { + // Fetch datafiles list to get filenames for selected acids + const { data: allDatafiles = [] } = useFetchDatafilesQuery(); + + // Map selected files with existing file info + const selectedDatafilesInfo = selectedExistingDataFiles + .map((acid) => { + const existingFile = allDatafiles.find((df: ExistingDataFile) => df.acid === acid); + return { + acid, + filename: existingFile?.filename + }; + }) + .filter((df) => df.acid); + return (
= ({ starter_code, suffix_code, name, - stdin + stdin, + selectedDatafilesInfo )} />
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/components/DataFilesEditor.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/components/DataFilesEditor.tsx new file mode 100644 index 000000000..f00d86cd0 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ActiveCodeExercise/components/DataFilesEditor.tsx @@ -0,0 +1,625 @@ +import { Button } from "primereact/button"; +import { ConfirmDialog, confirmDialog } from "primereact/confirmdialog"; +import { Dialog } from "primereact/dialog"; +import { FileUpload, FileUploadHandlerEvent } from "primereact/fileupload"; +import { InputText } from "primereact/inputtext"; +import { InputTextarea } from "primereact/inputtextarea"; +import { MultiSelect } from "primereact/multiselect"; +import { FC, useCallback, useRef, useState } from "react"; +import toast from "react-hot-toast"; + +import { + useCreateDatafileMutation, + useDeleteDatafileMutation, + useFetchDatafileQuery, + useFetchDatafilesQuery, + useUpdateDatafileMutation +} from "@/store/datafile/datafile.logic.api"; +import { DataFile, ExistingDataFile, SelectedDataFile } from "@/types/datafile"; + +import styles from "../../../shared/styles/CreateExercise.module.css"; + +// Supported file extensions for datafiles +const SUPPORTED_EXTENSIONS = [ + // Text files + ".txt", + ".csv", + // Code files + ".py", + ".jar", + ".js", + ".ts", + ".html", + ".css", + ".java", + ".c", + ".cpp", + ".h", + ".hpp", + ".sql", + // Image files + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg" +]; + +const hasValidExtension = (filename: string): boolean => { + if (!filename) return false; + const lowerFilename = filename.toLowerCase(); + return SUPPORTED_EXTENSIONS.some((ext) => lowerFilename.endsWith(ext)); +}; + +const validateFilename = (filename: string): { isValid: boolean; error: string } => { + if (!filename.trim()) { + return { isValid: false, error: "Filename is required" }; + } + + const invalidChars = /[<>:"/\\|?*\x00-\x1f]/; + if (invalidChars.test(filename)) { + return { isValid: false, error: "Filename contains invalid characters" }; + } + + if (!hasValidExtension(filename)) { + return { + isValid: false, + error: `Filename must have a valid extension: ${SUPPORTED_EXTENSIONS.join(", ")}` + }; + } + + return { isValid: true, error: "" }; +}; + +interface DataFilesEditorProps { + selectedDataFiles: SelectedDataFile[]; + onSelectedDataFilesChange: (files: SelectedDataFile[]) => void; +} + +// Initial state for new datafile in modal +const initialNewDataFile: DataFile = { + filename: "", + content: "", + isImage: false, + rows: 10, + cols: 60, + isEditable: true +}; + +export const DataFilesEditor: FC = ({ + selectedDataFiles, + onSelectedDataFilesChange +}) => { + const { data: existingDatafiles = [], isLoading } = useFetchDatafilesQuery(); + const [createDatafile, { isLoading: isCreating }] = useCreateDatafileMutation(); + const [updateDatafile, { isLoading: isUpdating }] = useUpdateDatafileMutation(); + const [deleteDatafile, { isLoading: isDeleting }] = useDeleteDatafileMutation(); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editingAcid, setEditingAcid] = useState(null); + const [editDataFile, setEditDataFile] = useState(initialNewDataFile); + const [newDataFile, setNewDataFile] = useState(initialNewDataFile); + const [filenameError, setFilenameError] = useState(""); + const fileUploadRef = useRef(null); + const editFileUploadRef = useRef(null); + + const { data: editingDatafileData, isFetching: isFetchingEditData } = useFetchDatafileQuery( + editingAcid || "", + { skip: !editingAcid } + ); + + const handleOpenModal = () => { + setNewDataFile(initialNewDataFile); + setFilenameError(""); + setIsModalOpen(true); + }; + + const handleCloseModal = () => { + setNewDataFile(initialNewDataFile); + setFilenameError(""); + setIsModalOpen(false); + if (fileUploadRef.current) { + fileUploadRef.current.clear(); + } + }; + + const handleUpdateNewDataFile = (field: keyof DataFile, value: any) => { + setNewDataFile((prev) => ({ ...prev, [field]: value })); + }; + + const handleFileUpload = useCallback((event: FileUploadHandlerEvent) => { + const file = event.files[0]; + if (!file) return; + + const reader = new FileReader(); + const isImage = file.type.startsWith("image/"); + + reader.onload = (e) => { + const content = e.target?.result as string; + setNewDataFile((prev) => ({ + ...prev, + filename: file.name, + content, + isImage, + isEditable: !isImage + })); + }; + + if (isImage) { + reader.readAsDataURL(file); + } else { + reader.readAsText(file); + } + + if (fileUploadRef.current) { + fileUploadRef.current.clear(); + } + }, []); + + const handleCreateDataFile = async () => { + const validation = validateFilename(newDataFile.filename); + if (!validation.isValid) { + setFilenameError(validation.error); + return; + } + setFilenameError(""); + + let contentToSave = newDataFile.content; + if (newDataFile.isImage && contentToSave.startsWith("data:")) { + const base64Index = contentToSave.indexOf(";base64,"); + if (base64Index !== -1) { + contentToSave = contentToSave.substring(base64Index + 8); + } + } + + try { + const response = await createDatafile({ + filename: newDataFile.filename, + main_code: contentToSave + }).unwrap(); + + const acid = response.acid; + + onSelectedDataFilesChange([...selectedDataFiles, acid]); + + handleCloseModal(); + } catch (error) { + console.error("Failed to create datafile:", error); + } + }; + + const handleOpenEditModal = (acid: string) => { + setEditingAcid(acid); + setIsEditModalOpen(true); + }; + + const handleCloseEditModal = () => { + setEditingAcid(null); + setEditDataFile(initialNewDataFile); + setIsEditModalOpen(false); + if (editFileUploadRef.current) { + editFileUploadRef.current.clear(); + } + }; + + const handleEditDataLoaded = useCallback(() => { + if (editingDatafileData) { + const isImage = editingDatafileData.filename?.match(/\.(png|jpg|jpeg|gif|svg)$/i) !== null; + setEditDataFile({ + filename: editingDatafileData.filename || "", + content: editingDatafileData.main_code || "", + isImage, + rows: 10, + cols: 60, + isEditable: !isImage + }); + } + }, [editingDatafileData]); + + if (editingDatafileData && isEditModalOpen && editDataFile.filename === "") { + handleEditDataLoaded(); + } + + const getFileExtension = (filename: string): string => { + const lastDot = filename.lastIndexOf("."); + if (lastDot === -1) return ""; + return filename.substring(lastDot).toLowerCase(); + }; + + const handleEditFileUpload = useCallback( + (event: FileUploadHandlerEvent) => { + const file = event.files[0]; + if (!file) return; + + const originalExtension = getFileExtension(editDataFile.filename); + const uploadedExtension = getFileExtension(file.name); + + if (originalExtension !== uploadedExtension) { + toast.error( + `File extension mismatch. Expected "${originalExtension}" but got "${uploadedExtension}". Please upload a file with the same extension.`, + { duration: 5000 } + ); + if (editFileUploadRef.current) { + editFileUploadRef.current.clear(); + } + return; + } + + const reader = new FileReader(); + const isImage = file.type.startsWith("image/"); + + reader.onload = (e) => { + const content = e.target?.result as string; + + setEditDataFile((prev) => ({ + ...prev, + content, + isImage, + isEditable: !isImage + })); + }; + + if (isImage) { + reader.readAsDataURL(file); + } else { + reader.readAsText(file); + } + + if (editFileUploadRef.current) { + editFileUploadRef.current.clear(); + } + }, + [editDataFile.filename] + ); + + const handleSaveEditDataFile = async () => { + if (!editingAcid) return; + + let contentToSave = editDataFile.content; + if (editDataFile.isImage && contentToSave.startsWith("data:")) { + const base64Index = contentToSave.indexOf(";base64,"); + if (base64Index !== -1) { + contentToSave = contentToSave.substring(base64Index + 8); + } + } + + try { + await updateDatafile({ + acid: editingAcid, + main_code: contentToSave + }).unwrap(); + + handleCloseEditModal(); + } catch (error) { + console.error("Failed to update datafile:", error); + } + }; + + const handleDeleteDatafile = (acid: string, filename: string) => { + confirmDialog({ + message: `Are you sure you want to delete "${filename}"? This action cannot be undone.`, + header: "Delete Data File", + icon: "pi pi-exclamation-triangle", + acceptClassName: "p-button-danger", + accept: async () => { + try { + await deleteDatafile(acid).unwrap(); + + onSelectedDataFilesChange(selectedDataFiles.filter((fileAcid) => fileAcid !== acid)); + } catch (error) { + console.error("Failed to delete datafile:", error); + } + } + }); + }; + + const existingDatafilesOptions = existingDatafiles + .filter((df: ExistingDataFile) => hasValidExtension(df.filename)) + .map((df: ExistingDataFile) => ({ + label: df.filename || df.acid, + value: df.acid + })); + + const modalFooter = ( +
+
+ ); + + return ( +
+
+
+ {isLoading ? ( +
+ + Loading data files... +
+ ) : ( + { + onSelectedDataFilesChange(e.value); + }} + placeholder="Select data files to include" + className="w-full" + display="chip" + filter + showSelectAll + maxSelectedLabels={5} + emptyMessage="No data files available" + emptyFilterMessage="No matching data files" + /> + )} +
+
+ + {selectedDataFiles.length > 0 && ( +
+
Selected Files ({selectedDataFiles.length})
+
+ {selectedDataFiles.map((selectedFileAcid) => { + const file = existingDatafiles.find((df) => df.acid === selectedFileAcid); + const isOwner = file?.owner !== null; // If owner is set and matches current user + return ( +
+
+ {file?.filename || selectedFileAcid} + {file?.owner && by {file.owner}} +
+
+ {isOwner && ( + <> +
+
+ ); + })} +
+
+ )} + + {/* Create New Data File Modal */} + +
+
+ + + Or fill in the form manually below + +
+ +
+ + { + handleUpdateNewDataFile("filename", e.target.value); + if (filenameError) setFilenameError(""); + }} + placeholder="e.g., data.txt or image.png" + className={`w-full ${filenameError ? "p-invalid" : ""}`} + /> + {filenameError && {filenameError}} +
+ + {newDataFile.isImage ? ( +
+ + {newDataFile.content ? ( + {newDataFile.filename} + ) : ( +
No image loaded
+ )} +
+ ) : ( + <> +
+ + handleUpdateNewDataFile("content", e.target.value)} + rows={10} + className="w-full font-mono" + placeholder="Enter file content here..." + style={{ resize: "vertical" }} + /> +
+ + )} +
+
+ + {/* Edit Data File Modal */} + +
+ } + closable={!isUpdating} + closeOnEscape={!isUpdating} + dismissableMask={!isUpdating} + modal + > + {isFetchingEditData ? ( +
+ + Loading datafile... +
+ ) : ( +
+
+ + + Upload a new file to replace current content + +
+ +
+ + + + Filename cannot be changed after creation + +
+ + {editDataFile.isImage ? ( +
+ + {editDataFile.content ? ( + {editDataFile.filename} + ) : ( +
No image loaded
+ )} +
+ ) : ( +
+ + + setEditDataFile((prev) => ({ ...prev, content: e.target.value })) + } + rows={10} + className="w-full font-mono" + placeholder="Enter file content here..." + style={{ resize: "vertical" }} + /> +
+ )} +
+ )} + + + {/* Confirm Dialog for delete */} + + + ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/config/stepConfigs.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/config/stepConfigs.ts index ca6489220..c5324a2f9 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/config/stepConfigs.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/config/stepConfigs.ts @@ -101,30 +101,34 @@ const stepConfigs: Record = { description: "Choose the programming language for this active code exercise" }, 1: { + title: "Data Files", + description: "Create or select data files that students can read from in their programs" + }, + 2: { title: "Write Instructions", description: "Provide instructions for the student" }, - 2: { + 3: { title: "Hidden Prefix", description: "Add code that runs before the student's code but is hidden from them" }, - 3: { + 4: { title: "Starter Code", description: "Provide initial code that students will see and modify" }, - 4: { + 5: { title: "Hidden Suffix", description: "Add code that runs after the student's code but is hidden from them" }, - 5: { + 6: { title: "Standard Input", description: "Provide input data for programs that read from stdin" }, - 6: { + 7: { title: "Exercise Settings", description: "Configure exercise settings such as name, points, etc." }, - 7: { + 8: { title: "Preview", description: "Preview the exercise as students will see it" } @@ -389,6 +393,12 @@ export const ACTIVE_CODE_STEP_VALIDATORS: StepValidator { + const errors: string[] = []; + + return errors; + }, // Instructions (data) => { const errors: string[] = []; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/shared/styles/CreateExercise.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/shared/styles/CreateExercise.module.css index 49b13d9b4..aef13aae6 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/shared/styles/CreateExercise.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/shared/styles/CreateExercise.module.css @@ -800,4 +800,39 @@ display: flex; justify-content: space-between; gap: 1rem; -} \ No newline at end of file +} + +/* Data Files Editor Styles */ +.dataFilesEditor { + width: 100%; + background: #fff; + border-radius: 8px; +} + +.dataFilesEditor :global(.p-tabview-nav) { + border-bottom: 1px solid #e2e8f0; + margin-bottom: 1rem; +} + +.dataFilesEditor :global(.p-tabview-panels) { + padding: 0; +} + +.dataFilesEditor :global(.p-fileupload-buttonbar) { + padding: 0; + background: transparent; + border: none; +} + +.dataFilesEditor :global(.p-fileupload-choose) { + background: #f8fafc; + border: 1px dashed #e2e8f0; + color: #64748b; + transition: all 0.2s ease; +} + +.dataFilesEditor :global(.p-fileupload-choose:hover) { + background: #f1f5f9; + border-color: var(--primary-400); + color: var(--primary-600); +} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts index bdfd3f7aa..0abaf111d 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts @@ -4,6 +4,7 @@ import { assignmentApi } from "@store/assignment/assignment.logic.api.js"; import { assignmentExerciseSlice } from "@store/assignmentExercise/assignmentExercise.logic"; import { assignmentExerciseApi } from "@store/assignmentExercise/assignmentExercise.logic.api"; import { chooseExercisesSlice } from "@store/chooseExercises/chooseExercises.logic"; +import { datafileApi } from "@store/datafile/datafile.logic.api"; import { datasetSlice } from "@store/dataset/dataset.logic"; import { datasetApi } from "@store/dataset/dataset.logic.api"; import { exercisesSlice } from "@store/exercises/exercises.logic"; @@ -46,7 +47,8 @@ const reducersMap = { assignmentExercise: assignmentExerciseSlice.reducer, [readingsApi.reducerPath]: readingsApi.reducer, [exercisesApi.reducerPath]: exercisesApi.reducer, - [datasetApi.reducerPath]: datasetApi.reducer + [datasetApi.reducerPath]: datasetApi.reducer, + [datafileApi.reducerPath]: datafileApi.reducer }; export type RootState = StateType; @@ -61,7 +63,8 @@ export const setupStore = (preloadedState?: Partial) => { assignmentExerciseApi.middleware, readingsApi.middleware, exercisesApi.middleware, - datasetApi.middleware + datasetApi.middleware, + datafileApi.middleware ); } }); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/datafile/datafile.logic.api.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/datafile/datafile.logic.api.ts new file mode 100644 index 000000000..750c91f1b --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/datafile/datafile.logic.api.ts @@ -0,0 +1,143 @@ +import { createApi, FetchBaseQueryError } from "@reduxjs/toolkit/query/react"; +import { baseQuery } from "@store/baseQuery"; +import toast from "react-hot-toast"; + +import { DetailResponse } from "@/types/api"; +import { + CreateDataFilePayload, + CreateDataFileResponse, + DeleteDataFileResponse, + ExistingDataFile, + FetchDataFilesResponse, + GetDataFileResponse, + UpdateDataFilePayload, + UpdateDataFileResponse +} from "@/types/datafile"; + +const getErrorMessage = (error: FetchBaseQueryError): string => { + if (error.status === 409) { + const data = error.data as { detail?: string }; + return data?.detail || "A datafile with this filename already exists"; + } + if (error.status === 403) { + const data = error.data as { detail?: string }; + return data?.detail || "You are not the owner of this datafile"; + } + if (error.status === 404) { + const data = error.data as { detail?: string }; + return data?.detail || "Datafile not found"; + } + if (error.data && typeof error.data === "object" && "detail" in error.data) { + return (error.data as { detail: string }).detail; + } + return "Error processing datafile request"; +}; + +export const datafileApi = createApi({ + reducerPath: "datafileAPI", + keepUnusedDataFor: 60, + baseQuery: baseQuery, + tagTypes: ["Datafiles", "Datafile"], + endpoints: (build) => ({ + fetchDatafiles: build.query({ + query: () => ({ + method: "GET", + url: `/assignment/instructor/datafiles` + }), + transformResponse: (response: DetailResponse) => { + return response.detail.datafiles || []; + }, + providesTags: ["Datafiles"], + onQueryStarted: (_, { queryFulfilled }) => { + queryFulfilled.catch(() => { + toast.error("Error fetching datafiles", { duration: 5000 }); + }); + } + }), + fetchDatafile: build.query({ + query: (acid) => ({ + method: "GET", + url: `/assignment/instructor/datafile/${encodeURIComponent(acid)}` + }), + transformResponse: (response: DetailResponse) => { + return response.detail; + }, + providesTags: (result, error, acid) => [{ type: "Datafile", id: acid }], + onQueryStarted: (_, { queryFulfilled }) => { + queryFulfilled.catch(() => { + toast.error("Error fetching datafile", { duration: 5000 }); + }); + } + }), + createDatafile: build.mutation({ + query: (body) => ({ + method: "POST", + url: "/assignment/instructor/datafile", + body + }), + transformResponse: (response: DetailResponse) => { + return response.detail; + }, + invalidatesTags: ["Datafiles"], + onQueryStarted: (_, { queryFulfilled }) => { + queryFulfilled + .then(() => { + toast.success("Datafile created successfully", { duration: 3000 }); + }) + .catch((error) => { + const message = getErrorMessage(error.error as FetchBaseQueryError); + toast.error(message, { duration: 5000 }); + }); + } + }), + updateDatafile: build.mutation({ + query: (body) => ({ + method: "PUT", + url: "/assignment/instructor/datafile", + body + }), + transformResponse: (response: DetailResponse) => { + return response.detail; + }, + invalidatesTags: (result, error, arg) => ["Datafiles", { type: "Datafile", id: arg.acid }], + onQueryStarted: (_, { queryFulfilled }) => { + queryFulfilled + .then(() => { + toast.success("Datafile updated successfully", { duration: 3000 }); + }) + .catch((error) => { + const message = getErrorMessage(error.error as FetchBaseQueryError); + toast.error(message, { duration: 5000 }); + }); + } + }), + deleteDatafile: build.mutation({ + query: (acid) => ({ + method: "DELETE", + url: `/assignment/instructor/datafile/${encodeURIComponent(acid)}` + }), + transformResponse: (response: DetailResponse) => { + return response.detail; + }, + invalidatesTags: ["Datafiles"], + onQueryStarted: (_, { queryFulfilled }) => { + queryFulfilled + .then(() => { + toast.success("Datafile deleted successfully", { duration: 3000 }); + }) + .catch((error) => { + const message = getErrorMessage(error.error as FetchBaseQueryError); + toast.error(message, { duration: 5000 }); + }); + } + }) + }) +}); + +export const { + useFetchDatafilesQuery, + useFetchDatafileQuery, + useCreateDatafileMutation, + useUpdateDatafileMutation, + useDeleteDatafileMutation +} = datafileApi; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts index 186bb3c71..69157bc45 100755 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts @@ -4,6 +4,7 @@ import { assignmentApi } from "@store/assignment/assignment.logic.api"; import { assignmentExerciseSlice } from "@store/assignmentExercise/assignmentExercise.logic"; import { assignmentExerciseApi } from "@store/assignmentExercise/assignmentExercise.logic.api"; import { chooseExercisesSlice } from "@store/chooseExercises/chooseExercises.logic"; +import { datafileApi } from "@store/datafile/datafile.logic.api"; import { datasetSlice } from "@store/dataset/dataset.logic"; import { datasetApi } from "@store/dataset/dataset.logic.api"; import { exercisesSlice } from "@store/exercises/exercises.logic"; @@ -27,7 +28,8 @@ const reducersMap = { [assignmentExerciseApi.reducerPath]: assignmentExerciseApi.reducer, [readingsApi.reducerPath]: readingsApi.reducer, [exercisesApi.reducerPath]: exercisesApi.reducer, - [datasetApi.reducerPath]: datasetApi.reducer + [datasetApi.reducerPath]: datasetApi.reducer, + [datafileApi.reducerPath]: datafileApi.reducer }; export const rootReducer = combineReducers(reducersMap); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts index 2b5bf6ddb..da0800904 100755 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts @@ -1,6 +1,7 @@ import { configureStore } from "@reduxjs/toolkit"; import { assignmentApi } from "@store/assignment/assignment.logic.api"; import { assignmentExerciseApi } from "@store/assignmentExercise/assignmentExercise.logic.api"; +import { datafileApi } from "@store/datafile/datafile.logic.api"; import { datasetApi } from "@store/dataset/dataset.logic.api"; import { exercisesApi } from "@store/exercises/exercises.logic.api"; import { readingsApi } from "@store/readings/readings.logic.api"; @@ -16,7 +17,8 @@ export const setupStore = (preloadedState?: Partial) => { assignmentExerciseApi.middleware, readingsApi.middleware, exercisesApi.middleware, - datasetApi.middleware + datasetApi.middleware, + datafileApi.middleware ); } }); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/types/datafile.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/types/datafile.ts new file mode 100644 index 000000000..5e0f4fad0 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/types/datafile.ts @@ -0,0 +1,60 @@ +// Types for DataFile management in ActiveCode exercises + +export interface DataFile { + id?: string; + filename: string; + content: string; + isImage: boolean; + rows?: number; + cols?: number; + isEditable?: boolean; + isNew?: boolean; +} + +export interface ExistingDataFile { + id: number; + acid: string; + filename: string; + course_id: string; + owner: string | null; + main_code: string; +} + +export type SelectedDataFile = string; + +export interface CreateDataFilePayload { + filename: string; + main_code: string; +} + +export interface CreateDataFileResponse { + status: string; + acid: string; +} + +export interface UpdateDataFilePayload { + acid: string; + main_code: string; +} + +export interface UpdateDataFileResponse { + status: string; +} + +export interface DeleteDataFileResponse { + status: string; +} + +export interface GetDataFileResponse { + id: number; + acid: string; + filename: string; + course_id: string; + owner: string | null; + main_code: string; + is_owner: boolean; +} + +export interface FetchDataFilesResponse { + datafiles: ExistingDataFile[]; +} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts index d0df39623..fe354fde0 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts @@ -1,6 +1,7 @@ import { BlankWithFeedback } from "@components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/FillInTheBlankExercise"; import { FilterMatchMode } from "primereact/api"; +import { SelectedDataFile } from "@/types/datafile"; import { ParsonsBlock } from "@/utils/preview/parsonsPreview"; export const supportedExerciseTypesToEdit = [ @@ -99,6 +100,7 @@ export type QuestionJSON = Partial<{ toggleOptions: string[]; dataLimitBasecourse: boolean; stdin: string; + selectedExistingDataFiles: SelectedDataFile[]; }>; export type CreateExerciseFormType = Omit & QuestionJSON; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/activeCode.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/activeCode.ts index 825d06244..93f8d29b9 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/activeCode.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/activeCode.ts @@ -1,5 +1,10 @@ import { sanitizeId } from "../sanitize"; +export interface DataFileInfo { + acid: string; + filename?: string; +} + export const generateActiveCodePreview = ( instructions: string, language: string, @@ -7,13 +12,18 @@ export const generateActiveCodePreview = ( starter_code: string, suffix_code: string, name: string, - stdin?: string + stdin?: string, + selectedDataFiles?: DataFileInfo[] ): string => { const safeId = sanitizeId(name); // Add data-stdin attribute to textarea if stdin is provided const stdinAttr = stdin && stdin.trim() ? ` data-stdin="${stdin}"` : ""; + const filenames = + selectedDataFiles && selectedDataFiles.length > 0 ? selectedDataFiles.map((df) => df.acid) : []; + const datafileAttr = filenames.length > 0 ? ` data-datafile="${filenames.join(",")}"` : ""; + return `
@@ -26,7 +36,7 @@ export const generateActiveCodePreview = ( data-timelimit=25000 data-codelens="true" data-audio='' data-wasm=/_static - ${stdinAttr} + ${stdinAttr}${datafileAttr} style="visibility: hidden;"> ${prefix_code} ^^^^ diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts index b3dcd01c8..a95d45526 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts @@ -10,7 +10,8 @@ export const buildQuestionJson = (data: CreateExerciseFormType) => { suffix_code: data.suffix_code, instructions: data.instructions, language: data.language, - stdin: data.stdin + stdin: data.stdin, + selectedExistingDataFiles: data.selectedExistingDataFiles }), ...(data.question_type === "shortanswer" && { attachment: data.attachment, diff --git a/bases/rsptx/assignment_server_api/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py index c2c666c4a..83c4e2579 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -60,6 +60,13 @@ get_peer_votes, search_exercises, create_api_token, + update_source_code, + fetch_all_datafiles, + check_datafile_exists, + generate_datafile_acid, + fetch_datafile_by_acid, + update_datafile, + delete_datafile, ) from rsptx.db.crud.question import validate_question_name_unique, copy_question from rsptx.db.crud.assignment import add_assignment_question, delete_assignment @@ -1564,3 +1571,298 @@ async def duplicate_assignment_endpoint( status=status.HTTP_400_BAD_REQUEST, detail=f"Error duplicating assignment: {str(e)}", ) + +class CreateDatafilePayload(BaseModel): + filename: str + main_code: str + + +@router.get("/datafiles") +@instructor_role_required() +@with_course() +async def get_datafiles( + request: Request, + course=None, +): + """ + Fetch all datafiles for a course. + Fetches from both base_course and current course_name to support derived courses. + """ + try: + datafiles = await fetch_all_datafiles(course.base_course, course.course_name) + + # Convert to dictionaries for JSON response + datafiles_list = [] + for df in datafiles: + if df is not None: + datafiles_list.append({ + "id": df.id, + "acid": df.acid, + "filename": df.filename, + "course_id": df.course_id, + "owner": df.owner, + "main_code": df.main_code[:100] + "..." if df.main_code and len(df.main_code) > 100 else df.main_code, # Truncate for list view + }) + + return make_json_response( + status=status.HTTP_200_OK, + detail={"datafiles": datafiles_list}, + ) + except Exception as e: + rslogger.error(f"Error fetching datafiles: {e}") + return make_json_response( + status=status.HTTP_400_BAD_REQUEST, + detail=f"Error fetching datafiles: {str(e)}", + ) + + + + +@router.post("/datafile") +@instructor_role_required() +@with_course() +async def create_datafile( + request: Request, + request_data: CreateDatafilePayload, + course=None, +): + """ + Create or update a datafile in the source_code table. + Saves to the current course (course_name), not the base_course. + + The unique constraint is: filename + owner + course_id. + The acid is generated based on these three values. + """ + try: + # Get the current user (owner) + user = request.state.user + if not user: + return make_json_response( + status=status.HTTP_401_UNAUTHORIZED, + detail="User not authenticated", + ) + owner = user.username + + # Check if a datafile with the same filename, owner, and course already exists + exists = await check_datafile_exists( + filename=request_data.filename, + owner=owner, + course_id=course.course_name, + ) + + if exists: + return make_json_response( + status=status.HTTP_409_CONFLICT, + detail=f"A datafile with filename '{request_data.filename}' already exists for this course. Please use a different filename.", + ) + + # Generate acid based on filename, owner, and course + acid = generate_datafile_acid( + filename=request_data.filename, + owner=owner, + course_id=course.course_name, + ) + + await update_source_code( + acid=acid, + filename=request_data.filename, + course_id=course.course_name, + main_code=request_data.main_code, + owner=owner, + ) + + return make_json_response( + status=status.HTTP_201_CREATED, + detail={"status": "success", "acid": acid}, + ) + except Exception as e: + rslogger.error(f"Error creating datafile: {e}") + return make_json_response( + status=status.HTTP_400_BAD_REQUEST, + detail=f"Error creating datafile: {str(e)}", + ) + + +class UpdateDatafilePayload(BaseModel): + acid: str + main_code: str + + +@router.put("/datafile") +@instructor_role_required() +@with_course() +async def update_datafile_endpoint( + request: Request, + request_data: UpdateDatafilePayload, + course=None, +): + """ + Update an existing datafile in the source_code table. + Only the owner of the datafile can update it. + Note: Filename cannot be changed after creation. + """ + try: + # Get the current user + user = request.state.user + if not user: + return make_json_response( + status=status.HTTP_401_UNAUTHORIZED, + detail="User not authenticated", + ) + + # Fetch the datafile to verify ownership + datafile = await fetch_datafile_by_acid( + acid=request_data.acid, + course_id=course.course_name, + ) + + if not datafile: + return make_json_response( + status=status.HTTP_404_NOT_FOUND, + detail=f"Datafile with acid '{request_data.acid}' not found", + ) + + # Check ownership + if datafile.owner != user.username: + return make_json_response( + status=status.HTTP_403_FORBIDDEN, + detail="You are not the owner of this datafile and cannot edit it", + ) + + # Update the datafile (only main_code, filename cannot be changed) + success = await update_datafile( + acid=request_data.acid, + course_id=course.course_name, + main_code=request_data.main_code, + ) + + if success: + return make_json_response( + status=status.HTTP_200_OK, + detail={"status": "success"}, + ) + else: + return make_json_response( + status=status.HTTP_404_NOT_FOUND, + detail="Datafile not found", + ) + except Exception as e: + rslogger.error(f"Error updating datafile: {e}") + return make_json_response( + status=status.HTTP_400_BAD_REQUEST, + detail=f"Error updating datafile: {str(e)}", + ) + + +@router.delete("/datafile/{acid}") +@instructor_role_required() +@with_course() +async def delete_datafile_endpoint( + request: Request, + acid: str, + course=None, +): + """ + Delete a datafile from the source_code table. + Only the owner of the datafile can delete it. + """ + try: + # Get the current user + user = request.state.user + if not user: + return make_json_response( + status=status.HTTP_401_UNAUTHORIZED, + detail="User not authenticated", + ) + + # Fetch the datafile to verify ownership + datafile = await fetch_datafile_by_acid( + acid=acid, + course_id=course.course_name, + ) + + if not datafile: + return make_json_response( + status=status.HTTP_404_NOT_FOUND, + detail=f"Datafile with acid '{acid}' not found", + ) + + # Check ownership + if datafile.owner != user.username: + return make_json_response( + status=status.HTTP_403_FORBIDDEN, + detail="You are not the owner of this datafile and cannot delete it", + ) + + # Delete the datafile + success = await delete_datafile( + acid=acid, + course_id=course.course_name, + ) + + if success: + return make_json_response( + status=status.HTTP_200_OK, + detail={"status": "success"}, + ) + else: + return make_json_response( + status=status.HTTP_404_NOT_FOUND, + detail="Datafile not found", + ) + except Exception as e: + rslogger.error(f"Error deleting datafile: {e}") + return make_json_response( + status=status.HTTP_400_BAD_REQUEST, + detail=f"Error deleting datafile: {str(e)}", + ) + + +@router.get("/datafile/{acid}") +@instructor_role_required() +@with_course() +async def get_datafile_endpoint( + request: Request, + acid: str, + course=None, +): + """ + Get a single datafile by its acid. + Returns full content (not truncated like the list endpoint). + """ + try: + datafile = await fetch_datafile_by_acid( + acid=acid, + course_id=course.course_name, + ) + + if not datafile: + return make_json_response( + status=status.HTTP_404_NOT_FOUND, + detail=f"Datafile with acid '{acid}' not found", + ) + + # Get current user to determine if they are the owner + user = request.state.user + is_owner = user and datafile.owner == user.username + + return make_json_response( + status=status.HTTP_200_OK, + detail={ + "id": datafile.id, + "acid": datafile.acid, + "filename": datafile.filename, + "course_id": datafile.course_id, + "owner": datafile.owner, + "main_code": datafile.main_code, + "is_owner": is_owner, + }, + ) + except Exception as e: + rslogger.error(f"Error fetching datafile: {e}") + return make_json_response( + status=status.HTTP_400_BAD_REQUEST, + detail=f"Error fetching datafile: {str(e)}", + ) + + diff --git a/components/rsptx/db/crud/__init__.py b/components/rsptx/db/crud/__init__.py index 828004771..bd6474339 100644 --- a/components/rsptx/db/crud/__init__.py +++ b/components/rsptx/db/crud/__init__.py @@ -161,7 +161,7 @@ update_question_grade_entry, ) -from .rsfiles import fetch_source_code, update_source_code, update_source_code_sync +from .rsfiles import fetch_source_code, update_source_code, update_source_code_sync, fetch_all_datafiles, check_datafile_exists, generate_datafile_acid, fetch_datafile_by_acid, update_datafile, delete_datafile from .rslogging import ( count_useinfo_for, @@ -382,6 +382,12 @@ "fetch_source_code", "update_source_code", "update_source_code_sync", + "fetch_all_datafiles", + "check_datafile_exists", + "generate_datafile_acid", + "fetch_datafile_by_acid", + "update_datafile", + "delete_datafile", ] # from .rslogging diff --git a/components/rsptx/db/crud/rsfiles.py b/components/rsptx/db/crud/rsfiles.py index cd5eacef1..d8e57faea 100644 --- a/components/rsptx/db/crud/rsfiles.py +++ b/components/rsptx/db/crud/rsfiles.py @@ -9,7 +9,7 @@ # We need a synchronous version of this function for use in manifest_data_to_db # if/when process_manifest moves to being async we could remove this -def update_source_code_sync(acid: str, filename: str, course_id: str, main_code: str): +def update_source_code_sync(acid: str, filename: str, course_id: str, main_code: str, owner: str = None): """ Update the source code for a given acid or filename """ @@ -25,6 +25,8 @@ def update_source_code_sync(acid: str, filename: str, course_id: str, main_code: if source_code_obj: source_code_obj.main_code = main_code source_code_obj.filename = filename + if owner is not None: + source_code_obj.owner = owner session.add(source_code_obj) else: new_entry = SourceCode( @@ -32,12 +34,13 @@ def update_source_code_sync(acid: str, filename: str, course_id: str, main_code: filename=filename, course_id=course_id, main_code=main_code, + owner=owner, ) session.add(new_entry) session.commit() -async def update_source_code(acid: str, filename: str, course_id: str, main_code: str): +async def update_source_code(acid: str, filename: str, course_id: str, main_code: str, owner: str = None): """ Update the source code for a given acid or filename """ @@ -53,6 +56,8 @@ async def update_source_code(acid: str, filename: str, course_id: str, main_code if source_code_obj: source_code_obj.main_code = main_code source_code_obj.filename = filename + if owner is not None: + source_code_obj.owner = owner session.add(source_code_obj) else: new_entry = SourceCode( @@ -60,11 +65,49 @@ async def update_source_code(acid: str, filename: str, course_id: str, main_code filename=filename, course_id=course_id, main_code=main_code, + owner=owner, ) session.add(new_entry) await session.commit() +async def check_datafile_exists(filename: str, owner: str, course_id: str) -> bool: + """ + Check if a datafile with the same filename, owner, and course already exists. + + :param filename: str, the filename to check + :param owner: str, the owner (username) to check + :param course_id: str, the course_id to check + :return: bool, True if exists, False otherwise + """ + query = select(SourceCode).where( + and_( + SourceCode.filename == filename, + SourceCode.owner == owner, + SourceCode.course_id == course_id, + ) + ) + async with async_session() as session: + res = await session.execute(query) + return res.scalars().first() is not None + + +def generate_datafile_acid(filename: str, owner: str, course_id: str) -> str: + """ + Generate a unique acid for a datafile based on filename, owner, and course_id. + + :param filename: str, the filename + :param owner: str, the owner (username) + :param course_id: str, the course_id + :return: str, the generated acid + """ + # Sanitize components to remove special characters + safe_filename = filename.replace("/", "_").replace("\\", "_").replace(" ", "_") + safe_owner = owner.replace("@", "_at_").replace(" ", "_") + safe_course = course_id.replace(" ", "_") + return f"datafile_{safe_course}_{safe_owner}_{safe_filename}" + + async def fetch_source_code( base_course: str, course_name: str, acid: str = None, filename: str = None ) -> SourceCodeValidator: @@ -107,3 +150,109 @@ async def fetch_source_code( async with async_session() as session: res = await session.execute(query) return SourceCodeValidator.from_orm(res.scalars().first()) + + +async def fetch_all_datafiles(base_course: str, course_name: str) -> list: + """ + Fetch all datafiles (source_code entries) for a course. + + Fetches from both base_course and course_name to support derived courses + that may have copied datafiles. + + :param base_course: str, the base course ID + :param course_name: str, the current course name + :return: list of SourceCodeValidator objects + """ + query = select(SourceCode).where( + or_( + SourceCode.course_id == base_course, + SourceCode.course_id == course_name, + ) + ) + async with async_session() as session: + res = await session.execute(query) + results = res.scalars().all() + return [SourceCodeValidator.from_orm(item) for item in results] + + +async def fetch_datafile_by_acid(acid: str, course_id: str) -> SourceCodeValidator: + """ + Fetch a datafile by its acid and course_id. + + :param acid: str, the acid of the datafile + :param course_id: str, the course_id + :return: SourceCodeValidator or None + """ + query = select(SourceCode).where( + and_( + SourceCode.acid == acid, + SourceCode.course_id == course_id, + ) + ) + async with async_session() as session: + res = await session.execute(query) + result = res.scalars().first() + if result: + return SourceCodeValidator.from_orm(result) + return None + + +async def update_datafile( + acid: str, + course_id: str, + main_code: str, +) -> bool: + """ + Update an existing datafile's content. + Note: Filename cannot be changed after creation. + + :param acid: str, the acid of the datafile to update + :param course_id: str, the course_id + :param main_code: str, new content + :return: bool, True if updated, False if not found + """ + query = select(SourceCode).where( + and_( + SourceCode.acid == acid, + SourceCode.course_id == course_id, + ) + ) + async with async_session() as session: + res = await session.execute(query) + source_code_obj = res.scalars().first() + if not source_code_obj: + return False + + source_code_obj.main_code = main_code + + session.add(source_code_obj) + await session.commit() + return True + + +async def delete_datafile(acid: str, course_id: str) -> bool: + """ + Delete a datafile by its acid and course_id. + + :param acid: str, the acid of the datafile to delete + :param course_id: str, the course_id + :return: bool, True if deleted, False if not found + """ + query = select(SourceCode).where( + and_( + SourceCode.acid == acid, + SourceCode.course_id == course_id, + ) + ) + async with async_session() as session: + res = await session.execute(query) + source_code_obj = res.scalars().first() + if not source_code_obj: + return False + + await session.delete(source_code_obj) + await session.commit() + return True + + + diff --git a/components/rsptx/db/models.py b/components/rsptx/db/models.py index ebca77ab4..84d8729e2 100644 --- a/components/rsptx/db/models.py +++ b/components/rsptx/db/models.py @@ -427,6 +427,9 @@ class SourceCode(Base, IdMixin): # Filename to use when saving contents to Jobe or trying to include # this file in a program. It is OK to reuse the same filename for different filename = Column(String(512)) + # Owner of the datafile (username of the instructor who created it) + # Used to enforce uniqueness: filename + owner + course_id should be unique + owner = Column(String(512), index=True) SourceCodeValidator: TypeAlias = sqlalchemy_to_pydantic(SourceCode) # type: ignore diff --git a/migrations/versions/9a1c2b3d4e5f_add_owner_to_source_code.py b/migrations/versions/9a1c2b3d4e5f_add_owner_to_source_code.py new file mode 100644 index 000000000..a91752ceb --- /dev/null +++ b/migrations/versions/9a1c2b3d4e5f_add_owner_to_source_code.py @@ -0,0 +1,34 @@ +"""add owner to source_code + +Revision ID: 9a1c2b3d4e5f +Revises: 2db61c1550a2 +Create Date: 2025-12-26 10:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "9a1c2b3d4e5f" +down_revision: Union[str, None] = "2db61c1550a2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add owner column to source_code table + op.add_column( + "source_code", sa.Column("owner", sa.String(length=512), nullable=True) + ) + # Add index on owner for faster lookups + op.create_index("ix_source_code_owner", "source_code", ["owner"]) + + +def downgrade() -> None: + op.drop_index("ix_source_code_owner", table_name="source_code") + op.drop_column("source_code", "owner") +