← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:mypy-next-cancel-url into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:mypy-next-cancel-url into launchpad:master.

Commit message:
Avoid substitution issue with next_url and cancel_url

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/437108

Launchpad's form views are allowed to define `next_url` and `cancel_url` to indicate where the form should redirect to after a successful submission or if the user chooses to cancel.  Some do this by assigning to those attributes (especially if the URL is computed based on the submitted form), while some do this by defining those attributes as properties (especially if there are multiple form actions that use the same URLs).  There's no obvious way to settle on one approach or the other here, but until now it hasn't mattered.

Unfortunately, `mypy` detects a technical difficulty here.  If a base class declares something as a plain attribute, then a subclass that redefines that as a property without also providing a setter (which wouldn't really make sense here) violates the Liskov substitution principle, because assigning to that attribute is allowed for instances of the base class but forbidden for instances of the subclass.  Conversely, if a base class declares something as a property without a setter, then instances of the subclass may not assign to it; and if a base class declares something as a property with a setter, then subclasses may not redefine it as a property without a setter.

None of the options here are very nice, but it would be useful to get out of this particular jam so that we can extend `mypy` coverage further.  The simplest approach I can find is to have `LaunchpadFormView` declare `next_url` and `cancel_url` as properties that return `mutable_next_url` and `mutable_cancel_url` respectively; then subclasses that want to assign to those attributes can assign to the mutable variants, and subclasses that want to define properties can continue doing so as before.  The result is a little more verbose, but it works.

(I also considered defining a `Protocol` that would declare types for `next_url` and `cancel_url` without having to actually initialize them in the base class, but I don't see how to concisely ensure that all form views implement that protocol without having to subclass it and thus getting back to the base class problem above.)
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:mypy-next-cancel-url into launchpad:master.
diff --git a/lib/lp/answers/browser/faq.py b/lib/lp/answers/browser/faq.py
index 732bd2d..724d27d 100644
--- a/lib/lp/answers/browser/faq.py
+++ b/lib/lp/answers/browser/faq.py
@@ -78,4 +78,4 @@ class FAQEditView(LaunchpadEditFormView):
     def save_action(self, action, data):
         """Update the FAQ details."""
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
diff --git a/lib/lp/answers/browser/faqtarget.py b/lib/lp/answers/browser/faqtarget.py
index 7f37282..457d455 100644
--- a/lib/lp/answers/browser/faqtarget.py
+++ b/lib/lp/answers/browser/faqtarget.py
@@ -51,4 +51,4 @@ class FAQCreateView(LaunchpadFormView):
             data["content"],
             keywords=data["keywords"],
         )
-        self.next_url = canonical_url(faq)
+        self.mutable_next_url = canonical_url(faq)
diff --git a/lib/lp/answers/browser/question.py b/lib/lp/answers/browser/question.py
index 65bbd94..3e1bf30 100644
--- a/lib/lp/answers/browser/question.py
+++ b/lib/lp/answers/browser/question.py
@@ -340,7 +340,7 @@ class QuestionSetView(LaunchpadFormView):
             scope = self.context
         else:
             scope = self.widgets["scope"].getInputValue()
-        self.next_url = "%s/+tickets?%s" % (
+        self.mutable_next_url = "%s/+tickets?%s" % (
             canonical_url(scope),
             self.request["QUERY_STRING"],
         )
@@ -1199,7 +1199,7 @@ class QuestionWorkflowView(LaunchpadFormView, LinkFAQMixin):
                 _("You have subscribed to this question.")
             )
 
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def new_question_url(self):
@@ -1416,7 +1416,7 @@ class QuestionCreateFAQView(LinkFAQMixin, LaunchpadFormView):
         self.context.linkFAQ(self.user, faq, data["message"])
 
         # Redirect to the question.
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class SearchableFAQRadioWidget(LaunchpadRadioWidget):
diff --git a/lib/lp/answers/browser/questiontarget.py b/lib/lp/answers/browser/questiontarget.py
index 8682cf9..1c33fb3 100644
--- a/lib/lp/answers/browser/questiontarget.py
+++ b/lib/lp/answers/browser/questiontarget.py
@@ -850,7 +850,7 @@ class ManageAnswerContactView(UserSupportLanguagesMixin, LaunchpadFormView):
                         )
                     )
 
-        self.next_url = canonical_url(self.context, rootsite="answers")
+        self.mutable_next_url = canonical_url(self.context, rootsite="answers")
 
     @property
     def administrated_teams(self):
diff --git a/lib/lp/app/browser/launchpadform.py b/lib/lp/app/browser/launchpadform.py
index 126eb3f..f6e12ae 100644
--- a/lib/lp/app/browser/launchpadform.py
+++ b/lib/lp/app/browser/launchpadform.py
@@ -66,11 +66,25 @@ class LaunchpadFormView(LaunchpadView):
     # Subset of fields to use
     field_names = None  # type: Optional[List[str]]
 
-    # The next URL to redirect to on successful form submission
-    next_url = None  # type: Optional[str]
+    # The next URL to redirect to on successful form submission.  Set this
+    # if your view needs to set the next URL in a way that can't easily be
+    # done by redefining the `next_url` property instead.
+    mutable_next_url = None  # type: Optional[str]
+    # The URL to use as the Cancel link in a form.  Set this if your view
+    # needs to set the cancel URL in a way that can't easily be done by
+    # redefining the `cancel_url` property instead.
+    mutable_cancel_url = None  # type: Optional[str]
+
+    # The next URL to redirect to on successful form submission.
+    @property
+    def next_url(self) -> Optional[str]:
+        return self.mutable_next_url
+
     # The cancel URL is rendered as a Cancel link in the form
     # macro if set in a derived class.
-    cancel_url = None  # type: Optional[str]
+    @property
+    def cancel_url(self) -> Optional[str]:
+        return self.mutable_cancel_url
 
     # The name of the widget that will receive initial focus in the form.
     # By default, the first widget will receive focus.  Set this to None
diff --git a/lib/lp/app/doc/launchpadform.rst b/lib/lp/app/doc/launchpadform.rst
index 996ec23..c237a8d 100644
--- a/lib/lp/app/doc/launchpadform.rst
+++ b/lib/lp/app/doc/launchpadform.rst
@@ -402,7 +402,7 @@ Form Rendering
     ...
     ...     @action("Redirect", name="redirect")
     ...     def redirect_action(self, action, data):
-    ...         self.next_url = "http://launchpad.test/";
+    ...         self.mutable_next_url = "http://launchpad.test/";
     ...
     ...     def handleUpdateFailure(self, action, data, errors):
     ...         return "Some errors occurred."
diff --git a/lib/lp/blueprints/browser/specification.py b/lib/lp/blueprints/browser/specification.py
index 1186390..1c0a987 100644
--- a/lib/lp/blueprints/browser/specification.py
+++ b/lib/lp/blueprints/browser/specification.py
@@ -888,7 +888,7 @@ class SpecificationEditView(LaunchpadEditFormView):
             self.request.response.addNotification(
                 'Blueprint is now considered "%s".' % new_status.title
             )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class SpecificationEditWhiteboardView(SpecificationEditView):
@@ -908,7 +908,7 @@ class SpecificationEditWorkItemsView(SpecificationEditView):
     def change_action(self, action, data):
         with notify_modified(self.context, ["workitems_text"]):
             self.context.setWorkItems(data["workitems_text"])
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class SpecificationEditPeopleView(SpecificationEditView):
@@ -1015,7 +1015,7 @@ class SpecificationGoalProposeView(LaunchpadEditFormView):
     def continue_action(self, action, data):
         self.context.whiteboard = data["whiteboard"]
         self.context.proposeGoal(data["distroseries"], self.user)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def cancel_url(self):
@@ -1030,7 +1030,7 @@ class SpecificationProductSeriesGoalProposeView(SpecificationGoalProposeView):
     def continue_action(self, action, data):
         self.context.whiteboard = data["whiteboard"]
         self.context.proposeGoal(data["productseries"], self.user)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def cancel_url(self):
@@ -1186,7 +1186,7 @@ class SpecificationSupersedingView(LaunchpadFormView):
             self.request.response.addNotification(
                 'Blueprint is now considered "%s".' % newstate.title
             )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def cancel_url(self):
@@ -1393,7 +1393,7 @@ class SpecificationSprintAddView(LaunchpadFormView):
     @action(_("Continue"), name="continue")
     def continue_action(self, action, data):
         self.context.linkSprint(data["sprint"], self.user)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def cancel_url(self):
@@ -1695,7 +1695,7 @@ class SpecificationSetView(AppFrontPageSearchView, HasSpecificationsView):
         search_text = data["search_text"]
         if search_text is not None:
             url += "?searchtext=" + search_text
