wiliot-certificate 4.5.0a2__py3-none-any.whl → 4.5.0a4__py3-none-any.whl

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 (65) hide show
  1. certificate/cert_common.py +35 -18
  2. certificate/cert_config.py +6 -6
  3. certificate/cert_data_sim.py +12 -9
  4. certificate/cert_defines.py +6 -0
  5. certificate/cert_gw_sim.py +3 -3
  6. certificate/cert_mqtt.py +5 -4
  7. certificate/cert_results.py +42 -32
  8. certificate/cert_utils.py +9 -10
  9. certificate/certificate.py +7 -5
  10. certificate/certificate_cli.py +9 -12
  11. certificate/certificate_eth_test_list.txt +3 -2
  12. certificate/certificate_sanity_test_list.txt +3 -2
  13. certificate/certificate_test_list.txt +3 -3
  14. certificate/tests/cloud_connectivity/acl_test/acl_test.py +13 -15
  15. certificate/tests/cloud_connectivity/brg_ota_test/brg_ota_test.json +1 -1
  16. certificate/tests/cloud_connectivity/channel_scan_behaviour_test/channel_scan_behaviour_test.py +2 -2
  17. certificate/tests/cloud_connectivity/connection_test/connection_test.py +4 -13
  18. certificate/tests/cloud_connectivity/deduplication_test/deduplication_test.py +1 -2
  19. certificate/tests/cloud_connectivity/downlink_test/downlink_test.py +1 -4
  20. certificate/tests/cloud_connectivity/ext_adv_stress_test/ext_adv_stress_test.py +12 -6
  21. certificate/tests/cloud_connectivity/registration_test/registration_test_cli.py +1 -1
  22. certificate/tests/cloud_connectivity/stress_test/stress_test.py +12 -7
  23. certificate/tests/cloud_connectivity/uplink_ext_adv_test/uplink_ext_adv_test.py +1 -2
  24. certificate/tests/cloud_connectivity/uplink_test/uplink_test.py +26 -20
  25. certificate/tests/datapath/event_ble5_test/event_ble5_test.json +1 -1
  26. certificate/tests/datapath/event_ble5_test/event_ble5_test.py +7 -13
  27. certificate/tests/datapath/event_test/event_test.json +1 -1
  28. certificate/tests/datapath/event_test/event_test.py +5 -10
  29. certificate/tests/datapath/pacer_interval_ble5_test/pacer_interval_ble5_test.py +4 -4
  30. certificate/tests/datapath/pkt_filter_ble5_chl21_test/pkt_filter_ble5_chl21_test.py +5 -5
  31. certificate/tests/datapath/pkt_filter_ble5_test/pkt_filter_ble5_test.py +5 -5
  32. certificate/tests/datapath/pkt_filter_brg2gw_ext_adv_test/pkt_filter_brg2gw_ext_adv_test.py +10 -8
  33. certificate/tests/datapath/rx_rate_gen2_test/rx_rate_gen2_test.py +1 -1
  34. certificate/tests/energy2400/signal_indicator_ble5_test/signal_indicator_ble5_test.py +4 -3
  35. certificate/tests/energy2400/signal_indicator_ext_adv_test/signal_indicator_ext_adv_test.json +8 -9
  36. certificate/tests/energy2400/signal_indicator_ext_adv_test/signal_indicator_ext_adv_test.py +113 -271
  37. certificate/tests/energy2400/signal_indicator_test/signal_indicator_test.py +1 -1
  38. certificate/tests/sensors/ext_sensor_test/ext_sensor_test.py +4 -9
  39. common/api_if/api_validation.py +6 -0
  40. common/web/templates/generator.html +141 -79
  41. common/web/web_utils.py +78 -56
  42. gui_certificate/server.py +255 -70
  43. gui_certificate/templates/cert_run.html +128 -98
  44. {wiliot_certificate-4.5.0a2.dist-info → wiliot_certificate-4.5.0a4.dist-info}/METADATA +6 -11
  45. {wiliot_certificate-4.5.0a2.dist-info → wiliot_certificate-4.5.0a4.dist-info}/RECORD +49 -65
  46. certificate/ag/wlt_types_ag_jsons/brg2brg_ota.json +0 -211
  47. certificate/ag/wlt_types_ag_jsons/brg2gw_hb.json +0 -894
  48. certificate/ag/wlt_types_ag_jsons/brg2gw_hb_sleep.json +0 -184
  49. certificate/ag/wlt_types_ag_jsons/calibration.json +0 -490
  50. certificate/ag/wlt_types_ag_jsons/custom.json +0 -614
  51. certificate/ag/wlt_types_ag_jsons/datapath.json +0 -900
  52. certificate/ag/wlt_types_ag_jsons/energy2400.json +0 -670
  53. certificate/ag/wlt_types_ag_jsons/energySub1g.json +0 -691
  54. certificate/ag/wlt_types_ag_jsons/externalSensor.json +0 -727
  55. certificate/ag/wlt_types_ag_jsons/interface.json +0 -1095
  56. certificate/ag/wlt_types_ag_jsons/powerManagement.json +0 -1439
  57. certificate/ag/wlt_types_ag_jsons/side_info_sensor.json +0 -105
  58. certificate/ag/wlt_types_ag_jsons/signal_indicator_data.json +0 -77
  59. certificate/ag/wlt_types_ag_jsons/unified_echo_ext_pkt.json +0 -126
  60. certificate/ag/wlt_types_ag_jsons/unified_echo_pkt.json +0 -175
  61. certificate/ag/wlt_types_ag_jsons/unified_sensor_pkt.json +0 -65
  62. {wiliot_certificate-4.5.0a2.dist-info → wiliot_certificate-4.5.0a4.dist-info}/WHEEL +0 -0
  63. {wiliot_certificate-4.5.0a2.dist-info → wiliot_certificate-4.5.0a4.dist-info}/entry_points.txt +0 -0
  64. {wiliot_certificate-4.5.0a2.dist-info → wiliot_certificate-4.5.0a4.dist-info}/licenses/LICENSE +0 -0
  65. {wiliot_certificate-4.5.0a2.dist-info → wiliot_certificate-4.5.0a4.dist-info}/top_level.txt +0 -0
