Skip to content

Assignment import: any shared course - #1356

Open
oscarlevin wants to merge 2 commits into
RunestoneInteractive:mainfrom
oscarlevin:assignment-import
Open

Assignment import: any shared course#1356
oscarlevin wants to merge 2 commits into
RunestoneInteractive:mainfrom
oscarlevin:assignment-import

Conversation

@oscarlevin

Copy link
Copy Markdown
Collaborator

This implements a broader assignment copy/import feature. Uses all the old mechanisms for copying assignments, but allows any instructor to mark their assignments as public for other instructors to import (even if they use a different base course).

Added a import selector from the "Create Assignment" page. You can filter by just your own courses (which you can always import from) or just courses that use the same book.

Similar features are available from the Copy Assignments page.

Happy to tweak if you have suggestions.

BTW, Claude did most of the work here, with some heavy steering and a few manual fixes.

@oscarlevin
oscarlevin requested a review from bnmnetp as a code owner August 10, 2026 14:33
Copilot AI lite review requested due to automatic review settings August 10, 2026 14:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements cross-course assignment sharing and import so instructors can discover assignments from other courses (including different base courses) when the owning course has opted in, preview them, and import individually or in bulk. This extends existing copy mechanisms while preserving question linking (no question duplication) and handling cross-book reading omissions.

Changes:

  • Added course-level sharing controls (share_assignments) and an instructor-provided sharing_description, plus updated admin Copy Assignments UI to browse shareable courses.
  • Implemented CRUD + API support for searching shareable assignments/courses, previewing, importing single assignments, and bulk importing a course’s assignments (including due-date shifting and name de-duplication).
  • Added DB support for import tracking (imported_from_assignment_id) and expanded frontend (React assignment builder) with an import modal and supporting RTK query endpoints/types/tests.

Reviewed changes

Copilot reviewed 33 out of 34 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
test/components/rsptx/db/test_assignment_sharing.py Adds CRUD-level coverage for visibility/search/preview/import/bulk-import behaviors and edge cases (DST, cross-book readings, already-imported marking).
test/bases/rsptx/assignment_server_api/test_instructor_routes.py Adds assignment-server route tests for search/tree/preview/import, plus guards on delete/duplicate for foreign assignments.
test/bases/rsptx/admin_server_api/test_copy_assignments_routes.py Adds admin-server route tests for the revamped Copy Assignments page behavior and authorization/visibility.
test/bases/rsptx/admin_server_api/test_copy_assignment_dates.py Moves due-date shifting tests to the shared CRUD helper used by imports/copying.
test/bases/rsptx/admin_server_api/conftest.py Adds an instructor fixture/client for admin-server instructor routes.
migrations/versions/f3b8d5c2a710_add_imported_from_to_assignments.py Adds imported_from_assignment_id with FK (SET NULL) and index for “already imported” detection.
migrations/versions/e7a3c9d1f4b2_add_is_private_to_assignments.py Adds is_private column to assignments (default private) used to keep imported copies from being re-shared implicitly.
components/rsptx/validation/schemas.py Introduces AssignmentsSearchRequest for assignment browsing/search/pagination/sorting/filters.
components/rsptx/templates/staticAssets/js/admin/copy_assignments.js Reworks Copy Assignments page JS to fetch/search shareable courses and copy via shared import endpoints.
components/rsptx/templates/staticAssets/css/admin.css Adds styling for the new course/assignment picker UI in Copy Assignments.
components/rsptx/templates/admin/instructor/menu.html Updates Copy Assignments menu description to reflect shared-course copying.
components/rsptx/templates/admin/instructor/course_settings.html Adds course-level “share assignments” toggle and sharing description textarea.
components/rsptx/templates/admin/instructor/copy_assignments.html Updates Copy Assignments page markup to a searchable course list + assignment picker flow.
components/rsptx/db/models.py Adds Assignment.is_private, imported_from_assignment_id, and an index to support import tracking.
components/rsptx/db/crud/assignment.py Implements term-start UTC math, due-date shifting, shareable search/tree, preview, import, and bulk-import logic.
components/rsptx/db/crud/init.py Exposes new assignment sharing/import CRUD functions via package exports.
bases/rsptx/interactives/package.json Adds allowScripts entries alongside existing dependencies.
bases/rsptx/interactives/package-lock.json Updates lockfile to reflect dependency graph changes for interactives.
bases/rsptx/assignment_server_api/routers/instructor.py Adds assignment search/preview/import/tree endpoints and tightens delete/duplicate scoping to current course.
bases/rsptx/assignment_server_api/assignment_builder/src/types/assignmentSharing.ts Adds frontend types for shareable tree, preview payload, and import results.
bases/rsptx/assignment_server_api/assignment_builder/src/types/assignment.ts Extends Assignment type to include is_private.
bases/rsptx/assignment_server_api/assignment_builder/src/store/assignment/assignment.logic.api.ts Adds RTK Query endpoints/mutations for shareable tree, preview, and imports + toast copy updates.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderDemoData.ts Updates demo assignment data to include is_private.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx Adds Import button hook to open the import modal.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.spec.tsx Updates list tests for is_private and adds Import button callback coverage.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/importAssignment/ImportPreviewPanel.tsx Adds modal preview panel for shared assignment metadata and question list.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/importAssignment/ImportAssignmentModal.tsx Adds tree-based browser for shareable courses/assignments with selection + bulk/single import flow.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/importAssignment/ImportAssignmentModal.spec.tsx Adds unit tests for tree building, selection behavior, preview, and import strategy (bulk vs single).
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/importAssignment/ImportAssignmentModal.module.css Adds styling for the import modal and preview question list.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.spec.ts Minor whitespace-only change.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx Wires import modal visibility state into AssignmentBuilder page.
bases/rsptx/admin_server_api/routers/instructor.py Refactors Copy Assignments routes to use shared CRUD import/search logic; adds shareable courses/source assignments endpoints; surfaces course sharing attrs.
assignment-sharing-plan.md Adds a plan document for the feature (currently inconsistent with implemented behavior).
.env.bak Adds an environment template file that currently contains secret-like values.
Files not reviewed (1)
  • bases/rsptx/interactives/package-lock.json: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .env.bak Outdated
