Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions Sources/APIServer/Routes/Web/AdminRoutes+Courses.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,32 @@ extension AdminRoutes {
.filter(\.$courseID == sourceID)
.sort(\.$sortOrder)
.all()
let sections = try await APICourseSection.query(on: req.db)
.filter(\.$courseID == sourceID)
.sort(\.$sortOrder)
.all()

let newCourseID = try await req.db.transaction { db -> UUID in
// 1. Create the new course.
let newCourse = APICourse(code: newCode, name: "\(source.name) (Copy)")
try await newCourse.save(on: db)
let newCourseID = try newCourse.requireID()

// 2. Copy each test setup (zip + optional notebook) to a new ID.
// 2. Copy sections, building an old→new UUID map.
var sectionIDMap: [UUID: UUID] = [:]
for section in sections {
guard let oldSectionID = section.id else { continue }
let newSection = APICourseSection(
name: section.name,
defaultGradingMode: section.defaultGradingMode,
sortOrder: section.sortOrder,
courseID: newCourseID
)
try await newSection.save(on: db)
sectionIDMap[oldSectionID] = try newSection.requireID()
}

// 3. Copy each test setup (zip + optional notebook) to a new ID.
var setupIDMap: [String: String] = [:]
for setup in setups {
guard let oldID = setup.id else { continue }
Expand All @@ -129,11 +147,16 @@ extension AdminRoutes {
let dstZip = URL(fileURLWithPath: setupsDir + "\(newID).zip")
try FileManager.default.copyItem(at: srcZip, to: dstZip)

// Copy the notebook using the actual stored path, not a reconstructed one.
var newNotebookPath: String? = nil
if setup.notebookPath != nil {
let srcNb = URL(fileURLWithPath: setupsDir + "\(oldID).ipynb")
if let srcPath = setup.notebookPath {
let srcNb = URL(fileURLWithPath: srcPath)
if FileManager.default.fileExists(atPath: srcNb.path) {
let dstNb = URL(fileURLWithPath: setupsDir + "\(newID).ipynb")
let filename = srcNb.lastPathComponent
let nbDir = setupsDir + "notebooks/\(newID)/"
try? FileManager.default.createDirectory(atPath: nbDir,
withIntermediateDirectories: true)
let dstNb = URL(fileURLWithPath: nbDir + filename)
try FileManager.default.copyItem(at: srcNb, to: dstNb)
newNotebookPath = dstNb.path
}
Expand All @@ -149,10 +172,11 @@ extension AdminRoutes {
try await newSetup.save(on: db)
}

// 3. Copy each assignment, remapping to the new test setup IDs.
// 4. Copy each assignment, remapping test setup IDs and section IDs.
// Validation state is reset so the instructor re-validates before opening.
for (idx, a) in assignments.enumerated() {
guard let newSetupID = setupIDMap[a.testSetupID] else { continue }
let newSectionID = a.sectionID.flatMap { sectionIDMap[$0] }
let newAssignment = APIAssignment(
testSetupID: newSetupID,
title: a.title,
Expand All @@ -161,6 +185,7 @@ extension AdminRoutes {
sortOrder: a.sortOrder ?? idx,
validationStatus: nil,
validationSubmissionID: nil,
sectionID: newSectionID,
courseID: newCourseID
)
try await newAssignment.save(on: db)
Expand Down
56 changes: 47 additions & 9 deletions Sources/APIServer/Routes/Web/CourseBundleRoutes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ struct CourseBundleRoutes: RouteCollection {
.filter(\.$courseID == courseUUID)
.all()

let sections = try await APICourseSection.query(on: req.db)
.filter(\.$courseID == courseUUID)
.sort(\.$sortOrder)
.all()

let enrollments = try await APICourseEnrollment.query(on: req.db)
.filter(\.$course.$id == courseUUID)
.all()
Expand Down Expand Up @@ -100,10 +105,11 @@ struct CourseBundleRoutes: RouteCollection {

// ── 2. Assign bundleIDs ────────────────────────────────────────────

var userBundleIDByUUID: [UUID: String] = [:]
var setupBundleIDByID: [String: String] = [:]
var assignBundleIDByID: [UUID: String] = [:]
var subBundleIDByID: [String: String] = [:]
var userBundleIDByUUID: [UUID: String] = [:]
var setupBundleIDByID: [String: String] = [:]
var assignBundleIDByID: [UUID: String] = [:]
var subBundleIDByID: [String: String] = [:]
var sectionBundleIDByUUID: [UUID: String] = [:]

for (i, u) in allUsers.enumerated() {
guard let uid = u.id else { continue }
Expand All @@ -121,6 +127,10 @@ struct CourseBundleRoutes: RouteCollection {
guard let sid = s.id else { continue }
subBundleIDByID[sid] = "sub_\(i + 1)"
}
for (i, sec) in sections.enumerated() {
guard let secID = sec.id else { continue }
sectionBundleIDByUUID[secID] = "section_\(i + 1)"
}

// ── 3. Build manifest ──────────────────────────────────────────────

Expand All @@ -143,17 +153,29 @@ struct CourseBundleRoutes: RouteCollection {
)
}

let bundledSections = sections.compactMap { sec -> BundledSection? in
guard let secID = sec.id, let bid = sectionBundleIDByUUID[secID] else { return nil }
return BundledSection(
bundleID: bid,
name: sec.name,
defaultGradingMode: sec.defaultGradingMode,
sortOrder: sec.sortOrder
)
}

let bundledAssignments = assignments.compactMap { a -> BundledAssignment? in
guard let aid = a.id, let bid = assignBundleIDByID[aid],
let setupBid = setupBundleIDByID[a.testSetupID]
else { return nil }
let sectionBid = a.sectionID.flatMap { sectionBundleIDByUUID[$0] }
return BundledAssignment(
bundleID: bid,
title: a.title,
dueAt: a.dueAt,
isOpen: a.isOpen,
sortOrder: a.sortOrder,
testSetupBundleID: setupBid
testSetupBundleID: setupBid,
sectionBundleID: sectionBid
)
}

Expand Down Expand Up @@ -192,6 +214,7 @@ struct CourseBundleRoutes: RouteCollection {
enrollmentMode: course.enrollmentMode),
users: bundledUsers,
enrolledUserBundleIDs: enrolledBundleIDs,
sections: bundledSections,
assignments: bundledAssignments,
testSetups: bundledSetups,
submissions: bundledSubmissions,
Expand Down Expand Up @@ -435,7 +458,20 @@ struct CourseBundleRoutes: RouteCollection {
}
}

// 6e. Create test setups → setupIDMap[bundleID] = new live ID
// 6e. Create sections → sectionIDMap[bundleID] = new live UUID
var sectionIDMap: [String: UUID] = [:]
for bundledSection in manifest.sections ?? [] {
let newSection = APICourseSection(
name: bundledSection.name,
defaultGradingMode: bundledSection.defaultGradingMode,
sortOrder: bundledSection.sortOrder,
courseID: t.courseID
)
try await newSection.save(on: db)
sectionIDMap[bundledSection.bundleID] = try newSection.requireID()
}

// 6f. Create test setups → setupIDMap[bundleID] = new live ID
var setupIDMap: [String: String] = [:]
for bundledSetup in manifest.testSetups {
let newSetupID = "setup_\(UUID().uuidString.lowercased().prefix(8))"
Expand Down Expand Up @@ -466,23 +502,25 @@ struct CourseBundleRoutes: RouteCollection {
t.testSetupsImported += 1
}

// 6f. Create assignments
// 6g. Create assignments
for bundledAssign in manifest.assignments {
guard let setupID = setupIDMap[bundledAssign.testSetupBundleID] else { continue }
let sectionID = bundledAssign.sectionBundleID.flatMap { sectionIDMap[$0] }
let newAssign = APIAssignment(
testSetupID: setupID,
title: bundledAssign.title,
dueAt: bundledAssign.dueAt,
isOpen: bundledAssign.isOpen,
sortOrder: bundledAssign.sortOrder,
validationStatus: nil, // not imported — requires re-validation
sectionID: sectionID,
courseID: t.courseID
)
try await newAssign.save(on: db)
t.assignmentsImported += 1
}

// 6g. Create submissions → subIDMap[bundleID] = new live ID
// 6h. Create submissions → subIDMap[bundleID] = new live ID
var subIDMap: [String: String] = [:]
for bundledSub in manifest.submissions {
guard let setupID = setupIDMap[bundledSub.testSetupBundleID] else { continue }
Expand Down Expand Up @@ -511,7 +549,7 @@ struct CourseBundleRoutes: RouteCollection {
t.submissionsImported += 1
}

// 6h. Create results
// 6i. Create results
for bundledResult in manifest.results {
guard let subID = subIDMap[bundledResult.submissionBundleID] else { continue }
let newResultID = "res_\(UUID().uuidString.lowercased().prefix(8))"
Expand Down
24 changes: 23 additions & 1 deletion Sources/Core/CourseBundleManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public struct CourseBundleManifest: Codable, Sendable {
public let users: [BundledUser]
/// bundleIDs of users enrolled in the course.
public let enrolledUserBundleIDs: [String]
/// Course sections (nil in bundles exported before this field was added).
public let sections: [BundledSection]?
public let assignments: [BundledAssignment]
public let testSetups: [BundledTestSetup]
/// Student submissions only (kind == "student"); validation runs excluded.
Expand All @@ -46,6 +48,7 @@ public struct CourseBundleManifest: Codable, Sendable {
course: BundledCourse,
users: [BundledUser],
enrolledUserBundleIDs: [String],
sections: [BundledSection] = [],
assignments: [BundledAssignment],
testSetups: [BundledTestSetup],
submissions: [BundledSubmission],
Expand All @@ -58,6 +61,7 @@ public struct CourseBundleManifest: Codable, Sendable {
self.course = course
self.users = users
self.enrolledUserBundleIDs = enrolledUserBundleIDs
self.sections = sections
self.assignments = assignments
self.testSetups = testSetups
self.submissions = submissions
Expand Down Expand Up @@ -106,6 +110,21 @@ public struct BundledUser: Codable, Sendable {
}
}

public struct BundledSection: Codable, Sendable {
public let bundleID: String
public let name: String
/// "browser" | "worker"
public let defaultGradingMode: String
public let sortOrder: Int

public init(bundleID: String, name: String, defaultGradingMode: String, sortOrder: Int) {
self.bundleID = bundleID
self.name = name
self.defaultGradingMode = defaultGradingMode
self.sortOrder = sortOrder
}
}

public struct BundledAssignment: Codable, Sendable {
public let bundleID: String
public let title: String
Expand All @@ -114,15 +133,18 @@ public struct BundledAssignment: Codable, Sendable {
public let sortOrder: Int?
/// References BundledTestSetup.bundleID.
public let testSetupBundleID: String
/// References BundledSection.bundleID; nil when assignment is ungrouped.
public let sectionBundleID: String?

public init(bundleID: String, title: String, dueAt: Date?, isOpen: Bool,
sortOrder: Int?, testSetupBundleID: String) {
sortOrder: Int?, testSetupBundleID: String, sectionBundleID: String? = nil) {
self.bundleID = bundleID
self.title = title
self.dueAt = dueAt
self.isOpen = isOpen
self.sortOrder = sortOrder
self.testSetupBundleID = testSetupBundleID
self.sectionBundleID = sectionBundleID
}
}

Expand Down
Loading
Loading