gui_certificate/server.py CHANGED
@@ -1,7 +1,8 @@
1
1
  import os
2
2
  import json
3
3
  import tempfile
4
- from flask import Flask, render_template, request, session, redirect, url_for, flash, send_file, jsonify
4
+ import datetime
5
+ from flask import Flask, render_template, request, session, redirect, url_for, flash, send_file
5
6
  from jinja2 import ChoiceLoader, PackageLoader
6
7
  from werkzeug.utils import secure_filename
7
8
  from certificate.certificate_cli import CertificateCLI
@@ -31,9 +32,21 @@ TEMP_BASE = tempfile.gettempdir()
31
32
  UPLOAD_FOLDER = os.path.join(TEMP_BASE, "wiliot_certificate_uploaded_schemas")
32
33
  CONFIG_FOLDER = os.path.join(TEMP_BASE, "wiliot_certificate_saved_configs")
33
34
  TEST_LIST_FOLDER = os.path.join(TEMP_BASE, "wiliot_certificate_test_lists")
35
+ CUSTOM_BROKER_FOLDER = os.path.join(TEMP_BASE, "wiliot_certificate_custom_brokers")
34
36
  ALLOWED_EXTENSIONS = {'json'}
35
37
  MAX_UPLOAD_SIZE = 16 * 1024 * 1024 # 16MB
36
38
 
39
+ # Default custom broker configuration (from hivemq.json)
40
+ DEFAULT_CUSTOM_BROKER = {
41
+ "port": 8883,
42
+ "brokerUrl": "mqtts://broker.hivemq.com",
43
+ "username": "",
44
+ "password": "",
45
+ "updateTopic": "update/wiliot/<gatewayId>",
46
+ "statusTopic": "status/wiliot/<gatewayId>",
47
+ "dataTopic": "data/wiliot/<gatewayId>"
48
+ }
49
+
37
50
  app = Flask(__name__)
38
51
  app.secret_key = os.urandom(24) # Required for sessions
39
52
  app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
@@ -43,6 +56,7 @@ app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE
43
56
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
44
57
  os.makedirs(CONFIG_FOLDER, exist_ok=True)
45
58
  os.makedirs(TEST_LIST_FOLDER, exist_ok=True)
59
+ os.makedirs(CUSTOM_BROKER_FOLDER, exist_ok=True)
46
60
 
47
61
  # extend Jinja search path to include shared dir