-        self.next_url = url
+        self.mutable_next_url = url
 
 
 @component.adapter(ISpecification, Interface, IWebServiceClientRequest)
diff --git a/lib/lp/blueprints/browser/specificationdependency.py b/lib/lp/blueprints/browser/specificationdependency.py
index cdf6ed7..121b6a5 100644
--- a/lib/lp/blueprints/browser/specificationdependency.py
+++ b/lib/lp/blueprints/browser/specificationdependency.py
@@ -80,7 +80,7 @@ class SpecificationDependencyRemoveView(LaunchpadFormView):
     @action("Continue", name="continue")
     def continue_action(self, action, data):
         self.context.removeDependency(data["dependency"])
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def cancel_url(self):
diff --git a/lib/lp/blueprints/browser/sprint.py b/lib/lp/blueprints/browser/sprint.py
index 047084a..179aee9 100644
--- a/lib/lp/blueprints/browser/sprint.py
+++ b/lib/lp/blueprints/browser/sprint.py
@@ -414,7 +414,7 @@ class SprintDeleteView(LaunchpadFormView):
     def delete_action(self, action, data):
         owner = self.context.owner
         self.context.destroySelf()
-        self.next_url = canonical_url(owner)
+        self.mutable_next_url = canonical_url(owner)
 
 
 class SprintTopicSetView(HasSpecificationsView, LaunchpadView):
diff --git a/lib/lp/bugs/browser/bugalsoaffects.py b/lib/lp/bugs/browser/bugalsoaffects.py
index 0ec2af3..f7f66f8 100644
--- a/lib/lp/bugs/browser/bugalsoaffects.py
+++ b/lib/lp/bugs/browser/bugalsoaffects.py
@@ -338,7 +338,7 @@ class BugTaskCreationStep(AlsoAffectsStep):
             )
 
         notify(ObjectCreatedEvent(task_added))
-        self.next_url = canonical_url(task_added)
+        self.mutable_next_url = canonical_url(task_added)
 
 
 class IAddDistroBugTaskForm(IAddBugTaskForm):
