From 5815b22687548f8b6279bf3978c38301231c7f59 Mon Sep 17 00:00:00 2001 From: andreimarozau Date: Tue, 10 Feb 2026 20:20:26 +0300 Subject: [PATCH 1/6] issue-814 Rename created_date to updated_date in assignments and related components issue-814 Rename created_date to updated_date in assignments and related components issue-814 Implement visibility management for assignments with new dropdown component issue - 814 Add local date handling methods and update DateTimePicker to support local time issue-814 Refactor date handling to use canonical UTC methods and improve date formatting in assignment components issue-814 Add DateTimePicker component and update assignment forms to use it issue-814 Add created_date and visible_on fields to assignments issue-814 Rename created_date to updated_date in assignments and related components --- .../admin_server_api/routers/instructor.py | 1 + .../rsptx/admin_server_api/routers/lti1p3.py | 3 +- .../assignment_builder/package-lock.json | 146 +++++-- .../assignment_builder/package.json | 1 + .../AssignmentBuilder.module.css | 1 - .../AssignmentBuilder/AssignmentBuilder.tsx | 47 ++- .../components/AssignmentBuilderCreate.tsx | 29 +- .../components/edit/AssignmentEdit.tsx | 19 +- .../components/edit/VisibilityControl.tsx | 236 +++++++++++ .../components/list/AssignmentList.tsx | 51 ++- .../components/list/VisibilityDropdown.tsx | 370 ++++++++++++++++++ .../components/list/VisibilityStatusBadge.tsx | 179 +++++++++ .../components/wizard/AssignmentWizard.tsx | 53 ++- .../AssignmentBuilder/defaultAssignment.ts | 6 +- .../hooks/useAssignmentRouting.ts | 6 +- .../DateTimePicker/DateTimePicker.module.css | 197 ++++++++++ .../ui/DateTimePicker/DateTimePicker.tsx | 59 +++ .../src/components/ui/DateTimePicker/index.ts | 1 + .../src/types/assignment.ts | 5 + .../assignment_builder/src/utils/date.ts | 95 ++++- .../routers/instructor.py | 10 +- .../assignment_server_api/routers/student.py | 34 +- bases/rsptx/book_server_api/routers/course.py | 24 +- bases/rsptx/interactives/package-lock.json | 190 ++++++--- components/rsptx/db/crud/assignment.py | 95 ++++- components/rsptx/db/crud/scoring.py | 33 +- components/rsptx/db/models.py | 3 + .../assignment/student/assignment_block.html | 12 +- components/rsptx/validation/schemas.py | 3 + ...2c3d4e5f6_add_created_and_visible_dates.py | 46 +++ 30 files changed, 1756 insertions(+), 199 deletions(-) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/VisibilityControl.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityStatusBadge.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.module.css create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/index.ts create mode 100644 migrations/versions/a1b2c3d4e5f6_add_created_and_visible_dates.py diff --git a/bases/rsptx/admin_server_api/routers/instructor.py b/bases/rsptx/admin_server_api/routers/instructor.py index 8215de8ce..704a04845 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..91f267947 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,7 @@ 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 079638c67..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", @@ -198,7 +199,6 @@ "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", @@ -779,7 +779,6 @@ "integrity": "sha512-B+O2DnPc0iG+YXFqOxv2WNuNU97ToWjOomUQ78DouOENWUaM5sVrmet9mcomUGQFwpJd//gvUagXBSdzO1fRKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -1588,7 +1587,6 @@ "integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-module-imports": "^7.25.9", @@ -2202,7 +2200,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2226,7 +2223,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2248,7 +2244,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2806,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", @@ -3608,7 +3656,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.5.tgz", "integrity": "sha512-jb0KTdUJaJY53JaN7ooY3XAxHQNoMYti/H6ANo707PsLXVeEqJ9o8+eBup1JU5CuwzrgnDc2dECt2WIGX9f8Jw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3691,7 +3738,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.11.5.tgz", "integrity": "sha512-ksxMMvqLDlC+ftcQLynqZMdlJT1iHYZorXsXw/n+wuRd7YElkRkd6YWUX/Pq/njFY6lDjKiqFLEXBJB8nrzzBA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4033,7 +4079,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.11.5.tgz", "integrity": "sha512-YUmYl0gILSd/u/ZkOmNxjNXVw+mu8fpC2f8G4I4tLODm0zCx09j9DDEJXSrM5XX72nxJQqtSQsCpNKnL0hfeEQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4073,7 +4118,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.5.tgz", "integrity": "sha512-z9JFtqc5ZOsdQLd9vRnXfTCQ8v5ADAfRt9Nm7SqP6FUHII8E1hs38ACzf5xursmth/VonJYb5+73Pqxk1hGIPw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.2.1", "prosemirror-collab": "^1.3.1", @@ -4174,7 +4218,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4292,7 +4337,6 @@ "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -4331,7 +4375,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4343,7 +4386,6 @@ "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4382,7 +4424,6 @@ "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", @@ -4579,7 +4620,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -4917,7 +4957,6 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5435,7 +5474,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -5658,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", @@ -5842,8 +5889,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -5920,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", @@ -6061,7 +6117,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -6438,7 +6495,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6530,7 +6586,6 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6573,7 +6628,6 @@ "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -6609,7 +6663,6 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -6915,7 +6968,6 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -8027,7 +8079,6 @@ "resolved": "https://registry.npmjs.org/handsontable/-/handsontable-14.6.1.tgz", "integrity": "sha512-uPvGTkV9dgndwRhSjlJpBzSLW7o7LX18GE70HbaBlcaNKUCEEXhzJ7WGlaw6X8rQZqShIB1HAC+h/t98odqVQQ==", "license": "SEE LICENSE IN LICENSE.txt", - "peer": true, "dependencies": { "@handsontable/pikaday": "^1.0.0", "@types/pikaday": "1.7.4", @@ -9111,7 +9162,6 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -9426,6 +9476,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10178,7 +10229,6 @@ "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10208,6 +10258,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10223,6 +10274,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10423,7 +10475,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.24.1.tgz", "integrity": "sha512-YM053N+vTThzlWJ/AtPtF1j0ebO36nvbmDy4U7qA2XQB8JVaQp1FmB9Jhrps8s+z+uxhhVTny4m20ptUvhk0Mg==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -10453,7 +10504,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -10502,7 +10552,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.38.0.tgz", "integrity": "sha512-O45kxXQTaP9wPdXhp8TKqCR+/unS/gnfg9Q93svQcB3j0mlp2XSPAmsPefxHADwzC+fbNS404jqRxm3UQaGvgw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -10583,7 +10632,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10591,12 +10639,26 @@ "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", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10643,7 +10705,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-property": { "version": "2.0.2", @@ -10656,7 +10719,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -10772,8 +10834,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12177,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", @@ -12574,7 +12641,6 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12830,7 +12896,6 @@ "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13026,7 +13091,6 @@ "integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "2.1.8", "@vitest/mocker": "2.1.8", 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..b5e25e2ea --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/VisibilityControl.tsx @@ -0,0 +1,236 @@ +import { RadioButton } from "primereact/radiobutton"; +import { Control, Controller, UseFormSetValue } from "react-hook-form"; + +import { Assignment } from "@/types/assignment"; +import { convertDateToISO, formatUTCDateLocaleString } 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 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); + // Keep visible_on or set to current date if null + if (!visibleOn) { + setValue("visible_on", convertDateToISO(new Date())); + } + break; + case "scheduled_hidden": + setValue("visible", true); + setValue("visible_on", null); + // Keep hidden_on or set to current date if null + if (!hiddenOn) { + setValue("hidden_on", convertDateToISO(new Date())); + } + break; + case "scheduled_period": + setValue("visible", false); + // Set both dates if not already set + if (!visibleOn) { + const now = new Date(); + setValue("visible_on", convertDateToISO(now)); + } + if (!hiddenOn) { + const twoHoursLater = new Date(); + twoHoursLater.setHours(twoHoursLater.getHours() + 2); + setValue("hidden_on", convertDateToISO(twoHoursLater)); + } + 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" && ( +
+
+ + ( + dateField.onChange(val)} + utc + /> + )} + /> +
+
+ + ( + dateField.onChange(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..fb69a2b86 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 @@ -7,6 +7,9 @@ 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 +21,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 +37,13 @@ export const AssignmentList = ({ onCreateNew, onEdit, onDuplicate, - onVisibilityChange, onReleasedChange, onEnforceDueChange, + onVisibilityChange, onRemove }: AssignmentListProps) => { 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 +102,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 +113,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} @@ -245,6 +257,13 @@ export const AssignmentList = ({ body={dueDateBodyTemplate} className={styles.dueDateColumn} /> + 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..490de464d --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx @@ -0,0 +1,370 @@ +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; + 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); + + if (now < visibleDate) { + return { + text: visibleDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }), + color: "#FFA500", + icon: "pi pi-clock" + }; + } else if (now >= visibleDate && now < hiddenDate) { + return { text: "Visible", color: "#28A745", icon: "pi pi-eye" }; + } else { + return { text: "Hidden", color: "#DC3545", icon: "pi pi-eye-slash" }; + } + } + + 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) { + newVisibleOn = convertDateToISO(new Date()); + } + if (newMode === "scheduled_hidden" && !newHiddenOn) { + newHiddenOn = convertDateToISO(new Date()); + } + if (newMode === "scheduled_period") { + if (!newVisibleOn) { + newVisibleOn = convertDateToISO(new Date()); + } + if (!newHiddenOn) { + const twoHoursLater = new Date(); + twoHoursLater.setHours(twoHoursLater.getHours() + 2); + newHiddenOn = convertDateToISO(twoHoursLater); + } + } + + setMode(newMode); + setVisibleOn(newVisibleOn); + setHiddenOn(newHiddenOn); + + const values = computeValues(newMode, newVisibleOn, newHiddenOn); + onChange(assignment, values); + }; + + const handleVisibleOnChange = (val: string) => { + setVisibleOn(val); + const values = computeValues(mode, val, hiddenOn); + onChange(assignment, values); + }; + + const handleHiddenOnChange = (val: string) => { + setHiddenOn(val); + const values = computeValues(mode, visibleOn, val); + onChange(assignment, values); + }; + + return ( + <> +
+ + {status.text} + +
+ +
+
+ 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..c398e3f3a --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityStatusBadge.tsx @@ -0,0 +1,179 @@ +import { Tooltip } from "primereact/tooltip"; + +import { Assignment } from "@/types/assignment"; +import { parseUTCDate } from "@/utils/date"; + +interface VisibilityStatus { + text: 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); + + if (now < visibleDate) { + // Period hasn't started yet + return { + text: visibleDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }), + color: "#FFA500", + icon: "pi pi-clock", + tooltip: `Visible from ${visibleDate.toLocaleString()} to ${hiddenDate.toLocaleString()}` + }; + } else if (now >= visibleDate && now < hiddenDate) { + // Currently in visible period + return { + text: "Visible", + color: "#28A745", + icon: "pi pi-eye", + tooltip: `Visible until ${hiddenDate.toLocaleString()}` + }; + } else { + // Period has ended + return { + text: "Hidden", + color: "#DC3545", + icon: "pi pi-eye-slash", + tooltip: "" + }; + } + } + + // 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} +
+ + ); +}; 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 e27ae44bf..134ba2fba 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -71,7 +71,7 @@ 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 +157,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}" @@ -430,10 +426,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 aa125651e..56ce51ceb 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,15 @@ 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) @@ -124,6 +135,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", { @@ -136,6 +156,7 @@ def sort_key(assignment): "student_page": True, "lti1p1": is_lti1p1_course, "now": now, + "visibility_map": visibility_map, }, ) @@ -257,11 +278,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 287329d11..31c52c031 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,15 @@ 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 {} @@ -125,6 +136,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", { @@ -144,6 +163,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/bases/rsptx/interactives/package-lock.json b/bases/rsptx/interactives/package-lock.json index 116c84044..3199d1280 100644 --- a/bases/rsptx/interactives/package-lock.json +++ b/bases/rsptx/interactives/package-lock.json @@ -87,7 +87,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz", "integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -2588,7 +2587,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2655,7 +2653,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -2730,6 +2727,7 @@ "deprecated": "This package is no longer supported.", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^2.0.6" @@ -2768,7 +2766,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3072,7 +3069,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -3116,6 +3112,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "peer": true, "engines": { "node": ">=6" } @@ -3160,6 +3157,7 @@ "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "nan": "^2.10.0" }, @@ -3174,6 +3172,7 @@ "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "node-pre-gyp": "^0.10.0" } @@ -3211,7 +3210,8 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/chrome-trace-event": { "version": "1.0.3", @@ -3438,7 +3438,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3511,7 +3510,8 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.3", @@ -3972,6 +3972,7 @@ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=4.0.0" } @@ -3987,6 +3988,7 @@ "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "license": "Apache-2.0", "optional": true, + "peer": true, "bin": { "detect-libc": "bin/detect-libc.js" }, @@ -4468,6 +4470,7 @@ "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "minipass": "^2.6.0" } @@ -4490,6 +4493,7 @@ "deprecated": "This package is no longer supported.", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", @@ -4507,6 +4511,7 @@ "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4517,6 +4522,7 @@ "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ansi-regex": "^2.0.0" }, @@ -4857,6 +4863,7 @@ "integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "minimatch": "^3.0.4" } @@ -4912,7 +4919,8 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/internmap": { "version": "1.0.1", @@ -5020,7 +5028,8 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/isexe": { "version": "2.0.0", @@ -5520,6 +5529,7 @@ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -5530,6 +5540,7 @@ "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -5540,7 +5551,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/minizlib": { "version": "1.3.3", @@ -5548,6 +5560,7 @@ "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "minipass": "^2.9.0" } @@ -5558,6 +5571,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -5593,7 +5607,8 @@ "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/nanoid": { "version": "3.3.4", @@ -5613,6 +5628,7 @@ "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "debug": "^3.2.6", "iconv-lite": "^0.4.4", @@ -5672,6 +5688,7 @@ "deprecated": "Please upgrade to @mapbox/node-pre-gyp: the non-scoped node-pre-gyp package is deprecated and only the @mapbox scoped package will recieve updates in the future", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "detect-libc": "^1.0.2", "mkdirp": "^0.5.1", @@ -5694,6 +5711,7 @@ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver" } @@ -5711,6 +5729,7 @@ "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "abbrev": "1", "osenv": "^0.1.4" @@ -5746,6 +5765,7 @@ "integrity": "sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "npm-normalize-package-bin": "^1.0.1" } @@ -5755,7 +5775,8 @@ "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/npm-packlist": { "version": "1.4.8", @@ -5763,6 +5784,7 @@ "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "ignore-walk": "^3.0.1", "npm-bundled": "^1.0.1", @@ -5795,6 +5817,7 @@ "deprecated": "This package is no longer supported.", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "are-we-there-yet": "~1.1.2", "console-control-strings": "~1.1.0", @@ -5872,6 +5895,7 @@ "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5895,6 +5919,7 @@ "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5906,6 +5931,7 @@ "deprecated": "This package is no longer supported.", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "os-homedir": "^1.0.0", "os-tmpdir": "^1.0.0" @@ -6109,7 +6135,6 @@ "url": "https://tidelift.com/funding/github/npm/postcss" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", @@ -6617,7 +6642,8 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/prr": { "version": "1.0.1", @@ -6679,6 +6705,7 @@ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "optional": true, + "peer": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -6695,6 +6722,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -6710,7 +6738,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/rechoir": { "version": "0.7.1", @@ -6894,6 +6923,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -7189,6 +7219,7 @@ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7281,6 +7312,7 @@ "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "chownr": "^1.1.4", "fs-minipass": "^1.2.7", @@ -7299,7 +7331,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/terser": { "version": "5.37.0", @@ -8113,7 +8146,6 @@ "integrity": "sha512-YJB/ESPUe2Locd0NKXmw72Dx8fZQk1gTzI6rc9TAT4+Sypbnhl8jd8RywB1bDsDF9Dy1RUR7gn3q/ZJTd0OZZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -8195,7 +8227,6 @@ "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -8373,7 +8404,8 @@ "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "peer": true }, "node_modules/yallist": { "version": "4.0.0", @@ -8393,6 +8425,7 @@ "version": "12.0.5", "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "peer": true, "dependencies": { "cliui": "^4.0.0", "decamelize": "^1.2.0", @@ -8412,6 +8445,7 @@ "version": "11.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "peer": true, "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" @@ -8421,6 +8455,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "peer": true, "engines": { "node": ">=4" } @@ -8429,6 +8464,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "peer": true, "dependencies": { "locate-path": "^3.0.0" }, @@ -8440,6 +8476,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "peer": true, "engines": { "node": ">=4" } @@ -8448,6 +8485,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "peer": true, "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" @@ -8460,6 +8498,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "peer": true, "dependencies": { "p-limit": "^2.0.0" }, @@ -8471,6 +8510,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "peer": true, "engines": { "node": ">=4" } @@ -8479,6 +8519,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "peer": true, "dependencies": { "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^4.0.0" @@ -8491,6 +8532,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "peer": true, "dependencies": { "ansi-regex": "^3.0.0" }, @@ -8536,7 +8578,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz", "integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==", "dev": true, - "peer": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -10358,8 +10399,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-import-phases": { "version": "1.0.3", @@ -10402,7 +10442,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -10451,6 +10490,7 @@ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", "optional": true, + "peer": true, "requires": { "delegates": "^1.0.0", "readable-stream": "^2.0.6" @@ -10479,7 +10519,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -10698,7 +10737,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", "dev": true, - "peer": true, "requires": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -10735,7 +10773,8 @@ "camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "peer": true }, "caniuse-api": { "version": "3.0.0", @@ -10760,6 +10799,7 @@ "resolved": "https://registry.npmjs.org/canvas/-/canvas-1.6.13.tgz", "integrity": "sha512-XAfzfEOHZ3JIPjEV+WSI6PpISgUta3dgmndWbsajotz+0TQOX/jDpp2kawjRERatOGv9sMMzk5auB3GKEKA6hg==", "optional": true, + "peer": true, "requires": { "nan": "^2.10.0" } @@ -10769,6 +10809,7 @@ "resolved": "https://registry.npmjs.org/canvas-prebuilt/-/canvas-prebuilt-1.6.11.tgz", "integrity": "sha512-ayBAayYLgFbGBX+cwtOzM4iEQP4XB5DuBbtjgvAwQ66/FMzSR7DhlCqtDZIq9UBbpFCb1QpyDgUNVclHDdBixg==", "optional": true, + "peer": true, "requires": { "node-pre-gyp": "^0.10.0" } @@ -10798,7 +10839,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "optional": true + "optional": true, + "peer": true }, "chrome-trace-event": { "version": "1.0.3", @@ -10971,7 +11013,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11023,7 +11064,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "optional": true + "optional": true, + "peer": true }, "cross-spawn": { "version": "7.0.3", @@ -11381,7 +11423,8 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "optional": true + "optional": true, + "peer": true }, "delegates": { "version": "1.0.0", @@ -11392,7 +11435,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "optional": true + "optional": true, + "peer": true }, "dir-glob": { "version": "3.0.1", @@ -11755,6 +11799,7 @@ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", "optional": true, + "peer": true, "requires": { "minipass": "^2.6.0" } @@ -11775,6 +11820,7 @@ "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", "optional": true, + "peer": true, "requires": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", @@ -11790,13 +11836,15 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "optional": true + "optional": true, + "peer": true }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", "optional": true, + "peer": true, "requires": { "ansi-regex": "^2.0.0" } @@ -12037,6 +12085,7 @@ "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz", "integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==", "optional": true, + "peer": true, "requires": { "minimatch": "^3.0.4" } @@ -12076,7 +12125,8 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "optional": true + "optional": true, + "peer": true }, "internmap": { "version": "1.0.1", @@ -12156,7 +12206,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "optional": true + "optional": true, + "peer": true }, "isexe": { "version": "2.0.0", @@ -12535,13 +12586,15 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "optional": true + "optional": true, + "peer": true }, "minipass": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", "optional": true, + "peer": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -12551,7 +12604,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "optional": true + "optional": true, + "peer": true } } }, @@ -12560,6 +12614,7 @@ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", "optional": true, + "peer": true, "requires": { "minipass": "^2.9.0" } @@ -12569,6 +12624,7 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "optional": true, + "peer": true, "requires": { "minimist": "^1.2.6" } @@ -12594,7 +12650,8 @@ "version": "2.24.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", - "optional": true + "optional": true, + "peer": true }, "nanoid": { "version": "3.3.4", @@ -12607,6 +12664,7 @@ "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", "optional": true, + "peer": true, "requires": { "debug": "^3.2.6", "iconv-lite": "^0.4.4", @@ -12647,6 +12705,7 @@ "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz", "integrity": "sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A==", "optional": true, + "peer": true, "requires": { "detect-libc": "^1.0.2", "mkdirp": "^0.5.1", @@ -12664,7 +12723,8 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "optional": true + "optional": true, + "peer": true } } }, @@ -12679,6 +12739,7 @@ "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", "optional": true, + "peer": true, "requires": { "abbrev": "1", "osenv": "^0.1.4" @@ -12701,6 +12762,7 @@ "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz", "integrity": "sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==", "optional": true, + "peer": true, "requires": { "npm-normalize-package-bin": "^1.0.1" } @@ -12709,13 +12771,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==", - "optional": true + "optional": true, + "peer": true }, "npm-packlist": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz", "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==", "optional": true, + "peer": true, "requires": { "ignore-walk": "^3.0.1", "npm-bundled": "^1.0.1", @@ -12742,6 +12806,7 @@ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "optional": true, + "peer": true, "requires": { "are-we-there-yet": "~1.1.2", "console-control-strings": "~1.1.0", @@ -12802,7 +12867,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==", - "optional": true + "optional": true, + "peer": true }, "os-locale": { "version": "3.1.0", @@ -12818,13 +12884,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "optional": true + "optional": true, + "peer": true }, "osenv": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "optional": true, + "peer": true, "requires": { "os-homedir": "^1.0.0", "os-tmpdir": "^1.0.0" @@ -12969,7 +13037,6 @@ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz", "integrity": "sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==", "dev": true, - "peer": true, "requires": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", @@ -13293,7 +13360,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "optional": true + "optional": true, + "peer": true }, "prr": { "version": "1.0.1", @@ -13337,6 +13405,7 @@ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "optional": true, + "peer": true, "requires": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -13349,6 +13418,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "optional": true, + "peer": true, "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -13363,7 +13433,8 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "optional": true + "optional": true, + "peer": true } } }, @@ -13509,6 +13580,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", "optional": true, + "peer": true, "requires": { "glob": "^7.1.3" } @@ -13723,7 +13795,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "optional": true + "optional": true, + "peer": true }, "stylehacks": { "version": "5.1.0", @@ -13784,6 +13857,7 @@ "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", "optional": true, + "peer": true, "requires": { "chownr": "^1.1.4", "fs-minipass": "^1.2.7", @@ -13798,7 +13872,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "optional": true + "optional": true, + "peer": true } } }, @@ -14485,7 +14560,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.100.1.tgz", "integrity": "sha512-YJB/ESPUe2Locd0NKXmw72Dx8fZQk1gTzI6rc9TAT4+Sypbnhl8jd8RywB1bDsDF9Dy1RUR7gn3q/ZJTd0OZZg==", "dev": true, - "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -14544,7 +14618,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, - "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -14660,7 +14733,8 @@ "y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "peer": true }, "yallist": { "version": "4.0.0", @@ -14677,6 +14751,7 @@ "version": "12.0.5", "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "peer": true, "requires": { "cliui": "^4.0.0", "decamelize": "^1.2.0", @@ -14695,12 +14770,14 @@ "ansi-regex": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", - "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==" + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "peer": true }, "find-up": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "peer": true, "requires": { "locate-path": "^3.0.0" } @@ -14708,12 +14785,14 @@ "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==" + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "peer": true }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "peer": true, "requires": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" @@ -14723,6 +14802,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "peer": true, "requires": { "p-limit": "^2.0.0" } @@ -14730,12 +14810,14 @@ "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==" + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "peer": true }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "peer": true, "requires": { "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^4.0.0" @@ -14745,6 +14827,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "peer": true, "requires": { "ansi-regex": "^3.0.0" } @@ -14755,6 +14838,7 @@ "version": "11.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "peer": true, "requires": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" diff --git a/components/rsptx/db/crud/assignment.py b/components/rsptx/db/crud/assignment.py index a1f083509..23c8e2dbe 100644 --- a/components/rsptx/db/crud/assignment.py +++ b/components/rsptx/db/crud/assignment.py @@ -1,4 +1,5 @@ from typing import Optional, List +from datetime import datetime from fastapi import HTTPException, status from asyncpg.exceptions import UniqueViolationError from rsptx.validation import schemas @@ -8,6 +9,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 +37,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 +244,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 +377,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 +651,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 +710,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 +927,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..64a2ddfb8 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,12 @@ 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 +126,32 @@ 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 +169,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') + From 70ff1e55dce3cc066f8cb7c4012b2e8872f09f81 Mon Sep 17 00:00:00 2001 From: andreimarozau Date: Tue, 24 Feb 2026 19:11:08 +0300 Subject: [PATCH 2/6] issue-1145 Implement sorting functionality in AssignmentList with localStorage persistence --- .../components/list/AssignmentList.tsx | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) 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 fb69a2b86..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,8 +1,10 @@ +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"; @@ -42,6 +44,28 @@ export const AssignmentList = ({ 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) => ( ); @@ -233,8 +257,9 @@ export const AssignmentList = ({ name: { value: globalFilter, matchMode: "contains" } }} sortMode="single" - sortField="name" - sortOrder={1} + sortField={sortField} + sortOrder={sortOrder} + onSort={handleSort} > Date: Wed, 25 Feb 2026 09:56:15 +0300 Subject: [PATCH 3/6] issue-814 fix formatting for python files --- .../admin_server_api/routers/instructor.py | 2 +- .../rsptx/admin_server_api/routers/lti1p3.py | 5 ++++- .../routers/instructor.py | 12 +++++++---- .../assignment_server_api/routers/student.py | 21 ++++++++++++------- bases/rsptx/book_server_api/routers/course.py | 11 +++++----- bases/rsptx/rsmanage/core.py | 8 +++++-- components/rsptx/build_tools/build.py | 9 ++++---- components/rsptx/build_tools/core.py | 13 ++++++++---- components/rsptx/db/crud/assignment.py | 6 +++--- components/rsptx/db/crud/rsfiles.py | 11 +++++----- components/rsptx/db/crud/scoring.py | 6 +++++- 11 files changed, 67 insertions(+), 37 deletions(-) diff --git a/bases/rsptx/admin_server_api/routers/instructor.py b/bases/rsptx/admin_server_api/routers/instructor.py index 704a04845..6e6959933 100644 --- a/bases/rsptx/admin_server_api/routers/instructor.py +++ b/bases/rsptx/admin_server_api/routers/instructor.py @@ -1257,7 +1257,7 @@ async def post_create_course_page( # if invoice is true then we need to create an invoice for the course if invoice == "true": res = await create_invoice_request( - user.username, projectname, 0.0, user.email + user.username, projectname, 0.0, user.email ) # Copy attributes from base course bc = await fetch_course(coursetype) diff --git a/bases/rsptx/admin_server_api/routers/lti1p3.py b/bases/rsptx/admin_server_api/routers/lti1p3.py index 91f267947..802b16627 100644 --- a/bases/rsptx/admin_server_api/routers/lti1p3.py +++ b/bases/rsptx/admin_server_api/routers/lti1p3.py @@ -446,7 +446,10 @@ async def launch(request: Request): status_code=400, detail=f"Assignment {lineitem_assign_id} not found" ) - if not is_assignment_visible_to_students(rs_assign) 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/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py index 134ba2fba..a832fcfea 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, is_assignment_visible_to_students +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 @@ -1218,9 +1222,9 @@ async def do_download_assignment( csv_buffer, media_type="text/csv", ) - response.headers["Content-Disposition"] = ( - f"attachment; filename=assignment_{assignment_id}.csv" - ) + response.headers[ + "Content-Disposition" + ] = f"attachment; filename=assignment_{assignment_id}.csv" return response diff --git a/bases/rsptx/assignment_server_api/routers/student.py b/bases/rsptx/assignment_server_api/routers/student.py index 56ce51ceb..2c8bc328f 100644 --- a/bases/rsptx/assignment_server_api/routers/student.py +++ b/bases/rsptx/assignment_server_api/routers/student.py @@ -107,9 +107,12 @@ async def get_assignments( # 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) + all_assignments = await fetch_assignments( + course.course_name, fetch_all=True + ) exception_assignments = [ - a for a in all_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 @@ -121,15 +124,19 @@ def sort_key(assignment): deadline = assignment.duedate if timezoneoffset: deadline = deadline + datetime.timedelta(hours=float(timezoneoffset)) - return (deadline < canonical_utcnow(), abs((deadline - canonical_utcnow()).total_seconds())) + return ( + deadline < canonical_utcnow(), + abs((deadline - canonical_utcnow()).total_seconds()), + ) else: - return (assignment.duedate < canonical_utcnow(), abs((assignment.duedate - canonical_utcnow()).total_seconds())) + return ( + assignment.duedate < canonical_utcnow(), + abs((assignment.duedate - canonical_utcnow()).total_seconds()), + ) # Sort assignments: upcoming assignments first (closest to current date), past due assignments last now = canonical_utcnow() - assignments.sort( - key=sort_key - ) + assignments.sort(key=sort_key) stats_list = await fetch_all_assignment_stats(course.course_name, user.id) stats = {} for s in stats_list: diff --git a/bases/rsptx/book_server_api/routers/course.py b/bases/rsptx/book_server_api/routers/course.py index 31c52c031..73d46fc63 100644 --- a/bases/rsptx/book_server_api/routers/course.py +++ b/bases/rsptx/book_server_api/routers/course.py @@ -101,9 +101,12 @@ async def index( # 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) + all_assignments = await fetch_assignments( + course.course_name, fetch_all=True + ) exception_assignments = [ - a for a in all_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 @@ -127,9 +130,7 @@ def sort_key(assignment): ) now = canonical_utcnow() - assignments.sort( - key=sort_key - ) + assignments.sort(key=sort_key) stats_list = await fetch_all_assignment_stats(course_name, user.id) stats = {} diff --git a/bases/rsptx/rsmanage/core.py b/bases/rsptx/rsmanage/core.py index bef971066..916734cd5 100644 --- a/bases/rsptx/rsmanage/core.py +++ b/bases/rsptx/rsmanage/core.py @@ -312,7 +312,9 @@ async def addcourse( basecourse = click.prompt("Base Course: ") bookrec = await fetch_library_book(basecourse) if not bookrec: - click.echo(f"{basecourse} Not Found: Please add the book first using `addbookauthor") + click.echo( + f"{basecourse} Not Found: Please add the book first using `addbookauthor" + ) exit(1) if bookrec and bookrec.build_system is None: click.echo( @@ -955,7 +957,9 @@ def grade(config, course, pset, enforce): ) @click.option("--sample_size", help="Number of courses to sample", default=0) @click.option("--course_list", help="List of courses to sample", default=None) -@click.option("--preserve_user_ids", is_flag=True, help="Preserve user ids in the datashop export") +@click.option( + "--preserve_user_ids", is_flag=True, help="Preserve user ids in the datashop export" +) @pass_config async def datashop(config, basecourse, sample_size, course_list, preserve_user_ids): """Export the course data to the datashop format""" diff --git a/components/rsptx/build_tools/build.py b/components/rsptx/build_tools/build.py index 4e16bacd6..fb6190676 100755 --- a/components/rsptx/build_tools/build.py +++ b/components/rsptx/build_tools/build.py @@ -431,9 +431,9 @@ def wheel(config): res = subprocess.run(lock_opts, capture_output=True) if res.returncode != 0: - status[proj] = ( - f"[red]Fail[/red] probable dependency conflict see {projdir}/build.log" - ) + status[ + proj + ] = f"[red]Fail[/red] probable dependency conflict see {projdir}/build.log" lt.update(generate_wheel_table(status)) if config.verbose: console.print( @@ -894,7 +894,8 @@ def dev(ctx, config): ctx.invoke(image) ctx.invoke(restart) -# This command should be used when you pull new code from github and want to rebuild and make + +# This command should be used when you pull new code from github and want to rebuild and make # sure you are using the latest database schema. Many people forget to run migrations # after pulling new code. @cli.command() diff --git a/components/rsptx/build_tools/core.py b/components/rsptx/build_tools/core.py index 8dc2a8879..a5d87f1db 100644 --- a/components/rsptx/build_tools/core.py +++ b/components/rsptx/build_tools/core.py @@ -191,7 +191,7 @@ def _build_ptx_book(config, gen, manifest, course, click=click, target="runeston if rs.output_dir_abspath().exists(): shutil.rmtree(rs.output_dir_abspath()) - + click.echo("Building the book") if gen: click.echo("Generating assets") @@ -341,7 +341,7 @@ def check_project_ptx(click=click, course=None, target="runestone"): tgt.output_dir = Path(docid) tgt.stringparams.update({"host-platform": "runestone"}) - + return tgt @@ -705,7 +705,10 @@ def _process_single_chapter(sess, db_context, chapter, chap_num, course_name): res = sess.execute(ins) return res.inserted_primary_key[0] + import pdb + + def _process_subchapters(sess, db_context, chapter, chapid, course_name): """Process all subchapters for a given chapter.""" subchap = 0 @@ -721,8 +724,10 @@ def _process_subchapters(sess, db_context, chapter, chapid, course_name): # at this point (7/28/2025) the only reason for a subsubchapter # is to have a timed assignment, so we can skip the rest of the # find all divs with a class of timedAssessment - #pdb.set_trace() - for timed_assessment_div in subchapter.findall(".//div[@class='timedAssessment']"): + # pdb.set_trace() + for timed_assessment_div in subchapter.findall( + ".//div[@class='timedAssessment']" + ): _process_single_timed_assignment( sess, db_context, diff --git a/components/rsptx/db/crud/assignment.py b/components/rsptx/db/crud/assignment.py index 23c8e2dbe..242ccb890 100644 --- a/components/rsptx/db/crud/assignment.py +++ b/components/rsptx/db/crud/assignment.py @@ -267,7 +267,7 @@ async def fetch_assignments( Assignment.visible_on.isnot(None), Assignment.hidden_on.isnot(None), Assignment.visible_on <= now, - Assignment.hidden_on > now + Assignment.hidden_on > now, ), # Case 2: "Visible on" mode (visible once visible_on passes) and_( @@ -282,8 +282,8 @@ async def fetch_assignments( # 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) - ) + or_(Assignment.hidden_on.is_(None), Assignment.hidden_on > now), + ), ) else: vclause = True diff --git a/components/rsptx/db/crud/rsfiles.py b/components/rsptx/db/crud/rsfiles.py index d8e57faea..8465407e4 100644 --- a/components/rsptx/db/crud/rsfiles.py +++ b/components/rsptx/db/crud/rsfiles.py @@ -9,7 +9,9 @@ # 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, owner: str = None): +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 """ @@ -40,7 +42,9 @@ def update_source_code_sync(acid: str, filename: str, course_id: str, main_code: session.commit() -async def update_source_code(acid: str, filename: str, course_id: str, main_code: str, owner: str = None): +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 """ @@ -253,6 +257,3 @@ async def delete_datafile(acid: str, course_id: str) -> bool: await session.delete(source_code_obj) await session.commit() return True - - - diff --git a/components/rsptx/db/crud/scoring.py b/components/rsptx/db/crud/scoring.py index 64a2ddfb8..1c3b743b6 100644 --- a/components/rsptx/db/crud/scoring.py +++ b/components/rsptx/db/crud/scoring.py @@ -103,7 +103,10 @@ async def is_assigned( return scoringSpec else: if not row.Assignment.enforce_due: - if is_assignment_visible_to_students(row.Assignment) or visible_exception: + if ( + is_assignment_visible_to_students(row.Assignment) + or visible_exception + ): scoringSpec.assigned = True return scoringSpec return schemas.ScoringSpecification() @@ -127,6 +130,7 @@ async def fetch_reading_assignment_spec( 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_( From f93442c1de729c41c155fd6db63c6dc72f913bec Mon Sep 17 00:00:00 2001 From: andreimarozau Date: Wed, 25 Feb 2026 10:01:01 +0300 Subject: [PATCH 4/6] issue-814 fix formatting for python files --- components/rsptx/build_tools/core.py | 2 -- .../rsptx/lti1p3/pylti1p3/tool_config/abstract.py | 12 ++++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/components/rsptx/build_tools/core.py b/components/rsptx/build_tools/core.py index cb4492c35..affa9a788 100644 --- a/components/rsptx/build_tools/core.py +++ b/components/rsptx/build_tools/core.py @@ -706,8 +706,6 @@ def _process_single_chapter(sess, db_context, chapter, chap_num, course_name): return res.inserted_primary_key[0] - - def _process_subchapters(sess, db_context, chapter, chapid, course_name): """Process all subchapters for a given chapter.""" subchap = 0 diff --git a/components/rsptx/lti1p3/pylti1p3/tool_config/abstract.py b/components/rsptx/lti1p3/pylti1p3/tool_config/abstract.py index dd421c192..432933de7 100644 --- a/components/rsptx/lti1p3/pylti1p3/tool_config/abstract.py +++ b/components/rsptx/lti1p3/pylti1p3/tool_config/abstract.py @@ -39,14 +39,14 @@ def check_iss_has_many_clients(self, iss: str) -> bool: return iss_type == IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER def set_iss_has_one_client(self, iss: str): - self.issuers_relation_types[iss] = ( - IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER - ) + self.issuers_relation_types[ + iss + ] = IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER def set_iss_has_many_clients(self, iss: str): - self.issuers_relation_types[iss] = ( - IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER - ) + self.issuers_relation_types[ + iss + ] = IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER async def find_registration(self, iss: str, *args, **kwargs) -> Registration: """ From 65e2bca324b3e5b6b5aa3824aea62bd746f1150f Mon Sep 17 00:00:00 2001 From: andreimarozau Date: Wed, 25 Feb 2026 10:27:00 +0300 Subject: [PATCH 5/6] Add pre-commit configuration and update dependencies in pyproject.toml --- .pre-commit-config.yaml | 14 ++ .../routers/instructor.py | 8 +- components/rsptx/build_tools/build.py | 6 +- components/rsptx/db/crud/assignment.py | 1 - .../lti1p3/pylti1p3/tool_config/abstract.py | 12 +- poetry.lock | 132 +++++++++++++++++- pyproject.toml | 2 + 7 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 .pre-commit-config.yaml 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/assignment_server_api/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py index a2eaede66..5a7bb62f6 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -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) @@ -1222,9 +1222,9 @@ async def do_download_assignment( csv_buffer, media_type="text/csv", ) - response.headers[ - "Content-Disposition" - ] = f"attachment; filename=assignment_{assignment_id}.csv" + response.headers["Content-Disposition"] = ( + f"attachment; filename=assignment_{assignment_id}.csv" + ) return response diff --git a/components/rsptx/build_tools/build.py b/components/rsptx/build_tools/build.py index fb6190676..4359885a6 100755 --- a/components/rsptx/build_tools/build.py +++ b/components/rsptx/build_tools/build.py @@ -431,9 +431,9 @@ def wheel(config): res = subprocess.run(lock_opts, capture_output=True) if res.returncode != 0: - status[ - proj - ] = f"[red]Fail[/red] probable dependency conflict see {projdir}/build.log" + status[proj] = ( + f"[red]Fail[/red] probable dependency conflict see {projdir}/build.log" + ) lt.update(generate_wheel_table(status)) if config.verbose: console.print( diff --git a/components/rsptx/db/crud/assignment.py b/components/rsptx/db/crud/assignment.py index 9505ee142..c81128397 100644 --- a/components/rsptx/db/crud/assignment.py +++ b/components/rsptx/db/crud/assignment.py @@ -1,5 +1,4 @@ from typing import Optional, List -from datetime import datetime from fastapi import HTTPException, status from asyncpg.exceptions import UniqueViolationError from rsptx.validation import schemas diff --git a/components/rsptx/lti1p3/pylti1p3/tool_config/abstract.py b/components/rsptx/lti1p3/pylti1p3/tool_config/abstract.py index 432933de7..dd421c192 100644 --- a/components/rsptx/lti1p3/pylti1p3/tool_config/abstract.py +++ b/components/rsptx/lti1p3/pylti1p3/tool_config/abstract.py @@ -39,14 +39,14 @@ def check_iss_has_many_clients(self, iss: str) -> bool: return iss_type == IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER def set_iss_has_one_client(self, iss: str): - self.issuers_relation_types[ - iss - ] = IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER + self.issuers_relation_types[iss] = ( + IssuerToClientRelation.ONE_CLIENT_ID_PER_ISSUER + ) def set_iss_has_many_clients(self, iss: str): - self.issuers_relation_types[ - iss - ] = IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER + self.issuers_relation_types[iss] = ( + IssuerToClientRelation.MANY_CLIENTS_IDS_PER_ISSUER + ) async def find_registration(self, iss: str, *args, **kwargs) -> Registration: """ 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" From b2e38cd96900c12b3d875727d32150ee7c853382 Mon Sep 17 00:00:00 2001 From: andreimarozau Date: Thu, 26 Feb 2026 09:47:44 +0300 Subject: [PATCH 6/6] issue-814 Enhance visibility control logic and UI to support dual date display --- .../components/edit/VisibilityControl.tsx | 53 +++++++-- .../components/list/VisibilityDropdown.tsx | 103 ++++++++++++++---- .../components/list/VisibilityStatusBadge.tsx | 43 +++++--- 3 files changed, 146 insertions(+), 53 deletions(-) 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 index b5e25e2ea..b1369112b 100644 --- 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 @@ -2,7 +2,7 @@ import { RadioButton } from "primereact/radiobutton"; import { Control, Controller, UseFormSetValue } from "react-hook-form"; import { Assignment } from "@/types/assignment"; -import { convertDateToISO, formatUTCDateLocaleString } from "@/utils/date"; +import { convertDateToISO, formatUTCDateLocaleString, parseUTCDate } from "@/utils/date"; import { DateTimePicker } from "../../../../ui/DateTimePicker"; @@ -47,6 +47,32 @@ export const VisibilityControl = ({ control, watch, setValue }: VisibilityContro 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": @@ -62,30 +88,33 @@ export const VisibilityControl = ({ control, watch, setValue }: VisibilityContro case "scheduled_visible": setValue("visible", false); setValue("hidden_on", null); - // Keep visible_on or set to current date if null if (!visibleOn) { - setValue("visible_on", convertDateToISO(new Date())); + 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); - // Keep hidden_on or set to current date if null if (!hiddenOn) { - setValue("hidden_on", convertDateToISO(new Date())); + 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 now = new Date(); - setValue("visible_on", convertDateToISO(now)); + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + setValue("visible_on", convertDateToISO(startOfDay)); } if (!hiddenOn) { - const twoHoursLater = new Date(); - twoHoursLater.setHours(twoHoursLater.getHours() + 2); - setValue("hidden_on", convertDateToISO(twoHoursLater)); + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 0, 0); + setValue("hidden_on", convertDateToISO(endOfDay)); } break; } @@ -192,7 +221,7 @@ export const VisibilityControl = ({ control, watch, setValue }: VisibilityContro render={({ field: dateField }) => ( dateField.onChange(val)} + onChange={(val) => handleVisibleOnChange(val)} utc /> )} @@ -206,7 +235,7 @@ export const VisibilityControl = ({ control, watch, setValue }: VisibilityContro render={({ field: dateField }) => ( dateField.onChange(val)} + onChange={(val) => handleHiddenOnChange(val)} utc /> )} 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 index 490de464d..5b17c4370 100644 --- 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 @@ -16,6 +16,7 @@ type VisibilityMode = interface VisibilityStatus { text: string; + secondLine?: string; color: string; icon: string; } @@ -49,21 +50,35 @@ const getVisibilityStatus = (assignment: Assignment): VisibilityStatus => { 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: visibleDate.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit" - }), + text: fmt(visibleDate), + secondLine: fmt(hiddenDate), color: "#FFA500", icon: "pi pi-clock" }; } else if (now >= visibleDate && now < hiddenDate) { - return { text: "Visible", color: "#28A745", icon: "pi pi-eye" }; + return { + text: fmt(visibleDate), + secondLine: fmt(hiddenDate), + color: "#28A745", + icon: "pi pi-calendar" + }; } else { - return { text: "Hidden", color: "#DC3545", icon: "pi pi-eye-slash" }; + return { + text: fmt(visibleDate), + secondLine: fmt(hiddenDate), + color: "#DC3545", + icon: "pi pi-calendar-times" + }; } } @@ -165,19 +180,25 @@ export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownP let newHiddenOn = hiddenOn; if (newMode === "scheduled_visible" && !newVisibleOn) { - newVisibleOn = convertDateToISO(new Date()); + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + newVisibleOn = convertDateToISO(startOfDay); } if (newMode === "scheduled_hidden" && !newHiddenOn) { - newHiddenOn = convertDateToISO(new Date()); + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 0, 0); + newHiddenOn = convertDateToISO(endOfDay); } if (newMode === "scheduled_period") { if (!newVisibleOn) { - newVisibleOn = convertDateToISO(new Date()); + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + newVisibleOn = convertDateToISO(startOfDay); } if (!newHiddenOn) { - const twoHoursLater = new Date(); - twoHoursLater.setHours(twoHoursLater.getHours() + 2); - newHiddenOn = convertDateToISO(twoHoursLater); + const endOfDay = new Date(); + endOfDay.setHours(23, 59, 0, 0); + newHiddenOn = convertDateToISO(endOfDay); } } @@ -189,15 +210,37 @@ export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownP 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, hiddenOn); + 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, visibleOn, val); + const values = computeValues(mode, newVisibleOn, val); onChange(assignment, values); }; @@ -211,18 +254,32 @@ export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownP fontWeight: 500, cursor: "pointer", display: "flex", + flexDirection: status.secondLine ? "column" : "row", alignItems: "center", justifyContent: "center", - gap: "4px" + gap: status.secondLine ? "0px" : "4px" }} title="Click to change visibility" > - - {status.text} - +
+ + {status.text} + {!status.secondLine && ( + + )} +
+ {status.secondLine && ( +
+ {status.secondLine} + +
+ )} { 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) { - // Period hasn't started yet return { - text: visibleDate.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit" - }), + 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) { - // Currently in visible period return { - text: "Visible", + text: formatShort(visibleDate), + secondLine: formatShort(hiddenDate), color: "#28A745", - icon: "pi pi-eye", + icon: "pi pi-calendar", tooltip: `Visible until ${hiddenDate.toLocaleString()}` }; } else { - // Period has ended return { - text: "Hidden", + text: formatShort(visibleDate), + secondLine: formatShort(hiddenDate), color: "#DC3545", - icon: "pi pi-eye-slash", - tooltip: "" + icon: "pi pi-calendar-times", + tooltip: `Period ended on ${hiddenDate.toLocaleString()}` }; } } @@ -157,8 +161,8 @@ export const VisibilityStatusBadge = ({ assignment }: VisibilityStatusBadgeProps
- - {status.text} +
+ + {status.text} +
+ {status.secondLine && {status.secondLine}}
);