ModelAdmin.change_view: Specifying object_id creates a new object with similar data

I am using an action to copy a model object and go to the change view of the new object.

When receiving the POST request (due to the administrator submitting the form) I expected my form to modify the new object. However, it is creating yet another object.

Pseudocode:

if (request.method != "POST"):
    clone = ModelObject.objects.create()
    copy(old_object, clone)
    change_view(request, object_id=str(clone.pk))
else:
    if (form.is_valid()):
        form.save()
        return HttpResponseRedirect(somewherenice)
    else:
        clone = form.instance
        return change_view(request, object_id=str(clone.pk))

The form.instance has a pk=None.

Is it supposed to be that way or did I mess up something ?

I think we need to see the complete view, not an edited summary of it. There may be something very subtle in what you’re doing that causes the symptoms you’re seeing.

Here is my code. I cut off everything that I tought was unrelated and clumsily translated it in english.

from somewhere.else import clone_existing_project

class ProjectAdmin(admin.ModelAdmin):
    """
    Admin view for the project model.
    """
    inlines = [ ... ]
    readonly_fields = [ ... ]
    list_display = (
        ...,
        "copy_action",
    )

    def get_urls(self):
        """
        The url that will be used for the form during the copy action needs
        to be added to the list.
        """
        urls = super().get_urls()
        custom_urls = [
            path(
                "<str:project_id>/copy/",
                self.admin_site.admin_view(self.process_copy),
                name="project-copy",
            ),
        ]
        return custom_urls + urls

    def copy_action(self, obj):
        """
        Definition of my action.

        The point is to have a button on the admin page that links to the method.
        """
        return format_html(
            '<a class="button" href="{}">Copier</a>',
            reverse("admin:project-copy", args=[obj.pk]),
        )
    copy_action.short_description = "Project Actions"
    copy_action.allow_tags = True

    def process_copy(
        self,
        request,
        project_id,
        *args,
        **kwargs
    ):
        """
        Method used to handle the cloning and redirections of the copy_action.
        """
        form_class = self.get_form(request)
        action_title = "Project copy"
        source_project = self.get_object(request, project_id)

        clone = None

        if request.method != "POST":
            # Note: Project.objects.create() is used in this method.
            clone = clone_existing_project(source_project )

        else:
            # Completing the form with request data.
            form = form_class(request.POST)

            # /!\ this is dangerous because some model modifications can break this.
            # /!\ If the cloned project was form.instance, this wouldn't be needed.
            # In order to be able to get the cloned object we need the private key
            # because every other field can be modified. However, the private key
            # being autogenerated, it doesn't appear directly in the form
            # thus this is the only way I found to get it.
            pk = form.data["foreignkey_set-__prefix__-project"]
            
            if form.is_valid():
                try:
                    new_project = form.save(commit=True)
                except Exception as e:
                    print("Exception during the save method, verify this.")
                    print(e)
                    pass
                else:
                    # If no error has been found, we can redirect the user with a success message
                    self.message_user(request, "Successfully copied.")
                    url = reverse(
                        "admin:myapp_project_changelist",
                        args=[],
                        current_app=self.admin_site.name,
                    )
                    return HttpResponseRedirect(url)

            # If the form is non-valid, we get the cloned object then redirect
            # the user to its change view.
            try:
                # Getting the cloned object.
                clone = Project.objects.get(
                    pk=pk
                )
            except Project.DoesNotExist:
                # This shouldn't be necessary.
                clone = clone_existing_project(source_project)
        
        context = self.admin_site.each_context(request)
        context["title"] = action_title

        return self.change_view(request, object_id=str(clone.pk), form_url="", extra_context=context)

To be clear here, when you select an instance to edit it, are you saying you’re getting two copies of the original object, so that you now have three of them?

I’d be curious to find out when this second copy is being created. (I’d examine the database before selecting an object, then again after it has been selected but before being saved, and then again after being saved to see how many instances exist at each point in time.)

I’d also want to review the clone_existing_project method to ensure it wasn’t creating the extra copy.

