From e492f35027ef5d4a66670867e0ba3f83f75ffa29 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Fri, 21 Oct 2022 13:14:06 +0200
Subject: [PATCH] misc: redirect form{def,data} URL to include category slug
 (#69546)

---
 tests/form_pages/test_all.py      | 92 ++++++++++++++++++++++++++++++-
 wcs/backoffice/data_management.py |  1 +
 wcs/backoffice/submission.py      |  1 +
 wcs/carddef.py                    |  2 +-
 wcs/formdata.py                   |  7 ++-
 wcs/formdef.py                    |  4 +-
 wcs/forms/common.py               | 12 ++++
 wcs/forms/root.py                 | 32 ++++++++++-
 wcs/root.py                       |  2 +-
 9 files changed, 145 insertions(+), 8 deletions(-)

diff --git a/tests/form_pages/test_all.py b/tests/form_pages/test_all.py
index c9e867f96..d4b2ee05d 100644
--- a/tests/form_pages/test_all.py
+++ b/tests/form_pages/test_all.py
@@ -175,8 +175,11 @@ def test_home_with_user_forms(pub):
     assert 'Draft' in resp
     assert '<a href="/test/%s"' % draft.id in resp
     resp = app.get('/test/%s' % formdata.id)
-    resp.status_int = 200
+    assert resp.location == 'http://example.net/test/1/'
     resp = app.get('/test/%s/' % draft.id, status=302)
+    assert resp.location.startswith('http://example.net/foobar/test/%s/' % draft.id)
+    resp = resp.follow(status=302)
+    assert resp.location.startswith('http://example.net/foobar/test/?mt=')
     resp = resp.follow(status=200)
 
     # disable formdef: formdatas are still visible and accessible, drafts are not
@@ -189,6 +192,9 @@ def test_home_with_user_forms(pub):
     assert '<a href="test/%s"' % draft.id not in resp
     resp = app.get('/test/%s/' % formdata.id)
     resp = app.get('/test/%s/' % draft.id, status=302)
+    assert resp.location.startswith('http://example.net/foobar/test/%s/' % draft.id)
+    resp = resp.follow(status=302)
+    assert resp.location.startswith('http://example.net/foobar/test/?mt=')
     resp = resp.follow(status=403)
 
 
@@ -9863,3 +9869,87 @@ def test_global_interactive_action(pub):
     resp = resp.follow()
 
     assert 'HELLO GLOBAL INTERACTIVE ACTION' in resp.text
+
+
+def test_category_redirection(pub):
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'test category redirection'
+    formdef.fields = []
+    formdef.store()
+
+    get_app(pub).get(formdef.get_url())
+
+    Category.wipe()
+    cat = Category(name='foo')
+    cat.store()
+
+    cat2 = Category(name='bar')
+    cat2.store()
+
+    formdef.category_id = cat.id
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.just_created()
+    formdata.store()
+
+    resp = get_app(pub).get(formdef.get_url(), status=302)
+    assert resp.location == 'http://example.net/foo/test-category-redirection/'
+    resp = resp.follow()
+
+    resp = get_app(pub).get(formdef.get_url() + '?test=toto', status=302)
+    assert resp.location == 'http://example.net/foo/test-category-redirection/?test=toto'
+
+    get_app(pub).get('/bar/test-category-redirection/', status=404)
+
+    resp = get_app(pub).get(formdata.get_url(), status=302)
+    assert (
+        urllib.parse.urlparse(resp.location).path
+        == urllib.parse.urlparse(formdata.get_url(include_category=True)).path
+    )
+    resp = get_app(pub).get(formdata.get_url() + '?test=toto', status=302)
+    assert (
+        urllib.parse.urlparse(resp.location).path
+        == urllib.parse.urlparse(formdata.get_url(include_category=True)).path
+    )
+    assert urllib.parse.urlparse(resp.location).query == 'test=toto'
+
+    # check with formdef and category with same slug
+    formdef = FormDef()
+    formdef.name = 'bar'
+    formdef.fields = []
+    formdef.category_id = cat2.id
+    formdef.store()
+
+    formdata2 = formdef.data_class()()
+    formdata2.just_created()
+    formdata2.store()
+
+    resp = get_app(pub).get('/bar/', status=302)  # redirect even if category with same slug
+    assert resp.location == 'http://example.net/bar/bar/'
+    resp = get_app(pub).get('/bar/bar/', status=200)  # get formpage when full url is used
+    assert resp.pyquery('#steps')  # make sure it's a form page
+    resp = get_app(pub).get('/bar/bar/bar/', status=404)  # do not allow to go deeper
+
+    resp = get_app(pub).get(formdata2.get_url(), status=302)
+    assert (
+        urllib.parse.urlparse(resp.location).path
+        == urllib.parse.urlparse(formdata2.get_url(include_category=True)).path
+    )
+
+    # check other pages are ok with and without category slug
+    get_app(pub).get('/bar/qrcode', status=200)
+    get_app(pub).get('/bar/bar/qrcode', status=200)
+
+    # check another formdef in same category is ok
+    formdef = FormDef()
+    formdef.name = 'bar2'
+    formdef.fields = []
+    formdef.category_id = cat2.id
+    formdef.store()
+
+    resp = get_app(pub).get('/bar2/', status=302)
+    assert resp.location == 'http://example.net/bar/bar2/'
+    resp = get_app(pub).get('/bar/bar2/', status=200)
+    assert resp.pyquery('#steps')  # make sure it's a form page
diff --git a/wcs/backoffice/data_management.py b/wcs/backoffice/data_management.py
index bf1a4fa03..e1ee89196 100644
--- a/wcs/backoffice/data_management.py
+++ b/wcs/backoffice/data_management.py
@@ -128,6 +128,7 @@ class CardPage(FormPage):
     search_label = _('Search in card content')
     formdef_view_label = _('View Card')
     has_json_export_support = True
+    ensure_parent_category_in_url = False
 
     @property
     def add(self):
diff --git a/wcs/backoffice/submission.py b/wcs/backoffice/submission.py
index 947991638..6558b71d0 100644
--- a/wcs/backoffice/submission.py
+++ b/wcs/backoffice/submission.py
@@ -110,6 +110,7 @@ class FormFillPage(PublicFormFillPage):
     steps_templates = ['wcs/formdata_steps.html']
     has_channel_support = True
     has_user_support = True
+    ensure_parent_category_in_url = False
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
diff --git a/wcs/carddef.py b/wcs/carddef.py
index 7b84046d0..e053da001 100644
--- a/wcs/carddef.py
+++ b/wcs/carddef.py
@@ -120,7 +120,7 @@ class CardDef(FormDef):
 
         return workflow
 
-    def get_url(self, backoffice=False):
+    def get_url(self, backoffice=False, **kwargs):
         # always return backoffice URL
         base_url = get_publisher().get_backoffice_url() + '/data'
         return '%s/%s/' % (base_url, self.url_name)
diff --git a/wcs/formdata.py b/wcs/formdata.py
index 0ac197379..37131e9f7 100644
--- a/wcs/formdata.py
+++ b/wcs/formdata.py
@@ -793,8 +793,11 @@ class FormData(StorableObject):
         self.status = status
         self.store()
 
-    def get_url(self, backoffice=False):
-        return '%s%s/' % (self.formdef.get_url(backoffice=backoffice), self.id)
+    def get_url(self, backoffice=False, include_category=False):
+        return '%s%s/' % (
+            self.formdef.get_url(backoffice=backoffice, include_category=include_category),
+            self.id,
+        )
 
     def get_backoffice_url(self):
         return self.get_url(backoffice=True)
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 02320abb8..a2f55ef98 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -776,13 +776,15 @@ class FormDef(StorableObject):
     def slug(self, value):
         self.url_name = value
 
-    def get_url(self, backoffice=False, preview=False):
+    def get_url(self, backoffice=False, preview=False, include_category=False):
         if backoffice:
             base_url = get_publisher().get_backoffice_url() + '/management'
         elif preview:
             base_url = get_publisher().get_frontoffice_url() + '/preview'
         else:
             base_url = get_publisher().get_frontoffice_url()
+        if include_category and self.category_id:
+            return '%s/%s/%s/' % (base_url, self.category.slug, self.url_name)
         return '%s/%s/' % (base_url, self.url_name)
 
     def get_api_url(self):
diff --git a/wcs/forms/common.py b/wcs/forms/common.py
index a49572629..df905920f 100644
--- a/wcs/forms/common.py
+++ b/wcs/forms/common.py
@@ -275,6 +275,18 @@ class FormStatusPage(Directory, FormTemplateMixin):
         return r.getvalue()
 
     def _q_index(self):
+        if (
+            self.formdef.category_id
+            and self.parent_view
+            and self.parent_view.ensure_parent_category_in_url
+            and get_request().get_method() == 'GET'
+            and not self.parent_view.parent_category
+        ):
+            url = self.filled.get_url(include_category=True)
+            if get_request().get_query():
+                url += '?' + get_request().get_query()
+            return redirect(url)
+
         if self.filled.is_draft():
             return self.restore_draft()
 
diff --git a/wcs/forms/root.py b/wcs/forms/root.py
index f2e964ec0..0f4c1f03e 100644
--- a/wcs/forms/root.py
+++ b/wcs/forms/root.py
@@ -273,18 +273,23 @@ class FormPage(FormdefDirectoryBase, FormTemplateMixin):
     ]
 
     do_not_call_in_templates = True