48
62
  app.jinja_loader = ChoiceLoader([
@@ -70,9 +84,32 @@ def get_mandatory_modules_for_flavor(flavor):
70
84
  return [MODULE_CLOUD_CONNECTIVITY, MODULE_EDGE_MGMT]
71
85
  return []
72
86
 
87
+ def get_mandatory_modules_by_schema(schema_data, flavor):
88
+ """Get modules that are mandatory by schema (modules declared in schema)."""
89
+ if not schema_data or "modules" not in schema_data:
90
+ return []
91
+
92
+ # Only apply to bridge flavors (bridge_only and combo)
93
+ if flavor not in (FLAVOR_BRIDGE_ONLY, FLAVOR_COMBO):
94
+ return []
95
+
96
+ schema_modules = schema_data.get("modules", {})
97
+ if not isinstance(schema_modules, dict):
98
+ return []
99
+
100
+ # Map schema module names to test module names
101
+ mandatory_by_schema = []
102
+ for schema_mod_name in schema_modules.keys():
103
+ # Map schema module name to test module name
104
+ test_mod_name = test_module_and_schema_module_to_other(schema_mod_name)
105
+ if test_mod_name not in mandatory_by_schema and test_mod_name != 'custom':
106
+ mandatory_by_schema.append(test_mod_name)
107
+
108
+ return mandatory_by_schema
109
+
73
110
  # TODO: This is a temporary mapping to map test module names to schema module names - remove this
74
- def test_module_to_schema_module(test_module_name):
75
- """Map test module names to validation schema module names."""
111
+ def test_module_and_schema_module_to_other(module_name):
112
+ """Map test module names and schema module names to one another."""
76
113
  mapping = {
77
114
  "calibration": "calibration",
78
115
  "datapath": "datapath",
@@ -82,7 +119,12 @@ def test_module_to_schema_module(test_module_name):
82
119
  "sensors": "externalSensor", # different name
83
120
  "custom": "custom"
84
121
  }
85
- return mapping.get(test_module_name, test_module_name) # Return as-is if not in mapping
122
+ for k, v in mapping.items():
123
+ if module_name == k:
124
+ return v
125
+ if module_name == v:
126
+ return k
127
+ return module_name # Return as-is if not in mapping
86
128
 
87
129
  def filter_modules_by_flavor(tests_schema, flavor, schema_data=None):
88
130
  """Filter modules and tests based on selected flavor."""
@@ -115,12 +157,17 @@ def filter_modules_by_flavor(tests_schema, flavor, schema_data=None):
115
157
  # Include all tests for combo
116
158
  filtered_tests.append(test)
117
159
 
118
- # Only include module if it has tests or is mandatory
160
+ # Get modules mandatory by schema
161
+ mandatory_by_schema = get_mandatory_modules_by_schema(schema_data, flavor) if schema_data else []
162
+ is_mandatory_by_schema = mod_name in mandatory_by_schema
163
+
164
+ # Only include module if it has tests or is mandatory (by flavor or schema)
119
165
  # For mandatory modules, include even if no tests match (they'll be shown but empty)
120
- if filtered_tests or mod_name in mandatory_modules:
166
+ if filtered_tests or mod_name in mandatory_modules or is_mandatory_by_schema:
121
167
  mod_copy = mod.copy()
122
168
  mod_copy["tests"] = filtered_tests
123
169
  mod_copy["is_mandatory"] = mod_name in mandatory_modules
170
+ mod_copy["is_mandatory_by_schema"] = is_mandatory_by_schema
124
171
  filtered_modules.append(mod_copy)
125
172
 
126
173
  # Sort modules: cloud_connectivity first, then edge_mgmt, then by schema order
@@ -136,7 +183,7 @@ def filter_modules_by_flavor(tests_schema, flavor, schema_data=None):
136
183
  if schema_module_order:
137
184
  try:
138
185
  # Map test module name to schema module name
139
- schema_mod_name = test_module_to_schema_module(mod_name)
186
+ schema_mod_name = test_module_and_schema_module_to_other(mod_name)
140
187
  idx = schema_module_order.index(schema_mod_name)
141
188
  return (2, idx)
142
189
  except ValueError:
@@ -285,30 +332,37 @@ def verify_schema_matches_selection(schema_data, flavor, selected_modules, selec
285
332
  bridge_modules_to_check = [m for m in selected_modules if m not in (MODULE_CLOUD_CONNECTIVITY, MODULE_EDGE_MGMT)]
286
333
  for test_mod_name in bridge_modules_to_check:
287
334
  # Map test module name to schema module name
288
- schema_mod_name = test_module_to_schema_module(test_mod_name)
335
+ schema_mod_name = test_module_and_schema_module_to_other(test_mod_name)
289
336
  if schema_mod_name not in schema_module_names:
290
337
  warnings.append(f"Module '{test_mod_name}' may not be supported according to the uploaded schema")
291
338
 
292
339
  return len(errors) == 0, errors, warnings
293
340
 
294
- def check_certification_status(flavor, selected_modules, selected_tests, tests_schema):
341
+ def check_certification_status(flavor, selected_modules, selected_tests, tests_schema, schema_data=None, unsterile_run=False):
295
342
  """Check if the selection qualifies for certification or is test-only."""
296
343
  mandatory_modules = get_mandatory_modules_for_flavor(flavor)
344
+ mandatory_by_schema = get_mandatory_modules_by_schema(schema_data, flavor) if schema_data else []
297
345
  all_mandatory_tests = []
298
346
 
299
- # Get all mandatory tests from mandatory modules
347
+ # Get selected module names (handle both dict and string formats)
348
+ selected_module_names = [m.get("name") if isinstance(m, dict) else m for m in selected_modules]
349
+
350
+ # Get all mandatory tests from ALL selected modules (not just mandatory modules)
351
+ # When a module is selected, its mandatory tests become mandatory
300
352
  for mod in tests_schema.get("modules", []):
301
353
  mod_name = mod.get("name", "")
302
- if mod_name in mandatory_modules:
354
+ if mod_name in selected_module_names:
303
355
  for test in mod.get("tests", []):
304
356
  if test.get("meta", {}).get("mandatory", 0) == 1:
305
357
  all_mandatory_tests.append(test.get("id"))
306
358
 
307
359
  # Check if all mandatory modules are selected
308
- selected_module_names = [m.get("name") if isinstance(m, dict) else m for m in selected_modules]
309
360
  missing_modules = [m for m in mandatory_modules if m not in selected_module_names]
310
361
 
311
- # Check if all mandatory tests are selected
362
+ # Check if all schema-mandatory modules are selected
363
+ missing_modules_by_schema = [m for m in mandatory_by_schema if m not in selected_module_names]
364
+
365
+ # Check if all mandatory tests (from selected modules) are selected
312
366
  missing_tests = [t for t in all_mandatory_tests if t not in selected_tests]
313
367
 
314
368
  # Check for "at least one additional module" requirement for Bridge Only and Combo
@@ -319,9 +373,11 @@ def check_certification_status(flavor, selected_modules, selected_tests, tests_s
319
373
  if len(additional_modules) == 0:
320
374
  missing_additional_module = True
321
375
 
322
- is_certified = len(missing_modules) == 0 and len(missing_tests) == 0 and not missing_additional_module
376
+ # A certifying run must be sterile - if unsterile_run is set, force non-certifying
377
+ is_certified = (len(missing_modules) == 0 and len(missing_modules_by_schema) == 0 and
378
+ len(missing_tests) == 0 and not missing_additional_module and not unsterile_run)
323
379
 
324
- return is_certified, missing_modules, missing_tests, missing_additional_module
380
+ return is_certified, missing_modules, missing_tests, missing_additional_module, missing_modules_by_schema
325
381
 
326
382
  def _prepare_form_data_for_template(session, cert_schema):
327
383
  """Prepare form_data for template, ensuring custom_broker_path is included if available."""
@@ -424,6 +480,43 @@ def _write_testlist(selected_ids: list[str], form_data=None, is_certified=False)
424
480
  print(f"Custom test list saved in {path}")
425
481
  return path
426
482
 
483
+ def _write_custom_broker_config(form_data, cert_schema_title="cert_run") -> str:
484
+ """
485
+ Generate a custom broker JSON configuration file from form data.
486
+ Extracts broker field values and creates a JSON file matching hivemq.json format.
487
+ """
488
+ # Extract broker configuration from form_data
489
+ broker_config = {}
490
+
491
+ # Field names follow pattern: {schema_title}_custom_broker_{field_name}
492
+ field_prefix = f"{cert_schema_title}_custom_broker_"
493
+
494
+ # Extract values from form_data, using defaults if not provided
495
+ for field in DEFAULT_CUSTOM_BROKER.keys():
496
+ field_name = f"{field_prefix}{field}"
497
+ value = form_data.get(field_name, "")
498
+
499
+ # Convert port to int if it's a number
500
+ if field == "port":
501
+ try:
502
+ broker_config[field] = int(value) if value else DEFAULT_CUSTOM_BROKER[field]
503
+ except (ValueError, TypeError):
504
+ broker_config[field] = DEFAULT_CUSTOM_BROKER[field]
505
+ else:
506
+ broker_config[field] = value
507
+
508
+ # Generate filename with timestamp
509
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
510
+ filename = f"custom_broker_{timestamp}.json"
511
+ filepath = os.path.join(CUSTOM_BROKER_FOLDER, filename)
512
+
513
+ # Write JSON file
514
+ with open(filepath, 'w', encoding='utf-8') as f:
515
+ json.dump(broker_config, f, indent=4)
516
+
517
+ print(f"Custom broker config saved in {filepath}")
518
+ return filepath
519
+
427
520
  @app.route("/")
428
521
  def index():
429
522
  """Redirect to step 1 or show current step."""
@@ -451,6 +544,7 @@ def step(step_num):
451
544
  session['selected_modules'] = []
452
545
  session['selected_tests'] = []
453
546
  session['form_data'] = {}
547
+ session['schema_mandatory_initialized'] = False
454
548
 
455
549
  title = "Run Certificate"
456
550
  tests_schema = web_utils.scan_tests_dir(CERT_TESTS_ROOT)
@@ -505,7 +599,22 @@ def step(step_num):
505
599
  flavor = session.get('flavor')
506
600
  if 'selected_modules' not in session or not session.get('selected_modules'):
507
601
  mandatory_modules = get_mandatory_modules_for_flavor(flavor)
508
- session['selected_modules'] = mandatory_modules
602
+ # Add schema-mandatory modules
603
+ schema_data = result # result is the validated schema data
604
+ mandatory_by_schema = get_mandatory_modules_by_schema(schema_data, flavor)
605
+ all_mandatory = list(set(mandatory_modules + mandatory_by_schema))
606
+ session['selected_modules'] = all_mandatory
607
+ # Mark schema-mandatory modules as initialized
608
+ session['schema_mandatory_initialized'] = True
609
+ else:
610
+ # If modules already exist, add schema-mandatory modules and mark as initialized
611
+ schema_data = result
612
+ mandatory_by_schema = get_mandatory_modules_by_schema(schema_data, flavor)
613
+ current_modules = session.get('selected_modules', [])
614
+ updated_modules = list(set(current_modules + mandatory_by_schema))
615
+ if len(updated_modules) > len(current_modules):
616
+ session['selected_modules'] = updated_modules
617
+ session['schema_mandatory_initialized'] = True
509
618
  # For GW only, skip step 3 (modules)
510
619
  if session.get('flavor') == FLAVOR_GW_ONLY:
511
620
  session['current_step'] = 4
@@ -575,43 +684,19 @@ def step(step_num):
575
684
  else:
576
685
  form_data_dict[key] = request.form.get(key)
577
686
 
578
- # Handle custom_broker file upload
687
+ # Handle custom_broker configuration from form fields
579
688
  cert_cli = CertificateCLI()
580
689
  cert_schema = web_utils.parser_to_schema(cert_cli.parser)
581
690
  custom_broker_field_name = f"{cert_schema['title']}_custom_broker"
582
- custom_broker_file_key = f"{custom_broker_field_name}_file"
583
-
584
- # Preserve existing custom_broker_path if no new file is uploaded
585
- existing_custom_broker_path = session.get('custom_broker_path')
586
- if existing_custom_broker_path:
587
- form_data_dict[custom_broker_field_name] = existing_custom_broker_path
588
691
 
589
- if custom_broker_file_key in request.files:
590
- file = request.files[custom_broker_file_key]
591
- if file and file.filename and allowed_file(file.filename):
592
- filename = secure_filename(file.filename)
593
- timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
594
- filename = f"{timestamp}_{filename}"
595
- filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
596
- file.save(filepath)
597
-
598
- # Validate file is valid JSON
599
- try:
600
- with open(filepath, "r", encoding="utf-8") as f:
601
- json.load(f)
602
- # Store normalized path
603
- normalized_path = os.path.normpath(filepath)
604
- session['custom_broker_path'] = normalized_path
605
- form_data_dict[custom_broker_field_name] = normalized_path
606
- except json.JSONDecodeError as e:
607
- error = f"Invalid JSON in custom broker file: {e}"
608
- os.remove(filepath)
609
- except Exception as e:
610
- error = f"Error reading custom broker file: {e}"
611
- if os.path.exists(filepath):
612
- os.remove(filepath)
613
- elif file and file.filename:
614
- error = "Custom broker file must be a JSON file"
692
+ # Generate custom broker JSON file from form fields
693
+ try:
694
+ broker_config_path = _write_custom_broker_config(form_data_dict, cert_schema['title'])
695
+ normalized_path = os.path.normpath(broker_config_path)
696
+ session['custom_broker_path'] = normalized_path
697
+ form_data_dict[custom_broker_field_name] = normalized_path
698
+ except Exception as e:
699
+ error = f"Error generating custom broker configuration: {e}"
615
700
 
616
701
  # Validate all required fields (required for moving forward, but allow going back)
617
702
  if action == 'next':
@@ -631,13 +716,22 @@ def step(step_num):
631
716
  field_name = f"{cert_schema['title']}_{field['name']}"
632
717
  field_value = form_data_dict.get(field_name, '')
633
718
 
634
- # Special handling for custom_broker file upload
719
+ # Special handling for custom_broker - check if all required broker fields are filled
635
720
  if field['name'] == 'custom_broker':
636
- # Check if file was uploaded or path exists in session
637
- custom_broker_file_key = f"{field_name}_file"
638
- if custom_broker_file_key not in request.files or not request.files[custom_broker_file_key].filename:
639
- # Check if path exists in form_data or session
640
- if not field_value and not session.get('custom_broker_path'):
721
+ # Check all broker fields except username and password are filled
722
+ broker_field_prefix = f"{cert_schema['title']}_custom_broker_"
723
+ optional_fields = {'username', 'password'}
724
+ for broker_field_key in DEFAULT_CUSTOM_BROKER.keys():
725
+ if broker_field_key not in optional_fields:
726
+ broker_field_name = f"{broker_field_prefix}{broker_field_key}"
727
+ broker_field_value = form_data_dict.get(broker_field_name, '')
728
+ if not broker_field_value or (isinstance(broker_field_value, str) and not broker_field_value.strip()):
729
+ missing_required.append(f"custom_broker.{broker_field_key}")
730
+ break
731
+ # Also check if path was generated (should be generated above if fields are valid)
732
+ if not field_value and not session.get('custom_broker_path'):
733
+ # Only add if we haven't already added a broker field error
734
+ if not any('custom_broker.' in req for req in missing_required):
641
735
  missing_required.append(field.get('name', field['label']))
642
736
  else:
643
737
  # For other fields, check if value is provided
@@ -681,9 +775,30 @@ def step(step_num):
681
775
  # This ensures mandatory items are only pre-checked at the beginning
682
776
  if flavor and not selected_modules:
683
777
  mandatory_modules = get_mandatory_modules_for_flavor(flavor)
684
- selected_modules = mandatory_modules
778
+ # Add schema-mandatory modules if schema is available
779
+ schema_data = load_schema_data(schema_path) if schema_path else None
780
+ mandatory_by_schema = get_mandatory_modules_by_schema(schema_data, flavor) if schema_data else []
781
+ all_mandatory = list(set(mandatory_modules + mandatory_by_schema))
782
+ selected_modules = all_mandatory
685
783
  session['selected_modules'] = selected_modules
686
784
 
785
+ # Ensure schema-mandatory modules are added when first reaching step 3 (only if not already initialized)
786
+ # Use a session flag to track if schema-mandatory modules have been initialized
787
+ if step_num == 3 and flavor and schema_path:
788
+ schema_mandatory_initialized = session.get('schema_mandatory_initialized', False)
789
+ if not schema_mandatory_initialized:
790
+ schema_data = load_schema_data(schema_path) if schema_path else None
791
+ if schema_data:
792
+ mandatory_by_schema = get_mandatory_modules_by_schema(schema_data, flavor)
793
+ # Add schema-mandatory modules that aren't already selected
794
+ current_modules = session.get('selected_modules', [])
795
+ updated_modules = list(set(current_modules + mandatory_by_schema))
796
+ if len(updated_modules) > len(current_modules):
797
+ selected_modules = updated_modules
798
+ session['selected_modules'] = selected_modules
799
+ # Mark as initialized so we don't re-add them when going back
800
+ session['schema_mandatory_initialized'] = True
801
+
687
802
  # Initialize mandatory tests only if not already in session and we have selected modules
688
803
  # Only initialize when first reaching step 4 (not on every GET request)
689
804
  if flavor and selected_modules and not selected_tests and step_num == 4:
@@ -714,17 +829,28 @@ def step(step_num):
714
829
  schema_data = load_schema_data(schema_path) if schema_path else None
715
830
  filtered_modules = filter_modules_by_flavor(tests_schema, flavor, schema_data)
716
831
 
832
+ # Get CLI schemas for form fields - use certificate_cli for all flavors (needed early for unsterile_run check)
833
+ cert_cli = CertificateCLI()
834
+ cert_schema = web_utils.parser_to_schema(cert_cli.parser)
835
+
836
+ # Check if unsterile_run is set in form_data
837
+ form_data = _prepare_form_data_for_template(session, cert_schema)
838
+ unsterile_run_field = f"{cert_schema['title']}_unsterile_run"
839
+ unsterile_run = bool(form_data.get(unsterile_run_field))
840
+
717
841
  # Get certification status
718
842
  is_certified = True
719
843
  missing_modules = []
720
844
  missing_tests = []
721
845
  missing_tests_details = [] # List of dicts with test id and label
722
846
  missing_additional_module = False
847
+ missing_modules_by_schema = []
723
848
  if step_num >= 4 and flavor and selected_modules and selected_tests:
724
849
  # Convert selected_modules to module dicts for check_certification_status
725
850
  module_dicts = [m for m in filtered_modules if m.get('name') in selected_modules]
726
- is_certified, missing_modules, missing_tests, missing_additional_module = check_certification_status(
727
- flavor, module_dicts, selected_tests, tests_schema
851
+ schema_data = load_schema_data(schema_path) if schema_path else None
852
+ is_certified, missing_modules, missing_tests, missing_additional_module, missing_modules_by_schema = check_certification_status(
853
+ flavor, module_dicts, selected_tests, tests_schema, schema_data, unsterile_run
728
854
  )
729
855
 
730
856
  # Get test details (labels) for missing tests
@@ -751,10 +877,12 @@ def step(step_num):
751
877
  )
752
878
  schema_errors = errors
753
879
  schema_warnings = warnings
880
+ # Get missing schema-mandatory modules for display (recalculate if not already set)
881
+ if not missing_modules_by_schema and step_num >= 4 and flavor and selected_modules:
882
+ _, _, _, _, missing_modules_by_schema = check_certification_status(
883
+ flavor, module_dicts, selected_tests, tests_schema, schema_data, unsterile_run
884
+ )
754
885
 
755
- # Get CLI schemas for form fields - use certificate_cli for all flavors
756
- cert_cli = CertificateCLI()
757
- cert_schema = web_utils.parser_to_schema(cert_cli.parser)
758
886
 
759
887
  # Calculate total test count including mandatory tests from selected modules
760
888
  total_test_count = len(selected_tests)
@@ -770,6 +898,8 @@ def step(step_num):
770
898
  # Prepare data for JavaScript validation
771
899
  # Always initialize these to avoid Undefined errors in template
772
900
  mandatory_modules_for_js = get_mandatory_modules_for_flavor(flavor) if flavor else []
901
+ schema_data_for_js = load_schema_data(schema_path) if schema_path else None
902
+ mandatory_modules_by_schema_for_js = get_mandatory_modules_by_schema(schema_data_for_js, flavor) if (schema_data_for_js and flavor) else []
773
903
  mandatory_tests_for_js = []
774
904
 
775
905
  if step_num in [3, 4] and flavor:
@@ -839,16 +969,21 @@ def step(step_num):
839
969
  missing_additional_module=missing_additional_module,
840
970
  schema_errors=schema_errors,
841
971
  schema_warnings=schema_warnings,
972
+ missing_modules_by_schema=missing_modules_by_schema,
842
973
  mandatory_modules_for_js=mandatory_modules_for_js,
974
+ mandatory_modules_by_schema_for_js=mandatory_modules_by_schema_for_js,
843
975
  mandatory_tests_for_js=mandatory_tests_for_js,
844
976
  error=error,
845
977
  warning=warning,
846
978
  form_data=_prepare_form_data_for_template(session, cert_schema),
979
+ custom_broker_defaults=DEFAULT_CUSTOM_BROKER,
980
+ custom_broker_field_keys=list(DEFAULT_CUSTOM_BROKER.keys()),
847
981
  run_completed=run_completed,
848
982
  run_terminal=run_terminal,
849
983
  run_pid=run_pid,
850
984
  run_is_certified=run_is_certified,
851
- available_ports=available_ports)
985
+ available_ports=available_ports,
986
+ unsterile_run=unsterile_run)
852
987
 
853
988
  def execute_certificate():
854
989
  """Execute the certificate run based on session data."""
@@ -874,7 +1009,13 @@ def execute_certificate():
874
1009
  tests_schema = web_utils.scan_tests_dir(CERT_TESTS_ROOT)
875
1010
  filtered_modules = filter_modules_by_flavor(tests_schema, flavor)
876
1011
  module_dicts = [m for m in filtered_modules if m.get('name') in session.get('selected_modules', [])]
877
- is_certified, _, _, _ = check_certification_status(flavor, module_dicts, selected_tests, tests_schema)
1012
+ schema_data = load_schema_data(schema_path) if schema_path else None
1013
+ # Check if unsterile_run is set in form_data
1014
+ cert_cli = CertificateCLI()
1015
+ cert_schema = web_utils.parser_to_schema(cert_cli.parser)
1016
+ unsterile_run_field = f"{cert_schema['title']}_unsterile_run"
1017
+ unsterile_run = bool(form_data.get(unsterile_run_field))
1018
+ is_certified, _, _, _, _ = check_certification_status(flavor, module_dicts, selected_tests, tests_schema, schema_data, unsterile_run)
878
1019
 
879
1020
  full_cmd = []
880
1021
 
@@ -937,6 +1078,17 @@ def export_config():
937
1078
  flash("No configuration to export. Please complete at least step 1 and 2.", "error")
938
1079
  return redirect(url_for('step', step_num=session.get('current_step', 1)))
939
1080
 
1081
+ # Extract custom broker form field values from form_data
1082
+ form_data = session.get('form_data', {})
1083
+ cert_cli = CertificateCLI()
1084
+ cert_schema = web_utils.parser_to_schema(cert_cli.parser)
1085
+ custom_broker_fields = {}
1086
+ field_prefix = f"{cert_schema['title']}_custom_broker_"
1087
+ for field_name in DEFAULT_CUSTOM_BROKER.keys():
1088
+ full_field_name = f"{field_prefix}{field_name}"
1089
+ if full_field_name in form_data:
1090
+ custom_broker_fields[field_name] = form_data[full_field_name]
1091
+
940
1092
  # Prepare configuration data
941
1093
  config = {
942
1094
  'version': '1.0',
@@ -945,9 +1097,10 @@ def export_config():
945
1097
  'schema_path': session.get('schema_path'),
946
1098
  'selected_modules': session.get('selected_modules', []),
947
1099
  'selected_tests': session.get('selected_tests', []),
948
- 'form_data': session.get('form_data', {}),
1100
+ 'form_data': form_data,
949
1101
  'schema_data': load_schema_data(session.get('schema_path')), # Load schema data from file for export
950
- 'custom_broker_path': session.get('custom_broker_path') # Include custom broker file path
1102
+ 'custom_broker_path': session.get('custom_broker_path'), # Include custom broker file path for backward compatibility
1103
+ 'custom_broker_fields': custom_broker_fields # Include custom broker form field values
951
1104
  }
952
1105
 
953
1106
  # Create filename with timestamp
@@ -1008,17 +1161,47 @@ def import_config():
1008
1161
  json.dump(schema_data, f, indent=2)
1009
1162
  except Exception:
1010
1163
  pass # If we can't save, user will need to re-upload
1011
- session['custom_broker_path'] = config_data.get('custom_broker_path')
1164
+ # Handle custom broker configuration
1165
+ custom_broker_fields = config_data.get('custom_broker_fields', {})
1166
+ if custom_broker_fields:
1167
+ # Restore broker form field values to form_data
1168
+ cert_cli = CertificateCLI()
1169
+ cert_schema = web_utils.parser_to_schema(cert_cli.parser)
1170
+ field_prefix = f"{cert_schema['title']}_custom_broker_"
1171
+ for field_name, field_value in custom_broker_fields.items():
1172
+ full_field_name = f"{field_prefix}{field_name}"
1173
+ session['form_data'][full_field_name] = field_value
1174
+ # Regenerate broker config file from form fields
1175
+ try:
1176
+ broker_config_path = _write_custom_broker_config(session['form_data'], cert_schema['title'])
1177
+ session['custom_broker_path'] = os.path.normpath(broker_config_path)
1178
+ except Exception:
1179
+ pass # If generation fails, user can reconfigure in step 4
1180
+ else:
1181
+ # Backward compatibility: try to use existing path
1182
+ session['custom_broker_path'] = config_data.get('custom_broker_path')
1012
1183
 
1013
1184
  # Validate schema file still exists
1014
1185
  if session['schema_path'] and not os.path.exists(session['schema_path']):
1015
1186
  flash(f"Warning: Schema file not found at {session['schema_path']}. Please re-upload it in step 2.", "warning")
1016
1187
  session['current_step'] = 2
1017
- # Validate custom_broker file still exists if present
1188
+ # Validate custom_broker file still exists if present (or regenerate if we have fields)
1018
1189
  elif session.get('custom_broker_path') and not os.path.exists(session['custom_broker_path']):
1019
- flash(f"Warning: Custom broker file not found at {session['custom_broker_path']}. Please re-upload it in step 4.", "warning")
1020
- if session.get('current_step', 0) < 4:
1021
- session['current_step'] = 4
1190
+ if custom_broker_fields:
1191
+ # Regenerate from fields if file doesn't exist
1192
+ try:
1193
+ cert_cli = CertificateCLI()
1194
+ cert_schema = web_utils.parser_to_schema(cert_cli.parser)
1195
+ broker_config_path = _write_custom_broker_config(session['form_data'], cert_schema['title'])
1196
+ session['custom_broker_path'] = os.path.normpath(broker_config_path)
1197
+ except Exception:
1198
+ flash(f"Warning: Custom broker configuration will be regenerated in step 4.", "warning")
1199
+ if session.get('current_step', 0) < 4:
1200
+ session['current_step'] = 4
1201
+ else:
1202
+ flash(f"Warning: Custom broker file not found at {session['custom_broker_path']}. Please reconfigure it in step 4.", "warning")
1203
+ if session.get('current_step', 0) < 4:
1204
+ session['current_step'] = 4
1022
1205
  else:
1023
1206
  # Determine appropriate step based on what's configured
1024
1207
  if session.get('selected_tests'):
@@ -1047,6 +1230,8 @@ def import_config():
1047
1230
  def clear_session():
1048
1231
  """Clear session and start fresh."""
1049
1232
  session.clear()
1233
+ # Initialize session flags
1234
+ session['schema_mandatory_initialized'] = False
1050
1235
  return redirect(url_for('step', step_num=1))
1051
1236
 
1052
1237
  @app.route("/parser")