diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..d8e90af92 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + types: [python] + files: ^(bases|components)/ + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.4 + hooks: + - id: ruff + types: [python] + files: ^(bases|components)/ diff --git a/bases/rsptx/admin_server_api/routers/instructor.py b/bases/rsptx/admin_server_api/routers/instructor.py index 078a52b76..49fb68fe9 100644 --- a/bases/rsptx/admin_server_api/routers/instructor.py +++ b/bases/rsptx/admin_server_api/routers/instructor.py @@ -1081,6 +1081,7 @@ async def _copy_one_assignment( course=target_course.id, name=old_assignment.name, duedate=due_date, + updated_date=datetime.datetime.now(), description=old_assignment.description, points=old_assignment.points, threshold_pct=old_assignment.threshold_pct, diff --git a/bases/rsptx/admin_server_api/routers/lti1p3.py b/bases/rsptx/admin_server_api/routers/lti1p3.py index d47e0e6c6..802b16627 100644 --- a/bases/rsptx/admin_server_api/routers/lti1p3.py +++ b/bases/rsptx/admin_server_api/routers/lti1p3.py @@ -77,6 +77,7 @@ fetch_instructor_courses, validate_user_credentials, ) +from rsptx.db.crud.assignment import is_assignment_visible_to_students from rsptx.configuration import settings from rsptx.logging import rslogger @@ -445,7 +446,10 @@ async def launch(request: Request): status_code=400, detail=f"Assignment {lineitem_assign_id} not found" ) - if not rs_assign.visible and not message_launch.check_teacher_access(): + if ( + not is_assignment_visible_to_students(rs_assign) + and not message_launch.check_teacher_access() + ): raise HTTPException( status_code=400, detail=f"Assignment {rs_assign.name} is not open for students", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json b/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json index 011172706..62a430369 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json @@ -54,6 +54,7 @@ "prismjs": "^1.30.0", "quill": "^2.0.3", "react": "^18.2.0", + "react-datepicker": "^7.6.0", "react-dom": "^18.2.0", "react-hook-form": "^7.53.2", "react-hot-toast": "^2.4.1", @@ -2800,6 +2801,59 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.18", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.18.tgz", + "integrity": "sha512-xJWJxvmy3a05j643gQt+pRbht5XnTlGpsEsAPnMi5F5YTOEEJymA90uZKBD8OvIv5XvZ1qi4GcccSlqT3Bq44Q==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.7", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.7.2", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", @@ -5642,6 +5696,15 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5903,6 +5966,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -10566,6 +10639,21 @@ "node": ">=0.10.0" } }, + "node_modules/react-datepicker": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-7.6.0.tgz", + "integrity": "sha512-9cQH6Z/qa4LrGhzdc3XoHbhrxNcMi9MKjZmYgF/1MNNaJwvdSjv3Xd+jjvrEEbKEf71ZgCA3n7fQbdwd70qCRw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.0", + "clsx": "^2.1.1", + "date-fns": "^3.6.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -12150,6 +12238,12 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, "node_modules/test-exclude": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package.json b/bases/rsptx/assignment_server_api/assignment_builder/package.json index 023d2f403..bb9983de1 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package.json @@ -54,6 +54,7 @@ "prismjs": "^1.30.0", "quill": "^2.0.3", "react": "^18.2.0", + "react-datepicker": "^7.6.0", "react-dom": "^18.2.0", "react-hook-form": "^7.53.2", "react-hot-toast": "^2.4.1", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css index a653d10d8..861a021e7 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css @@ -329,7 +329,6 @@ .formField textarea, .formField :global(.p-inputtext), .formField :global(.p-inputtextarea), -.formField :global(.p-calendar), .formField :global(.p-inputnumber), .formField :global(.p-selectbutton) { width: 100%; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx index 5d270662e..ce76e96d0 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx @@ -96,7 +96,7 @@ export const AssignmentBuilder = () => { // Event handlers const handleCreateNew = () => { navigateToCreate("basic"); - reset(defaultAssignment); + reset(defaultAssignment as unknown as Assignment); }; const handleEdit = (assignment: Assignment) => { @@ -108,18 +108,6 @@ export const AssignmentBuilder = () => { await duplicateAssignment(assignment.id); }; - const handleVisibilityChange = async (assignment: Assignment, visible: boolean) => { - try { - await updateAssignment({ - ...assignment, - visible - }); - toast.success(`Assignment ${visible ? "visible" : "hidden"} for students`); - } catch (error) { - toast.error("Failed to update assignment visibility"); - } - }; - const handleReleasedChange = async (assignment: Assignment, released: boolean) => { try { await updateAssignment({ @@ -144,6 +132,21 @@ export const AssignmentBuilder = () => { } }; + const handleVisibilityChange = async ( + assignment: Assignment, + data: { visible: boolean; visible_on: string | null; hidden_on: string | null } + ) => { + try { + await updateAssignment({ + ...assignment, + ...data + }); + toast.success("Visibility updated"); + } catch (error) { + toast.error("Failed to update visibility"); + } + }; + const handleWizardComplete = async () => { const formValues = getValues(); const payload: CreateAssignmentPayload = { @@ -156,7 +159,9 @@ export const AssignmentBuilder = () => { nofeedback: formValues.nofeedback, nopause: formValues.nopause, peer_async_visible: formValues.peer_async_visible, - visible: false, + visible: formValues.visible, + visible_on: formValues.visible_on || null, + hidden_on: formValues.hidden_on || null, released: true, enforce_due: formValues.enforce_due || false }; @@ -192,9 +197,9 @@ export const AssignmentBuilder = () => { onCreateNew={handleCreateNew} onEdit={handleEdit} onDuplicate={handleDuplicate} - onVisibilityChange={handleVisibilityChange} onReleasedChange={handleReleasedChange} onEnforceDueChange={handleEnforceDueChange} + onVisibilityChange={handleVisibilityChange} onRemove={onRemove} /> )} @@ -205,13 +210,21 @@ export const AssignmentBuilder = () => { nameError={nameError} canProceed={canProceed} onBack={() => { - if (wizardStep === "type") { + if (wizardStep === "visibility") { + updateWizardStep("type"); + } else if (wizardStep === "type") { updateWizardStep("basic"); } else { navigateToList(); } }} - onNext={() => updateWizardStep("type")} + onNext={() => { + if (wizardStep === "basic") { + updateWizardStep("type"); + } else if (wizardStep === "type") { + updateWizardStep("visibility"); + } + }} onComplete={handleWizardComplete} onNameChange={handleNameChange} onTypeSelect={(type) => handleTypeSelect(type, setValue)} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/AssignmentBuilderCreate.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/AssignmentBuilderCreate.tsx index 9577a1fa5..5b140e461 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/AssignmentBuilderCreate.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/AssignmentBuilderCreate.tsx @@ -2,7 +2,7 @@ import { useCreateAssignmentMutation, useGetAssignmentsQuery } from "@store/assignment/assignment.logic.api"; -import { useParams } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { CreateAssignmentPayload } from "@/types/assignment"; @@ -16,6 +16,7 @@ import { AssignmentWizard } from "./wizard/AssignmentWizard"; export const AssignmentBuilderCreate = () => { const { step } = useParams<{ step?: string }>(); + const navigate = useNavigate(); const { isLoading, isError, data: assignments = [] } = useGetAssignmentsQuery(); const [createAssignment] = useCreateAssignmentMutation(); @@ -32,18 +33,24 @@ export const AssignmentBuilderCreate = () => { watch }); - const wizardStep = step === "type" ? "type" : "basic"; - + const wizardStep = step === "type" ? "type" : step === "visibility" ? "visibility" : "basic"; + console.log(step, wizardStep); const handleBack = () => { - if (wizardStep === "type") { - window.location.href = "/builder/create"; - } else { - window.location.href = "/builder"; + if (wizardStep === "basic") { + navigate("/builder"); + } else if (wizardStep === "type") { + navigate("/builder/create"); + } else if (wizardStep === "visibility") { + navigate("/builder/create/type"); } }; const handleNext = () => { - window.location.href = "/builder/create/type"; + if (wizardStep === "basic") { + navigate("/builder/create/type"); + } else if (wizardStep === "type") { + navigate("/builder/create/visibility"); + } }; const handleWizardComplete = () => { @@ -58,13 +65,15 @@ export const AssignmentBuilderCreate = () => { nofeedback: formValues.nofeedback, nopause: formValues.nopause, peer_async_visible: formValues.peer_async_visible, - visible: false, + visible: formValues.visible, + visible_on: formValues.visible_on || null, + hidden_on: formValues.hidden_on || null, released: true, enforce_due: formValues.enforce_due || false }; createAssignment(payload); - window.location.href = "/builder"; + navigate("/builder"); }; if (isLoading) { diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx index 61b1282b9..470fdeb56 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx @@ -5,7 +5,6 @@ import { useDialogContext } from "@components/ui/DialogContext"; import classNames from "classnames"; import { BreadCrumb } from "primereact/breadcrumb"; import { Button } from "primereact/button"; -import { Calendar } from "primereact/calendar"; import { Checkbox } from "primereact/checkbox"; import { InputNumber } from "primereact/inputnumber"; import { InputText } from "primereact/inputtext"; @@ -17,9 +16,11 @@ import { Control, Controller, UseFormSetValue } from "react-hook-form"; import { useExercisesSelector } from "@/hooks/useExercisesSelector"; import { Assignment, KindOfAssignment } from "@/types/assignment"; -import { convertDateToISO, getDateFormat } from "@/utils/date"; + +import { DateTimePicker } from "../../../../ui/DateTimePicker"; import { AssignmentReadings } from "../reading/AssignmentReadings"; +import { VisibilityControl } from "./VisibilityControl"; interface AssignmentEditProps { control: Control; @@ -239,16 +240,9 @@ export const AssignmentEdit = ({ control={control} defaultValue="" render={({ field }) => ( - field.onChange(convertDateToISO(e.value!))} - showTime - showIcon - appendTo={document.body} - panelClassName="calendar-panel" + field.onChange(val)} /> )} /> @@ -273,6 +267,7 @@ export const AssignmentEdit = ({ /> +
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/VisibilityControl.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/VisibilityControl.tsx new file mode 100644 index 000000000..b1369112b --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/VisibilityControl.tsx @@ -0,0 +1,265 @@ +import { RadioButton } from "primereact/radiobutton"; +import { Control, Controller, UseFormSetValue } from "react-hook-form"; + +import { Assignment } from "@/types/assignment"; +import { convertDateToISO, formatUTCDateLocaleString, parseUTCDate } from "@/utils/date"; + +import { DateTimePicker } from "../../../../ui/DateTimePicker"; + +// eslint-disable-next-line no-restricted-imports +import styles from "../../AssignmentBuilder.module.css"; + +interface VisibilityControlProps { + control: Control; + watch: (name: keyof Assignment) => any; + setValue: UseFormSetValue; +} + +type VisibilityMode = + | "hidden" + | "visible" + | "scheduled_visible" + | "scheduled_hidden" + | "scheduled_period"; + +export const VisibilityControl = ({ control, watch, setValue }: VisibilityControlProps) => { + const visible = watch("visible"); + const visibleOn = watch("visible_on"); + const hiddenOn = watch("hidden_on"); + + // Determine current visibility mode + const getVisibilityMode = (): VisibilityMode => { + if (!visible) { + if (visibleOn && hiddenOn) { + return "scheduled_period"; + } + if (visibleOn) { + return "scheduled_visible"; + } + return "hidden"; + } else { + if (hiddenOn) { + return "scheduled_hidden"; + } + return "visible"; + } + }; + + const visibilityMode = getVisibilityMode(); + + const DAY_MS = 24 * 60 * 60 * 1000; + + const handleVisibleOnChange = (val: string) => { + setValue("visible_on", val); + if (hiddenOn) { + const newVisibleDate = parseUTCDate(val); + const currentHiddenDate = parseUTCDate(hiddenOn); + if (newVisibleDate >= currentHiddenDate) { + const adjusted = new Date(newVisibleDate.getTime() + DAY_MS); + setValue("hidden_on", convertDateToISO(adjusted)); + } + } + }; + + const handleHiddenOnChange = (val: string) => { + setValue("hidden_on", val); + if (visibleOn) { + const newHiddenDate = parseUTCDate(val); + const currentVisibleDate = parseUTCDate(visibleOn); + if (newHiddenDate <= currentVisibleDate) { + const adjusted = new Date(newHiddenDate.getTime() - DAY_MS); + setValue("visible_on", convertDateToISO(adjusted)); + } + } + }; + + const handleModeChange = (mode: VisibilityMode) => { + switch (mode) { + case "hidden": + setValue("visible", false); + setValue("visible_on", null); + setValue("hidden_on", null); + break; + case "visible": + setValue("visible", true); + setValue("visible_on", null); + setValue("hidden_on", null); + break; + case "scheduled_visible": + setValue("visible", false); + setValue("hidden_on", null); + if (!visibleOn) { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + setValue("visible_on", convertDateToISO(startOfDay)); + } + break; + case "scheduled_hidden": + setValue("visible", true); + setValue("visible_on", null); + if (!hiddenOn) { + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 0, 0); + setValue("hidden_on", convertDateToISO(endOfDay)); + } + break; + case "scheduled_period": + setValue("visible", false); + // Set both dates if not already set + if (!visibleOn) { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + setValue("visible_on", convertDateToISO(startOfDay)); + } + if (!hiddenOn) { + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 0, 0); + setValue("hidden_on", convertDateToISO(endOfDay)); + } + break; + } + }; + + return ( +
+ +
+
+
+ handleModeChange("hidden")} + /> + +
+ +
+ handleModeChange("visible")} + /> + +
+ +
+
+ handleModeChange("scheduled_visible")} + /> + +
+ {visibilityMode === "scheduled_visible" && ( + ( + dateField.onChange(val)} + utc + /> + )} + /> + )} + +
+ handleModeChange("scheduled_hidden")} + /> + +
+ {visibilityMode === "scheduled_hidden" && ( + ( + dateField.onChange(val)} + utc + /> + )} + /> + )} + +
+ handleModeChange("scheduled_period")} + /> + +
+ {visibilityMode === "scheduled_period" && ( +
+
+ + ( + handleVisibleOnChange(val)} + utc + /> + )} + /> +
+
+ + ( + handleHiddenOnChange(val)} + utc + /> + )} + /> +
+
+ )} +
+
+
+ + {visibilityMode === "hidden" && "Assignment is hidden from students"} + {visibilityMode === "visible" && "Assignment is currently visible to students"} + {visibilityMode === "scheduled_visible" && + visibleOn && + `Assignment will become visible on ${formatUTCDateLocaleString(visibleOn)}`} + {visibilityMode === "scheduled_hidden" && + hiddenOn && + `Assignment will be hidden on ${formatUTCDateLocaleString(hiddenOn)}`} + {visibilityMode === "scheduled_period" && + visibleOn && + hiddenOn && + `Assignment will be visible from ${formatUTCDateLocaleString(visibleOn)} until ${formatUTCDateLocaleString(hiddenOn)}`} + +
+ ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx index 43f39954d..8bfb84609 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx @@ -1,12 +1,17 @@ +import { useCallback, useState } from "react"; + import { SearchInput } from "@components/ui/SearchInput"; import { Button } from "primereact/button"; import { Column } from "primereact/column"; import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog"; -import { DataTable } from "primereact/datatable"; +import { DataTable, DataTableSortEvent } from "primereact/datatable"; import { InputSwitch } from "primereact/inputswitch"; import { classNames } from "primereact/utils"; import { Assignment } from "@/types/assignment"; +import { formatLocalDateForDisplay, formatUTCDateForDisplay } from "@/utils/date"; + +import { VisibilityDropdown } from "./VisibilityDropdown"; // eslint-disable-next-line no-restricted-imports import styles from "../../AssignmentBuilder.module.css"; @@ -18,9 +23,12 @@ interface AssignmentListProps { onCreateNew: () => void; onEdit: (assignment: Assignment) => void; onDuplicate: (assignment: Assignment) => void; - onVisibilityChange: (assignment: Assignment, visible: boolean) => void; onReleasedChange: (assignment: Assignment, released: boolean) => void; onEnforceDueChange: (assignment: Assignment, enforce_due: boolean) => void; + onVisibilityChange: ( + assignment: Assignment, + data: { visible: boolean; visible_on: string | null; hidden_on: string | null } + ) => void; onRemove: (assignment: Assignment) => void; } @@ -31,23 +39,35 @@ export const AssignmentList = ({ onCreateNew, onEdit, onDuplicate, - onVisibilityChange, onReleasedChange, onEnforceDueChange, + onVisibilityChange, onRemove }: AssignmentListProps) => { + const SORT_STORAGE_KEY = "assignmentList_sortField"; + const ORDER_STORAGE_KEY = "assignmentList_sortOrder"; + + const [sortField, setSortField] = useState(() => { + return localStorage.getItem(SORT_STORAGE_KEY) || "name"; + }); + const [sortOrder, setSortOrder] = useState<1 | -1 | 0>(() => { + const stored = localStorage.getItem(ORDER_STORAGE_KEY); + + return stored ? (Number(stored) as 1 | -1) : 1; + }); + + const handleSort = useCallback((e: DataTableSortEvent) => { + const field = (e.sortField as string) || "name"; + const order = e.sortOrder as 1 | -1; + + setSortField(field); + setSortOrder(order); + localStorage.setItem(SORT_STORAGE_KEY, field); + localStorage.setItem(ORDER_STORAGE_KEY, String(order)); + }, []); + const visibilityBodyTemplate = (rowData: Assignment) => ( -
- onVisibilityChange(rowData, e.value)} - tooltip={rowData.visible ? "Visible to students" : "Hidden from students"} - tooltipOptions={{ - position: "top" - }} - className={styles.smallSwitch} - /> -
+ ); const releasedBodyTemplate = (rowData: Assignment) => ( @@ -106,7 +126,7 @@ export const AssignmentList = ({ const dueDateBodyTemplate = (rowData: Assignment) => (
- {new Date(rowData.duedate).toLocaleDateString(undefined, { + {formatLocalDateForDisplay(rowData.duedate, { year: "numeric", month: "short", day: "numeric", @@ -117,6 +137,22 @@ export const AssignmentList = ({
); + const updatedDateBodyTemplate = (rowData: Assignment) => ( +
+ + {rowData.updated_date + ? formatUTCDateForDisplay(rowData.updated_date, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }) + : ""} + +
+ ); + const pointsBodyTemplate = (rowData: Assignment) => (
{rowData.points} @@ -221,8 +257,9 @@ export const AssignmentList = ({ name: { value: globalFilter, matchMode: "contains" } }} sortMode="single" - sortField="name" - sortOrder={1} + sortField={sortField} + sortOrder={sortOrder} + onSort={handleSort} > + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx new file mode 100644 index 000000000..5b17c4370 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx @@ -0,0 +1,427 @@ +import { OverlayPanel } from "primereact/overlaypanel"; +import { RadioButton } from "primereact/radiobutton"; +import { useRef, useState } from "react"; + +import { Assignment } from "@/types/assignment"; +import { convertDateToISO, parseUTCDate } from "@/utils/date"; + +import { DateTimePicker } from "../../../../ui/DateTimePicker"; + +type VisibilityMode = + | "hidden" + | "visible" + | "scheduled_visible" + | "scheduled_hidden" + | "scheduled_period"; + +interface VisibilityStatus { + text: string; + secondLine?: string; + color: string; + icon: string; +} + +interface VisibilityDropdownProps { + assignment: Assignment; + onChange: ( + assignment: Assignment, + data: { visible: boolean; visible_on: string | null; hidden_on: string | null } + ) => void; +} + +const getVisibilityMode = (assignment: Assignment): VisibilityMode => { + const { visible, visible_on, hidden_on } = assignment; + + if (!visible) { + if (visible_on && hidden_on) return "scheduled_period"; + if (visible_on) return "scheduled_visible"; + return "hidden"; + } else { + if (hidden_on) return "scheduled_hidden"; + return "visible"; + } +}; + +const getVisibilityStatus = (assignment: Assignment): VisibilityStatus => { + const now = new Date(); + const { visible, visible_on, hidden_on } = assignment; + + if (visible_on && hidden_on && !visible) { + const visibleDate = parseUTCDate(visible_on); + const hiddenDate = parseUTCDate(hidden_on); + + const fmt = (d: Date) => + d.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }); + + if (now < visibleDate) { + return { + text: fmt(visibleDate), + secondLine: fmt(hiddenDate), + color: "#FFA500", + icon: "pi pi-clock" + }; + } else if (now >= visibleDate && now < hiddenDate) { + return { + text: fmt(visibleDate), + secondLine: fmt(hiddenDate), + color: "#28A745", + icon: "pi pi-calendar" + }; + } else { + return { + text: fmt(visibleDate), + secondLine: fmt(hiddenDate), + color: "#DC3545", + icon: "pi pi-calendar-times" + }; + } + } + + if (!visible) { + if (visible_on) { + const visibleDate = parseUTCDate(visible_on); + if (now >= visibleDate) { + // visible_on has passed, assignment is now visible + return { text: "Visible", color: "#28A745", icon: "pi pi-eye" }; + } + return { + text: visibleDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }), + color: "#FFA500", + icon: "pi pi-clock" + }; + } + return { text: "Hidden", color: "#DC3545", icon: "pi pi-eye-slash" }; + } + + if (hidden_on) { + const hiddenDate = parseUTCDate(hidden_on); + if (now >= hiddenDate) { + return { text: "Hidden", color: "#DC3545", icon: "pi pi-eye-slash" }; + } + return { + text: hiddenDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }), + color: "#17A2B8", + icon: "pi pi-calendar-times" + }; + } + + if (visible_on) { + const visibleDate = parseUTCDate(visible_on); + if (now >= visibleDate) { + return { text: "Visible", color: "#28A745", icon: "pi pi-eye" }; + } + return { + text: visibleDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }), + color: "#FFA500", + icon: "pi pi-clock" + }; + } + + return { text: "Visible", color: "#28A745", icon: "pi pi-eye" }; +}; + +export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownProps) => { + const overlayRef = useRef(null); + const [mode, setMode] = useState(() => getVisibilityMode(assignment)); + const [visibleOn, setVisibleOn] = useState(assignment.visible_on); + const [hiddenOn, setHiddenOn] = useState(assignment.hidden_on); + + const status = getVisibilityStatus(assignment); + + const handleOpen = (e: React.MouseEvent) => { + // Reset local state to current assignment values when opening + setMode(getVisibilityMode(assignment)); + setVisibleOn(assignment.visible_on); + setHiddenOn(assignment.hidden_on); + overlayRef.current?.toggle(e); + }; + + const computeValues = ( + newMode: VisibilityMode, + newVisibleOn: string | null, + newHiddenOn: string | null + ) => { + switch (newMode) { + case "hidden": + return { visible: false, visible_on: null, hidden_on: null }; + case "visible": + return { visible: true, visible_on: null, hidden_on: null }; + case "scheduled_visible": + return { visible: false, visible_on: newVisibleOn, hidden_on: null }; + case "scheduled_hidden": + return { visible: true, visible_on: null, hidden_on: newHiddenOn }; + case "scheduled_period": + return { visible: false, visible_on: newVisibleOn, hidden_on: newHiddenOn }; + } + }; + + const handleModeChange = (newMode: VisibilityMode) => { + let newVisibleOn = visibleOn; + let newHiddenOn = hiddenOn; + + if (newMode === "scheduled_visible" && !newVisibleOn) { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + newVisibleOn = convertDateToISO(startOfDay); + } + if (newMode === "scheduled_hidden" && !newHiddenOn) { + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 0, 0); + newHiddenOn = convertDateToISO(endOfDay); + } + if (newMode === "scheduled_period") { + if (!newVisibleOn) { + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + newVisibleOn = convertDateToISO(startOfDay); + } + if (!newHiddenOn) { + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 0, 0); + newHiddenOn = convertDateToISO(endOfDay); + } + } + + setMode(newMode); + setVisibleOn(newVisibleOn); + setHiddenOn(newHiddenOn); + + const values = computeValues(newMode, newVisibleOn, newHiddenOn); + onChange(assignment, values); + }; + + const DAY_MS = 24 * 60 * 60 * 1000; + + const handleVisibleOnChange = (val: string) => { + let newHiddenOn = hiddenOn; + if (mode === "scheduled_period" && newHiddenOn) { + const newVisibleDate = parseUTCDate(val); + const currentHiddenDate = parseUTCDate(newHiddenOn); + if (newVisibleDate >= currentHiddenDate) { + const adjusted = new Date(newVisibleDate.getTime() + DAY_MS); + newHiddenOn = convertDateToISO(adjusted); + setHiddenOn(newHiddenOn); + } + } + setVisibleOn(val); + const values = computeValues(mode, val, newHiddenOn); + onChange(assignment, values); + }; + + const handleHiddenOnChange = (val: string) => { + let newVisibleOn = visibleOn; + if (mode === "scheduled_period" && newVisibleOn) { + const newHiddenDate = parseUTCDate(val); + const currentVisibleDate = parseUTCDate(newVisibleOn); + if (newHiddenDate <= currentVisibleDate) { + const adjusted = new Date(newHiddenDate.getTime() - DAY_MS); + newVisibleOn = convertDateToISO(adjusted); + setVisibleOn(newVisibleOn); + } + } + setHiddenOn(val); + const values = computeValues(mode, newVisibleOn, val); + onChange(assignment, values); + }; + + return ( + <> +
+
+ + {status.text} + {!status.secondLine && ( + + )} +
+ {status.secondLine && ( +
+ {status.secondLine} + +
+ )} +
+ +
+
+ Visibility Status +
+ +
+ handleModeChange("hidden")} + /> + +
+ +
+ handleModeChange("visible")} + /> + +
+ +
+
+ handleModeChange("scheduled_visible")} + /> + +
+ {mode === "scheduled_visible" && ( +
+ +
+ )} +
+ +
+
+ handleModeChange("scheduled_hidden")} + /> + +
+ {mode === "scheduled_hidden" && ( +
+ +
+ )} +
+ +
+
+ handleModeChange("scheduled_period")} + /> + +
+ {mode === "scheduled_period" && ( +
+
+ + +
+
+ + +
+
+ )} +
+
+
+ + ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityStatusBadge.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityStatusBadge.tsx new file mode 100644 index 000000000..a0b506327 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityStatusBadge.tsx @@ -0,0 +1,186 @@ +import { Tooltip } from "primereact/tooltip"; + +import { Assignment } from "@/types/assignment"; +import { parseUTCDate } from "@/utils/date"; + +interface VisibilityStatus { + text: string; + secondLine?: string; + color: string; + icon: string; + tooltip: string; +} + +interface VisibilityStatusBadgeProps { + assignment: Assignment; +} + +const getVisibilityStatus = (assignment: Assignment): VisibilityStatus => { + const now = new Date(); + const { visible, visible_on, hidden_on } = assignment; + + // Check for scheduled period (both dates set) + if (visible_on && hidden_on && !visible) { + const visibleDate = parseUTCDate(visible_on); + const hiddenDate = parseUTCDate(hidden_on); + + const formatShort = (d: Date) => + d.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }); + + if (now < visibleDate) { + return { + text: formatShort(visibleDate), + secondLine: formatShort(hiddenDate), + color: "#FFA500", + icon: "pi pi-clock", + tooltip: `Visible from ${visibleDate.toLocaleString()} to ${hiddenDate.toLocaleString()}` + }; + } else if (now >= visibleDate && now < hiddenDate) { + return { + text: formatShort(visibleDate), + secondLine: formatShort(hiddenDate), + color: "#28A745", + icon: "pi pi-calendar", + tooltip: `Visible until ${hiddenDate.toLocaleString()}` + }; + } else { + return { + text: formatShort(visibleDate), + secondLine: formatShort(hiddenDate), + color: "#DC3545", + icon: "pi pi-calendar-times", + tooltip: `Period ended on ${hiddenDate.toLocaleString()}` + }; + } + } + + // Hidden state + if (!visible) { + if (visible_on) { + const visibleDate = parseUTCDate(visible_on); + if (now >= visibleDate) { + // visible_on has passed - assignment is now visible + return { + text: "Visible", + color: "#28A745", + icon: "pi pi-eye", + tooltip: "" + }; + } + return { + text: visibleDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }), + color: "#FFA500", + icon: "pi pi-clock", + tooltip: `Will become visible on ${visibleDate.toLocaleString()}` + }; + } + // Simple hidden - no tooltip needed + return { + text: "Hidden", + color: "#DC3545", + icon: "pi pi-eye-slash", + tooltip: "" + }; + } + + // Visible state + if (hidden_on) { + const hiddenDate = parseUTCDate(hidden_on); + if (now >= hiddenDate) { + // Already hidden by schedule - no tooltip needed + return { + text: "Hidden", + color: "#DC3545", + icon: "pi pi-eye-slash", + tooltip: "" + }; + } + return { + text: hiddenDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }), + color: "#17A2B8", + icon: "pi pi-calendar-times", + tooltip: `Will be hidden on ${hiddenDate.toLocaleString()}` + }; + } + + if (visible_on) { + const visibleDate = parseUTCDate(visible_on); + if (now >= visibleDate) { + // Simple visible now - no tooltip needed + return { + text: "Visible", + color: "#28A745", + icon: "pi pi-eye", + tooltip: "" + }; + } + return { + text: visibleDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }), + color: "#FFA500", + icon: "pi pi-clock", + tooltip: `Will become visible on ${visibleDate.toLocaleString()}` + }; + } + + // Simple visible - no tooltip needed + return { + text: "Visible", + color: "#28A745", + icon: "pi pi-eye", + tooltip: "" + }; +}; + +export const VisibilityStatusBadge = ({ assignment }: VisibilityStatusBadgeProps) => { + const status = getVisibilityStatus(assignment); + const hasTooltip = status.tooltip !== ""; + + return ( + <> + {hasTooltip && } +
+
+ + {status.text} +
+ {status.secondLine && {status.secondLine}} +
+ + ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.tsx index a78008fcf..2f4ebc000 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.tsx @@ -1,6 +1,5 @@ import classNames from "classnames"; import { Button } from "primereact/button"; -import { Calendar } from "primereact/calendar"; import { Card } from "primereact/card"; import { Checkbox } from "primereact/checkbox"; import { InputNumber } from "primereact/inputnumber"; @@ -11,14 +10,17 @@ import { Steps } from "primereact/steps"; import { Control, Controller, UseFormSetValue } from "react-hook-form"; import { Assignment, KindOfAssignment } from "@/types/assignment"; -import { convertDateToISO, getDateFormat } from "@/utils/date"; + +import { DateTimePicker } from "../../../../ui/DateTimePicker"; + +import { VisibilityControl } from "../edit/VisibilityControl"; // eslint-disable-next-line no-restricted-imports import styles from "../../AssignmentBuilder.module.css"; interface AssignmentWizardProps { control: Control; - wizardStep: "basic" | "type"; + wizardStep: "basic" | "type" | "visibility"; nameError: string | null; canProceed: boolean; onBack: () => void; @@ -30,7 +32,11 @@ interface AssignmentWizardProps { setValue: UseFormSetValue; } -const wizardSteps = [{ label: "Basic Info" }, { label: "Assignment Type" }]; +const wizardSteps = [ + { label: "Basic Info" }, + { label: "Assignment Type" }, + { label: "Visibility" } +]; const assignmentTypeCards = [ { @@ -112,17 +118,7 @@ export const AssignmentWizard = ({ control={control} defaultValue="" render={({ field }) => ( - field.onChange(convertDateToISO(e.value!))} - showTime - showIcon - // appendTo={document.body} - panelClassName="calendar-panel" - /> + field.onChange(val)} /> )} />
@@ -282,7 +278,29 @@ export const AssignmentWizard = ({ onClick={onBack} className="p-button-secondary" /> -
+
+ ); + + const renderVisibility = () => ( +
+

Visibility Settings

+
+

+ Control when this assignment becomes visible to students. You can make it visible + immediately, schedule it for a future date, or set it to hide automatically. +

+ +
+
+
); @@ -297,6 +315,8 @@ export const AssignmentWizard = ({ return s.label === "Basic Info"; case "type": return s.label === "Assignment Type"; + case "visibility": + return s.label === "Visibility"; default: return false; } @@ -304,6 +324,7 @@ export const AssignmentWizard = ({ /> {wizardStep === "basic" && renderBasicInfo()} {wizardStep === "type" && renderTypeSelection()} + {wizardStep === "visibility" && renderVisibility()}
); }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/defaultAssignment.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/defaultAssignment.ts index 032d0732b..f085ee607 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/defaultAssignment.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/defaultAssignment.ts @@ -1,10 +1,10 @@ import { CreateAssignmentPayload } from "@/types/assignment"; -import { convertDateToISO } from "@/utils/date"; +import { convertDateToLocalISO } from "@/utils/date"; export const defaultAssignment: CreateAssignmentPayload = { name: "", description: "", - duedate: convertDateToISO(new Date()), + duedate: convertDateToLocalISO(new Date()), points: 0, kind: "Regular", time_limit: null, @@ -12,6 +12,8 @@ export const defaultAssignment: CreateAssignmentPayload = { nopause: false, peer_async_visible: false, visible: false, + visible_on: null, + hidden_on: null, released: true, enforce_due: false }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/hooks/useAssignmentRouting.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/hooks/useAssignmentRouting.ts index 3bd14a0e9..7bb31a8fa 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/hooks/useAssignmentRouting.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/hooks/useAssignmentRouting.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; export type AssignmentMode = "list" | "create" | "edit"; -export type WizardStep = "basic" | "type"; +export type WizardStep = "basic" | "type" | "visibility"; export type EditTab = "basic" | "readings" | "exercises"; export type ExerciseViewMode = "list" | "browse" | "search" | "create" | "edit"; @@ -52,6 +52,8 @@ export const useAssignmentRouting = () => { state.mode = "create"; if (path.includes("/type")) { state.wizardStep = "type"; + } else if (path.includes("/visibility")) { + state.wizardStep = "visibility"; } else { state.wizardStep = "basic"; } @@ -96,6 +98,8 @@ export const useAssignmentRouting = () => { (step?: WizardStep) => { if (step === "type") { navigate("/builder/create/type"); + } else if (step === "visibility") { + navigate("/builder/create/visibility"); } else { navigate("/builder/create"); } diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.module.css new file mode 100644 index 000000000..c36e0d718 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.module.css @@ -0,0 +1,197 @@ +.wrapper { + width: 100%; +} + +.datepickerWrapper { + width: 100% !important; + display: block !important; +} + +.input { + width: 100% !important; + padding: 0.5rem 0.75rem 0.5rem 2.5rem !important; + border: 1px solid var(--surface-border, #dee2e6) !important; + border-radius: var(--border-radius, 6px) !important; + font-size: 1rem !important; + font-family: inherit !important; + color: var(--text-color, #495057) !important; + background: var(--surface-card, #ffffff) !important; + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box !important; + height: 2.6rem; +} + +.input:focus { + outline: none !important; + border-color: var(--primary-color, #3B82F6) !important; + box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25) !important; +} + +.input::placeholder { + color: var(--text-color-secondary, #6c757d); +} + +/* Icon styling */ +.wrapper :global(.react-datepicker__input-container) { + display: flex !important; + align-items: center; + width: 100%; +} + +.wrapper :global(.react-datepicker__calendar-icon) { + position: absolute !important; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + padding: 0 !important; + color: var(--text-color-secondary, #6c757d); + font-size: 1rem; + pointer-events: none; + z-index: 1; + width: auto !important; + height: auto !important; +} + +.wrapper :global(.react-datepicker__calendar-icon svg) { + display: none; +} + +/* Popper / dropdown */ +.popper { + z-index: 9999 !important; +} + +/* Calendar panel */ +.calendar { + font-family: inherit !important; + border: 1px solid var(--surface-border, #dee2e6) !important; + border-radius: var(--border-radius, 6px) !important; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1) !important; + background: var(--surface-card, #ffffff) !important; + padding: 0.5rem !important; +} + +/* Header */ +.calendar :global(.react-datepicker__header) { + background: var(--surface-card, #ffffff) !important; + border-bottom: 1px solid var(--surface-border, #dee2e6) !important; + padding-top: 0.5rem !important; + border-top-left-radius: var(--border-radius, 6px) !important; + border-top-right-radius: var(--border-radius, 6px) !important; +} + +.calendar :global(.react-datepicker__current-month), +.calendar :global(.react-datepicker-time__header) { + font-weight: 600 !important; + color: var(--text-color, #495057) !important; + font-size: 1rem !important; + padding-bottom: 0.5rem; +} + +/* Navigation arrows */ +.calendar :global(.react-datepicker__navigation) { + top: 0.75rem !important; +} + +.calendar :global(.react-datepicker__navigation-icon::before) { + border-color: var(--text-color-secondary, #6c757d) !important; + border-width: 2px 2px 0 0 !important; + height: 8px !important; + width: 8px !important; +} + +.calendar :global(.react-datepicker__navigation:hover .react-datepicker__navigation-icon::before) { + border-color: var(--primary-color, #3B82F6) !important; +} + +/* Day names */ +.calendar :global(.react-datepicker__day-name) { + color: var(--text-color-secondary, #6c757d) !important; + font-weight: 600 !important; + width: 2.25rem !important; + line-height: 2.25rem !important; + margin: 0.1rem !important; + font-size: 0.85rem !important; +} + +/* Days */ +.calendar :global(.react-datepicker__day) { + color: var(--text-color, #495057) !important; + width: 2.25rem !important; + line-height: 2.25rem !important; + margin: 0.1rem !important; + border-radius: 50% !important; + font-size: 0.9rem !important; + transition: background-color 0.2s, color 0.2s; +} + +.calendar :global(.react-datepicker__day:hover) { + background-color: var(--surface-hover, #e9ecef) !important; + color: var(--text-color, #495057) !important; + border-radius: 50% !important; +} + +.calendar :global(.react-datepicker__day--selected), +.calendar :global(.react-datepicker__day--keyboard-selected) { + background-color: var(--primary-color, #3B82F6) !important; + color: var(--primary-color-text, #ffffff) !important; + border-radius: 50% !important; + font-weight: 600 !important; +} + +.calendar :global(.react-datepicker__day--selected:hover) { + background-color: var(--primary-color, #3B82F6) !important; + opacity: 0.9; +} + +.calendar :global(.react-datepicker__day--today) { + font-weight: 700 !important; +} + +.calendar :global(.react-datepicker__day--outside-month) { + color: var(--text-color-secondary, #6c757d) !important; + opacity: 0.5; +} + +/* Time select */ +.calendar :global(.react-datepicker__time-container) { + border-left: 1px solid var(--surface-border, #dee2e6) !important; + width: 100px !important; +} + +.calendar :global(.react-datepicker__time-container .react-datepicker__time) { + background: var(--surface-card, #ffffff) !important; +} + +.calendar :global(.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box) { + width: 100px !important; +} + +.calendar :global(.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button)) { + right: 105px; +} + +.calendar :global(.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item) { + height: auto !important; + padding: 0.35rem 0.5rem !important; + font-size: 0.85rem !important; + color: var(--text-color, #495057); + transition: background-color 0.2s; +} + +.calendar :global(.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover) { + background-color: var(--surface-hover, #e9ecef) !important; + color: var(--text-color, #495057) !important; +} + +.calendar :global(.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected) { + background-color: var(--primary-color, #3B82F6) !important; + color: var(--primary-color-text, #ffffff) !important; + font-weight: 600 !important; +} + +/* Triangle arrow */ +.calendar :global(.react-datepicker__triangle) { + display: none; +} + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.tsx new file mode 100644 index 000000000..dec63f821 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.tsx @@ -0,0 +1,59 @@ +import "react-datepicker/dist/react-datepicker.css"; + +import classNames from "classnames"; +import DatePicker from "react-datepicker"; + +import { + convertDateToISO, + convertDateToLocalISO, + getDatePickerFormat, + parseUTCDate, + parseLocalDate +} from "@/utils/date"; + +import styles from "./DateTimePicker.module.css"; + +interface DateTimePickerProps { + value: string | null | undefined; + onChange: (isoString: string) => void; + placeholder?: string; + className?: string; + /** If true, treat dates as UTC. If false (default), treat as local time. */ + utc?: boolean; +} + +export const DateTimePicker = ({ + value, + onChange, + placeholder = "Select date and time", + className, + utc = false +}: DateTimePickerProps) => { + const handleChange = (date: Date | null) => { + if (date) { + onChange(utc ? convertDateToISO(date) : convertDateToLocalISO(date)); + } + }; + + const parseDate = (val: string) => (utc ? parseUTCDate(val) : parseLocalDate(val)); + + return ( +
+ } + popperClassName={styles.popper} + portalId="root" + /> +
+ ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/index.ts new file mode 100644 index 000000000..d1ac0e6e7 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/index.ts @@ -0,0 +1 @@ +export { DateTimePicker } from "./DateTimePicker"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/types/assignment.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/types/assignment.ts index 9ed290cff..e8ae4fe5b 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/types/assignment.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/types/assignment.ts @@ -7,6 +7,9 @@ export type Assignment = { name: string; description: string; duedate: string; + updated_date: string | null; + visible_on: string | null; + hidden_on: string | null; points: number; visible: boolean; is_peer: boolean; @@ -42,6 +45,8 @@ export type CreateAssignmentPayload = { nopause: boolean; peer_async_visible: boolean; visible: boolean; + visible_on: string | null; + hidden_on: string | null; released: boolean; enforce_due: boolean; }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.ts index d67047a01..c4cc29e61 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.ts @@ -1,10 +1,95 @@ +/** + * Converts a local Date object to a UTC ISO string (without 'Z' suffix) + * for sending to the backend which stores dates in UTC. + */ export const convertDateToISO = (date: Date): string => { - const offset = date.getTimezoneOffset(); - const localDate = new Date(date.getTime() - offset * 60 * 1000); + return date.toISOString().slice(0, 19); // UTC ISO string without 'Z' and milliseconds +}; + +/** + * Converts a local Date object to a local ISO-like string (without timezone info) + * for sending to the backend which stores due dates in local time (naive datetime). + * This preserves the original behavior where due dates are stored as-is in the instructor's + * local timezone without any UTC conversion. + */ +export const convertDateToLocalISO = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; +}; + +export const getDatePickerFormat = (locale = navigator.language) => { + return locale.endsWith("US") ? "MM/dd/yyyy h:mm aa" : "dd/MM/yyyy HH:mm"; +}; + +/** + * Parses a UTC date string from the backend into a local Date object. + * Backend stores dates in UTC as naive strings (e.g., "2026-02-24T15:00:00"). + * We append 'Z' so JavaScript correctly interprets it as UTC. + */ +export const parseUTCDate = (dateString: string): Date => { + // If the string already ends with 'Z' or has timezone info, parse as-is + if (dateString.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(dateString)) { + return new Date(dateString); + } + return new Date(dateString + "Z"); +}; + +/** + * Parses a naive date string from the backend as LOCAL time (not UTC). + * Backend stores due dates in the instructor's local timezone as naive strings + * (e.g., "2026-02-24T15:00:00"). We parse them without appending 'Z' so + * JavaScript interprets them as local time. + */ +export const parseLocalDate = (dateString: string): Date => { + // If the string already has timezone info, parse as-is + if (dateString.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(dateString)) { + return new Date(dateString); + } + // Parse as local time by NOT appending 'Z' + return new Date(dateString); +}; + +/** + * Formats a UTC date string from the backend for display in the user's local timezone. + */ +export const formatUTCDateForDisplay = ( + utcString: string, + options?: Intl.DateTimeFormatOptions +): string => { + const date = parseUTCDate(utcString); + return date.toLocaleDateString(undefined, options); +}; + +/** + * Formats a local (naive) date string from the backend for display. + * Since the date is already in local time, no timezone conversion is needed. + */ +export const formatLocalDateForDisplay = ( + localString: string, + options?: Intl.DateTimeFormatOptions +): string => { + const date = parseLocalDate(localString); + return date.toLocaleDateString(undefined, options); +}; - return localDate.toISOString().slice(0, 19); // Remove 'Z' and milliseconds +/** + * Formats a UTC date string from the backend as a locale string in the user's local timezone. + */ +export const formatUTCDateLocaleString = (utcString: string): string => { + const date = parseUTCDate(utcString); + return date.toLocaleString(); }; -export const getDateFormat = (locale = navigator.language) => { - return locale.endsWith("US") ? "mm/dd/yy" : "dd/mm/yy"; +/** + * Formats a local (naive) date string from the backend as a locale string. + * Since the date is already in local time, no timezone conversion is needed. + */ +export const formatLocalDateLocaleString = (localString: string): string => { + const date = parseLocalDate(localString); + return date.toLocaleString(); }; diff --git a/bases/rsptx/assignment_server_api/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py index 079e3140f..5a7bb62f6 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -71,7 +71,11 @@ 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 +from rsptx.db.crud.assignment import ( + add_assignment_question, + delete_assignment, + is_assignment_visible_to_students, +) from rsptx.auth.session import auth_manager, is_instructor from rsptx.templates import template_folder from rsptx.configuration import settings @@ -157,11 +161,7 @@ async def review_peer_assignment( ) return RedirectResponse("/runestone/peer/instructor.html") - if ( - assignment.visible == "F" - or assignment.visible is None - or assignment.visible == False # noqa: E712 - ): + if not is_assignment_visible_to_students(assignment): if not user_is_instructor: rslogger.error( f"Attempt to access invisible assignment {assignment_id} by {user.username}" @@ -357,7 +357,7 @@ async def get_assignment_gb( names = {} for ix, row in pt.iterrows(): - if type(row.first_name) is str and type(row.last_name) is str: + if isinstance(row.first_name, str) and isinstance(row.last_name, str): names[row.username] = row.first_name + " " + row.last_name # pt = pt.drop(columns=["username"], axis=1) @@ -430,10 +430,10 @@ async def new_assignment( new_assignment = AssignmentValidator( **request_data.model_dump(), course=course.id, - visible=False, released=True, from_source=False, current_index=0, + updated_date=canonical_utcnow(), ) try: res = await create_assignment(new_assignment) diff --git a/bases/rsptx/assignment_server_api/routers/student.py b/bases/rsptx/assignment_server_api/routers/student.py index efb034a4e..2c8bc328f 100644 --- a/bases/rsptx/assignment_server_api/routers/student.py +++ b/bases/rsptx/assignment_server_api/routers/student.py @@ -46,6 +46,7 @@ from rsptx.grading_helpers.core import check_for_exceptions from rsptx.db.models import GradeValidator, UseinfoValidation, CoursesValidator +from rsptx.db.crud.assignment import is_assignment_visible_to_students from rsptx.auth.session import auth_manager, is_instructor from rsptx.templates import template_folder from rsptx.response_helpers.core import ( @@ -93,7 +94,9 @@ async def get_assignments( # if the user is an instructor, we need to show all assignments assignments = await fetch_assignments(course.course_name, fetch_all=True) else: - assignments = await fetch_assignments(course.course_name) + # Use is_visible=True to apply SQL-level scheduled visibility filtering + # (respects visible_on and hidden_on dates) + assignments = await fetch_assignments(course.course_name, is_visible=True) # fetch all deadline exceptions for the user accommodations = await fetch_deadline_exception( course.id, user.username, fetch_all=True @@ -101,7 +104,18 @@ async def get_assignments( # filter assignments based on deadline exceptions assignment_ids = [a.assignment_id for a in accommodations] if not user_is_instructor: - assignments = [a for a in assignments if a.visible or a.id in assignment_ids] + # Also include assignments the student has deadline exceptions for, + # even if they are not currently visible via scheduled dates + if assignment_ids: + all_assignments = await fetch_assignments( + course.course_name, fetch_all=True + ) + exception_assignments = [ + a + for a in all_assignments + if a.id in assignment_ids and not is_assignment_visible_to_students(a) + ] + assignments = list(assignments) + exception_assignments parsed_js = json.loads(RS_info) if RS_info else {} timezoneoffset = parsed_js.get("tz_offset", None) @@ -128,6 +142,15 @@ def sort_key(assignment): for s in stats_list: stats[s.assignment] = s rslogger.debug(f"stats: {stats}") + + # Build a visibility map for the template. + # For instructors: enables the "Student View: Hide Hidden Assignments" toggle + # For students: ensures scheduled assignments (visible_on/hidden_on) get correct CSS class + # This takes into account visible_on and hidden_on dates, not just the visible flag + visibility_map = {} + for a in assignments: + visibility_map[a.id] = is_assignment_visible_to_students(a) + return templates.TemplateResponse( "assignment/student/chooseAssignment.html", { @@ -140,6 +163,7 @@ def sort_key(assignment): "student_page": True, "lti1p1": is_lti1p1_course, "now": now, + "visibility_map": visibility_map, }, ) @@ -261,11 +285,10 @@ async def doAssignment( deadline_exception = await check_for_exceptions(user, assignment_id) - if ( - assignment.visible == "F" - or assignment.visible is None - or assignment.visible == False # noqa: E712 - ): + # Check if assignment is visible to students based on visible, visible_on, and hidden_on + + if not is_assignment_visible_to_students(assignment): + # Allow access for instructors and students with exceptions if not ( await is_instructor(request) or deadline_exception.visible diff --git a/bases/rsptx/book_server_api/routers/course.py b/bases/rsptx/book_server_api/routers/course.py index 2f2b451ef..73d46fc63 100644 --- a/bases/rsptx/book_server_api/routers/course.py +++ b/bases/rsptx/book_server_api/routers/course.py @@ -37,6 +37,7 @@ from rsptx.logging import rslogger from rsptx.response_helpers.core import canonical_utcnow, make_json_response from rsptx.auth.session import is_instructor +from rsptx.db.crud.assignment import is_assignment_visible_to_students from rsptx.grading_helpers.core import adjust_deadlines @@ -87,7 +88,9 @@ async def index( # if the user is an instructor, we need to show all assignments assignments = await fetch_assignments(course.course_name, fetch_all=True) else: - assignments = await fetch_assignments(course.course_name) + # Use is_visible=True to apply SQL-level scheduled visibility filtering + # (respects visible_on and hidden_on dates) + assignments = await fetch_assignments(course.course_name, is_visible=True) accommodations = await fetch_deadline_exception( course.id, user.username, fetch_all=True @@ -95,7 +98,18 @@ async def index( # filter assignments based on deadline exceptions assignment_ids = [a.assignment_id for a in accommodations] if not user_is_instructor: - assignments = [a for a in assignments if a.visible or a.id in assignment_ids] + # Also include assignments the student has deadline exceptions for, + # even if they are not currently visible via scheduled dates + if assignment_ids: + all_assignments = await fetch_assignments( + course.course_name, fetch_all=True + ) + exception_assignments = [ + a + for a in all_assignments + if a.id in assignment_ids and not is_assignment_visible_to_students(a) + ] + assignments = list(assignments) + exception_assignments assignments = adjust_deadlines(assignments, accommodations) parsed_js = json.loads(RS_info) if RS_info else {} @@ -123,6 +137,14 @@ def sort_key(assignment): for s in stats_list: stats[s.assignment] = s rslogger.debug(f"stats: {stats}") + + # Build a visibility map for the template. + # For instructors: enables the "Student View: Hide Hidden Assignments" toggle + # For students: ensures scheduled assignments (visible_on/hidden_on) get correct CSS class + visibility_map = {} + for a in assignments: + visibility_map[a.id] = is_assignment_visible_to_students(a) + return templates.TemplateResponse( "book/course/current_course.html", { @@ -142,6 +164,7 @@ def sort_key(assignment): "has_discussion_group": any([book.social_url for book in books]), "lti1p1": is_lti1p1_course, "now": now, + "visibility_map": visibility_map, }, ) diff --git a/components/rsptx/db/crud/assignment.py b/components/rsptx/db/crud/assignment.py index 90ee93ac0..c81128397 100644 --- a/components/rsptx/db/crud/assignment.py +++ b/components/rsptx/db/crud/assignment.py @@ -8,6 +8,7 @@ ) from rsptx.data_types.which_to_grade import WhichToGradeOptions from rsptx.data_types.autograde import AutogradeOptions +from rsptx.response_helpers.core import canonical_utcnow import logging from sqlalchemy import select, update, delete, and_, or_, func @@ -35,6 +36,53 @@ rslogger = logging.getLogger(__name__) +def is_assignment_visible_to_students(assignment: Assignment) -> bool: + """ + Check if an assignment is currently visible to students based on visible, visible_on, and hidden_on fields. + + Logic: + - If visible = False and both visible_on and hidden_on are set: scheduled period + - Visible only between visible_on and hidden_on + - If visible = False and only visible_on is set: will become visible at that time + - Visible once visible_on has passed + - If visible = False with no dates: always hidden + - If visible = True: + - If visible_on is set and current time < visible_on, assignment is hidden + - If hidden_on is set and current time >= hidden_on, assignment is hidden + - Otherwise, assignment is visible + + :param assignment: Assignment object + :return: bool, True if assignment should be visible to students + """ + now = canonical_utcnow() + + # Handle scheduled period (both dates set, visible=False) + if not assignment.visible and assignment.visible_on and assignment.hidden_on: + # Assignment is visible only during the specified period + return assignment.visible_on <= now < assignment.hidden_on + + # If visible = False, check other conditions + if not assignment.visible: + # "Visible on" mode: visible_on is set, no hidden_on + # Assignment becomes visible once visible_on passes + if assignment.visible_on: + return now >= assignment.visible_on + # No dates set - always hidden + return False + + # visible = True cases + + # Check if assignment should become visible in the future + if assignment.visible_on and now < assignment.visible_on: + return False + + # Check if assignment should be hidden now + if assignment.hidden_on and now >= assignment.hidden_on: + return False + + return True + + async def fetch_deadline_exception( course_id: int, username: str, assignment_id: int = None, fetch_all: bool = False ) -> DeadlineExceptionValidator: @@ -195,14 +243,47 @@ async def fetch_assignments( """ Fetch all Assignment objects for the given course name. If is_peer is True then only select asssigments for peer isntruction. - If is_visible is True then only fetch visible assignments. + If is_visible is True then only fetch visible assignments (considering visible_on and hidden_on). :param course_name: str, the course name :param is_peer: bool, whether or not the assignment is a peer assignment + :param is_visible: bool, whether to filter by visibility (including scheduled visibility) + :param fetch_all: bool, whether to fetch all assignments regardless of visibility/peer status :return: List[AssignmentValidator], a list of AssignmentValidator objects """ if is_visible: - vclause = Assignment.visible == is_visible + # For students: check visible flag AND scheduled visibility + now = canonical_utcnow() + + # Complex visibility logic: + # 1. Scheduled period: visible=False AND both dates set AND now is between them + # 2. "Visible on" mode: visible=False AND only visible_on set AND visible_on has passed + # 3. Regular visible: visible=True AND (no visible_on OR visible_on passed) AND (no hidden_on OR hidden_on not reached) + vclause = or_( + # Case 1: Scheduled period (visible during specific timeframe) + and_( + Assignment.visible == False, # noqa: E712 + Assignment.visible_on.isnot(None), + Assignment.hidden_on.isnot(None), + Assignment.visible_on <= now, + Assignment.hidden_on > now, + ), + # Case 2: "Visible on" mode (visible once visible_on passes) + and_( + Assignment.visible == False, # noqa: E712 + Assignment.visible_on.isnot(None), + Assignment.hidden_on.is_(None), + Assignment.visible_on <= now, + ), + # Case 3: Regular visible assignment + and_( + Assignment.visible == True, # noqa: E712 + # visible_on check + or_(Assignment.visible_on.is_(None), Assignment.visible_on <= now), + # hidden_on check + or_(Assignment.hidden_on.is_(None), Assignment.hidden_on > now), + ), + ) else: vclause = True @@ -295,6 +376,10 @@ async def update_assignment(assignment: AssignmentValidator, pi_update=False) -> assignment_updates = assignment.dict() if not pi_update: assignment_updates["current_index"] = 0 + + # Always update the updated_date to track last modification + assignment_updates["updated_date"] = canonical_utcnow() + del assignment_updates["id"] stmt = ( @@ -565,6 +650,8 @@ async def update_assignment_exercises( # Step 5: Update points in Assignment assignment.points += points_to_add - points_to_remove + # Update the updated_date to track when exercises were modified + assignment.updated_date = canonical_utcnow() session.add(assignment) # Step 6: Apply changes @@ -622,6 +709,8 @@ async def add_assignment_question( ) ) assignment.points += data.points + # Update the updated_date to track when question was added + assignment.updated_date = canonical_utcnow() await session.commit() @@ -837,6 +926,7 @@ async def duplicate_assignment( name=new_name, description=original_assignment.description, duedate=original_assignment.duedate, + updated_date=canonical_utcnow(), points=original_assignment.points, kind=original_assignment.kind, time_limit=original_assignment.time_limit, diff --git a/components/rsptx/db/crud/scoring.py b/components/rsptx/db/crud/scoring.py index ec56d61a7..1c3b743b6 100644 --- a/components/rsptx/db/crud/scoring.py +++ b/components/rsptx/db/crud/scoring.py @@ -16,6 +16,7 @@ from rsptx.validation import schemas from .crud import EVENT2TABLE from rsptx.logging import rslogger +from .assignment import is_assignment_visible_to_students async def fetch_answers(question_id: str, event: str, course_name: str, username: str): @@ -97,12 +98,15 @@ async def is_assigned( if accommodation and accommodation.duedate: row.Assignment.duedate += datetime.timedelta(days=accommodation.duedate) if course_tz_now <= row.Assignment.duedate.replace(tzinfo=tz): - if row.Assignment.visible: # todo update this when we have a visible by + if is_assignment_visible_to_students(row.Assignment): scoringSpec.assigned = True return scoringSpec else: if not row.Assignment.enforce_due: - if row.Assignment.visible or visible_exception: + if ( + is_assignment_visible_to_students(row.Assignment) + or visible_exception + ): scoringSpec.assigned = True return scoringSpec return schemas.ScoringSpecification() @@ -125,6 +129,33 @@ async def fetch_reading_assignment_spec( tz = ZoneInfo(timezone) course_tz_now = datetime.datetime.now(tz) course_tz_now = course_tz_now.replace(tzinfo=None) + from rsptx.response_helpers.core import canonical_utcnow + + now = canonical_utcnow() + # Visibility clause that respects visible_on and hidden_on scheduling + vclause = or_( + # Case 1: Scheduled period (visible=False, both dates set, now is between them) + and_( + Assignment.visible == False, # noqa: E712 + Assignment.visible_on.isnot(None), + Assignment.hidden_on.isnot(None), + Assignment.visible_on <= now, + Assignment.hidden_on > now, + ), + # Case 2: "Visible on" mode (visible=False, only visible_on set, passed) + and_( + Assignment.visible == False, # noqa: E712 + Assignment.visible_on.isnot(None), + Assignment.hidden_on.is_(None), + Assignment.visible_on <= now, + ), + # Case 3: Regular visible assignment + and_( + Assignment.visible == True, # noqa: E712 + or_(Assignment.visible_on.is_(None), Assignment.visible_on <= now), + or_(Assignment.hidden_on.is_(None), Assignment.hidden_on > now), + ), + ) query = ( select( AssignmentQuestion.activities_required, @@ -142,7 +173,7 @@ async def fetch_reading_assignment_spec( AssignmentQuestion.reading_assignment == True, # noqa: E712 Question.chapter == chapter, Question.subchapter == subchapter, - Assignment.visible == True, # noqa: E712 + vclause, or_( Assignment.duedate > course_tz_now, Assignment.enforce_due == False, # noqa: E712 diff --git a/components/rsptx/db/models.py b/components/rsptx/db/models.py index 84d8729e2..6279f89f1 100644 --- a/components/rsptx/db/models.py +++ b/components/rsptx/db/models.py @@ -640,6 +640,9 @@ class Assignment(Base, IdMixin): released = Column(Web2PyBoolean, nullable=False) description = Column(Text) duedate = Column(DateTime, nullable=False) + updated_date = Column(DateTime, nullable=True) + visible_on = Column(DateTime, nullable=True) + hidden_on = Column(DateTime, nullable=True) visible = Column(Web2PyBoolean, nullable=False) threshold_pct = Column(Float(53)) allow_self_autograde = Column(Web2PyBoolean) diff --git a/components/rsptx/templates/assignment/student/assignment_block.html b/components/rsptx/templates/assignment/student/assignment_block.html index 3738cbc2e..cd706eea7 100644 --- a/components/rsptx/templates/assignment/student/assignment_block.html +++ b/components/rsptx/templates/assignment/student/assignment_block.html @@ -23,11 +23,17 @@

Assignments

{% for assignment in assignment_list %} - {% if assignment.visible %} + {% if visibility_map is defined and assignment.id in visibility_map %} + {% if visibility_map[assignment.id] %} - {% else %} + {% else %} - {% endif %} + {% endif %} + {% elif assignment.visible %} + + {% else %} + + {% endif %} {{assignment.name}} diff --git a/components/rsptx/validation/schemas.py b/components/rsptx/validation/schemas.py index df6aa4952..e1cbabe19 100644 --- a/components/rsptx/validation/schemas.py +++ b/components/rsptx/validation/schemas.py @@ -241,6 +241,9 @@ class AssignmentIncoming(BaseModel): is_timed: Optional[bool] = False is_peer: Optional[bool] = False enforce_due: Optional[bool] = False + visible: Optional[bool] = False + visible_on: Optional[datetime] = None + hidden_on: Optional[datetime] = None class QuestionIncoming(BaseModel): diff --git a/migrations/versions/a1b2c3d4e5f6_add_created_and_visible_dates.py b/migrations/versions/a1b2c3d4e5f6_add_created_and_visible_dates.py new file mode 100644 index 000000000..84a4f60e9 --- /dev/null +++ b/migrations/versions/a1b2c3d4e5f6_add_created_and_visible_dates.py @@ -0,0 +1,46 @@ +"""add created_date and visible_on to assignments + +Revision ID: a1b2c3d4e5f6 +Revises: 9a1c2b3d4e5f +Create Date: 2026-02-10 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, None] = '9a1c2b3d4e5f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add created_date column to assignments table as nullable + # For existing assignments, created_date will be NULL + # For new assignments, it will be set automatically + op.add_column('assignments', + sa.Column('updated_date', sa.DateTime(), nullable=True) + ) + + # Add visible_on column to assignments table as nullable + # This will be used to control when assignment becomes visible to students + op.add_column('assignments', + sa.Column('visible_on', sa.DateTime(), nullable=True) + ) + + # Add hidden_on column to assignments table as nullable + # This will be used to control when assignment becomes hidden from students + op.add_column('assignments', + sa.Column('hidden_on', sa.DateTime(), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column('assignments', 'hidden_on') + op.drop_column('assignments', 'visible_on') + op.drop_column('assignments', 'updated_date') + diff --git a/poetry.lock b/poetry.lock index 50f3d63a7..ac1e53d3e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiofiles" @@ -992,6 +992,18 @@ files = [ [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} +[[package]] +name = "cfgv" +version = "3.5.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, + {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -1680,6 +1692,18 @@ files = [ {file = "diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc"}, ] +[[package]] +name = "distlib" +version = "0.4.0" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, +] + [[package]] name = "distro" version = "1.9.0" @@ -1886,6 +1910,18 @@ files = [ [package.extras] devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] +[[package]] +name = "filelock" +version = "3.24.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d"}, + {file = "filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa"}, +] + [[package]] name = "flake8" version = "7.3.0" @@ -2319,6 +2355,21 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "identify" +version = "2.6.16" +description = "File identification library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0"}, + {file = "identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" version = "3.11" @@ -4055,6 +4106,18 @@ files = [ {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, + {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, +] + [[package]] name = "notebook-shim" version = "0.2.4" @@ -4726,6 +4789,25 @@ files = [ {file = "polling2-0.5.0.tar.gz", hash = "sha256:90b7da82cf7adbb48029724d3546af93f21ab6e592ec37c8c4619aedd010e342"}, ] +[[package]] +name = "pre-commit" +version = "4.5.1" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77"}, + {file = "pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "pretext" version = "2.35.0" @@ -6306,6 +6388,34 @@ tqdm = "^4.66.4" type = "directory" url = "projects/rsmanage" +[[package]] +name = "ruff" +version = "0.15.2" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d"}, + {file = "ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e"}, + {file = "ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a"}, + {file = "ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956"}, + {file = "ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4"}, + {file = "ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de"}, + {file = "ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c"}, + {file = "ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8"}, + {file = "ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f"}, + {file = "ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5"}, + {file = "ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e"}, + {file = "ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342"}, +] + [[package]] name = "runestone" version = "7.11.8" @@ -7573,6 +7683,24 @@ files = [ {file = "vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0"}, ] +[[package]] +name = "virtualenv" +version = "20.39.0" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "virtualenv-20.39.0-py3-none-any.whl", hash = "sha256:44888bba3775990a152ea1f73f8e5f566d49f11bbd1de61d426fd7732770043e"}, + {file = "virtualenv-20.39.0.tar.gz", hash = "sha256:a15f0cebd00d50074fd336a169d53422436a12dfe15149efec7072cfe817df8b"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} +platformdirs = ">=3.9.1,<5" +typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} + [[package]] name = "wcwidth" version = "0.2.14" @@ -7869,4 +7997,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "ef5a8fdbc2bfdd177672a49ee9bee6d4961f0c4e99396e79cb6e166f63ec13e2" +content-hash = "ce0879ba9130bb4e88a4f76998a3b3216394c2de469e6e98e39f099614377d09" diff --git a/pyproject.toml b/pyproject.toml index a4185c9c8..33c99913e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,8 @@ json2xml = "^3.21.0" pytest-asyncio = "^0.24.0" openai = "^1.59.3" javalang = "^0.13.0" +pre-commit = "^4.5.1" +ruff = "^0.15.2"