@@ -982,4 +982,4 @@ class BugAlsoAffectsProductWithProductCreationView(
 
         if set_bugtracker:
             data["product"].bugtracker = view.task_added.bugwatch.bugtracker
-        self.next_url = canonical_url(view.task_added)
+        self.mutable_next_url = canonical_url(view.task_added)
diff --git a/lib/lp/bugs/browser/bugattachment.py b/lib/lp/bugs/browser/bugattachment.py
index ce004b8..48534a1 100644
--- a/lib/lp/bugs/browser/bugattachment.py
+++ b/lib/lp/bugs/browser/bugattachment.py
@@ -109,7 +109,7 @@ class BugAttachmentEditView(LaunchpadFormView, BugAttachmentContentCheck):
 
     def __init__(self, context, request):
         LaunchpadFormView.__init__(self, context, request)
-        self.next_url = self.cancel_url = canonical_url(
+        self.mutable_next_url = self.mutable_cancel_url = canonical_url(
             ICanonicalUrlData(context).inside
         )
         if not context.libraryfile:
@@ -158,8 +158,8 @@ class BugAttachmentEditView(LaunchpadFormView, BugAttachmentContentCheck):
                 if new_type_consistent_with_guessed_type:
                     self.context.type = new_type
                 else:
-                    self.next_url = self.nextUrlForInconsistentPatchFlags(
-                        self.context
+                    self.mutable_next_url = (
+                        self.nextUrlForInconsistentPatchFlags(self.context)
                     )
             else:
                 self.context.type = new_type
@@ -211,7 +211,7 @@ class BugAttachmentPatchConfirmationView(LaunchpadFormView):
 
     def __init__(self, context, request):
         LaunchpadFormView.__init__(self, context, request)
-        self.next_url = self.cancel_url = canonical_url(
+        self.mutable_next_url = self.mutable_cancel_url = canonical_url(
             ICanonicalUrlData(context).inside
         )
 
diff --git a/lib/lp/bugs/browser/buglinktarget.py b/lib/lp/bugs/browser/buglinktarget.py
index 9dda500..512fe12 100644
--- a/lib/lp/bugs/browser/buglinktarget.py
+++ b/lib/lp/bugs/browser/buglinktarget.py
@@ -76,7 +76,7 @@ class BugLinkView(LaunchpadFormView):
             )
         )
         notify(ObjectModifiedEvent(self.context, target_unmodified, ["bugs"]))
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class BugLinksListingView(LaunchpadView):
@@ -166,7 +166,7 @@ class BugsUnlinkView(LaunchpadFormView):
                     )
                 )
         notify(ObjectModifiedEvent(self.context, target_unmodified, ["bugs"]))
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     def bugsWithPermission(self):
         """Return the bugs that the user has permission to remove. This
diff --git a/lib/lp/bugs/browser/bugmessage.py b/lib/lp/bugs/browser/bugmessage.py
index 30cff9e..ac7ffbc 100644
--- a/lib/lp/bugs/browser/bugmessage.py
+++ b/lib/lp/bugs/browser/bugmessage.py
@@ -107,7 +107,7 @@ class BugMessageAddFormView(LaunchpadFormView, BugAttachmentContentCheck):
                     "Thank you for your comment."
                 )
 
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
         attachment_description = data.get("attachment_description")
 
@@ -150,7 +150,7 @@ class BugMessageAddFormView(LaunchpadFormView, BugAttachmentContentCheck):
             )
 
             if not patch_flag_consistent:
-                self.next_url = self.nextUrlForInconsistentPatchFlags(
+                self.mutable_next_url = self.nextUrlForInconsistentPatchFlags(
                     attachment
                 )
 
diff --git a/lib/lp/bugs/browser/bugtarget.py b/lib/lp/bugs/browser/bugtarget.py
index 5ae70f2..89b33ad 100644
--- a/lib/lp/bugs/browser/bugtarget.py
+++ b/lib/lp/bugs/browser/bugtarget.py
@@ -771,7 +771,7 @@ class FileBugViewBase(LaunchpadFormView):
                     "You have subscribed to this bug report."
                 )
 
-        self.next_url = canonical_url(bug.bugtasks[0])
+        self.mutable_next_url = canonical_url(bug.bugtasks[0])
 
     def showFileBugForm(self):
         """Override this method in base classes to show the filebug form."""
@@ -1357,7 +1357,7 @@ class OfficialBugTagsManageView(LaunchpadEditFormView):
     def save_action(self, action, data):
         """Action for saving new official bug tags."""
         self.context.official_bug_tags = data["official_bug_tags"]
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def tags_js_data(self):
diff --git a/lib/lp/bugs/browser/bugtracker.py b/lib/lp/bugs/browser/bugtracker.py
index 7460e9b..a69d798 100644
--- a/lib/lp/bugs/browser/bugtracker.py
+++ b/lib/lp/bugs/browser/bugtracker.py
@@ -152,7 +152,7 @@ class BugTrackerAddView(LaunchpadFormView):
             contactdetails=data["contactdetails"],
             owner=getUtility(ILaunchBag).user,
         )
-        self.next_url = canonical_url(bugtracker)
+        self.mutable_next_url = canonical_url(bugtracker)
 
     @property
     def cancel_url(self):
@@ -348,7 +348,7 @@ class BugTrackerEditView(LaunchpadEditFormView):
             data["aliases"].append(current_baseurl)
 
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @cachedproperty
     def delete_not_possible_reasons(self):
@@ -440,7 +440,7 @@ class BugTrackerEditView(LaunchpadEditFormView):
         )
 
         # Go back to the bug tracker listing.
-        self.next_url = canonical_url(getUtility(IBugTrackerSet))
+        self.mutable_next_url = canonical_url(getUtility(IBugTrackerSet))
 
     @property
     def cancel_url(self):
@@ -464,7 +464,7 @@ class BugTrackerEditView(LaunchpadEditFormView):
         self.request.response.addInfoNotification(
             "All bug watches on %s have been rescheduled." % self.context.title
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class BugTrackerNavigation(Navigation):
diff --git a/lib/lp/buildmaster/browser/builder.py b/lib/lp/buildmaster/browser/builder.py
index 1c24da5..c9d0e49 100644
--- a/lib/lp/buildmaster/browser/builder.py
+++ b/lib/lp/buildmaster/browser/builder.py
@@ -466,7 +466,7 @@ class BuilderSetAddView(LaunchpadFormView):
             restricted_resources=data.get("restricted_resources"),
         )
         notify(ObjectCreatedEvent(builder))
-        self.next_url = canonical_url(builder)
+        self.mutable_next_url = canonical_url(builder)
 
     @property
     def page_title(self):
diff --git a/lib/lp/charms/browser/charmrecipe.py b/lib/lp/charms/browser/charmrecipe.py
index f80aebb..e63611f 100644
--- a/lib/lp/charms/browser/charmrecipe.py
+++ b/lib/lp/charms/browser/charmrecipe.py
@@ -301,8 +301,10 @@ def log_oops(error, request):
 class CharmRecipeAuthorizeMixin:
     def requestAuthorization(self, recipe):
         try:
-            self.next_url = CharmRecipeAuthorizeView.requestAuthorization(
-                recipe, self.request
+            self.mutable_next_url = (
+                CharmRecipeAuthorizeView.requestAuthorization(
+                    recipe, self.request
+                )
             )
         except BadRequestPackageUploadResponse as e:
             self.setFieldError(
@@ -393,7 +395,7 @@ class CharmRecipeAddView(CharmRecipeAuthorizeMixin, LaunchpadFormView):
         if data["store_upload"]:
             self.requestAuthorization(recipe)
         else:
-            self.next_url = canonical_url(recipe)
+            self.mutable_next_url = canonical_url(recipe)
 
     def validate(self, data):
         super().validate(data)
@@ -478,7 +480,7 @@ class BaseCharmRecipeEditView(
         if need_charmhub_reauth:
             self.requestAuthorization(self.context)
         else:
-            self.next_url = canonical_url(self.context)
+            self.mutable_next_url = canonical_url(self.context)
 
     @property
     def adapters(self):
@@ -634,7 +636,9 @@ class CharmRecipeDeleteView(BaseCharmRecipeEditView):
     def delete_action(self, action, data):
         owner = self.context.owner
         self.context.destroySelf()
-        self.next_url = canonical_url(owner, view_name="+charm-recipes")
+        self.mutable_next_url = canonical_url(
+            owner, view_name="+charm-recipes"
+        )
 
 
 class CharmRecipeRequestBuildsView(LaunchpadFormView):
@@ -675,4 +679,4 @@ class CharmRecipeRequestBuildsView(LaunchpadFormView):
         self.request.response.addNotification(
             _("Builds will be dispatched soon.")
         )
-        self.next_url = self.cancel_url
+        self.mutable_next_url = self.cancel_url
diff --git a/lib/lp/code/browser/branch.py b/lib/lp/code/browser/branch.py
index 0b4add6..51c6dd3 100644
--- a/lib/lp/code/browser/branch.py
+++ b/lib/lp/code/browser/branch.py
@@ -652,7 +652,7 @@ class BranchRescanView(LaunchpadEditFormView):
     def rescan(self, action, data):
         self.context.unscan(rescan=True)
         self.request.response.addNotification("Branch scan scheduled")
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class BranchEditFormView(LaunchpadEditFormView):
@@ -1030,7 +1030,7 @@ class BranchDeletionView(LaunchpadFormView):
         if self.all_permitted():
             # Since the user is going to delete the branch, we need to have
             # somewhere valid to send them next.
-            self.next_url = canonical_url(branch.target)
+            self.mutable_next_url = canonical_url(branch.target)
             message = "Branch %s deleted." % branch.unique_name
             self.context.destroySelf(break_references=True)
             self.request.response.addNotification(message)
@@ -1038,7 +1038,7 @@ class BranchDeletionView(LaunchpadFormView):
             self.request.response.addNotification(
                 "This branch cannot be deleted."
             )
-            self.next_url = canonical_url(branch)
+            self.mutable_next_url = canonical_url(branch)
 
     @property
     def branch_deletion_actions(self):
@@ -1393,7 +1393,7 @@ class RegisterBranchMergeProposalView(LaunchpadFormView):
                 )
                 return None
             else:
-                self.next_url = canonical_url(proposal)
+                self.mutable_next_url = canonical_url(proposal)
         except InvalidBranchMergeProposal as error:
             self.addError(str(error))
 
diff --git a/lib/lp/code/browser/branchmergeproposal.py b/lib/lp/code/browser/branchmergeproposal.py
index a789afb..72dcf5d 100644
--- a/lib/lp/code/browser/branchmergeproposal.py
+++ b/lib/lp/code/browser/branchmergeproposal.py
@@ -656,7 +656,7 @@ class BranchMergeProposalView(
                 request.claimReview(self.user)
             except ClaimReviewFailed as e:
                 self.request.response.addErrorNotification(str(e))
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def comment_location(self):
@@ -899,7 +899,7 @@ class BranchMergeProposalRescanView(LaunchpadEditFormView):
         if target_job and target_job.job.status == JobStatus.FAILED:
             self.context.merge_target.rescan()
         self.request.response.addNotification("Rescan scheduled")
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 @delegate_to(ICodeReviewVoteReference)
@@ -1046,7 +1046,7 @@ class BranchMergeProposalScheduleUpdateDiffView(LaunchpadEditFormView):
     def update(self, action, data):
         self.context.scheduleDiffUpdates()
         self.request.response.addNotification("Diff update scheduled")
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class IReviewRequest(Interface):
@@ -1120,8 +1120,8 @@ class MergeProposalEditView(
 
     def initialize(self):
         # Record next_url and cancel url now
-        self.next_url = canonical_url(self.context)
-        self.cancel_url = self.next_url
+        self.mutable_next_url = canonical_url(self.context)
+        self.mutable_cancel_url = self.next_url
         super().initialize()
 
 
@@ -1157,7 +1157,7 @@ class BranchMergeProposalResubmitView(
     )
 
     def initialize(self):
-        self.cancel_url = canonical_url(self.context)
+        self.mutable_cancel_url = canonical_url(self.context)
         super().initialize()
 
     @property
@@ -1222,12 +1222,12 @@ class BranchMergeProposalResubmitView(
                 url=canonical_url(e.existing_proposal),
             )
             self.request.response.addErrorNotification(message)
-            self.next_url = canonical_url(self.context)
+            self.mutable_next_url = canonical_url(self.context)
             return None
         except InvalidBranchMergeProposal as e:
             self.addError(str(e))
             return None
-        self.next_url = canonical_url(proposal)
+        self.mutable_next_url = canonical_url(proposal)
         return proposal
 
 
@@ -1291,7 +1291,7 @@ class BranchMergeProposalDeleteView(MergeProposalEditView):
         """Delete the merge proposal and go back to the source branch."""
         self.context.deleteProposal()
         # Override the next url to be the source branch.
-        self.next_url = canonical_url(self.merge_source)
+        self.mutable_next_url = canonical_url(self.merge_source)
 
 
 class BranchMergeProposalMergedView(LaunchpadEditFormView):
diff --git a/lib/lp/code/browser/codeimport.py b/lib/lp/code/browser/codeimport.py
index 7856269..122fcc2 100644
--- a/lib/lp/code/browser/codeimport.py
+++ b/lib/lp/code/browser/codeimport.py
@@ -487,7 +487,7 @@ class CodeImportNewView(CodeImportBaseView, CodeImportNameValidationMixin):
             self.user,
         )
 
-        self.next_url = canonical_url(code_import.target)
+        self.mutable_next_url = canonical_url(code_import.target)
 
         self.request.response.addNotification(
             """
@@ -660,7 +660,9 @@ class CodeImportEditView(CodeImportBaseView):
         if not self._is_edit_user and not self._is_moderator_user:
             raise Unauthorized
         # The next and cancel location is the target details page.
-        self.cancel_url = self.next_url = canonical_url(self.context)
+        self.mutable_cancel_url = self.mutable_next_url = canonical_url(
+            self.context
+        )
         super().initialize()
 
     @property
diff --git a/lib/lp/code/browser/gitref.py b/lib/lp/code/browser/gitref.py
index df8c704..98930f2 100644
--- a/lib/lp/code/browser/gitref.py
+++ b/lib/lp/code/browser/gitref.py
@@ -456,7 +456,7 @@ class GitRefRegisterMergeProposalView(LaunchpadFormView):
                 )
                 return None
             else:
-                self.next_url = canonical_url(proposal)
+                self.mutable_next_url = canonical_url(proposal)
         except InvalidBranchMergeProposal as error:
             self.addError(str(error))
 
diff --git a/lib/lp/code/browser/gitrepository.py b/lib/lp/code/browser/gitrepository.py
index c3724f0..84f29f2 100644
--- a/lib/lp/code/browser/gitrepository.py
+++ b/lib/lp/code/browser/gitrepository.py
@@ -586,7 +586,7 @@ class GitRepositoryForkView(LaunchpadEditFormView):
     def fork(self, action, data):
         forked = self.context.fork(self.user, data.get("owner"))
         self.request.response.addNotification("Repository forked.")
-        self.next_url = canonical_url(forked)
+        self.mutable_next_url = canonical_url(forked)
 
     @property
     def cancel_url(self):