Comment on lines +68 to +72
# This replaces the private/auth.key file for web2py
WEB2PY_PRIVATE_KEY = sha512:24c4e0f1-df85-44cf-87b9-67fc714f5653
# This is the secret key for the javascript web token
JWT_SECRET = supersecret

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove the .env.bak from this PR

Comment thread .env.bak Outdated
Comment on lines +90 to +92
# The FERNET_SECRET is used for API Token storage in postgresql
# in Python use from cryptography import Fernet; Fernet.generate_key()
FERNET_SECRET = hRjuISXQoWd2kQBT8g-2LkmLw4jdv8NCGXUE_GfFZ1w=
Comment thread assignment-sharing-plan.md Outdated
Comment on lines +669 to +674
# When False this assignment is discoverable and importable by instructors
# in other courses. Nullable with no Python-side default, matching
# ``Question.is_private``: the sharing predicate tests ``== False``, which
# in SQL does not match NULL, so an unset value stays private. Sharing is
# only ever an explicit act.
is_private = Column(Web2PyBoolean)
Comment on lines +3 to +6
Marks whether an assignment may be discovered and imported by instructors
outside the course that owns it. Existing assignments were authored with no
expectation of being visible elsewhere, so they all become private and sharing
is only ever an explicit act.
Copilot AI review requested due to automatic review settings August 10, 2026 17:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 34 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • bases/rsptx/interactives/package-lock.json: Generated file
Suppressed comments (5)

.env.bak:72

  • This PR adds .env.bak to the repo, but it contains credential-like values (e.g., WEB2PY_PRIVATE_KEY, JWT_SECRET, FERNET_SECRET). This file should not be committed; it should be removed from the PR (and ideally ignored), especially since init_runestone.sh already treats .env.bak as a transient artifact.
## !! change these if running a real server !!
# This replaces the private/auth.key file for web2py
WEB2PY_PRIVATE_KEY = sha512:24c4e0f1-df85-44cf-87b9-67fc714f5653
# This is the secret key for the javascript web token
JWT_SECRET = supersecret