Also note that if form.is_valid is false and the form is regenerated for display, it looks like you’re going to get an extra instance. Every time the form is displayed, a new instance is created.

I ran it again and the extra object gets created only when I modify the form, it doesn’t matter whether the form is valid or not.

If the form is non valid, then it displays in red the errors, and I only have 1 extra copy when I finally successfully submit the form, rather than 1 per non valid submit (if that makes sense).

To be clear here, when you select an instance to edit it, are you saying you’re getting two copies of the original object, so that you now have three of them?

Yes, I have a copy that is precisely like the one I generate it with clone_existing_project and one that is like I expect it to be when I modify the form.

For example, let’s say I have an object named “foo”, clone_existing_project will create a copy named “foo_1”. If I modify the form to name it “foo_extra”, then I end up with 3 objects : “foo”, “foo_1”, “foo_extra”.

def clone_existing_project(project: Project):
    """
    To understand this function, you need to understand that my Project model:
    - has some regular fields,
    - has a foreignkey,
    - is the subject of foreignkey from 2 other models.
    
    For these 2 other models, we don't want the cloned object to be linked 
    to the existing instances that are related to the source project.
    We want to create similar instances and link them to the cloned project.

    See the diagram below to understand why.
    """
    clone = None

    # We try to find a name that can be used for the cloned object based on the
    # source object.
    # We'll try the name : clone_name = sourcename + "_1", but for
    # unicity reasons, we must first find out if this name is already taken.
    name_accepted  = False
    i = 1
    while not(name_accepted) and i < 10000:
        try:
            existing_project = Project.objects.get(name=project.name + "_" + str(i))
            if not(existing_project):
                # That is unneeded.
                name_accepted = True
            else:
                i += 1
        except Project.DoesNotExist:
            name_accepted = True

    if not(name_accepted):
        raise ValueError("Couldn't find a name")

    # We create the project using its manager, nothing else will be done
    # directly to it afterwards.
    clone = Project.objects.create(
        name=project.name + "_" + str(i),
        myforeignkey=project.myforeignkey;
        someotherfield=project.someotherfield
    )

    # TODO: add the foreignkey field (which isn't required).

    # We create the objects that have the cloned project as foreignkey
    # because we don't want the cloned project to be associated with models
    # related to the source project.
    for relation in project.relation1_set.all():
        new_relation = Relation1.objects.create(
            project=clone,
            usableitem=relation.usableitem,
            field=relation.field
        )

    for relation in project.relation2_set.all():
        new_relation = Relation2.objects.create(
            project=clone,
            interestingfeature=relation.interestingfeature,
            field1=relation.field1,
            field2=relation.field2
        )
    
    print("Created clone with pk = " + str(clone.pk))
    return clone

The reason why I create these relations is because my models look something like :

(I know it isn’t how a diagram should be drawn, but I wanted to give an idea without losing to much time.)

Writing this reply made me realise that the cloned object has the relations like expected, however, the extra object doesn’t. The extra object only has the regular fields and foreignkey, it has not been added to the relation instances as a foreignkey.

As for when is the extra object created, it right there :

What happens is that because the form.instance.pk = None, the form saves it as an extra object. Im tempted to do the following :

...
if form.is_valid():
    try:
        new_project_data = form.save(commit=False)
        clone = Project.objects.get(
             pk=pk
        )
        copy_regular_fields(new_project_data, clone)
        update_relations(new_project_data, clone)
...

But I don’t understand why the form wants to create a new instance, that’s my inquiry.

I did the following:

...
if form.is_valid():
    try:
        new_project = form.save(commit=False)
        clone = Project.objects.get(
            pk=pk
        )
                    
        # Updating already existing clone.
        copy_project_regular_fields(new_project_data, clone)
        copy_project_associations(where_is_this_data, clone)
...

def copy_project_regular_fields(source, dest):
    """
    Copying regular fields from source to dest.
    """
    if (source and dest):
        dest.name = source.name,
        dest.myforeignkey = source.myforeignkey;
        dest.someotherfield = source.someotherfield
    
    return

Not only am i stuck not knowing where to get data to copy relations from, but I still don’t understand the logic.