@@ -603,7 +603,7 @@ class GitRepositoryRescanView(LaunchpadEditFormView):
     def rescan(self, action, data):
         self.context.rescan()
         self.request.response.addNotification("Repository scan scheduled")
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class GitRepositoryEditFormView(LaunchpadEditFormView):
@@ -1545,7 +1545,9 @@ class GitRepositoryPermissionsView(LaunchpadFormView):
         self.request.response.addNotification(
             "Saved permissions for %s" % self.context.identity
         )
-        self.next_url = canonical_url(self.context, view_name="+permissions")
+        self.mutable_next_url = canonical_url(
+            self.context, view_name="+permissions"
+        )
 
 
 class GitRepositoryDeletionView(LaunchpadFormView):
@@ -1601,7 +1603,7 @@ class GitRepositoryDeletionView(LaunchpadFormView):
         if self.all_permitted():
             # Since the user is going to delete the repository, we need to
             # have somewhere valid to send them next.
-            self.next_url = canonical_url(repository.target)
+            self.mutable_next_url = canonical_url(repository.target)
             message = "Repository %s deleted." % repository.unique_name
             self.context.destroySelf(break_references=True)
             self.request.response.addNotification(message)
@@ -1609,7 +1611,7 @@ class GitRepositoryDeletionView(LaunchpadFormView):
             self.request.response.addNotification(
                 "This repository cannot be deleted."
             )
-            self.next_url = canonical_url(repository)
+            self.mutable_next_url = canonical_url(repository)
 
     @property
     def repository_deletion_actions(self):