+    ensure_parent_category_in_url = True
     filling_templates = ['wcs/front/formdata_filling.html', 'wcs/formdata_filling.html']
     validation_templates = ['wcs/front/formdata_validation.html', 'wcs/formdata_validation.html']
     steps_templates = ['wcs/front/formdata_steps.html', 'wcs/formdata_steps.html']
     sidebox_templates = ['wcs/front/formdata_sidebox.html', 'wcs/formdata_sidebox.html']
     formdef_class = FormDef
 
-    def __init__(self, component):
+    def __init__(self, component, parent_category=None):
         try:
             self.formdef = self.formdef_class.get_by_urlname(component)
         except KeyError:
             raise errors.TraversalError()
 
+        self.parent_category = parent_category
+        if self.parent_category and self.formdef.category_id != self.parent_category.id:
+            raise errors.TraversalError()
+
         self.substvars = {}
         get_publisher().substitutions.feed(self)
         get_publisher().substitutions.feed(self.formdef)
@@ -941,6 +946,16 @@ class FormPage(FormdefDirectoryBase, FormTemplateMixin):
         self._pages = None
 
     def _q_index(self):
+        if (
+            self.ensure_parent_category_in_url
+            and get_request().get_method() == 'GET'
+            and self.formdef.category_id
+            and not self.parent_category
+        ):
+            url = self.formdef.get_url(include_category=True)
+            if get_request().get_query():
+                url += '?' + get_request().get_query()
+            return redirect(url)
         self.check_access()
         authentication_context_check_result = self.check_authentication_context()
         if authentication_context_check_result:
@@ -1762,6 +1777,19 @@ class FormPage(FormdefDirectoryBase, FormTemplateMixin):
         return PublicFormStatusPage
 
     def _q_lookup(self, component):
+        if (
+            self.ensure_parent_category_in_url
+            and get_request().get_method() == 'GET'
+            and not self.parent_category
+        ):
+            # handle special case where there's a formdef and a category with the same
+            # slug; recurse into self with parent_category set to avoid the redirect to
+            # full URL step.
+            category = Category.get_by_slug(self.formdef.url_name, ignore_errors=True)
+            formdef = self.formdef_class.get_by_urlname(component, ignore_errors=True)
+            if category and formdef:
+                return self.__class__(component, parent_category=category)
+
         try:
             filled = self.formdef.data_class().get(component)
         except KeyError:
@@ -2109,7 +2137,7 @@ class RootDirectory(AccessControlled, Directory):
         return ApiCategoriesDirectory()._q_index()
 
     def _q_lookup(self, component):
-        return FormPage(component)
+        return FormPage(component, parent_category=self.category)
 
 
 class PublicFormStatusPage(FormStatusPage):
diff --git a/wcs/root.py b/wcs/root.py
index 94c662fd9..a4f8c04e3 100644
--- a/wcs/root.py
+++ b/wcs/root.py
@@ -415,7 +415,7 @@ class RootDirectory(Directory):
         except KeyError:
             pass
         else:
-            # redirect to category if there's not a formdef with same slug
+            # display category unless there's a formdef with same slug
             try:
                 FormDef.get_by_urlname(component)
             except KeyError:
-- 
2.37.2

