From 2e601618f2851d4f162a836d4b53c0d2f9c47918 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Tue, 20 Oct 2020 15:19:07 +0200
Subject: [PATCH] backoffice: allow selecting user during submission (#8177)

---
 tests/test_backoffice_pages.py       | 82 ++++++++++++++++++++++++++--
 wcs/api.py                           |  7 ++-
 wcs/backoffice/data_management.py    |  2 +
 wcs/backoffice/submission.py         | 74 ++++++++++++++++++-------
 wcs/forms/root.py                    |  2 +
 wcs/qommon/static/css/dc2/admin.css  |  5 ++
 wcs/qommon/static/js/qommon.admin.js | 27 +++++++++
 7 files changed, 173 insertions(+), 26 deletions(-)

diff --git a/tests/test_backoffice_pages.py b/tests/test_backoffice_pages.py
index f47c7f694..56c0353ff 100644
--- a/tests/test_backoffice_pages.py
+++ b/tests/test_backoffice_pages.py
@@ -87,11 +87,18 @@ coucou = 1234
 
 
 def create_user(pub, is_admin=False):
-    if pub.user_class.select(lambda x: x.name == 'admin'):
-        user1 = pub.user_class.select(lambda x: x.name == 'admin')[0]
-        user1.is_admin = is_admin
-        user1.roles = [x.id for x in Role.select() if x.name == 'foobar']
-        user1.store()
+    user1 = None
+    for user in pub.user_class.select():
+        if user.name == 'admin':
+            user1 = user
+            user1.is_admin = is_admin
+            user1.roles = [x.id for x in Role.select() if x.name == 'foobar']
+            user1.store()
+        elif user.email == 'jean.darmette@triffouilis.fr':
+            pass  # don't remove user created by local_user fixture
+        else:
+            user.remove_self()
+    if user1:
         return user1
     user1 = pub.user_class(name='admin')
     user1.email = 'admin@localhost'
@@ -3527,6 +3534,71 @@ def test_backoffice_submission_only_one_check(pub, local_user):
     assert 'This form is limited to one per user' in resp
 
 
+def test_backoffice_submission_user_selection(pub):
+    user = create_user(pub)
+    create_environment(pub)
+
+    for i in range(10):
+        random_user = pub.user_class()
+        random_user.name = 'random user %s' % i
+        random_user.store()
+
+    app = login(get_app(pub))
+
+    formdef = FormDef.get_by_urlname('form-title')
+    formdef.fields = [
+        fields.PageField(id='0', label='1st page', type='page'),
+        fields.StringField(id='1', label='Field on 1st page', type='string'),
+        fields.PageField(id='2', label='2nd page', type='page'),
+        fields.StringField(id='3', label='Field on 2nd page', type='string'),
+    ]
+
+    formdef.backoffice_submission_roles = user.roles[:]
+    formdef.store()
+    resp = app.get('/backoffice/submission/')
+    resp = resp.click(formdef.name)
+
+    assert resp.form['submission_channel'].attrs['type'] == 'hidden'
+    assert resp.pyquery('.submit-user-selection')
+    resp.form['user_id'] = str(random_user.id)  # happens via javascript
+    resp.form['f1'] = 'test submission'
+    resp = resp.form.submit('submit')  # -> 2nd page
+    assert not resp.pyquery('.submit-user-selection')
+    assert '>%s</p>' % random_user.name in resp
+    resp.form['f3'] = 'baz'
+    resp = resp.form.submit('submit')  # -> validation page
+    assert 'Check values then click submit.' in resp
+    assert '<p>%s</p>' % random_user.name in resp
+
+    # final submit
+    resp = resp.form.submit('submit')
+
+    formdata_no = resp.location.split('/')[-2]
+    data_class = formdef.data_class()
+    assert data_class.get(formdata_no).user.name == random_user.name
+
+    # select user on second page
+    resp = app.get('/backoffice/submission/')
+    resp = resp.click(formdef.name)
+
+    assert resp.form['submission_channel'].attrs['type'] == 'hidden'
+    resp.form['f1'] = 'test submission'
+    resp = resp.form.submit('submit')  # -> 2nd page
+    assert resp.pyquery('.submit-user-selection')
+    resp.form['user_id'] = str(random_user.id)  # happens via javascript
+    resp.form['f3'] = 'baz'
+    resp = resp.form.submit('submit')  # -> validation page
+    assert 'Check values then click submit.' in resp
+    assert '<p>%s</p>' % random_user.name in resp
+
+    # final submit
+    resp = resp.form.submit('submit')
+
+    formdata_no = resp.location.split('/')[-2]
+    data_class = formdef.data_class()
+    assert data_class.get(formdata_no).user.name == random_user.name
+
+
 def test_backoffice_wscall_failure_display(http_requests, pub):
     user = create_user(pub)
     create_environment(pub)
diff --git a/wcs/api.py b/wcs/api.py
index 4ad41419f..e712ce410 100644
--- a/wcs/api.py
+++ b/wcs/api.py
@@ -44,6 +44,7 @@ from wcs.api_utils import sign_url_auto_orig, is_url_signed, get_user_from_api_q
 
 from .backoffice.management import FormPage as BackofficeFormPage
 from .backoffice.management import ManagementDirectory
+from .backoffice.submission import SubmissionDirectory
 
 
 def posted_json_data_to_formdata_data(formdef, data):
@@ -760,7 +761,11 @@ class ApiUsersDirectory(Directory):
     def _q_index(self):
         get_response().set_content_type('application/json')
         if not (is_url_signed() or (
-                get_request().user and get_request().user.can_go_in_admin())):
+                get_request().user and (
+                    get_request().user.can_go_in_admin() or
+                    SubmissionDirectory().is_accessible(get_request().user)))):
+            # request must be signed, or user must be an administrator or
+            # allowed to submit forms (as they have a form to select an user).
             raise AccessForbiddenError('unsigned request or user has no access to backoffice')
 
         criterias = []
diff --git a/wcs/backoffice/data_management.py b/wcs/backoffice/data_management.py
index 915ce1b71..614d946c4 100644
--- a/wcs/backoffice/data_management.py
+++ b/wcs/backoffice/data_management.py
@@ -305,6 +305,8 @@ class CardPage(FormPage):
 
 class CardFillPage(FormFillPage):
     formdef_class = CardDef
+    has_channel_support = False
+    has_user_support = False
 
     def submitted(self, form, *args):
         super(CardFillPage, self).submitted(form, *args)
diff --git a/wcs/backoffice/submission.py b/wcs/backoffice/submission.py
index 118241dca..7e1690885 100644
--- a/wcs/backoffice/submission.py
+++ b/wcs/backoffice/submission.py
@@ -95,11 +95,18 @@ class FormFillPage(PublicFormFillPage):
     filling_templates = ['wcs/formdata_filling.html']
     validation_templates = ['wcs/formdata_validation.html']
     steps_templates = ['wcs/formdata_steps.html']
+    has_channel_support = True
+    has_user_support = True
 
     def __init__(self, *args, **kwargs):
         super(FormFillPage, self).__init__(*args, **kwargs)
         self.selected_submission_channel = None
+        self.selected_user_id = None
         self.remove_draft = RemoveDraftDirectory(self)
+        if get_publisher().get_site_option('welco_url', 'options'):
+            # when welco is deployed, do not let agent manually change the
+            # submission channel
+            self.has_channel_support = False
 
     def _q_index(self, *args, **kwargs):
         # if NameID, return URL or submission channel are in query string,
@@ -126,7 +133,8 @@ class FormFillPage(PublicFormFillPage):
             self.set_tracking_code(formdata)
             return redirect('%s/' % formdata.id)
 
-        self.selected_submission_channel = get_request().form.get('submission_channel') or ''
+        self.selected_submission_channel = get_request().form.get('channel') or get_request().form.get('submission_channel')
+        self.selected_user_id = get_request().form.get('user_id')
         return super(FormFillPage, self)._q_index(*args, **kwargs)
 
     def html_top(self, *args, **kwargs):
@@ -182,6 +190,9 @@ class FormFillPage(PublicFormFillPage):
                 except KeyError:  # it may not exist
                     pass
 
+            if formdata and self.selected_user_id:
+                formdata.user_id = self.selected_user_id
+
         if self.formdef.enable_tracking_codes and not self.edit_mode:
             r += htmltext('<h3>%s</h3>') % _('Tracking Code')
             if formdata and formdata.tracking_code:
@@ -189,38 +200,59 @@ class FormFillPage(PublicFormFillPage):
             else:
                 r += htmltext('<p>-</p>')
 
-        welco_url = get_publisher().get_site_option('welco_url', 'options')
-        if formdata and (formdata.submission_context or formdata.user_id):
+        if formdata and self.on_validation_page:
+            if self.has_channel_support and self.selected_submission_channel:
+                formdata.submission_channel = self.selected_submission_channel
+            if self.has_user_support and self.selected_user_id:
+                formdata.user_id = self.selected_user_id
+
+        if (formdata and (formdata.submission_context or formdata.user_id)) or self.on_validation_page:
             from .management import FormBackOfficeStatusPage
             r += FormBackOfficeStatusPage(self.formdef, formdata).get_extra_context_bar()
-        elif not welco_url and not self.edit_mode:
-            r += htmltext('<div class="submit-channel-selection" style="display: none;">')
-            r += htmltext('<h3>%s</h3>') % _('Channel')
-            r += htmltext('<select>')
-            for channel_key, channel_label in [('', '-')] + list(FormData.get_submission_channels().items()):
-                selected = ''
-                if self.selected_submission_channel == channel_key:
-                    selected = 'selected="selected"'
-                r += htmltext('<option value="%s" %s>' % (channel_key, selected))
-                r += htmltext('%s</option>') % channel_label
-            r += htmltext('</select>')
-            r += htmltext('</div>')
+        elif not self.edit_mode:
+            if self.has_channel_support:
+                r += htmltext('<div class="submit-channel-selection" style="display: none;">')
+                r += htmltext('<h3>%s</h3>') % _('Channel')
+                r += htmltext('<select>')
+                for channel_key, channel_label in [('', '-')] + list(FormData.get_submission_channels().items()):
+                    selected = ''
+                    if self.selected_submission_channel == channel_key:
+                        selected = 'selected="selected"'
+                    r += htmltext('<option value="%s" %s>' % (channel_key, selected))
+                    r += htmltext('%s</option>') % channel_label
+                r += htmltext('</select>')
+                r += htmltext('</div>')
+
+            if self.has_user_support:
+                r += htmltext('<div class="submit-user-selection" style="display: none;">')
+                get_response().add_javascript(['select2.js'])
+                r += htmltext('<h3>%s</h3>') % _('Associated User')
+                r += htmltext('<select>')
+                if self.selected_user_id:
+                    r += htmltext('<option value="%s">%s</option>') % (
+                        self.selected_user_id,
+                        get_publisher().user_class.get(self.selected_user_id)
+                    )
+                r += htmltext('</select>')
+                r += htmltext('</div>')
 
         return r.getvalue()
 
     def create_view_form(self, *args, **kwargs):
         form = super(FormFillPage, self).create_view_form(*args, **kwargs)
-        welco_url = get_publisher().get_site_option('welco_url', 'options')
-        if not welco_url:
+        if self.has_channel_support:
             form.add_hidden('submission_channel', self.selected_submission_channel)
+        if self.has_user_support:
+            form.add_hidden('user_id', self.selected_user_id)
         return form
 
     def create_form(self, *args, **kwargs):
         form = super(FormFillPage, self).create_form(*args, **kwargs)
         form.attrs['data-live-url'] = self.formdef.get_backoffice_submission_url() + 'live'
-        welco_url = get_publisher().get_site_option('welco_url', 'options')
-        if not welco_url:
+        if self.has_channel_support:
             form.add_hidden('submission_channel', self.selected_submission_channel)
+        if self.has_user_support:
+            form.add_hidden('user_id', self.selected_user_id)
         return form
 
     def form_side(self, step_no, page, data=None, magictoken=None):
@@ -248,8 +280,10 @@ class FormFillPage(PublicFormFillPage):
         filled.backoffice_submission = True
         if not filled.submission_context:
             filled.submission_context = {}
-        if self.selected_submission_channel:
+        if self.has_channel_support and self.selected_submission_channel:
             filled.submission_channel = self.selected_submission_channel
+        if self.has_user_support and self.selected_user_id:
+            filled.user_id = self.selected_user_id
         filled.submission_agent_id = str(get_request().user.id)
         filled.store()
 
diff --git a/wcs/forms/root.py b/wcs/forms/root.py
index 1d93c81d1..863d64186 100644
--- a/wcs/forms/root.py
+++ b/wcs/forms/root.py
@@ -215,6 +215,7 @@ class FormPage(Directory, FormTemplateMixin):
         self.code = TrackingCodesDirectory(self.formdef)
         self.action_url = '.'
         self.edit_mode = False
+        self.on_validation_page = False
         self.user = get_request().user
         get_response().breadcrumb.append( (component + '/', self.formdef.name) )
 
@@ -1371,6 +1372,7 @@ class FormPage(Directory, FormTemplateMixin):
         return get_session().get_tempfile_content(t).get_file_pointer().read()
 
     def validating(self, data):
+        self.on_validation_page = True
         get_request().view_name = 'validation'
         self.html_top(self.formdef.name)
         # fake a GET request to avoid previous page POST data being carried
diff --git a/wcs/qommon/static/css/dc2/admin.css b/wcs/qommon/static/css/dc2/admin.css
index a4b4f2f11..12ecee97b 100644
--- a/wcs/qommon/static/css/dc2/admin.css
+++ b/wcs/qommon/static/css/dc2/admin.css
@@ -633,6 +633,11 @@ aside#sidebar select {
 	margin: 1px 0;
 }
 
+aside#sidebar span.select2-container {
+	width: 100% !important;
+	min-width: auto !important;
+}
+
 div.TextWidget textarea:focus,
 div.widget input[type="text"]:focus,
 div.TextWidget textarea,
diff --git a/wcs/qommon/static/js/qommon.admin.js b/wcs/qommon/static/js/qommon.admin.js
index dd38af8d9..dbee78e1b 100644
--- a/wcs/qommon/static/js/qommon.admin.js
+++ b/wcs/qommon/static/js/qommon.admin.js
@@ -137,6 +137,33 @@ $(function() {
       $('input[type=hidden][name=submission_channel]').val($(this).val());
     });
 
+    /* user id */
+    var select2_options = {
+      language: {
+        errorLoading: function() { return WCS_I18N.s2_errorloading; },
+        noResults: function () { return WCS_I18N.s2_nomatches; },
+        inputTooShort: function (input, min) { return WCS_I18N.s2_tooshort; },
+        loadingMore: function () { return WCS_I18N.s2_loadmore; },
+        searching: function () { return WCS_I18N.s2_searching; }
+      },
+      ajax: {
+        delay: 250,
+        dataType: 'json',
+        data: function(params) {
+          return {q: params.term, page_limit: 10};
+        },
+        processResults: function (data, params) {
+          return {results: data.data};
+        },
+        url: '/api/users/'
+      },
+      placeholder: '-'
+    }
+    $('div.submit-user-selection select').select2(select2_options);
+    $('div.submit-user-selection').show().find('select').on('change', function() {
+      $('input[type=hidden][name=user_id]').val($(this).val());
+    });
+
     /* new action form */
     $('#new-action-form select').on('change', function() {
       if ($(this).val() == '') {
-- 
2.28.0