diff --git a/lib/lp/code/browser/sourcepackagerecipe.py b/lib/lp/code/browser/sourcepackagerecipe.py
index 002520d..f80dbec 100644
--- a/lib/lp/code/browser/sourcepackagerecipe.py
+++ b/lib/lp/code/browser/sourcepackagerecipe.py
@@ -458,7 +458,7 @@ class SourcePackageRecipeRequestBuildsHtmlView(
     @action("Request builds", name="request")
     def request_action(self, action, data):
         builds, informational = self.requestBuild(data)
-        self.next_url = self.cancel_url
+        self.mutable_next_url = self.cancel_url
         already_pending = informational.get("already_pending")
         notification_text = new_builds_notification_text(
             builds, already_pending
@@ -554,7 +554,7 @@ class SourcePackageRecipeRequestDailyBuildView(LaunchpadFormView):
             builds = recipe.performDailyBuild()
         except ArchiveDisabled as e:
             self.request.response.addErrorNotification(str(e))
-            self.next_url = canonical_url(recipe)
+            self.mutable_next_url = canonical_url(recipe)
             return
 
         if self.request.is_ajax:
@@ -566,7 +566,7 @@ class SourcePackageRecipeRequestDailyBuildView(LaunchpadFormView):
             contains_unbuildable = recipe.containsUnbuildableSeries(
                 recipe.daily_build_archive
             )
-            self.next_url = canonical_url(recipe)
+            self.mutable_next_url = canonical_url(recipe)
             self.request.response.addNotification(
                 new_builds_notification_text(
                     builds, contains_unbuildable=contains_unbuildable
@@ -915,7 +915,7 @@ class SourcePackageRecipeAddView(
         except ErrorHandled:
             return
 
-        self.next_url = canonical_url(source_package_recipe)
+        self.mutable_next_url = canonical_url(source_package_recipe)
 
     def validate(self, data):
         super().validate(data)
@@ -1038,7 +1038,7 @@ class SourcePackageRecipeEditView(
                 )
             )
 
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def adapters(self):
diff --git a/lib/lp/coop/answersbugs/browser.py b/lib/lp/coop/answersbugs/browser.py
index 879a801..9aa286c 100644
--- a/lib/lp/coop/answersbugs/browser.py
+++ b/lib/lp/coop/answersbugs/browser.py
@@ -65,4 +65,4 @@ class QuestionMakeBugView(LaunchpadFormView):
         self.request.response.addNotification(
             _("Thank you! Bug #$bugid created.", mapping={"bugid": bug.id})
         )
-        self.next_url = canonical_url(bug)
+        self.mutable_next_url = canonical_url(bug)
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 1e5e06e..877b02b 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -788,7 +788,7 @@ class OCIRecipeEditPushRulesView(LaunchpadFormView):
 
         if not self.errors:
             self.request.response.addNotification("Saved push rules")
-            self.next_url = canonical_url(self.context)
+            self.mutable_next_url = canonical_url(self.context)
 
 
 class OCIRecipeRequestBuildsView(LaunchpadFormView):
@@ -842,7 +842,7 @@ class OCIRecipeRequestBuildsView(LaunchpadFormView):
             "Your builds were scheduled and should start soon. "
             "Refresh this page for details."
         )
-        self.next_url = self.cancel_url
+        self.mutable_next_url = self.cancel_url
 
 
 class IOCIRecipeEditSchema(Interface):
@@ -1127,7 +1127,7 @@ class OCIRecipeAddView(
             # image_name is only available if using distribution credentials.
             image_name=data.get("image_name"),
         )
-        self.next_url = canonical_url(recipe)
+        self.mutable_next_url = canonical_url(recipe)
 
 
 class BaseOCIRecipeEditView(LaunchpadEditFormView):
@@ -1159,7 +1159,7 @@ class BaseOCIRecipeEditView(LaunchpadEditFormView):
             )
 
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def adapters(self):
@@ -1350,4 +1350,4 @@ class OCIRecipeDeleteView(BaseOCIRecipeEditView):
     def delete_action(self, action, data):
         oci_project = self.context.oci_project
         self.context.destroySelf()
-        self.next_url = canonical_url(oci_project)
+        self.mutable_next_url = canonical_url(oci_project)
diff --git a/lib/lp/registry/browser/announcement.py b/lib/lp/registry/browser/announcement.py
index ef7957e..6c91284 100644
--- a/lib/lp/registry/browser/announcement.py
+++ b/lib/lp/registry/browser/announcement.py
@@ -146,7 +146,7 @@ class AnnouncementAddView(LaunchpadFormView):
             url=data.get("url"),
             publication_date=data.get("publication_date"),
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def action_url(self):
@@ -184,7 +184,9 @@ class AnnouncementEditView(AnnouncementFormMixin, LaunchpadFormView):
             summary=data.get("summary"),
             url=data.get("url"),
         )
-        self.next_url = canonical_url(self.context.target) + "/+announcements"
+        self.mutable_next_url = (
+            canonical_url(self.context.target) + "/+announcements"
+        )
 
 
 class AnnouncementRetargetForm(Interface):
@@ -234,7 +236,9 @@ class AnnouncementRetargetView(AnnouncementFormMixin, LaunchpadFormView):
     def retarget_action(self, action, data):
         target = data.get("target")
         self.context.retarget(target)
-        self.next_url = canonical_url(self.context.target) + "/+announcements"
+        self.mutable_next_url = (
+            canonical_url(self.context.target) + "/+announcements"
+        )
 
 
 class AnnouncementPublishView(AnnouncementFormMixin, LaunchpadFormView):
@@ -250,7 +254,9 @@ class AnnouncementPublishView(AnnouncementFormMixin, LaunchpadFormView):
     def publish_action(self, action, data):
         publication_date = data["publication_date"]
         self.context.setPublicationDate(publication_date)
-        self.next_url = canonical_url(self.context.target) + "/+announcements"
+        self.mutable_next_url = (
+            canonical_url(self.context.target) + "/+announcements"
+        )
 
 
 class AnnouncementRetractView(AnnouncementFormMixin, LaunchpadFormView):
@@ -262,7 +268,9 @@ class AnnouncementRetractView(AnnouncementFormMixin, LaunchpadFormView):
     @action(_("Retract"), name="retract")
     def retract_action(self, action, data):
         self.context.retract()
-        self.next_url = canonical_url(self.context.target) + "/+announcements"
+        self.mutable_next_url = (
+            canonical_url(self.context.target) + "/+announcements"
+        )
 
 
 class AnnouncementDeleteView(AnnouncementFormMixin, LaunchpadFormView):
@@ -274,7 +282,9 @@ class AnnouncementDeleteView(AnnouncementFormMixin, LaunchpadFormView):
     @action(_("Delete"), name="delete", validator="validate_cancel")
     def action_delete(self, action, data):
         self.context.destroySelf()
-        self.next_url = canonical_url(self.context.target) + "/+announcements"
+        self.mutable_next_url = (
+            canonical_url(self.context.target) + "/+announcements"
+        )
 
 
 @implementer(IAnnouncementCreateMenu)
diff --git a/lib/lp/registry/browser/codeofconduct.py b/lib/lp/registry/browser/codeofconduct.py
index 3c47070..120891a 100644
--- a/lib/lp/registry/browser/codeofconduct.py
+++ b/lib/lp/registry/browser/codeofconduct.py
@@ -198,7 +198,7 @@ class AffirmCodeOfConductView(LaunchpadFormView):
             if error_message:
                 self.addError(error_message)
                 return
-        self.next_url = canonical_url(self.user) + "/+codesofconduct"
+        self.mutable_next_url = canonical_url(self.user) + "/+codesofconduct"
 
 
 class SignedCodeOfConductAddView(LaunchpadFormView):
@@ -222,7 +222,7 @@ class SignedCodeOfConductAddView(LaunchpadFormView):
         if error_message:
             self.addError(error_message)
             return
-        self.next_url = canonical_url(self.user) + "/+codesofconduct"
+        self.mutable_next_url = canonical_url(self.user) + "/+codesofconduct"
 
     @property
     def current(self):
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index 7180941..d2f3b4b 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -1100,7 +1100,7 @@ class DistributionAddView(
         )
 
         notify(ObjectCreatedEvent(distribution))
-        self.next_url = canonical_url(distribution)
+        self.mutable_next_url = canonical_url(distribution)
 
 
 class DistributionEditView(
@@ -1256,7 +1256,7 @@ class DistributionAdminView(LaunchpadEditFormView):
     @action("Change", name="change")
     def change_action(self, action, data):
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class DistributionSeriesBaseView(LaunchpadView):
@@ -1650,4 +1650,4 @@ class DistributionPublisherConfigView(LaunchpadFormView):
         self.request.response.addInfoNotification(
             "Your changes have been applied."
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
diff --git a/lib/lp/registry/browser/distributionmirror.py b/lib/lp/registry/browser/distributionmirror.py
index 262784e..527e493 100644
--- a/lib/lp/registry/browser/distributionmirror.py
+++ b/lib/lp/registry/browser/distributionmirror.py
@@ -198,10 +198,10 @@ class DistributionMirrorDeleteView(LaunchpadFormView):
             self.request.response.addInfoNotification(
                 "This mirror has been probed and thus can't be deleted."
             )
-            self.next_url = canonical_url(self.context)
+            self.mutable_next_url = canonical_url(self.context)
             return
 
-        self.next_url = canonical_url(
+        self.mutable_next_url = canonical_url(
             self.context.distribution, view_name="+pendingreviewmirrors"
         )
         self.request.response.addInfoNotification(
@@ -265,7 +265,7 @@ class DistributionMirrorAddView(LaunchpadFormView):
             official_candidate=data["official_candidate"],
         )
 
-        self.next_url = canonical_url(mirror)
+        self.mutable_next_url = canonical_url(mirror)
         notify(ObjectCreatedEvent(mirror))
 
 
@@ -296,7 +296,7 @@ class DistributionMirrorReviewView(LaunchpadEditFormView):
             context.reviewer = self.user
             context.date_reviewed = datetime.now(pytz.timezone("UTC"))
         self.updateContextFromData(data)
-        self.next_url = canonical_url(context)
+        self.mutable_next_url = canonical_url(context)
 
 
 class DistributionMirrorEditView(LaunchpadEditFormView):
@@ -335,7 +335,7 @@ class DistributionMirrorEditView(LaunchpadEditFormView):
     @action(_("Save"), name="save")
     def action_save(self, action, data):
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class DistributionMirrorResubmitView(LaunchpadEditFormView):
@@ -359,7 +359,7 @@ class DistributionMirrorResubmitView(LaunchpadEditFormView):
                 "The mirror is not in the correct state"
                 " (broken) and cannot be resubmitted."
             )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class DistributionMirrorReassignmentView(ObjectReassignmentView):
diff --git a/lib/lp/registry/browser/distroseries.py b/lib/lp/registry/browser/distroseries.py
index f1a25fe..facb566 100644
--- a/lib/lp/registry/browser/distroseries.py
+++ b/lib/lp/registry/browser/distroseries.py
@@ -655,7 +655,7 @@ class DistroSeriesEditView(LaunchpadEditFormView, SeriesStatusMixin):
         self.request.response.addInfoNotification(
             "Your changes have been applied."
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class DistroSeriesAdminView(LaunchpadEditFormView, SeriesStatusMixin):
@@ -710,7 +710,7 @@ class DistroSeriesAdminView(LaunchpadEditFormView, SeriesStatusMixin):
         self.request.response.addInfoNotification(
             "Your changes have been applied."
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class IDistroSeriesAddForm(Interface):
@@ -773,7 +773,7 @@ class DistroSeriesAddView(LaunchpadFormView):
             registrant=self.user,
         )
         notify(ObjectCreatedEvent(distroseries))
-        self.next_url = canonical_url(distroseries)
+        self.mutable_next_url = canonical_url(distroseries)
         return distroseries
 
     @property
@@ -1050,7 +1050,7 @@ class DistroSeriesDifferenceBaseView(
             # The copy worked so we redirect back to show the results. Include
             # the query string so that the user ends up on the same batch page
             # with the same filtering parameters as before.
-            self.next_url = self.request.getURL(include_query=True)
+            self.mutable_next_url = self.request.getURL(include_query=True)
 
     @property
     def action_url(self):
diff --git a/lib/lp/registry/browser/featuredproject.py b/lib/lp/registry/browser/featuredproject.py
index 4152919..968a5d2 100644
--- a/lib/lp/registry/browser/featuredproject.py
+++ b/lib/lp/registry/browser/featuredproject.py
@@ -62,11 +62,11 @@ class FeaturedProjectsView(LaunchpadFormView):
             for project in remove:
                 getUtility(IPillarNameSet).remove_featured_project(project)
 
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @action(_("Cancel"), name="cancel", validator="validate_cancel")
     def action_cancel(self, action, data):
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def action_url(self):
diff --git a/lib/lp/registry/browser/karma.py b/lib/lp/registry/browser/karma.py
index 19daccc..5b25f9e 100644
--- a/lib/lp/registry/browser/karma.py
+++ b/lib/lp/registry/browser/karma.py
@@ -61,7 +61,7 @@ class KarmaActionEditView(LaunchpadEditFormView):
     @action(_("Change"), name="change")
     def change_action(self, action, data):
         self.updateContextFromData(data)
-        self.next_url = self.cancel_url
+        self.mutable_next_url = self.cancel_url
 
 
 class KarmaContextContributor:
diff --git a/lib/lp/registry/browser/milestone.py b/lib/lp/registry/browser/milestone.py
index e6926a3..862563c 100644
--- a/lib/lp/registry/browser/milestone.py
+++ b/lib/lp/registry/browser/milestone.py
@@ -493,7 +493,7 @@ class MilestoneAddView(MilestoneTagBase, LaunchpadFormView):
         tags = data.get("tags")
         if tags:
             milestone.setTags(tags.lower().split(), self.user)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def action_url(self):
@@ -574,7 +574,7 @@ class MilestoneEditView(MilestoneTagBase, LaunchpadEditFormView):
         tags = data.pop("tags") or ""
         self.updateContextFromData(data)
         self.context.setTags(tags.lower().split(), self.user)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class MilestoneDeleteView(LaunchpadFormView, RegistryDeleteViewMixin):
@@ -622,7 +622,7 @@ class MilestoneDeleteView(LaunchpadFormView, RegistryDeleteViewMixin):
         self.request.response.addInfoNotification(
             "Milestone %s deleted." % name
         )
-        self.next_url = canonical_url(series)
+        self.mutable_next_url = canonical_url(series)
 
 
 class ISearchMilestoneTagsForm(Interface):
@@ -665,7 +665,9 @@ class MilestoneTagView(
     def search_by_tags(self, action, data):
         tags = data["tags"].split()
         milestone_tag = ProjectGroupMilestoneTag(self.context.target, tags)
-        self.next_url = canonical_url(milestone_tag, request=self.request)
+        self.mutable_next_url = canonical_url(
+            milestone_tag, request=self.request
+        )
 
 
 class ObjectMilestonesView(LaunchpadView):
diff --git a/lib/lp/registry/browser/ociproject.py b/lib/lp/registry/browser/ociproject.py
index e5d00c7..16fc785 100644
--- a/lib/lp/registry/browser/ociproject.py
+++ b/lib/lp/registry/browser/ociproject.py
@@ -129,7 +129,7 @@ class OCIProjectAddView(LaunchpadFormView):
         oci_project = getUtility(IOCIProjectSet).new(
             registrant=self.user, pillar=self.context, name=oci_project_name
         )
-        self.next_url = canonical_url(oci_project)
+        self.mutable_next_url = canonical_url(oci_project)
 
     def validate(self, data):
         super().validate(data)
diff --git a/lib/lp/registry/browser/peoplemerge.py b/lib/lp/registry/browser/peoplemerge.py
index 63f7bd9..24ac34b 100644
--- a/lib/lp/registry/browser/peoplemerge.py
+++ b/lib/lp/registry/browser/peoplemerge.py
@@ -175,7 +175,7 @@ class AdminMergeBaseView(ValidatingMergeView):
             requester=self.user,
         )
         self.request.response.addInfoNotification(self.merge_message)
-        self.next_url = self.success_url
+        self.mutable_next_url = self.success_url
 
 
 class AdminPeopleMergeView(AdminMergeBaseView):
@@ -494,7 +494,9 @@ class RequestPeopleMergeView(ValidatingMergeView):
             # The dupe account have more than one email address. Must redirect
             # the user to another page to ask which of those emails they
             # want to claim.
-            self.next_url = "+requestmerge-multiple?dupe=%d" % dupeaccount.id
+            self.mutable_next_url = (
+                "+requestmerge-multiple?dupe=%d" % dupeaccount.id
+            )
             return
 
         assert emails_count == 1
@@ -510,4 +512,4 @@ class RequestPeopleMergeView(ValidatingMergeView):
             LoginTokenType.ACCOUNTMERGE,
         )
         token.sendMergeRequestEmail()
-        self.next_url = "./+mergerequest-sent?dupe=%d" % dupeaccount.id
+        self.mutable_next_url = "./+mergerequest-sent?dupe=%d" % dupeaccount.id
diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py
index 8145cc0..0ca2baf 100644
--- a/lib/lp/registry/browser/person.py
+++ b/lib/lp/registry/browser/person.py
@@ -1100,7 +1100,7 @@ class PersonDeactivateAccountView(LaunchpadFormView):
         self.request.response.addInfoNotification(
             _("Your account has been deactivated.")
         )
-        self.next_url = self.request.getApplicationURL()
+        self.mutable_next_url = self.request.getApplicationURL()
 
 
 class BeginTeamClaimView(LaunchpadFormView):
@@ -3095,7 +3095,7 @@ class PersonEditEmailsView(LaunchpadFormView):
         self.request.response.addInfoNotification(
             "The email address '%s' has been removed." % emailaddress.email
         )
-        self.next_url = self.action_url
+        self.mutable_next_url = self.action_url
 
     def validate_action_set_preferred(self, action, data):
         """Make sure the user selected an address."""
@@ -3126,7 +3126,7 @@ class PersonEditEmailsView(LaunchpadFormView):
                 "Your contact address has been changed to: %s"
                 % (emailaddress.email)
             )
-        self.next_url = self.action_url
+        self.mutable_next_url = self.action_url
 
     def validate_action_confirm(self, action, data):
         """Make sure the user selected an email address to confirm."""
@@ -3151,7 +3151,7 @@ class PersonEditEmailsView(LaunchpadFormView):
             "instructions on how to confirm that "
             "it belongs to you." % email
         )
-        self.next_url = self.action_url
+        self.mutable_next_url = self.action_url
 
     def validate_action_remove_unvalidated(self, action, data):
         """Make sure the user selected an email address to remove."""
@@ -3189,7 +3189,7 @@ class PersonEditEmailsView(LaunchpadFormView):
         self.request.response.addInfoNotification(
             "The email address '%s' has been removed." % email
         )
-        self.next_url = self.action_url
+        self.mutable_next_url = self.action_url
 
     def validate_action_add_email(self, action, data):
         """Make sure the user entered a valid email address.
@@ -3267,7 +3267,7 @@ class PersonEditEmailsView(LaunchpadFormView):
             "provider might use 'greylisting', which could delay the "
             "message for up to an hour or two.)" % newemail
         )
-        self.next_url = self.action_url
+        self.mutable_next_url = self.action_url
 
 
 @implementer(IPersonEditMenu)
@@ -3465,7 +3465,7 @@ class PersonEditMailingListsView(LaunchpadFormView):
                         mailing_list.changeAddress(self.context, new_value)
         if dirty:
             self.request.response.addInfoNotification("Subscriptions updated.")
-        self.next_url = self.action_url
+        self.mutable_next_url = self.action_url
 
     def validate_action_update_autosubscribe_policy(self, action, data):
         """Ensure that the requested auto-subscribe setting is valid."""
@@ -3489,7 +3489,7 @@ class PersonEditMailingListsView(LaunchpadFormView):
         self.request.response.addInfoNotification(
             "Your auto-subscribe policy has been updated."
         )
-        self.next_url = self.action_url
+        self.mutable_next_url = self.action_url
 
 
 class BaseWithStats:
@@ -4357,7 +4357,7 @@ class PersonEditOCIRegistryCredentialsView(LaunchpadFormView):
 
         if not self.errors:
             self.request.response.addNotification("Saved credentials")
-            self.next_url = canonical_url(self.context)
+            self.mutable_next_url = canonical_url(self.context)
 
 
 class PersonLiveFSView(LaunchpadView):
@@ -4733,7 +4733,7 @@ class EmailToPersonView(LaunchpadFormView):
                     "does not have a preferred email address."
                 )
             )
-            self.next_url = canonical_url(self.context)
+            self.mutable_next_url = canonical_url(self.context)
             return
         try:
             send_direct_contact_email(
@@ -4759,7 +4759,7 @@ class EmailToPersonView(LaunchpadFormView):
                     mapping=dict(name=self.context.displayname),
                 )
             )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def cancel_url(self):
diff --git a/lib/lp/registry/browser/poll.py b/lib/lp/registry/browser/poll.py
index b182a91..9c3aa96 100644
--- a/lib/lp/registry/browser/poll.py
+++ b/lib/lp/registry/browser/poll.py
@@ -442,7 +442,7 @@ class PollAddView(LaunchpadFormView):
             secrecy,
             data["allowspoilt"],
         )
-        self.next_url = canonical_url(poll)
+        self.mutable_next_url = canonical_url(poll)
         notify(ObjectCreatedEvent(poll))
 
 
@@ -468,7 +468,7 @@ class PollEditView(LaunchpadEditFormView):
     @action("Save", name="save")
     def save_action(self, action, data):
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class PollOptionEditView(LaunchpadEditFormView):
@@ -488,7 +488,7 @@ class PollOptionEditView(LaunchpadEditFormView):
     @action("Save", name="save")
     def save_action(self, action, data):
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context.poll)
+        self.mutable_next_url = canonical_url(self.context.poll)
 
 
 class PollOptionAddView(LaunchpadFormView):
@@ -508,7 +508,7 @@ class PollOptionAddView(LaunchpadFormView):
     @action("Create", name="create")
     def create_action(self, action, data):
         polloption = self.context.newOption(data["name"], data["title"])
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
         notify(ObjectCreatedEvent(polloption))
 
 
diff --git a/lib/lp/registry/browser/product.py b/lib/lp/registry/browser/product.py
index f90e27a..448a7ff 100644
--- a/lib/lp/registry/browser/product.py
+++ b/lib/lp/registry/browser/product.py
@@ -2894,9 +2894,9 @@ class ProjectAddStepTwo(StepView, ProductLicenseMixin, ReturnToReferrerMixin):
         self.link_source_package(self.product, data)
 
         if self._return_url is None:
-            self.next_url = canonical_url(self.product)
+            self.mutable_next_url = canonical_url(self.product)
         else:
-            self.next_url = self._return_url
+            self.mutable_next_url = self._return_url
 
 
 class ProductAddView(PillarViewMixin, MultiStepView):
diff --git a/lib/lp/registry/browser/productrelease.py b/lib/lp/registry/browser/productrelease.py
index 3bf5b32..3760fb1 100644
--- a/lib/lp/registry/browser/productrelease.py
+++ b/lib/lp/registry/browser/productrelease.py
@@ -134,7 +134,7 @@ class ProductReleaseAddViewBase(LaunchpadFormView):
         # should not be targeted to a milestone in the past.
         if data.get("keep_milestone_active") is False:
             milestone.active = False
-        self.next_url = canonical_url(newrelease.milestone)
+        self.mutable_next_url = canonical_url(newrelease.milestone)
         notify(ObjectCreatedEvent(newrelease))
 
     @property
@@ -254,7 +254,7 @@ class ProductReleaseEditView(LaunchpadEditFormView):
     @action("Change", name="change")
     def change_action(self, action, data):
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def cancel_url(self):
@@ -353,7 +353,7 @@ class ProductReleaseAddDownloadFileView(LaunchpadFormView):
                 % release_file.libraryfile.filename
             )
 
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def cancel_url(self):
@@ -381,7 +381,7 @@ class ProductReleaseDeleteView(LaunchpadFormView, RegistryDeleteViewMixin):
         self.request.response.addInfoNotification(
             "Release %s deleted." % version
         )
-        self.next_url = canonical_url(series)
+        self.mutable_next_url = canonical_url(series)
 
     @property
     def cancel_url(self):
diff --git a/lib/lp/registry/browser/productseries.py b/lib/lp/registry/browser/productseries.py
index 9b05317..533ced7 100644
--- a/lib/lp/registry/browser/productseries.py
+++ b/lib/lp/registry/browser/productseries.py
@@ -817,7 +817,7 @@ class ProductSeriesDeleteView(RegistryDeleteViewMixin, LaunchpadEditFormView):
         name = self.context.name
         self._deleteProductSeries(self.context)
         self.request.response.addInfoNotification("Series %s deleted." % name)
-        self.next_url = canonical_url(product)
+        self.mutable_next_url = canonical_url(product)
 
 
 class ProductSeriesSetBranchView(ProductSetBranchView, ProductSeriesView):
@@ -869,7 +869,7 @@ class ProductSeriesReviewView(LaunchpadEditFormView):
         self.request.response.addInfoNotification(
             _("This Series has been changed")
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class ProductSeriesRdfView(BaseRdfView):
diff --git a/lib/lp/registry/browser/sourcepackage.py b/lib/lp/registry/browser/sourcepackage.py
index 461a963..2c628d0 100644
--- a/lib/lp/registry/browser/sourcepackage.py
+++ b/lib/lp/registry/browser/sourcepackage.py
@@ -393,7 +393,7 @@ class SourcePackageChangeUpstreamStepTwo(ReturnToReferrerMixin, StepView):
         productseries = data["productseries"]
         # Because it is part of a multistep view, the next_url can't
         # be set until the action is called, or it will skip the step.
-        self.next_url = self._return_url
+        self.mutable_next_url = self._return_url
         if self.context.productseries == productseries:
             # There is nothing to do.
             return
@@ -657,7 +657,7 @@ class SourcePackageAssociationPortletView(LaunchpadFormView):
         upstream = data.get("upstream")
         if upstream is self.other_upstream:
             # The user wants to link to an alternate upstream project.
-            self.next_url = canonical_url(
+            self.mutable_next_url = canonical_url(
                 self.context, view_name="+edit-packaging"
             )
             return
@@ -671,7 +671,7 @@ class SourcePackageAssociationPortletView(LaunchpadFormView):
             "The project %s was linked to this source package."
             % upstream.displayname
         )
-        self.next_url = self.request.getURL()
+        self.mutable_next_url = self.request.getURL()
 
 
 class PackageUpstreamTracking(EnumeratedType):
diff --git a/lib/lp/registry/browser/team.py b/lib/lp/registry/browser/team.py
index 8de222c..1ca580f 100644
--- a/lib/lp/registry/browser/team.py
+++ b/lib/lp/registry/browser/team.py
@@ -650,7 +650,7 @@ class TeamMailingListConfigurationView(MailingListTeamBaseView):
         ):
             self.mailing_list.welcome_message = welcome_message
 
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     def cancel_list_creation_validator(self, action, data):
         """Validator for the `cancel_list_creation` action.
@@ -675,7 +675,7 @@ class TeamMailingListConfigurationView(MailingListTeamBaseView):
         self.request.response.addInfoNotification(
             "Mailing list application cancelled."
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     def create_list_creation_validator(self, action, data):
         """Validator for the `create_list_creation` action.
@@ -701,7 +701,7 @@ class TeamMailingListConfigurationView(MailingListTeamBaseView):
             "The mailing list is being created and will be available for "
             "use in a few minutes."
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     def deactivate_list_validator(self, action, data):
         """Adds an error if someone tries to deactivate a non-active list.
@@ -722,7 +722,7 @@ class TeamMailingListConfigurationView(MailingListTeamBaseView):
         self.request.response.addInfoNotification(
             "The mailing list will be deactivated within a few minutes."
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     def reactivate_list_validator(self, action, data):
         """Adds an error if a non-deactivated list is reactivated.
@@ -742,7 +742,7 @@ class TeamMailingListConfigurationView(MailingListTeamBaseView):
         self.request.response.addInfoNotification(
             "The mailing list will be reactivated within a few minutes."
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     def purge_list_validator(self, action, data):
         """Adds an error if the list is not safe to purge.
@@ -762,7 +762,7 @@ class TeamMailingListConfigurationView(MailingListTeamBaseView):
         self.request.response.addInfoNotification(
             "The mailing list has been purged."
         )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def list_is_usable_but_not_contact_method(self):
@@ -1043,7 +1043,7 @@ class TeamMailingListModerationView(MailingListTeamBaseView):
                 "Messages still held for review: %d of %d"
                 % (still_held, reviewable)
             )
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
 
 class TeamMailingListArchiveView(LaunchpadView):
@@ -1118,7 +1118,7 @@ class TeamAddView(TeamFormMixin, HasRenewalPolicyMixin, LaunchpadFormView):
 
         if self.request.is_ajax:
             return ""
-        self.next_url = canonical_url(team)
+        self.mutable_next_url = canonical_url(team)
 
     def _validateVisibilityConsistency(self, value):
         """See `TeamFormMixin`."""
@@ -1221,9 +1221,9 @@ class ProposedTeamMembersEditView(LaunchpadFormView):
                         mapping=mapping,
                     )
                 )
-            self.next_url = ""
+            self.mutable_next_url = ""
         else:
-            self.next_url = self._next_url
+            self.mutable_next_url = self._next_url
 
     @property
     def page_title(self):
@@ -2104,7 +2104,7 @@ class TeamAddMyTeamsView(LaunchpadFormView):
             self.label = "Propose these teams as members"
         else:
             self.label = "Add these teams to %s" % context.displayname
-        self.next_url = canonical_url(context)
+        self.mutable_next_url = canonical_url(context)
         super().initialize()
 
     def setUpFields(self):
diff --git a/lib/lp/services/oauth/browser/__init__.py b/lib/lp/services/oauth/browser/__init__.py
index 2a67f72..9e72a40 100644
--- a/lib/lp/services/oauth/browser/__init__.py
+++ b/lib/lp/services/oauth/browser/__init__.py
@@ -411,7 +411,7 @@ class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin):
             return
         callback = self.request.form.get("oauth_callback")
         if callback:
-            self.next_url = callback
+            self.mutable_next_url = callback
 
 
 def lookup_oauth_context(context):
diff --git a/lib/lp/services/webhooks/browser.py b/lib/lp/services/webhooks/browser.py
index fefbf51..a72d563 100644
--- a/lib/lp/services/webhooks/browser.py
+++ b/lib/lp/services/webhooks/browser.py
@@ -137,7 +137,7 @@ class WebhookAddView(LaunchpadFormView):
             active=data["active"],
             secret=data["secret"],
         )
-        self.next_url = canonical_url(webhook)
+        self.mutable_next_url = canonical_url(webhook)
 
 
 class WebhookView(LaunchpadEditFormView):
@@ -198,4 +198,4 @@ class WebhookDeleteView(LaunchpadFormView):
         self.request.response.addNotification(
             "Webhook for %s deleted." % self.context.delivery_url
         )
-        self.next_url = canonical_url(target, view_name="+webhooks")
+        self.mutable_next_url = canonical_url(target, view_name="+webhooks")
diff --git a/lib/lp/snappy/browser/snap.py b/lib/lp/snappy/browser/snap.py
index 80819c0..1641bab 100644
--- a/lib/lp/snappy/browser/snap.py
+++ b/lib/lp/snappy/browser/snap.py
@@ -477,7 +477,7 @@ class SnapRequestBuildsView(LaunchpadFormView):
         self.request.response.addNotification(
             _("Builds will be dispatched soon.")
         )
