Skip to content

Copying a Learning Class does not deep-copy the underlying LearningActivity — copied classes end up sharing activity content #6904

Description

@stanalyst

Description

When you copy/duplicate a LearningClass, the new class gets its own LearningClassActivity rows (due dates, points, order, etc.), but each new LearningClassActivity still points at the same LearningActivity record as the original class. Since LearningActivity holds the actual activity content (name, description, activity type, and ActivityComponentSettingsJson — the real quiz/assignment configuration), editing an activity's content in either class edits it in both.

This does not match the behavior of the sibling "copy a single activity" feature, which correctly clones the LearningActivity as well.

Also based on a comment from Jon in the LMS channel on rocketchat, describing the intended use of "Copy Class":

A class is an instance of a course. It's easier to think about it in the context of a college setting. ENG101 (Course) has curriculum. That course is offered M,W,F at 9am or T,Th at 2pm as classes. A common pattern for many churches will be that a Baptism Course only has one class that is used on-going. But... when the curriculum needs to change you can clone the original class, change the activities in the new class then make it active. It's a nice way to transition the fire hose from the original curriculum to the new one. The one class concept only works if you don't have a 'cohort' of students that need to be grouped together with a specific facilitator.

That workflow only works if the cloned class's activities can be edited independently of the original's. As implemented, editing an activity in the new class also silently rewrites it in the class you're trying to phase out, which defeats the purpose of cloning in the first place.

Actual Behavior

When copying/duplicating a LearningClass - edits to an activity show up in both classes, because both LearningClassActivity records reference the same LearningActivityId.

Expected Behavior

Each class should have an independent copy of the activity. Editing content in one class's activity should not affect the other class.

Steps to Reproduce

  1. Go to a Learning Program > Course > Class that has at least one activity in its learning plan.
  2. Use "Copy Class" to duplicate the class.
  3. Open the same activity in each of the two classes and edit its name, description, or activity-type-specific content (e.g. quiz questions) in one of them.
  4. Reload the other class and view the same activity.

NOTES

I used Claude cowork to diagnosis the behavior we were seeing - this submission was almost entirely created by it. Here are some additional details it included that don't exactly fit anywhere else in this form:

Root Cause

LearningClassService.Copy(LearningClass, bool includeActivities) in Rock/Model/LMS/LearningClass/LearningClassService.Partial.cs clones each LearningClassActivity but never clones its related LearningActivity:

foreach ( var activity in activities )
{
    var newActivity = activity.CloneWithoutIdentity();
    newActivity.LearningClassId = newLearningClass.Id;
    activityService.Add( newActivity );
    activity.LoadAttributes();
    newActivity.LoadAttributes();
    newActivity.CopyAttributesFrom( activity );
}

newActivity.LearningActivityId (copied by CloneWithoutIdentity()) still points at the original LearningActivity row.

Compare this to LearningClassActivityService.Copy(string key) in Rock/Model/LMS/LearningClassActivity/LearningClassActivityService.Partial.cs, which is used when copying a single activity within a class, and does this correctly:

var newActivity = activity.CloneWithoutIdentity();
newActivity.LearningActivity = activity.LearningActivity.CloneWithoutIdentity();
newActivity.LearningActivity.Name += " - Copy";

The class-level copy path should follow the same pattern: clone the LearningActivity for each activity being copied (unless LearningActivity.IsShared is true for that activity — see note below), rather than reusing the same LearningActivityId.

Secondary bug in the same method

In LearningClassService.Copy(), a newActivities list is declared to later persist attribute values for the copied activities, but it is never populated:

var newActivities = new List<LearningClassActivity>();
...
foreach ( var activity in activities )
{
    var newActivity = activity.CloneWithoutIdentity();
    // newActivity is never added to newActivities
    ...
}

rockContext.WrapTransaction( () =>
{
    rockContext.SaveChanges();
    newLearningClass.SaveAttributeValues( rockContext );

    foreach ( var newActivity in newActivities ) // always empty — never runs
    {
        rockContext.SaveChanges();
        newActivity.SaveAttributeValues( rockContext );
    }
} );

As a result, any custom Attribute values on copied LearningClassActivity records are computed in memory (CopyAttributesFrom) but never persisted to the database.

Additional context: the IsShared flag

LearningActivity.IsShared (bit NOT NULL, present in the schema since Rock v17) is documented as: "Indicates whether or not this activity is intended to be shared as a template by multiple LearningClassActivity." This suggests the data model already anticipated the sharing-vs-independent distinction that this bug runs into.

However, this flag is not currently exposed anywhere:

  • LearningClassActivityBag (the DTO used by the Class Detail activity editor) has no IsShared property.
  • There is no standalone LearningActivityDetail block for managing LearningActivity templates directly.
  • No block, REST endpoint, or Obsidian component appears to read or write this column.

Since nothing sets it, every activity effectively behaves as IsShared = false today, which makes the current copy behavior doubly unexpected: even in the case the schema explicitly designed for non-sharing, Copy() shares content anyway with no way to opt out.

Suggested Fix

In LearningClassService.Copy(), when copying each LearningClassActivity, also clone the associated LearningActivity (matching the pattern already used in LearningClassActivityService.Copy()) — unless LearningActivity.IsShared is true, in which case reusing the same LearningActivityId is correct. Also fix the newActivities list population so attribute values are saved for copied activities.

Environment

  • Reviewed against the develop branch on GitHub as of 2026-07-02.

Issue Confirmation

  • Perform a search on the Github Issues to see if your bug is already reported.
  • Reproduced the problem on a fresh install or on the demo site.

Rock Version

v18.3.5

Client Culture Setting

en-US

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions