ccs-digitalmarketplace-frameworks 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/README.md +3 -1
  2. package/frameworks/digital-outcomes-and-specialists-7/manifests/edit_service.yml +11 -6
  3. package/frameworks/digital-outcomes-and-specialists-7/manifests/edit_service_as_admin.yml +11 -0
  4. package/frameworks/digital-outcomes-and-specialists-7/manifests/edit_submission.yml +11 -6
  5. package/frameworks/digital-outcomes-and-specialists-7/questions/services/itOperationsRolesItServiceManagerHeadDayRate.yml +1 -1
  6. package/frameworks/digital-outcomes-and-specialists-7/questions/services/itOperationsRolesItServiceManagerHeadDayRateSpecialist.yml +1 -1
  7. package/frameworks/digital-outcomes-and-specialists-7/questions/services/itOperationsRolesItServiceManagerHeadLocations.yml +2 -2
  8. package/frameworks/digital-outcomes-and-specialists-7/questions/services/itOperationsRolesItServiceManagerHeadUkDayRate.yml +1 -1
  9. package/frameworks/digital-outcomes-and-specialists-7/questions/services/itOperationsRolesItServiceManagerSeniorDayRate.yml +1 -1
  10. package/frameworks/digital-outcomes-and-specialists-7/questions/services/itOperationsRolesItServiceManagerSeniorDayRateSpecialist.yml +1 -1
  11. package/frameworks/digital-outcomes-and-specialists-7/questions/services/itOperationsRolesItServiceManagerSeniorLocations.yml +2 -2
  12. package/frameworks/digital-outcomes-and-specialists-7/questions/services/itOperationsRolesItServiceManagerSeniorUkDayRate.yml +1 -1
  13. package/frameworks/digital-outcomes-and-specialists-7/questions/services/userResearchParticipantsEssentialsManage.yml +22 -0
  14. package/frameworks/digital-outcomes-and-specialists-7/questions/services/userResearchParticipantsEssentialsRecruit.yml +22 -0
  15. package/frameworks/digital-outcomes-and-specialists-7/questions/services/whatUserResearchServices.yml +26 -0
  16. package/package.json +1 -1
  17. package/requirements-dev.txt +1 -1
  18. package/requirements.in +1 -1
  19. package/requirements.txt +1 -1
  20. package/schema_generator/validation.py +83 -15
  21. package/schemas/questions.json +17 -0
  22. package/tests/test_content.py +30 -0
  23. package/tests/test_generate_validation_schema.py +86 -2
package/README.md CHANGED
@@ -85,7 +85,9 @@ Question keys
85
85
  * `dynamic_field` defines the field in the filter context that will be used to generate dynamic list questions (eg when passing
86
86
  a brief as filter context setting `dynamic_field` to `brief.niceToHaveRequirements` will generate questions for nice-to-have
87
87
  requirements) (only valid for `dynamic_list` questions)
88
- * `followup` is a dictionary where each key is the name of the question that will be required/shown and the value for the key is a list of question answers that should display the follow up (only valid for `boolean`, `radios` and `checkboxes` questions)
88
+ * `followup` is a dictionary where each key is the name of the question that will be required/shown and the value for the key is a list of question answers that should display the follow up (only valid for `boolean`, `radios` and `checkboxes` questions). **Important** These questions appear on the same page as the parent question
89
+ * `dependent_follow_up` is a dictionary where each key is the name of the question that will be required/shown and the value for the key is a list of question answers that should display the follow up (only valid for `boolean`, `radios` and `checkboxes` questions). **Important** These questions appear in a different section to the parent question and need a corresponding `depends_on_follow_up` to link the questions
90
+ * `depends_on_follow_up` is a dictionary where each key is the name of the question this question depends on and the value is a list of values that will determine if it appears. **Important** These belong to a parent question and need a corresponding `dependent_followup` to link the questions
89
91
 
90
92
 
91
93
  Manifest files and section keys
@@ -1,9 +1,3 @@
1
- -
2
- name: Research studio name
3
- editable: True
4
- questions:
5
- - serviceName
6
-
7
1
  - name: Service essentials
8
2
  editable: False
9
3
  questions:
@@ -206,3 +200,14 @@
206
200
  editable: False
207
201
  questions:
208
202
  - outcomesLocations
203
+
204
+ - name: User research services
205
+ editable: False
206
+ questions:
207
+ - whatUserResearchServices
208
+
209
+ - name: User research participants essentials
210
+ editable: False
211
+ questions:
212
+ - userResearchParticipantsEssentialsRecruit
213
+ - userResearchParticipantsEssentialsManage
@@ -231,3 +231,14 @@
231
231
  editable: True
232
232
  questions:
233
233
  - outcomesLocations
234
+
235
+ - name: User research services
236
+ editable: True
237
+ questions:
238
+ - whatUserResearchServices
239
+
240
+ - name: User research participants essentials
241
+ editable: True
242
+ questions:
243
+ - userResearchParticipantsEssentialsRecruit
244
+ - userResearchParticipantsEssentialsManage
@@ -1,9 +1,3 @@
1
- -
2
- name: Research studio name
3
- editable: True
4
- questions:
5
- - serviceName
6
-
7
1
  - name: Service essentials
8
2
  editable: True
9
3
  description: >
@@ -271,3 +265,14 @@
271
265
  editable: True
272
266
  questions:
273
267
  - outcomesLocations
268
+
269
+ - name: User research services
270
+ editable: True
271
+ questions:
272
+ - whatUserResearchServices
273
+
274
+ - name: User research participants essentials
275
+ editable: True
276
+ questions:
277
+ - userResearchParticipantsEssentialsRecruit
278
+ - userResearchParticipantsEssentialsManage
@@ -20,7 +20,7 @@ digit_limits:
20
20
  validations:
21
21
  - name: answer_required
22
22
  field: itOperationsRolesItServiceManagerHeadPriceMax
23
- message: Enter the amount you will charge for a head of it service management
23
+ message: Enter the amount you will charge for a head of IT service management
24
24
  - name: not_money_format
25
25
  field: itOperationsRolesItServiceManagerHeadPriceMax
26
26
  message: Maximum price must be a number less than 10,000, without units, like 99.95
@@ -20,7 +20,7 @@ digit_limits:
20
20
  validations:
21
21
  - name: answer_required
22
22
  field: itOperationsRolesItServiceManagerHeadPriceMax
23
- message: Enter the amount you will charge for a head of it service management
23
+ message: Enter the amount you will charge for a head of IT service management
24
24
  - name: not_money_format
25
25
  field: itOperationsRolesItServiceManagerHeadPriceMax
26
26
  message: Maximum price must be a number less than 10,000, without units, like 99.95
@@ -1,6 +1,6 @@
1
- name: Where will your head of it service management work?
1
+ name: Where will your head of IT service management work?
2
2
  question: |
3
- <p class="govuk-!-margin-bottom-2"><span class="govuk-fieldset__legend--s">Where will your head of it service management work?</span></p>
3
+ <p class="govuk-!-margin-bottom-2"><span class="govuk-fieldset__legend--s">Where will your head of IT service management work?</span></p>
4
4
  <p class="govuk-!-margin-bottom-0"><span class="govuk-fieldset__legend--s">Choose all the locations where the specialist can work at buyer’s sites, and whether they can work offsite.</span></p>
5
5
  hint: |
6
6
  ‘Offsite’ means the supplier will not be working at the buyer’s own sites.
@@ -20,7 +20,7 @@ digit_limits:
20
20
  validations:
21
21
  - name: answer_required
22
22
  field: itOperationsRolesItServiceManagerHeadUkPriceMax
23
- message: Enter the amount you will charge for a head of it service management
23
+ message: Enter the amount you will charge for a head of IT service management
24
24
  - name: not_money_format
25
25
  field: itOperationsRolesItServiceManagerHeadUkPriceMax
26
26
  message: Maximum price must be a number less than 10,000, without units, like 99.95
@@ -20,7 +20,7 @@ digit_limits:
20
20
  validations:
21
21
  - name: answer_required
22
22
  field: itOperationsRolesItServiceManagerSeniorPriceMax
23
- message: Enter the amount you will charge for a senior it service manager
23
+ message: Enter the amount you will charge for a senior IT service manager
24
24
  - name: not_money_format
25
25
  field: itOperationsRolesItServiceManagerSeniorPriceMax
26
26
  message: Maximum price must be a number less than 10,000, without units, like 99.95
@@ -20,7 +20,7 @@ digit_limits:
20
20
  validations:
21
21
  - name: answer_required
22
22
  field: itOperationsRolesItServiceManagerSeniorPriceMax
23
- message: Enter the amount you will charge for a senior it service manager
23
+ message: Enter the amount you will charge for a senior IT service manager
24
24
  - name: not_money_format
25
25
  field: itOperationsRolesItServiceManagerSeniorPriceMax
26
26
  message: Maximum price must be a number less than 10,000, without units, like 99.95
@@ -1,6 +1,6 @@
1
- name: Where will your senior it service manager work?
1
+ name: Where will your senior IT service manager work?
2
2
  question: |
3
- <p class="govuk-!-margin-bottom-2"><span class="govuk-fieldset__legend--s">Where will your senior it service manager work?</span></p>
3
+ <p class="govuk-!-margin-bottom-2"><span class="govuk-fieldset__legend--s">Where will your senior IT service manager work?</span></p>
4
4
  <p class="govuk-!-margin-bottom-0"><span class="govuk-fieldset__legend--s">Choose all the locations where the specialist can work at buyer’s sites, and whether they can work offsite.</span></p>
5
5
  hint: |
6
6
  ‘Offsite’ means the supplier will not be working at the buyer’s own sites.
@@ -20,7 +20,7 @@ digit_limits:
20
20
  validations:
21
21
  - name: answer_required
22
22
  field: itOperationsRolesItServiceManagerSeniorUkPriceMax
23
- message: Enter the amount you will charge for a senior it service manager
23
+ message: Enter the amount you will charge for a senior IT service manager
24
24
  - name: not_money_format
25
25
  field: itOperationsRolesItServiceManagerSeniorUkPriceMax
26
26
  message: Maximum price must be a number less than 10,000, without units, like 99.95
@@ -0,0 +1,22 @@
1
+ name: Manage incentives to user research participants
2
+ question: Do you agree to manage any incentives to user research participants?
3
+
4
+ depends:
5
+ - "on": lot
6
+ being:
7
+ - user-research-studios-and-participants
8
+
9
+ depends_on_follow_up:
10
+ whatUserResearchServices:
11
+ - user-research-participants
12
+
13
+ type: checkboxes
14
+
15
+ options:
16
+ - label: "Yes"
17
+ value: "yes"
18
+
19
+ validations:
20
+ -
21
+ name: answer_required
22
+ message: Select if you agree to manage any incentives to user research participants
@@ -0,0 +1,22 @@
1
+ name: Recruit participants without revealing details
2
+ question: Can you, if asked, recruit participants without revealing details of the organisation you’re recruiting for?
3
+
4
+ depends:
5
+ - "on": lot
6
+ being:
7
+ - user-research-studios-and-participants
8
+
9
+ depends_on_follow_up:
10
+ whatUserResearchServices:
11
+ - user-research-participants
12
+
13
+ type: checkboxes
14
+
15
+ options:
16
+ - label: "Yes"
17
+ value: "yes"
18
+
19
+ validations:
20
+ -
21
+ name: answer_required
22
+ message: Select if you can recruit participants without revealing details of the organisation you’re recruiting for
@@ -0,0 +1,26 @@
1
+ name: What services can you provide?
2
+ question: What services can you provide?
3
+
4
+ depends:
5
+ - "on": lot
6
+ being:
7
+ - user-research-studios-and-participants
8
+
9
+ type: checkboxes
10
+
11
+ options:
12
+ - label: "User research studios"
13
+ value: "user-research-studios"
14
+ - label: "User research participants"
15
+ value: "user-research-participants"
16
+
17
+ dependent_follow_up:
18
+ userResearchParticipantsEssentialsRecruit:
19
+ - user-research-participants
20
+ userResearchParticipantsEssentialsManage:
21
+ - user-research-participants
22
+
23
+ validations:
24
+ -
25
+ name: answer_required
26
+ message: You must select the services you can provide
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccs-digitalmarketplace-frameworks",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "description": "Data files for Digital Marketplace’s procurement frameworks",
5
5
  "repository": "git@github.com:Crown-Commercial-Service/ccs-digitalmarketplace-frameworks",
6
6
  "author": "enquiries@digitalmarketplace.service.gov.uk"
@@ -34,7 +34,7 @@ ccs-digitalmarketplace-apiclient==25.11.0
34
34
  # via
35
35
  # -r requirements.txt
36
36
  # ccs-digitalmarketplace-utils
37
- ccs-digitalmarketplace-content-loader==11.6.0
37
+ ccs-digitalmarketplace-content-loader==11.7.0
38
38
  # via -r requirements.txt
39
39
  ccs-digitalmarketplace-utils==68.0.0
40
40
  # via
package/requirements.in CHANGED
@@ -1,5 +1,5 @@
1
1
  docopt==0.6.2
2
2
  deepmerge==2.0
3
3
  python-json-logger==2.0.7
4
- ccs-digitalmarketplace-content-loader==11.6.0
4
+ ccs-digitalmarketplace-content-loader==11.7.0
5
5
  ccs-digitalmarketplace-utils==68.0.0
package/requirements.txt CHANGED
@@ -18,7 +18,7 @@ cachelib==0.13.0
18
18
  # via flask-session
19
19
  ccs-digitalmarketplace-apiclient==25.11.0
20
20
  # via ccs-digitalmarketplace-utils
21
- ccs-digitalmarketplace-content-loader==11.6.0
21
+ ccs-digitalmarketplace-content-loader==11.7.0
22
22
  # via -r requirements.in
23
23
  ccs-digitalmarketplace-utils==68.0.0
24
24
  # via
@@ -348,22 +348,41 @@ def uri_property(question):
348
348
  }}
349
349
 
350
350
 
351
+ def has_dependent_follow_up(property_func):
352
+ """
353
+ Adds the additional dependent follow up schema to the property
354
+ """
355
+ def inner_func(question):
356
+ schema = property_func(question)
357
+ schema_addition = {}
358
+
359
+ if question.get('dependent_follow_up'):
360
+ schema_addition = _dependent_follow_up(question)
361
+
362
+ return schema, schema_addition
363
+
364
+ return inner_func
365
+
366
+
367
+ @has_dependent_follow_up
351
368
  def checkbox_property(question):
352
369
  """
353
370
  Convert a checkbox question into JSON Schema.
354
371
  """
355
- return {question['id']: {
356
- "type": "array",
357
- "uniqueItems": True,
358
- "minItems": 0 if question.get('optional') else 1,
359
- "maxItems": question.get('number_of_items', len(question['options'])),
360
- "items": {
361
- "enum": [
362
- option.get('value', option['label'])
363
- for option in question['options']
364
- ]
372
+ return {
373
+ question['id']: {
374
+ "type": "array",
375
+ "uniqueItems": True,
376
+ "minItems": 0 if question.get('optional') else 1,
377
+ "maxItems": question.get('number_of_items', len(question['options'])),
378
+ "items": {
379
+ "enum": [
380
+ option.get('value', option['label'])
381
+ for option in question['options']
382
+ ]
383
+ }
365
384
  }
366
- }}
385
+ }
367
386
 
368
387
 
369
388
  def checkbox_tree_property(question):
@@ -625,6 +644,45 @@ def _followup(question, root):
625
644
  return {'allOf': schemas}
626
645
 
627
646
 
647
+ def _dependent_follow_up(question):
648
+ schemas = []
649
+ question_values = [
650
+ option.get('value', option['label'])
651
+ for option in question.options
652
+ ]
653
+ for dependent_follow_up_field, values in question["dependent_follow_up"].items():
654
+ non_dependent_question_values = [
655
+ value
656
+ for value in question_values
657
+ if value not in values
658
+ ]
659
+ should_not_have_followup = {
660
+ "items": {"enum": non_dependent_question_values}
661
+ }
662
+ should_have_followup = {
663
+ "not": {"items": {"enum": non_dependent_question_values}}
664
+ }
665
+
666
+ schemas.append({
667
+ 'oneOf': [
668
+ {
669
+ "properties": {
670
+ question['id']: should_not_have_followup,
671
+ dependent_follow_up_field: {"type": "null"}
672
+ },
673
+ },
674
+ {
675
+ "properties": {
676
+ question['id']: should_have_followup,
677
+ },
678
+ "required": [dependent_follow_up_field]
679
+ },
680
+ ]
681
+ })
682
+
683
+ return {'allOf': schemas}
684
+
685
+
628
686
  def _flat_multiquestion(question):
629
687
  properties = {}
630
688
  for nested_question in question.questions:
@@ -825,8 +883,13 @@ def add_assurance(value_schema, assurance_approach):
825
883
  def build_question_properties(question):
826
884
  question_data = QUESTION_TYPES[question['type']](question)
827
885
  if question.get('assuranceApproach'):
828
- for key, value_schema in question_data.items():
829
- question_data[key] = add_assurance(value_schema, question['assuranceApproach'])
886
+ if isinstance(question_data, tuple):
887
+ for key, value_schema in question_data[0].items():
888
+ question_data[0][key] = add_assurance(value_schema, question['assuranceApproach'])
889
+ else:
890
+ for key, value_schema in question_data.items():
891
+ question_data[key] = add_assurance(value_schema, question['assuranceApproach'])
892
+
830
893
  return question_data
831
894
 
832
895
 
@@ -842,8 +905,13 @@ def build_schema_properties(schema, questions):
842
905
  property_schema = build_question_properties(question)
843
906
  if isinstance(property_schema, tuple):
844
907
  property_schema, schema_addition = property_schema
845
- schema['properties'].update(property_schema)
846
- schema = merge_schemas(schema, schema_addition)
908
+
909
+ if schema_addition:
910
+ schema['properties'].update(property_schema)
911
+ schema = merge_schemas(schema, schema_addition)
912
+ else:
913
+ schema['properties'].update(property_schema)
914
+ schema['required'].extend(question.required_form_fields)
847
915
  else:
848
916
  schema['properties'].update(property_schema)
849
917
  schema['required'].extend(question.required_form_fields)
@@ -48,6 +48,11 @@
48
48
  "properties": {
49
49
  "type": {"enum": ["boolean", "radios", "checkboxes"]}
50
50
  }
51
+ },
52
+ "dependent_follow_up": {
53
+ "properties": {
54
+ "type": {"enum": ["checkboxes"]}
55
+ }
51
56
  }
52
57
  },
53
58
  "properties": {
@@ -321,6 +326,18 @@
321
326
  "^[a-zA-Z0-9]+$": {"type": "array"}
322
327
  }
323
328
  },
329
+ "dependent_follow_up": {
330
+ "type": "object",
331
+ "patternProperties": {
332
+ "^[a-zA-Z0-9]+$": {"type": "array"}
333
+ }
334
+ },
335
+ "depends_on_follow_up": {
336
+ "type": "object",
337
+ "patternProperties": {
338
+ "^[a-zA-Z0-9]+$": {"type": "array"}
339
+ }
340
+ },
324
341
  "dynamic_field": {
325
342
  "type": "string"
326
343
  },
@@ -6,6 +6,7 @@ import pytest
6
6
 
7
7
  from dmcontent import ContentLoader
8
8
  from dmcontent.utils import TemplateField
9
+ from dmcontent.questions import Multiquestion
9
10
 
10
11
  content = ContentLoader('./')
11
12
 
@@ -130,6 +131,35 @@ def test_render_question(framework, question_id, question_set, question):
130
131
  )
131
132
 
132
133
 
134
+ @pytest.mark.parametrize("framework, question_set, manifest", get_manifests())
135
+ def test_dependent_follow_ups(framework, question_set, manifest):
136
+ content.load_manifest(framework, question_set, manifest)
137
+ loaded_content = content.get_manifest(framework, manifest)
138
+
139
+ questions_with_dependent_follow_up = {}
140
+ expected_questions_with_dependent_follow_up = {}
141
+
142
+ for section in loaded_content.sections:
143
+ for section_question in section.questions:
144
+ if isinstance(section_question, Multiquestion):
145
+ questions = section_question.get_questions_flat()
146
+ else:
147
+ questions = [section_question]
148
+
149
+ for question in questions:
150
+ if question.get("dependent_follow_up"):
151
+ questions_with_dependent_follow_up[question.id] = question.get("dependent_follow_up")
152
+
153
+ for question_id, question_values in question.get("depends_on_follow_up", {}).items():
154
+ if question_id not in expected_questions_with_dependent_follow_up:
155
+ expected_questions_with_dependent_follow_up[question_id] = {}
156
+
157
+ for field in question.form_fields:
158
+ expected_questions_with_dependent_follow_up[question_id][field] = question_values
159
+
160
+ assert questions_with_dependent_follow_up == expected_questions_with_dependent_follow_up
161
+
162
+
133
163
  def strip_paragraph_wrapper(value):
134
164
  match = re.fullmatch(r"^(<p.*?>)(.+?)(</p>)", value)
135
165
  if match:
@@ -274,12 +274,14 @@ def test_checkbox_property(id, options):
274
274
  "id": id,
275
275
  "options": options
276
276
  }
277
- result = checkbox_property(question)
277
+ result, additional_schema = checkbox_property(question)
278
278
  assert result[id]['minItems'] == 1
279
+ assert additional_schema == {}
279
280
 
280
281
  question['optional'] = True
281
- result = checkbox_property(question)
282
+ result, additional_schema = checkbox_property(question)
282
283
  assert result[id]['minItems'] == 0
284
+ assert additional_schema == {}
283
285
 
284
286
  enum = result[id]['items']['enum']
285
287
  assert len(enum) == len(options)
@@ -289,6 +291,88 @@ def test_checkbox_property(id, options):
289
291
  for option in options if 'value' not in option)
290
292
 
291
293
 
294
+ def test_checkbox_property_with_dependent_follow_up():
295
+ options = [
296
+ {"value": "1", "label": "One"},
297
+ {"label": "Two"},
298
+ {"value": "3", "label": "Three"}
299
+ ]
300
+
301
+ question = ContentQuestion({
302
+ "id": "q1",
303
+ "options": options,
304
+ "dependent_follow_up": {
305
+ "a": [
306
+ "1",
307
+ "Two"
308
+ ],
309
+ "b": [
310
+ "Two",
311
+ ],
312
+ "c": [
313
+ "1",
314
+ "3"
315
+ ]
316
+ }
317
+ })
318
+ result, additional_schema = checkbox_property(question)
319
+ assert result['q1']['minItems'] == 1
320
+
321
+ enum = result['q1']['items']['enum']
322
+ assert len(enum) == len(options)
323
+ assert all(option['value'] in enum
324
+ for option in options if 'value' in option)
325
+ assert all(option['label'] in enum
326
+ for option in options if 'value' not in option)
327
+
328
+ assert additional_schema == {
329
+ "allOf": [
330
+ {
331
+ "oneOf": [
332
+ {
333
+ "properties": {
334
+ "q1": {"items": {"enum": ["3"]}},
335
+ "a": {"type": "null"},
336
+ }
337
+ },
338
+ {
339
+ "properties": {"q1": {"not": {"items": {"enum": ["3"]}}}},
340
+ "required": ["a"],
341
+ },
342
+ ]
343
+ },
344
+ {
345
+ "oneOf": [
346
+ {
347
+ "properties": {
348
+ "q1": {"items": {"enum": ["1", "3"]}},
349
+ "b": {"type": "null"},
350
+ }
351
+ },
352
+ {
353
+ "properties": {"q1": {"not": {"items": {"enum": ["1", "3"]}}}},
354
+ "required": ["b"],
355
+ },
356
+ ]
357
+ },
358
+ {
359
+ "oneOf": [
360
+ {
361
+ "properties": {
362
+ "q1": {"items": {"enum": ["Two"]}},
363
+ "c": {"type": "null"},
364
+ }
365
+ },
366
+ {
367
+ "properties": {"q1": {"not": {"items": {"enum": ["Two"]}}}},
368
+ "required": ["c"],
369
+ },
370
+ ]
371
+ },
372
+ ]
373
+ }
374
+
375
+
292
376
  @settings(suppress_health_check=[HealthCheck.too_slow])
293
377
  @given(st.text(), nested_checkboxes_list(), st.integers(min_value=1))
294
378
  def test_checkbox_tree_property(id, options, number_of_items):