-        self.next_url = self.cancel_url
+        self.mutable_next_url = self.cancel_url
 
 
 class ISnapEditSchema(Interface):
@@ -529,7 +529,7 @@ def log_oops(error, request):
 class SnapAuthorizeMixin:
     def requestAuthorization(self, snap):
         try:
-            self.next_url = SnapAuthorizeView.requestAuthorization(
+            self.mutable_next_url = SnapAuthorizeView.requestAuthorization(
                 snap, self.request
             )
         except BadRequestPackageUploadResponse as e:
@@ -746,7 +746,7 @@ class SnapAddView(
         if data["store_upload"]:
             self.requestAuthorization(snap)
         else:
-            self.next_url = canonical_url(snap)
+            self.mutable_next_url = canonical_url(snap)
 
     def validate(self, data):
         super().validate(data)
@@ -890,7 +890,7 @@ class BaseSnapEditView(
         if need_store_reauth:
             self.requestAuthorization(self.context)
         else:
-            self.next_url = canonical_url(self.context)
+            self.mutable_next_url = canonical_url(self.context)
 
     @property
     def adapters(self):
@@ -1157,4 +1157,4 @@ class SnapDeleteView(BaseSnapEditView):
     def delete_action(self, action, data):
         owner = self.context.owner
         self.context.destroySelf()
-        self.next_url = canonical_url(owner, view_name="+snaps")
+        self.mutable_next_url = canonical_url(owner, view_name="+snaps")
diff --git a/lib/lp/soyuz/browser/archive.py b/lib/lp/soyuz/browser/archive.py
index 6bbca05..d11a2a5 100644
--- a/lib/lp/soyuz/browser/archive.py
+++ b/lib/lp/soyuz/browser/archive.py
@@ -1189,9 +1189,9 @@ class ArchiveSourceSelectionFormView(ArchiveSourcePackageListViewBase):
         """
         query_string = self.request.get("QUERY_STRING", "")
         if query_string:
-            self.next_url = "%s?%s" % (self.request.URL, query_string)
+            self.mutable_next_url = "%s?%s" % (self.request.URL, query_string)
         else:
-            self.next_url = self.request.URL
+            self.mutable_next_url = self.request.URL
 
     def setUpWidgets(self, context=None):
         """Setup our custom widget depending on the filter widget values."""
@@ -1761,7 +1761,7 @@ class ArchiveEditDependenciesView(ArchiveViewBase, LaunchpadFormView):
     page_title = label
 
     def initialize(self):
-        self.cancel_url = canonical_url(self.context)
+        self.mutable_cancel_url = canonical_url(self.context)
         self._messages = []
         LaunchpadFormView.initialize(self)
 
@@ -2107,7 +2107,7 @@ class ArchiveEditDependenciesView(ArchiveViewBase, LaunchpadFormView):
         if len(self.messages) > 0:
             self.request.response.addNotification(structured(self.messages))
         # Redirect after POST.
-        self.next_url = self.request.URL
+        self.mutable_next_url = self.request.URL
 
 
 class ArchiveActivateView(LaunchpadFormView):
@@ -2202,7 +2202,7 @@ class ArchiveActivateView(LaunchpadFormView):
             description,
             private=self.is_private_team,
         )
-        self.next_url = canonical_url(ppa)
+        self.mutable_next_url = canonical_url(ppa)
 
     @property
     def is_private_team(self):
@@ -2254,7 +2254,7 @@ class BaseArchiveEditView(LaunchpadEditFormView, ArchiveViewBase):
                 )
             del data["processors"]
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def cancel_url(self):
diff --git a/lib/lp/soyuz/browser/archivesubscription.py b/lib/lp/soyuz/browser/archivesubscription.py
index de0bb9a..fa07b03 100644
--- a/lib/lp/soyuz/browser/archivesubscription.py
+++ b/lib/lp/soyuz/browser/archivesubscription.py
@@ -254,7 +254,7 @@ class ArchiveSubscribersView(LaunchpadFormView):
         self.request.response.addNotification(notification)
 
         # Just ensure a redirect happens (back to ourselves).
-        self.next_url = str(self.request.URL)
+        self.mutable_next_url = str(self.request.URL)
 
 
 class ArchiveSubscriptionEditView(LaunchpadEditFormView):
diff --git a/lib/lp/soyuz/browser/build.py b/lib/lp/soyuz/browser/build.py
index 27d3b5b..af00a85 100644
--- a/lib/lp/soyuz/browser/build.py
+++ b/lib/lp/soyuz/browser/build.py
@@ -358,7 +358,7 @@ class BuildRescoringView(LaunchpadFormView):
         any action will send the user back to the context build page.
         """
         build_url = canonical_url(self.context)
-        self.next_url = self.cancel_url = build_url
+        self.mutable_next_url = self.mutable_cancel_url = build_url
 
         if not self.context.can_be_rescored:
             self.request.response.redirect(build_url)
diff --git a/lib/lp/soyuz/browser/distroarchseries.py b/lib/lp/soyuz/browser/distroarchseries.py
index d3dae16..50c582e 100644
--- a/lib/lp/soyuz/browser/distroarchseries.py
+++ b/lib/lp/soyuz/browser/distroarchseries.py
@@ -125,7 +125,7 @@ class DistroArchSeriesAddView(LaunchpadFormView):
             data["official"],
             self.user,
         )
-        self.next_url = canonical_url(distroarchseries)
+        self.mutable_next_url = canonical_url(distroarchseries)
 
 
 class DistroArchSeriesAdminView(LaunchpadEditFormView):
diff --git a/lib/lp/soyuz/browser/livefs.py b/lib/lp/soyuz/browser/livefs.py
index 0fea4d9..210bede 100644
--- a/lib/lp/soyuz/browser/livefs.py
+++ b/lib/lp/soyuz/browser/livefs.py
@@ -261,7 +261,7 @@ class LiveFSAddView(LiveFSMetadataValidatorMixin, LaunchpadFormView):
             data["name"],
             json.loads(data["metadata"]),
         )
-        self.next_url = canonical_url(livefs)
+        self.mutable_next_url = canonical_url(livefs)
 
     def validate(self, data):
         super().validate(data)
@@ -289,7 +289,7 @@ class BaseLiveFSEditView(LaunchpadEditFormView):
     @action("Update live filesystem", name="update")
     def request_action(self, action, data):
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        self.mutable_next_url = canonical_url(self.context)
 
     @property
     def adapters(self):
@@ -394,4 +394,4 @@ class LiveFSDeleteView(BaseLiveFSEditView):
         self.context.destroySelf()
         # XXX cjwatson 2015-05-07 bug=1332479: This should go to
         # Person:+livefs once that exists.
-        self.next_url = canonical_url(owner)
+        self.mutable_next_url = canonical_url(owner)
diff --git a/lib/lp/translations/browser/distroseries.py b/lib/lp/translations/browser/distroseries.py
index 563d9b0..ec3a352 100644
--- a/lib/lp/translations/browser/distroseries.py
+++ b/lib/lp/translations/browser/distroseries.py
@@ -187,7 +187,7 @@ class DistroSeriesLanguagePackView(LaunchpadEditFormView):
         self.request.response.addInfoNotification(
             "Your changes have been applied."
         )
-        self.next_url = canonical_url(
+        self.mutable_next_url = canonical_url(
             self.context, rootsite="translations", view_name="+language-packs"
         )
 
@@ -195,7 +195,7 @@ class DistroSeriesLanguagePackView(LaunchpadEditFormView):
     def request_action(self, action, data):
         self.updateContextFromData(data)
         self._request_full_export()
-        self.next_url = canonical_url(
+        self.mutable_next_url = canonical_url(
             self.context, rootsite="translations", view_name="+language-packs"
         )
 
diff --git a/lib/lp/translations/browser/hastranslationimports.py b/lib/lp/translations/browser/hastranslationimports.py
index c1f7623..f133d02 100644
--- a/lib/lp/translations/browser/hastranslationimports.py
+++ b/lib/lp/translations/browser/hastranslationimports.py
@@ -205,7 +205,7 @@ class HasTranslationImportsView(LaunchpadFormView):
             )
 
         # Redirect to the filtered URL.
-        self.next_url = (
+        self.mutable_next_url = (
             "%s?%sfield.filter_status=%s&field.filter_extension=%s"
             % (
                 self.request.URL,
diff --git a/lib/lp/translations/browser/person.py b/lib/lp/translations/browser/person.py
index 12e0c34..0504f68 100644
--- a/lib/lp/translations/browser/person.py
+++ b/lib/lp/translations/browser/person.py
@@ -524,7 +524,7 @@ class PersonTranslationRelicensingView(LaunchpadFormView):
             raise AssertionError(
                 "Unknown allow_relicensing value: %r" % allow_relicensing
             )
-        self.next_url = self.getSafeRedirectURL(data["back_to"])
+        self.mutable_next_url = self.getSafeRedirectURL(data["back_to"])
 
 
 class TranslationActivityView(LaunchpadView):
diff --git a/lib/lp/translations/browser/translationgroup.py b/lib/lp/translations/browser/translationgroup.py
index 2aa2d50..9f7361b 100644
--- a/lib/lp/translations/browser/translationgroup.py
+++ b/lib/lp/translations/browser/translationgroup.py
@@ -208,7 +208,7 @@ class TranslationGroupAddView(LaunchpadFormView):
             owner=self.user,
         )
 
-        self.next_url = canonical_url(new_group)
+        self.mutable_next_url = canonical_url(new_group)
 
     def validate(self, data):
         """Do not allow new groups with duplicated names."""

Follow ups