components/rsptx/db/models.py:674

  • The comment on Assignment.is_private says it controls cross-course discoverability/importability, but the sharing/visibility predicate in this PR is course-level (share_assignments) and does not filter on Assignment.is_private. This doc comment should be updated so future readers don’t misinterpret the flag’s effect.
    # When False this assignment is discoverable and importable by instructors
    # in other courses. Nullable with no Python-side default, matching
    # ``Question.is_private``: the sharing predicate tests ``== False``, which
    # in SQL does not match NULL, so an unset value stays private. Sharing is
    # only ever an explicit act.
    is_private = Column(Web2PyBoolean)

migrations/versions/e7a3c9d1f4b2_add_is_private_to_assignments.py:6

  • This migration’s docstring says is_private controls whether an assignment may be discovered/imported cross-course, but the current implementation in this PR makes visibility depend on the course-level share_assignments attribute instead. The docstring should be corrected to match the implemented behavior so the migration history doesn’t mislead future maintenance.
"""add is_private to assignments

Marks whether an assignment may be discovered and imported by instructors
outside the course that owns it. Existing assignments were authored with no
expectation of being visible elsewhere, so they all become private and sharing
is only ever an explicit act.

assignment-sharing-plan.md:16

  • This plan document says the sharing granularity is per-assignment, but the implemented feature (and the status line) is course-level opt-in via share_assignments. Updating the “What we’re building” bullets here will keep the plan consistent with the actual design shipped in this PR.
Three pieces:

1. **A per-assignment "shareable" toggle** so an instructor can opt individual assignments into being importable by others.
2. **A searchable/filterable browser** over all shareable assignments, filterable by course name *and* book, with pagination.
3. **An import action** that copies a chosen assignment into the importing instructor's course, linking its exercises where they already live — including when the source course uses a *different book*.

Plus a small addition: **a course-level sharing description** so an instructor can explain their problem sets to prospective importers.

assignment-sharing-plan.md:22

  • The “Sharing granularity” decision row says per-assignment, but the implementation in this PR uses course-level opt-in (share_assignments). The decision table should be updated to avoid contradicting the rest of the PR.
| Question | Decision |
|---|---|
| Sharing granularity | **Per-assignment** toggle (not course-wide, not both) |

Copilot AI review requested due to automatic review settings August 10, 2026 18:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 31 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • bases/rsptx/interactives/package-lock.json: Generated file
Suppressed comments (4)

migrations/versions/e7a3c9d1f4b2_add_is_private_to_assignments.py:6

  • The migration docstring says is_private controls whether assignments are discoverable/importable cross-course, but the PR implements course-level sharing via the share_assignments course attribute (see COURSE_SHARING_ATTR usage in CRUD). This docstring should be updated to avoid misleading future readers.
Marks whether an assignment may be discovered and imported by instructors
outside the course that owns it. Existing assignments were authored with no
expectation of being visible elsewhere, so they all become private and sharing
is only ever an explicit act.

components/rsptx/db/models.py:674

  • The comment on Assignment.is_private still describes per-assignment cross-course discoverability, but the new sharing rules are course-level (share_assignments attribute / base-course exemption). Updating this comment will prevent future confusion about what toggling is_private actually does.
    # When False this assignment is discoverable and importable by instructors
    # in other courses. Nullable with no Python-side default, matching
    # ``Question.is_private``: the sharing predicate tests ``== False``, which
    # in SQL does not match NULL, so an unset value stays private. Sharing is
    # only ever an explicit act.
    is_private = Column(Web2PyBoolean)

migrations/versions/e7a3c9d1f4b2_add_is_private_to_assignments.py:37

  • This inline comment references a “sharing predicate” that tests is_private = 'F', but the current sharing/search implementation no longer filters by Assignment.is_private. Please update the comment to reflect that NULL behaves like private and that sharing is enforced elsewhere.
    # Backfill rather than constrain: the column stays nullable to match
    # questions.is_private, and the sharing predicate tests `is_private = 'F'`,
    # which no NULL row satisfies. An unset value is therefore private.

bases/rsptx/assignment_server_api/assignment_builder/src/types/assignment.ts:36

  • The Assignment.is_private field comment implies per-assignment cross-course importability, but the backend sharing rules are course-level (via share_assignments) and do not currently filter on this column. Please adjust the comment so the frontend type docs match the actual behavior.
  /** False makes this assignment importable by instructors in other courses. */
  is_private: boolean;

Copilot AI review requested due to automatic review settings August 10, 2026 19:07
@oscarlevin

Copy link
Copy Markdown
Collaborator Author

I've cleaned this up, rebased on main, and force pushed. Should be ready for review now.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 31 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • bases/rsptx/interactives/package-lock.json: Generated file
Suppressed comments (2)

migrations/versions/e7a3c9d1f4b2_add_is_private_to_assignments.py:6

  • The migration docstring says is_private controls whether assignments are discoverable/importable outside the owning course. In this PR’s implementation, visibility/authorization is course-level (share_assignments) plus the base-course (“official”) exception; Assignment.is_private is not used in those predicates. Updating the migration’s description would avoid future confusion when someone tries to understand why a private assignment is still importable once a course opts in.
"""add is_private to assignments

Marks whether an assignment may be discovered and imported by instructors
outside the course that owns it. Existing assignments were authored with no
expectation of being visible elsewhere, so they all become private and sharing
is only ever an explicit act.

bases/rsptx/assignment_server_api/assignment_builder/src/types/assignment.ts:36

  • The is_private doc comment suggests that setting it to false makes an assignment importable by other courses. In the backend sharing implementation for this PR, importability is controlled by course-level opt-in (share_assignments) and the “official” base-course exception; Assignment.is_private is not consulted for visibility/authorization. This comment is therefore misleading for consumers of the type.
  /** False makes this assignment importable by instructors in other courses. */
  is_private: boolean;

Comment thread components/rsptx/db/crud/assignment.py
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 10, 2026 19:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 31 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • bases/rsptx/interactives/package-lock.json: Generated file
Suppressed comments (2)

bases/rsptx/assignment_server_api/routers/instructor.py:2095

  • fetch_one_assignment() raises HTTPException(404) when the assignment doesn’t exist, so this endpoint can currently return a non-make_json_response 404 for missing IDs. Catching HTTPException here also lets you reliably return 404 for foreign-course IDs without depending on unreachable if not source logic.
    source = await fetch_one_assignment(assignment_id)
    if not source or source.course != course.id:
        return make_json_response(
            status=status.HTTP_404_NOT_FOUND, detail="Assignment not found"
        )

bases/rsptx/assignment_server_api/routers/instructor.py:1853

  • fetch_one_assignment() raises HTTPException(404) when the assignment doesn’t exist, so the if not existing branch here is effectively unreachable and missing IDs will bypass make_json_response (returning the default FastAPI error shape instead). Wrapping the fetch in a try/except HTTPException keeps the response consistent with the rest of the router and avoids relying on unreachable logic.

This issue also appears on line 2091 of the same file.

    # delete_assignment keys off the id alone, so without this check any
    # instructor could delete any assignment on the platform by guessing an id.
    existing = await fetch_one_assignment(assignment_id)
    if not existing or existing.course != course.id:
        return make_json_response(

Comment on lines +1963 to +1970
# The same three ways in that ``_visibility`` allows, asked of one row: the
# owning course offers its assignments, it belongs to a book (which has
# always been copyable), or the caller already instructs that course.
allowed = (
await course_shares_assignments(source_course.id)
or source_course.course_name == source_course.base_course
or bool(await fetch_instructor_courses(user_id, source_course.id))
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree with the copilot suggestion here. Once an assignment is imported, while it starts out as private to that course (and hidden for students), if the instructor modifies the assignment and builds a new course that involves it, they should be able to reshare.

Note that I do not expect many instructors to make their course assignments shareable. It is for base courses and select users who want to share their assignments with others at their own institution (a sort of local base course).

@oscarlevin

Copy link
Copy Markdown
Collaborator Author

Anything I can do to help push this along? We can chat at drop-in too, of course.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants