loading
Generated 2025-08-17T03:22:51+00:00

All Files ( 89.7% covered at 610.08 hits/line )

105 files in total.
4883 relevant lines, 4380 lines covered and 503 lines missed. ( 89.7% )
1347 total branches, 1018 branches covered and 329 branches missed. ( 75.58% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/controllers/admin_controller.rb 80.00 % 122 60 48 12 2.37 70.00 % 10 7 3
app/controllers/anchorage_assessments_controller.rb 100.00 % 4 3 3 0 4.00 100.00 % 0 0 0
app/controllers/application_controller.rb 94.83 % 226 116 110 6 317.03 75.61 % 41 31 10
app/controllers/backups_controller.rb 95.08 % 122 61 58 3 3.10 71.43 % 14 10 4
app/controllers/concerns/assessment_controller.rb 94.19 % 162 86 81 5 20.19 80.00 % 10 8 2
app/controllers/concerns/image_processable.rb 72.73 % 85 44 32 12 19.27 70.00 % 10 7 3
app/controllers/concerns/inspection_turbo_streams.rb 97.92 % 132 48 47 1 18.52 91.67 % 12 11 1
app/controllers/concerns/public_viewable.rb 79.41 % 79 34 27 7 23.79 70.00 % 10 7 3
app/controllers/concerns/safety_standards_turbo_streams.rb 100.00 % 35 17 17 0 5.65 100.00 % 0 0 0
app/controllers/concerns/session_management.rb 100.00 % 28 12 12 0 211.75 75.00 % 4 3 1
app/controllers/concerns/turbo_stream_responders.rb 93.33 % 100 45 42 3 12.09 75.00 % 12 9 3
app/controllers/concerns/user_activity_check.rb 91.67 % 23 12 11 1 34.08 100.00 % 2 2 0
app/controllers/credentials_controller.rb 100.00 % 74 33 33 0 5.30 100.00 % 6 6 0
app/controllers/enclosed_assessments_controller.rb 100.00 % 3 2 2 0 4.00 100.00 % 0 0 0
app/controllers/errors_controller.rb 81.82 % 42 22 18 4 1.91 25.00 % 4 1 3
app/controllers/fan_assessments_controller.rb 100.00 % 3 2 2 0 4.00 100.00 % 0 0 0
app/controllers/guides_controller.rb 33.33 % 51 24 8 16 1.33 0.00 % 2 0 2
app/controllers/inspections_controller.rb 93.77 % 557 273 256 17 63.23 82.22 % 90 74 16
app/controllers/inspector_companies_controller.rb 100.00 % 62 28 28 0 9.21 100.00 % 4 4 0
app/controllers/materials_assessments_controller.rb 100.00 % 3 2 2 0 4.00 100.00 % 0 0 0
app/controllers/pages_controller.rb 100.00 % 75 34 34 0 12.91 100.00 % 8 8 0
app/controllers/safety_standards_controller.rb 98.06 % 446 155 152 3 27.04 86.49 % 37 32 5
app/controllers/search_controller.rb 100.00 % 7 4 4 0 4.50 100.00 % 0 0 0
app/controllers/sessions_controller.rb 77.08 % 103 48 37 11 103.13 66.67 % 6 4 2
app/controllers/slide_assessments_controller.rb 100.00 % 4 3 3 0 4.00 100.00 % 0 0 0
app/controllers/structure_assessments_controller.rb 100.00 % 3 2 2 0 4.00 100.00 % 0 0 0
app/controllers/units_controller.rb 94.08 % 332 152 143 9 20.51 86.96 % 46 40 6
app/controllers/user_height_assessments_controller.rb 94.74 % 91 38 36 2 12.63 68.75 % 16 11 5
app/controllers/users_controller.rb 90.73 % 317 151 137 14 5.32 79.59 % 49 39 10
app/errors/application_errors.rb 85.71 % 13 7 6 1 3.29 100.00 % 0 0 0
app/helpers/application_helper.rb 96.05 % 131 76 73 3 656.61 100.00 % 20 20 0
app/helpers/inspections_helper.rb 97.33 % 172 75 73 2 166.28 85.29 % 34 29 5
app/helpers/pages_helper.rb 100.00 % 6 2 2 0 4.00 100.00 % 0 0 0
app/helpers/sessions_helper.rb 100.00 % 102 58 58 0 741.74 94.44 % 18 17 1
app/helpers/units_helper.rb 100.00 % 77 23 23 0 41.74 100.00 % 4 4 0
app/helpers/users_helper.rb 100.00 % 23 13 13 0 3.54 100.00 % 6 6 0
app/jobs/application_job.rb 100.00 % 9 1 1 0 4.00 100.00 % 0 0 0
app/jobs/s3_backup_job.rb 33.33 % 17 9 3 6 1.33 0.00 % 4 0 4
app/jobs/sentry_test_job.rb 18.75 % 29 16 3 13 0.75 0.00 % 6 0 6
app/models/application_record.rb 100.00 % 7 3 3 0 4.00 100.00 % 0 0 0
app/models/assessments/anchorage_assessment.rb 96.43 % 85 28 27 1 5.14 66.67 % 6 4 2
app/models/assessments/enclosed_assessment.rb 100.00 % 41 10 10 0 4.00 100.00 % 0 0 0
app/models/assessments/fan_assessment.rb 100.00 % 53 12 12 0 4.00 100.00 % 0 0 0
app/models/assessments/materials_assessment.rb 94.12 % 62 17 16 1 3.76 100.00 % 0 0 0
app/models/assessments/slide_assessment.rb 79.31 % 95 29 23 6 3.55 20.00 % 10 2 8
app/models/assessments/structure_assessment.rb 100.00 % 106 24 24 0 10.58 58.33 % 12 7 5
app/models/assessments/user_height_assessment.rb 91.30 % 85 23 21 2 3.96 50.00 % 4 2 2
app/models/concerns/assessment_completion.rb 98.57 % 143 70 69 1 13032.51 87.50 % 8 7 1
app/models/concerns/assessment_logging.rb 100.00 % 23 10 10 0 947.60 100.00 % 0 0 0
app/models/concerns/column_name_syms.rb 100.00 % 16 8 8 0 668.25 100.00 % 0 0 0
app/models/concerns/custom_id_generator.rb 100.00 % 45 25 25 0 10651.24 83.33 % 6 5 1
app/models/concerns/form_configurable.rb 100.00 % 36 21 21 0 260.67 50.00 % 2 1 1
app/models/concerns/public_field_filtering.rb 100.00 % 46 12 12 0 4.08 100.00 % 0 0 0
app/models/concerns/validation_configurable.rb 97.83 % 86 46 45 1 75.65 76.47 % 17 13 4
app/models/credential.rb 100.00 % 34 6 6 0 4.00 100.00 % 0 0 0
app/models/event.rb 93.94 % 114 33 31 2 295.30 50.00 % 2 1 1
app/models/inspection.rb 93.02 % 588 258 240 18 1477.00 74.39 % 82 61 21
app/models/inspector_company.rb 96.55 % 144 58 56 2 71.48 77.78 % 18 14 4
app/models/page.rb 100.00 % 41 14 14 0 129.29 100.00 % 0 0 0
app/models/unit.rb 89.58 % 214 96 86 10 35.75 75.00 % 32 24 8
app/models/user.rb 90.91 % 246 121 110 11 292.74 59.38 % 32 19 13
app/models/user_session.rb 100.00 % 47 13 13 0 165.77 100.00 % 0 0 0
app/serializers/base_assessment_blueprint.rb 75.00 % 12 4 3 1 3.00 100.00 % 0 0 0
app/serializers/inspection_blueprint.rb 97.37 % 89 38 37 1 205.89 75.00 % 16 12 4
app/serializers/json_date_transformer.rb 92.31 % 31 13 12 1 1141.15 85.71 % 7 6 1
app/serializers/unit_blueprint.rb 100.00 % 69 30 30 0 15.07 75.00 % 16 12 4
app/services/concerns/s3_backup_operations.rb 100.00 % 68 40 40 0 2.65 90.00 % 10 9 1
app/services/concerns/s3_helpers.rb 60.00 % 20 10 6 4 2.40 0.00 % 4 0 4
app/services/image_processor_service.rb 100.00 % 57 26 26 0 3.88 84.62 % 13 11 2
app/services/inspection_creation_service.rb 100.00 % 115 50 50 0 9.30 93.75 % 16 15 1
app/services/inspection_csv_export_service.rb 100.00 % 65 38 38 0 19.29 88.00 % 25 22 3
app/services/json_serializer_service.rb 54.55 % 45 22 12 10 3.27 40.00 % 10 4 6
app/services/ntfy_service.rb 93.10 % 53 29 27 2 5.31 66.67 % 6 4 2
app/services/pdf_cache_service.rb 100.00 % 219 102 102 0 110.63 84.85 % 33 28 5
app/services/pdf_generator_service.rb 90.22 % 210 92 83 9 41.64 60.00 % 30 18 12
app/services/pdf_generator_service/assessment_block.rb 100.00 % 25 15 15 0 2717.00 100.00 % 0 0 0
app/services/pdf_generator_service/assessment_block_builder.rb 100.00 % 186 93 93 0 1333.65 85.11 % 47 40 7
app/services/pdf_generator_service/assessment_block_renderer.rb 94.12 % 105 51 48 3 8375.67 73.91 % 23 17 6
app/services/pdf_generator_service/assessment_columns.rb 95.24 % 193 84 80 4 1171.95 92.31 % 13 12 1
app/services/pdf_generator_service/configuration.rb 100.00 % 111 66 66 0 6.82 100.00 % 0 0 0
app/services/pdf_generator_service/debug_info_renderer.rb 14.81 % 58 27 4 23 0.59 0.00 % 4 0 4
app/services/pdf_generator_service/disclaimer_footer_renderer.rb 100.00 % 117 44 44 0 47.14 84.62 % 26 22 4
app/services/pdf_generator_service/header_generator.rb 79.22 % 149 77 61 16 21.66 61.11 % 18 11 7
app/services/pdf_generator_service/image_error.rb 100.00 % 48 17 17 0 2.24 100.00 % 0 0 0
app/services/pdf_generator_service/image_orientation_processor.rb 77.78 % 24 9 7 2 2.89 100.00 % 0 0 0
app/services/pdf_generator_service/image_processor.rb 89.66 % 117 58 52 6 11.81 72.22 % 18 13 5
app/services/pdf_generator_service/photos_renderer.rb 100.00 % 127 63 63 0 9.35 100.00 % 6 6 0
app/services/pdf_generator_service/position_calculator.rb 100.00 % 92 41 41 0 6.63 100.00 % 10 10 0
app/services/pdf_generator_service/table_builder.rb 93.67 % 306 158 148 10 20.15 85.71 % 56 48 8
app/services/pdf_generator_service/utilities.rb 67.74 % 63 31 21 10 18.39 100.00 % 13 13 0
app/services/photo_processing_service.rb 95.12 % 79 41 39 2 12.46 55.00 % 20 11 9
app/services/qr_code_service.rb 100.00 % 71 31 31 0 32.45 75.00 % 4 3 1
app/services/rpii_verification_service.rb 40.00 % 169 80 32 48 1.60 0.00 % 18 0 18
app/services/s3_backup_service.rb 12.50 % 57 32 4 28 0.50 0.00 % 6 0 6
app/services/seed_data_service.rb 97.69 % 299 130 127 3 351.43 83.87 % 31 26 5
app/services/sentry_test_service.rb 13.04 % 79 23 3 20 0.52 0.00 % 4 0 4
app/services/unit_creation_from_inspection_service.rb 100.00 % 59 36 36 0 3.92 100.00 % 8 8 0
app/services/unit_csv_export_service.rb 100.00 % 23 12 12 0 3.58 100.00 % 0 0 0
lib/code_standards_checker.rb 97.87 % 297 141 138 3 117.86 88.24 % 34 30 4
lib/erb_lint_runner.rb 13.51 % 126 74 10 64 0.54 0.00 % 16 0 16
lib/i18n_usage_tracker.rb 95.95 % 147 74 71 3 4108.78 70.83 % 24 17 7
lib/rubocop/cop/custom/ternary_line_breaks.rb 100.00 % 60 26 26 0 5.31 100.00 % 4 4 0
lib/s3_rake_helpers.rb 22.58 % 59 31 7 24 0.90 0.00 % 6 0 6
lib/seed_data.rb 100.00 % 250 78 78 0 593.27 100.00 % 36 36 0
lib/test_data_helpers.rb 100.00 % 68 18 18 0 14.56 100.00 % 0 0 0

Controllers ( 91.66% covered at 50.83 hits/line )

29 files in total.
1511 relevant lines, 1385 lines covered and 126 lines missed. ( 91.66% )
393 total branches, 314 branches covered and 79 branches missed. ( 79.9% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/controllers/admin_controller.rb 80.00 % 122 60 48 12 2.37 70.00 % 10 7 3
app/controllers/anchorage_assessments_controller.rb 100.00 % 4 3 3 0 4.00 100.00 % 0 0 0
app/controllers/application_controller.rb 94.83 % 226 116 110 6 317.03 75.61 % 41 31 10
app/controllers/backups_controller.rb 95.08 % 122 61 58 3 3.10 71.43 % 14 10 4
app/controllers/concerns/assessment_controller.rb 94.19 % 162 86 81 5 20.19 80.00 % 10 8 2
app/controllers/concerns/image_processable.rb 72.73 % 85 44 32 12 19.27 70.00 % 10 7 3
app/controllers/concerns/inspection_turbo_streams.rb 97.92 % 132 48 47 1 18.52 91.67 % 12 11 1
app/controllers/concerns/public_viewable.rb 79.41 % 79 34 27 7 23.79 70.00 % 10 7 3
app/controllers/concerns/safety_standards_turbo_streams.rb 100.00 % 35 17 17 0 5.65 100.00 % 0 0 0
app/controllers/concerns/session_management.rb 100.00 % 28 12 12 0 211.75 75.00 % 4 3 1
app/controllers/concerns/turbo_stream_responders.rb 93.33 % 100 45 42 3 12.09 75.00 % 12 9 3
app/controllers/concerns/user_activity_check.rb 91.67 % 23 12 11 1 34.08 100.00 % 2 2 0
app/controllers/credentials_controller.rb 100.00 % 74 33 33 0 5.30 100.00 % 6 6 0
app/controllers/enclosed_assessments_controller.rb 100.00 % 3 2 2 0 4.00 100.00 % 0 0 0
app/controllers/errors_controller.rb 81.82 % 42 22 18 4 1.91 25.00 % 4 1 3
app/controllers/fan_assessments_controller.rb 100.00 % 3 2 2 0 4.00 100.00 % 0 0 0
app/controllers/guides_controller.rb 33.33 % 51 24 8 16 1.33 0.00 % 2 0 2
app/controllers/inspections_controller.rb 93.77 % 557 273 256 17 63.23 82.22 % 90 74 16
app/controllers/inspector_companies_controller.rb 100.00 % 62 28 28 0 9.21 100.00 % 4 4 0
app/controllers/materials_assessments_controller.rb 100.00 % 3 2 2 0 4.00 100.00 % 0 0 0
app/controllers/pages_controller.rb 100.00 % 75 34 34 0 12.91 100.00 % 8 8 0
app/controllers/safety_standards_controller.rb 98.06 % 446 155 152 3 27.04 86.49 % 37 32 5
app/controllers/search_controller.rb 100.00 % 7 4 4 0 4.50 100.00 % 0 0 0
app/controllers/sessions_controller.rb 77.08 % 103 48 37 11 103.13 66.67 % 6 4 2
app/controllers/slide_assessments_controller.rb 100.00 % 4 3 3 0 4.00 100.00 % 0 0 0
app/controllers/structure_assessments_controller.rb 100.00 % 3 2 2 0 4.00 100.00 % 0 0 0
app/controllers/units_controller.rb 94.08 % 332 152 143 9 20.51 86.96 % 46 40 6
app/controllers/user_height_assessments_controller.rb 94.74 % 91 38 36 2 12.63 68.75 % 16 11 5
app/controllers/users_controller.rb 90.73 % 317 151 137 14 5.32 79.59 % 49 39 10

Channels ( 100.0% covered at 0.0 hits/line )

0 files in total.
0 relevant lines, 0 lines covered and 0 lines missed. ( 100.0% )
0 total branches, 0 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches

Models ( 94.13% covered at 1751.27 hits/line )

23 files in total.
937 relevant lines, 882 lines covered and 55 lines missed. ( 94.13% )
231 total branches, 160 branches covered and 71 branches missed. ( 69.26% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/models/application_record.rb 100.00 % 7 3 3 0 4.00 100.00 % 0 0 0
app/models/assessments/anchorage_assessment.rb 96.43 % 85 28 27 1 5.14 66.67 % 6 4 2
app/models/assessments/enclosed_assessment.rb 100.00 % 41 10 10 0 4.00 100.00 % 0 0 0
app/models/assessments/fan_assessment.rb 100.00 % 53 12 12 0 4.00 100.00 % 0 0 0
app/models/assessments/materials_assessment.rb 94.12 % 62 17 16 1 3.76 100.00 % 0 0 0
app/models/assessments/slide_assessment.rb 79.31 % 95 29 23 6 3.55 20.00 % 10 2 8
app/models/assessments/structure_assessment.rb 100.00 % 106 24 24 0 10.58 58.33 % 12 7 5
app/models/assessments/user_height_assessment.rb 91.30 % 85 23 21 2 3.96 50.00 % 4 2 2
app/models/concerns/assessment_completion.rb 98.57 % 143 70 69 1 13032.51 87.50 % 8 7 1
app/models/concerns/assessment_logging.rb 100.00 % 23 10 10 0 947.60 100.00 % 0 0 0
app/models/concerns/column_name_syms.rb 100.00 % 16 8 8 0 668.25 100.00 % 0 0 0
app/models/concerns/custom_id_generator.rb 100.00 % 45 25 25 0 10651.24 83.33 % 6 5 1
app/models/concerns/form_configurable.rb 100.00 % 36 21 21 0 260.67 50.00 % 2 1 1
app/models/concerns/public_field_filtering.rb 100.00 % 46 12 12 0 4.08 100.00 % 0 0 0
app/models/concerns/validation_configurable.rb 97.83 % 86 46 45 1 75.65 76.47 % 17 13 4
app/models/credential.rb 100.00 % 34 6 6 0 4.00 100.00 % 0 0 0
app/models/event.rb 93.94 % 114 33 31 2 295.30 50.00 % 2 1 1
app/models/inspection.rb 93.02 % 588 258 240 18 1477.00 74.39 % 82 61 21
app/models/inspector_company.rb 96.55 % 144 58 56 2 71.48 77.78 % 18 14 4
app/models/page.rb 100.00 % 41 14 14 0 129.29 100.00 % 0 0 0
app/models/unit.rb 89.58 % 214 96 86 10 35.75 75.00 % 32 24 8
app/models/user.rb 90.91 % 246 121 110 11 292.74 59.38 % 32 19 13
app/models/user_session.rb 100.00 % 47 13 13 0 165.77 100.00 % 0 0 0

Mailers ( 100.0% covered at 0.0 hits/line )

0 files in total.
0 relevant lines, 0 lines covered and 0 lines missed. ( 100.0% )
0 total branches, 0 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches

Helpers ( 97.98% covered at 430.8 hits/line )

6 files in total.
247 relevant lines, 242 lines covered and 5 lines missed. ( 97.98% )
82 total branches, 76 branches covered and 6 branches missed. ( 92.68% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/helpers/application_helper.rb 96.05 % 131 76 73 3 656.61 100.00 % 20 20 0
app/helpers/inspections_helper.rb 97.33 % 172 75 73 2 166.28 85.29 % 34 29 5
app/helpers/pages_helper.rb 100.00 % 6 2 2 0 4.00 100.00 % 0 0 0
app/helpers/sessions_helper.rb 100.00 % 102 58 58 0 741.74 94.44 % 18 17 1
app/helpers/units_helper.rb 100.00 % 77 23 23 0 41.74 100.00 % 4 4 0
app/helpers/users_helper.rb 100.00 % 23 13 13 0 3.54 100.00 % 6 6 0

Jobs ( 26.92% covered at 1.08 hits/line )

3 files in total.
26 relevant lines, 7 lines covered and 19 lines missed. ( 26.92% )
10 total branches, 0 branches covered and 10 branches missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/jobs/application_job.rb 100.00 % 9 1 1 0 4.00 100.00 % 0 0 0
app/jobs/s3_backup_job.rb 33.33 % 17 9 3 6 1.33 0.00 % 4 0 4
app/jobs/sentry_test_job.rb 18.75 % 29 16 3 13 0.75 0.00 % 6 0 6

Libraries ( 78.73% covered at 831.25 hits/line )

7 files in total.
442 relevant lines, 348 lines covered and 94 lines missed. ( 78.73% )
120 total branches, 87 branches covered and 33 branches missed. ( 72.5% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
lib/code_standards_checker.rb 97.87 % 297 141 138 3 117.86 88.24 % 34 30 4
lib/erb_lint_runner.rb 13.51 % 126 74 10 64 0.54 0.00 % 16 0 16
lib/i18n_usage_tracker.rb 95.95 % 147 74 71 3 4108.78 70.83 % 24 17 7
lib/rubocop/cop/custom/ternary_line_breaks.rb 100.00 % 60 26 26 0 5.31 100.00 % 4 4 0
lib/s3_rake_helpers.rb 22.58 % 59 31 7 24 0.90 0.00 % 6 0 6
lib/seed_data.rb 100.00 % 250 78 78 0 593.27 100.00 % 36 36 0
lib/test_data_helpers.rb 100.00 % 68 18 18 0 14.56 100.00 % 0 0 0

Ungrouped ( 88.14% covered at 457.8 hits/line )

37 files in total.
1720 relevant lines, 1516 lines covered and 204 lines missed. ( 88.14% )
511 total branches, 381 branches covered and 130 branches missed. ( 74.56% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/errors/application_errors.rb 85.71 % 13 7 6 1 3.29 100.00 % 0 0 0
app/serializers/base_assessment_blueprint.rb 75.00 % 12 4 3 1 3.00 100.00 % 0 0 0
app/serializers/inspection_blueprint.rb 97.37 % 89 38 37 1 205.89 75.00 % 16 12 4
app/serializers/json_date_transformer.rb 92.31 % 31 13 12 1 1141.15 85.71 % 7 6 1
app/serializers/unit_blueprint.rb 100.00 % 69 30 30 0 15.07 75.00 % 16 12 4
app/services/concerns/s3_backup_operations.rb 100.00 % 68 40 40 0 2.65 90.00 % 10 9 1
app/services/concerns/s3_helpers.rb 60.00 % 20 10 6 4 2.40 0.00 % 4 0 4
app/services/image_processor_service.rb 100.00 % 57 26 26 0 3.88 84.62 % 13 11 2
app/services/inspection_creation_service.rb 100.00 % 115 50 50 0 9.30 93.75 % 16 15 1
app/services/inspection_csv_export_service.rb 100.00 % 65 38 38 0 19.29 88.00 % 25 22 3
app/services/json_serializer_service.rb 54.55 % 45 22 12 10 3.27 40.00 % 10 4 6
app/services/ntfy_service.rb 93.10 % 53 29 27 2 5.31 66.67 % 6 4 2
app/services/pdf_cache_service.rb 100.00 % 219 102 102 0 110.63 84.85 % 33 28 5
app/services/pdf_generator_service.rb 90.22 % 210 92 83 9 41.64 60.00 % 30 18 12
app/services/pdf_generator_service/assessment_block.rb 100.00 % 25 15 15 0 2717.00 100.00 % 0 0 0
app/services/pdf_generator_service/assessment_block_builder.rb 100.00 % 186 93 93 0 1333.65 85.11 % 47 40 7
app/services/pdf_generator_service/assessment_block_renderer.rb 94.12 % 105 51 48 3 8375.67 73.91 % 23 17 6
app/services/pdf_generator_service/assessment_columns.rb 95.24 % 193 84 80 4 1171.95 92.31 % 13 12 1
app/services/pdf_generator_service/configuration.rb 100.00 % 111 66 66 0 6.82 100.00 % 0 0 0
app/services/pdf_generator_service/debug_info_renderer.rb 14.81 % 58 27 4 23 0.59 0.00 % 4 0 4
app/services/pdf_generator_service/disclaimer_footer_renderer.rb 100.00 % 117 44 44 0 47.14 84.62 % 26 22 4
app/services/pdf_generator_service/header_generator.rb 79.22 % 149 77 61 16 21.66 61.11 % 18 11 7
app/services/pdf_generator_service/image_error.rb 100.00 % 48 17 17 0 2.24 100.00 % 0 0 0
app/services/pdf_generator_service/image_orientation_processor.rb 77.78 % 24 9 7 2 2.89 100.00 % 0 0 0
app/services/pdf_generator_service/image_processor.rb 89.66 % 117 58 52 6 11.81 72.22 % 18 13 5
app/services/pdf_generator_service/photos_renderer.rb 100.00 % 127 63 63 0 9.35 100.00 % 6 6 0
app/services/pdf_generator_service/position_calculator.rb 100.00 % 92 41 41 0 6.63 100.00 % 10 10 0
app/services/pdf_generator_service/table_builder.rb 93.67 % 306 158 148 10 20.15 85.71 % 56 48 8
app/services/pdf_generator_service/utilities.rb 67.74 % 63 31 21 10 18.39 100.00 % 13 13 0
app/services/photo_processing_service.rb 95.12 % 79 41 39 2 12.46 55.00 % 20 11 9
app/services/qr_code_service.rb 100.00 % 71 31 31 0 32.45 75.00 % 4 3 1
app/services/rpii_verification_service.rb 40.00 % 169 80 32 48 1.60 0.00 % 18 0 18
app/services/s3_backup_service.rb 12.50 % 57 32 4 28 0.50 0.00 % 6 0 6
app/services/seed_data_service.rb 97.69 % 299 130 127 3 351.43 83.87 % 31 26 5
app/services/sentry_test_service.rb 13.04 % 79 23 3 20 0.52 0.00 % 4 0 4
app/services/unit_creation_from_inspection_service.rb 100.00 % 59 36 36 0 3.92 100.00 % 8 8 0
app/services/unit_csv_export_service.rb 100.00 % 23 12 12 0 3.58 100.00 % 0 0 0

app/controllers/admin_controller.rb

80.0% lines covered

70.0% branches covered

60 relevant lines. 48 lines covered and 12 lines missed.
10 total branches, 7 branches covered and 3 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class AdminController < ApplicationController
  4. 4 before_action :require_admin
  5. 4 def index
  6. 10 @show_backups = ENV["USE_S3_STORAGE"] == "true"
  7. end
  8. 4 def releases
  9. 6 @releases = Rails.cache.fetch("github_releases", expires_in: 1.hour) do
  10. 5 fetch_github_releases
  11. end
  12. rescue => e
  13. 1 Rails.logger.error "Failed to fetch GitHub releases: #{e.message}"
  14. 1 @releases = []
  15. 1 flash.now[:error] = t("admin.releases.fetch_error")
  16. end
  17. 4 def files
  18. 4 @blobs = ActiveStorage::Blob
  19. .where.not(id: ActiveStorage::VariantRecord.select(:blob_id))
  20. .includes(attachments: :record)
  21. .order(created_at: :desc)
  22. end
  23. 4 private
  24. 4 def fetch_github_releases
  25. 2 response = make_github_api_request
  26. 2 parse_github_response(response)
  27. end
  28. 4 def make_github_api_request
  29. require "net/http"
  30. uri = URI("https://api.github.com/repos/chobbledotcom/play-test/releases")
  31. http = Net::HTTP.new(uri.host, uri.port)
  32. http.use_ssl = true
  33. http.read_timeout = 10
  34. request = Net::HTTP::Get.new(uri)
  35. request["Accept"] = "application/vnd.github.v3+json"
  36. request["User-Agent"] = "PlayTest-Admin"
  37. http.request(request)
  38. end
  39. 4 def parse_github_response(response)
  40. 2 require "json"
  41. 2 then: 2 if response.code == "200"
  42. 4 JSON.parse(response.body).map { |release| format_release(release) }
  43. else: 0 else
  44. log_msg = "GitHub API returned #{response.code}: #{response.body}"
  45. Rails.logger.error log_msg
  46. []
  47. end
  48. end
  49. 4 def format_release(release)
  50. {
  51. 2 name: release["name"],
  52. tag_name: release["tag_name"],
  53. published_at: Time.zone.parse(release["published_at"]),
  54. body: process_release_body(release["body"]),
  55. html_url: release["html_url"],
  56. author: release["author"]["login"],
  57. is_bot: release["author"]["login"].include?("[bot]")
  58. }
  59. end
  60. 4 def process_release_body(body)
  61. 2 then: 0 else: 2 return nil if body.blank?
  62. # Find the position of "## Docker Images" and truncate
  63. 2 docker_index = body.index("## Docker Images")
  64. 2 then: 1 else: 1 processed_body = docker_index ? body[0...docker_index].strip : body
  65. # Remove [Read the full changelog here] links
  66. 2 changelog_pattern = /\[Read the full changelog here\]\([^)]+\)/
  67. 2 processed_body = processed_body.gsub(changelog_pattern, "")
  68. 2 processed_body = processed_body.strip
  69. 2 convert_markdown_to_html(processed_body)
  70. end
  71. 4 def convert_markdown_to_html(text)
  72. # Remove headers (they duplicate version info)
  73. 2 html = text.gsub(/^### .+$/, "")
  74. 2 html = html.gsub(/^## .+$/, "")
  75. 2 html = html.gsub(/^# .+$/, "")
  76. # Clean up extra newlines from removed headers
  77. 2 html = html.gsub(/\n{3,}/, "\n\n").strip
  78. # Convert bold and bullet points
  79. 2 html = html.gsub(/\*\*(.+?)\*\*/, '<strong>\1</strong>')
  80. 2 html = convert_bullet_points(html)
  81. # Convert line breaks to paragraphs
  82. 2 wrap_paragraphs(html)
  83. end
  84. 4 def convert_bullet_points(text)
  85. 2 html = text.gsub(/^- (.+)$/, '<li>\1</li>')
  86. 3 html.gsub(/(<li>.*<\/li>)/m) { |match| "<ul>#{match}</ul>" }
  87. end
  88. 4 def wrap_paragraphs(text)
  89. 2 text.split("\n\n").map do |paragraph|
  90. 3 then: 0 else: 3 next if paragraph.strip.empty?
  91. 3 then: 1 if paragraph.include?("<h") || paragraph.include?("<ul>")
  92. 1 paragraph
  93. else: 2 else
  94. 2 "<p>#{paragraph}</p>"
  95. end
  96. end.compact.join("\n")
  97. end
  98. end

app/controllers/anchorage_assessments_controller.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 class AnchorageAssessmentsController < ApplicationController
  2. 4 include AssessmentController
  3. 4 include SafetyStandardsTurboStreams
  4. end

app/controllers/application_controller.rb

94.83% lines covered

75.61% branches covered

116 relevant lines. 110 lines covered and 6 lines missed.
41 total branches, 31 branches covered and 10 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 class ApplicationController < ActionController::Base
  4. 4 extend T::Sig
  5. 4 include SessionsHelper
  6. 4 include ImageProcessable
  7. 4 before_action :require_login, unless: :skip_authentication?
  8. 4 before_action :update_last_active_at, unless: :skip_authentication?
  9. 4 before_action :start_debug_timer, if: :admin_debug_enabled?
  10. 4 after_action :cleanup_debug_subscription, if: :admin_debug_enabled?
  11. 2794 around_action :n_plus_one_detection, unless: -> { Rails.env.production? || skip_authentication? }
  12. 4 rescue_from StandardError do |exception|
  13. 18 then: 4 else: 14 if Rails.env.production? && should_notify_error?(exception)
  14. 4 then: 3 else: 1 user_email = current_user&.email || app_i18n(:errors, :not_logged_in)
  15. 4 user_label = app_i18n(:errors, :user_label)
  16. 4 user_info = "#{user_label}: #{user_email}"
  17. 4 controller_label = app_i18n(:errors, :controller_label)
  18. 4 path_label = app_i18n(:errors, :path_label)
  19. 4 method_label = app_i18n(:errors, :method_label)
  20. 4 ip_label = app_i18n(:errors, :ip_label)
  21. 4 backtrace_label = app_i18n(:errors, :backtrace_label)
  22. 4 error_subject = app_i18n(:errors, :production_error_subject)
  23. message = <<~MESSAGE
  24. 4 #{error_subject}
  25. #{exception.class}: #{exception.message}
  26. #{user_info}
  27. #{controller_label}: #{controller_name}##{action_name}
  28. #{path_label}: #{request.fullpath}
  29. #{method_label}: #{request.request_method}
  30. #{ip_label}: #{request.remote_ip}
  31. #{backtrace_label}:
  32. #{exception.backtrace.first(5).join("\n")}
  33. MESSAGE
  34. 4 NtfyService.notify(message)
  35. end
  36. 18 raise exception
  37. end
  38. 4 private
  39. 8 sig { returns(T::Boolean) }
  40. 4 def skip_authentication?
  41. 8110 false
  42. end
  43. 5 sig { params(table: Symbol, key: Symbol, args: T.untyped).returns(String) }
  44. 4 def app_i18n(table, key, **args)
  45. 31 I18n.t("application.#{table}.#{key}", **args)
  46. end
  47. 8 sig { params(form: Symbol, key: T.any(Symbol, String), args: T.untyped).returns(String) }
  48. 4 def form_i18n(form, key, **args)
  49. 42 I18n.t("forms.#{form}.#{key}", **args)
  50. end
  51. 8 sig { void }
  52. 4 def require_login
  53. 1247 then: 1209 else: 38 return if logged_in?
  54. 38 flash[:alert] = form_i18n(:session_new, :"status.login_required")
  55. 38 redirect_to login_path
  56. end
  57. 8 sig { void }
  58. 4 def require_logged_out
  59. 1000 else: 3 then: 997 return unless logged_in?
  60. 3 flash[:alert] = form_i18n(:session_new, :"status.already_logged_in")
  61. 3 redirect_to inspections_path
  62. end
  63. 8 sig { void }
  64. 4 def update_last_active_at
  65. 2773 else: 1484 then: 1289 return unless current_user.is_a?(User)
  66. 1484 current_user.update(last_active_at: Time.current)
  67. # Update UserSession last_active_at
  68. 1484 then: 1445 else: 39 if session[:session_token]
  69. 1445 then: 1445 else: 0 current_session&.touch_last_active
  70. end
  71. end
  72. 8 sig { void }
  73. 4 def require_admin
  74. 208 then: 207 else: 1 then: 170 else: 38 return if current_user&.admin?
  75. 38 flash[:alert] = I18n.t("forms.session_new.status.admin_required")
  76. 38 redirect_to root_path
  77. end
  78. 8 sig { returns(T::Boolean) }
  79. 4 def admin_debug_enabled?
  80. 7047 Rails.env.development?
  81. end
  82. 5 sig { returns(T::Boolean) }
  83. 4 def seed_data_action?
  84. 4 seed_actions = %w[add_seeds delete_seeds]
  85. 4 controller_name == "users" && seed_actions.include?(action_name)
  86. end
  87. 5 sig { returns(T::Boolean) }
  88. 4 def impersonating?
  89. 2 session[:original_admin_id].present?
  90. end
  91. 5 sig { void }
  92. 4 def start_debug_timer
  93. 1 @debug_start_time = Time.current
  94. 1 @debug_sql_queries = []
  95. 1 then: 0 else: 1 ActiveSupport::Notifications.unsubscribe(@debug_subscription) if @debug_subscription
  96. 1 @debug_subscription = ActiveSupport::Notifications
  97. .subscribe("sql.active_record") do |_name, start, finish, _id, payload|
  98. 11 else: 0 then: 11 unless payload[:name] == "SCHEMA" || payload[:sql] =~ /^PRAGMA/
  99. 11 @debug_sql_queries << {
  100. sql: payload[:sql],
  101. 11 duration: ((finish - start) * 1000).round(2),
  102. name: payload[:name]
  103. }
  104. end
  105. end
  106. end
  107. # Make debug data available to views
  108. 4 helper_method :admin_debug_enabled?,
  109. :impersonating?,
  110. :debug_render_time,
  111. :debug_sql_queries
  112. 5 sig { returns(T.nilable(Float)) }
  113. 4 def debug_render_time
  114. 2 else: 1 then: 1 return unless @debug_start_time
  115. 1 ((Time.current - @debug_start_time) * 1000).round(2)
  116. end
  117. 7 sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
  118. 4 def debug_sql_queries
  119. 47 @debug_sql_queries || []
  120. end
  121. 8 sig { void }
  122. 4 def n_plus_one_detection
  123. 2787 Prosopite.scan
  124. 2787 yield
  125. ensure
  126. 2787 Prosopite.finish
  127. end
  128. 5 sig { returns(T::Boolean) }
  129. 4 def processing_image_upload?
  130. 5 case controller_name
  131. when: 2 when "users"
  132. 2 action_name == "update_settings" && params.dig(:user, :logo).present?
  133. when: 2 when "units"
  134. 2 %w[create update].include?(action_name) &&
  135. params.dig(:unit, :photo).present?
  136. else: 1 else
  137. 1 false
  138. end
  139. end
  140. 5 sig { void }
  141. 4 def cleanup_debug_subscription
  142. 1 else: 1 then: 0 return unless @debug_subscription
  143. 1 ActiveSupport::Notifications.unsubscribe(@debug_subscription)
  144. 1 @debug_subscription = nil
  145. end
  146. 5 sig { params(exception: StandardError).returns(T::Boolean) }
  147. 4 def should_notify_error?(exception)
  148. 9 then: 4 else: 5 if exception.is_a?(ActionController::InvalidAuthenticityToken)
  149. csrf_ignored_actions = [
  150. 4 %w[sessions create],
  151. %w[users create]
  152. ]
  153. 4 action = [controller_name, action_name]
  154. 4 then: 3 else: 1 return false if csrf_ignored_actions.include?(action)
  155. end
  156. 6 then: 0 else: 6 if exception.is_a?(ActionController::InvalidCrossOriginRequest)
  157. else: 0 then: 0 return false unless logged_in?
  158. end
  159. 6 true
  160. end
  161. 7 sig { params(result: T.untyped, filename: String).void }
  162. 4 def handle_pdf_response(result, filename)
  163. 45 else: 0 case result.type
  164. when: 0 when :redirect
  165. Rails.logger.info "PDF response: Redirecting to S3 URL for #{filename}"
  166. redirect_to result.data, allow_other_host: true
  167. when: 0 when :stream
  168. Rails.logger.info "PDF response: Streaming #{filename} from S3 through Rails"
  169. expires_in 0, public: false
  170. send_data result.data.download,
  171. filename: filename,
  172. type: "application/pdf",
  173. disposition: "inline"
  174. when: 45 when :pdf_data
  175. 45 Rails.logger.info "PDF response: Sending generated PDF data for #{filename}"
  176. 45 send_data result.data,
  177. filename: filename,
  178. type: "application/pdf",
  179. disposition: "inline"
  180. end
  181. end
  182. end

app/controllers/backups_controller.rb

95.08% lines covered

71.43% branches covered

61 relevant lines. 58 lines covered and 3 lines missed.
14 total branches, 10 branches covered and 4 branches missed.
    
  1. # frozen_string_literal: true
  2. 4 class BackupsController < ApplicationController
  3. 4 before_action :require_admin
  4. 4 before_action :ensure_s3_enabled
  5. 4 def index
  6. 2 @backups = fetch_backups
  7. end
  8. 4 def download
  9. 5 date = params[:date]
  10. 5 else: 2 then: 3 return redirect_with_error("invalid_date") unless valid_date?(date)
  11. 2 backup_key = build_backup_key(date)
  12. 2 else: 1 then: 1 unless backup_exists?(backup_key)
  13. 1 return redirect_with_error("backup_not_found")
  14. end
  15. 1 presigned_url = generate_download_url(backup_key)
  16. 1 redirect_to presigned_url, allow_other_host: true
  17. end
  18. 4 private
  19. 4 def ensure_s3_enabled
  20. 8 then: 7 else: 1 return if ENV["USE_S3_STORAGE"] == "true"
  21. 1 flash[:error] = t("backups.errors.s3_not_enabled")
  22. 1 redirect_to admin_path
  23. end
  24. 4 def get_s3_service
  25. 5 service = ActiveStorage::Blob.service
  26. # Only check S3Service class if it's loaded (production/S3 environments)
  27. 5 then: 0 else: 5 if defined?(ActiveStorage::Service::S3Service)
  28. else: 0 then: 0 unless service.is_a?(ActiveStorage::Service::S3Service)
  29. raise t("backups.errors.s3_not_configured")
  30. end
  31. end
  32. 5 service
  33. end
  34. 4 def fetch_backups
  35. 4 service = get_s3_service
  36. 4 bucket = service.send(:bucket)
  37. 3 backups = build_backup_list(bucket)
  38. 6 backups.sort_by { |b| b[:last_modified] }.reverse
  39. end
  40. 4 def backup_exists?(key)
  41. 3 fetch_backups.any? { |backup| backup[:key] == key }
  42. end
  43. 4 def redirect_with_error(error_key)
  44. 4 flash[:error] = t("backups.errors.#{error_key}")
  45. 4 redirect_to backups_path
  46. end
  47. 4 def generate_download_url(backup_key)
  48. 1 service = get_s3_service
  49. 1 bucket = service.send(:bucket)
  50. 1 object = bucket.object(backup_key)
  51. 1 filename = File.basename(backup_key)
  52. 1 disposition = build_content_disposition(filename)
  53. 1 object.presigned_url(
  54. :get,
  55. expires_in: 300,
  56. response_content_disposition: disposition
  57. )
  58. end
  59. 4 def build_backup_list(bucket)
  60. 3 backups = []
  61. 3 bucket.objects(prefix: "db_backups/").each do |object|
  62. 3 else: 3 then: 0 next unless valid_backup_filename?(object.key)
  63. 3 backups << build_backup_info(object)
  64. end
  65. 3 backups
  66. end
  67. 4 def valid_backup_filename?(key)
  68. 3 key.match?(/database-\d{4}-\d{2}-\d{2}\.tar\.gz$/)
  69. end
  70. 4 def build_backup_info(object)
  71. {
  72. 3 key: object.key,
  73. filename: File.basename(object.key),
  74. size: object.size,
  75. last_modified: object.last_modified,
  76. size_mb: calculate_size_in_mb(object.size)
  77. }
  78. end
  79. 4 def calculate_size_in_mb(size_bytes)
  80. 3 (size_bytes / 1024.0 / 1024.0).round(2)
  81. end
  82. 4 def build_content_disposition(filename)
  83. 1 "attachment; filename=\"#{filename}\""
  84. end
  85. 4 def valid_date?(date)
  86. 5 else: 3 then: 2 return false unless date.is_a?(String) && date.present?
  87. 3 date.match?(/\A\d{4}-\d{2}-\d{2}\z/) && Date.parse(date)
  88. rescue Date::Error
  89. false
  90. end
  91. 4 def build_backup_key(date)
  92. 2 "db_backups/database-#{date}.tar.gz"
  93. end
  94. end

app/controllers/concerns/assessment_controller.rb

94.19% lines covered

80.0% branches covered

86 relevant lines. 81 lines covered and 5 lines missed.
10 total branches, 8 branches covered and 2 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module AssessmentController
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 include UserActivityCheck
  7. 4 include InspectionTurboStreams
  8. 4 included do
  9. 28 before_action :set_inspection
  10. 28 before_action :check_inspection_owner
  11. 28 before_action :require_user_active
  12. 28 before_action :set_assessment
  13. 28 before_action :set_previous_inspection
  14. end
  15. 8 sig { void }
  16. 4 def update
  17. # Apply any model-specific preprocessing (like setting defaults)
  18. 56 then: 15 else: 41 preprocess_values if respond_to?(:preprocess_values, true)
  19. 56 then: 55 if @assessment.update(assessment_params)
  20. 55 handle_successful_update
  21. else: 1 else
  22. 1 handle_failed_update
  23. end
  24. end
  25. 4 private
  26. 8 sig { void }
  27. 4 def handle_successful_update
  28. 55 additional_info = build_additional_info
  29. 55 respond_to do |format|
  30. 55 format.html do
  31. 38 flash[:notice] = build_flash_message(additional_info)
  32. 38 redirect_to @inspection
  33. end
  34. 55 format.json do
  35. success_response = {
  36. status: I18n.t("shared.api.success"),
  37. inspection: @inspection
  38. }
  39. render json: success_response
  40. end
  41. 55 format.turbo_stream do
  42. 17 render turbo_stream: success_turbo_streams(additional_info:)
  43. end
  44. end
  45. end
  46. # Override in specific controllers to provide additional info
  47. 7 sig { returns(T.nilable(String)) }
  48. 4 def build_additional_info
  49. 41 nil
  50. end
  51. 8 sig { params(additional_info: T.nilable(String)).returns(String) }
  52. 4 def build_flash_message(additional_info)
  53. 38 base_message = I18n.t("inspections.messages.updated")
  54. 38 else: 6 then: 32 return base_message unless additional_info
  55. 6 "#{base_message} #{additional_info}"
  56. end
  57. 5 sig { void }
  58. 4 def handle_failed_update
  59. 1 respond_to do |format|
  60. 2 format.html { render_edit_with_errors }
  61. 1 format.json do
  62. errors = {errors: @assessment.errors}
  63. render json: errors, status: :unprocessable_content
  64. end
  65. 1 format.turbo_stream { render turbo_stream: error_turbo_streams }
  66. end
  67. end
  68. 5 sig { void }
  69. 4 def render_edit_with_errors
  70. 1 params[:tab] = assessment_type
  71. 1 @inspection.association(assessment_association).target = @assessment
  72. 1 render "inspections/edit", status: :unprocessable_content
  73. end
  74. 8 sig { void }
  75. 4 def set_inspection
  76. 56 @inspection = Inspection
  77. .includes(
  78. :user,
  79. :inspector_company,
  80. :unit,
  81. *Inspection::ALL_ASSESSMENT_TYPES.keys
  82. )
  83. .find(params[:inspection_id])
  84. end
  85. 8 sig { void }
  86. 4 def check_inspection_owner
  87. 56 else: 56 then: 0 head :not_found unless @inspection.user == current_user
  88. end
  89. 8 sig { void }
  90. 4 def set_assessment
  91. 56 @assessment = @inspection.send(assessment_association)
  92. end
  93. # Default implementation that permits all attributes except sensitive ones
  94. # Can be overridden in including controllers if needed
  95. 8 sig { returns(ActionController::Parameters) }
  96. 4 def assessment_params
  97. 56 params.require(param_key).permit(permitted_attributes)
  98. end
  99. 8 sig { returns(Symbol) }
  100. 4 def param_key
  101. # Use the model's actual param_key to avoid namespace mismatches
  102. 56 assessment_class.model_name.param_key.to_sym
  103. end
  104. 8 sig { returns(T::Array[String]) }
  105. 4 def permitted_attributes
  106. # Get all attributes except sensitive ones
  107. 56 excluded_attrs = %w[id inspection_id created_at updated_at]
  108. 56 assessment_class.attribute_names - excluded_attrs
  109. end
  110. # Automatically derive from controller name
  111. 8 sig { returns(String) }
  112. 4 def assessment_association
  113. # e.g. "MaterialsAssessmentsController" -> "materials_assessment"
  114. 57 controller_name.singularize
  115. end
  116. # Automatically derive from controller name
  117. 7 sig { returns(String) }
  118. 4 def assessment_type
  119. # e.g. "MaterialsAssessmentsController" -> "materials"
  120. 19 controller_name.singularize.sub(/_assessment$/, "")
  121. end
  122. # Automatically derive from controller name
  123. 8 sig { returns(T.class_of(ActiveRecord::Base)) }
  124. 4 def assessment_class
  125. # e.g. "MaterialsAssessmentsController"
  126. # -> Assessments::MaterialsAssessment
  127. 129 "Assessments::#{controller_name.singularize.camelize}".constantize
  128. end
  129. 8 sig { void }
  130. 4 def set_previous_inspection
  131. 56 else: 56 then: 0 return unless @inspection.unit
  132. 56 @previous_inspection = @inspection.unit.last_inspection
  133. end
  134. 4 sig { void }
  135. 4 def handle_inactive_user_redirect
  136. redirect_to edit_inspection_path(@inspection)
  137. end
  138. end

app/controllers/concerns/image_processable.rb

72.73% lines covered

70.0% branches covered

44 relevant lines. 32 lines covered and 12 lines missed.
10 total branches, 7 branches covered and 3 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 require "vips"
  4. 4 module ImageProcessable
  5. 4 extend ActiveSupport::Concern
  6. 4 extend T::Sig
  7. 4 included do
  8. 4 rescue_from ApplicationErrors::NotAnImageError do |exception|
  9. handle_image_error(exception)
  10. end
  11. 4 rescue_from ApplicationErrors::ImageProcessingError do |exception|
  12. handle_image_error(exception)
  13. end
  14. end
  15. 4 private
  16. 4 sig {
  17. 4 params(
  18. params_hash: T.any(ActionController::Parameters,
  19. T::Hash[T.untyped, T.untyped]),
  20. image_fields: T.untyped
  21. ).returns(T.any(ActionController::Parameters,
  22. T::Hash[T.untyped, T.untyped]))
  23. }
  24. 4 def process_image_params(params_hash, *image_fields)
  25. 138 image_fields.each do |field|
  26. 348 then: 331 else: 17 next if params_hash[field].blank?
  27. 17 uploaded_file = params_hash[field]
  28. 17 else: 17 then: 0 next unless uploaded_file.respond_to?(:read)
  29. begin
  30. 17 processed_io = process_image(uploaded_file)
  31. 14 then: 14 else: 0 params_hash[field] = processed_io if processed_io
  32. rescue ApplicationErrors::NotAnImageError,
  33. ApplicationErrors::ImageProcessingError => e
  34. 3 @image_processing_error = e
  35. 3 params_hash[field] = nil
  36. end
  37. end
  38. 138 params_hash
  39. end
  40. 7 sig { params(uploaded_file: T.untyped).returns(T.untyped) }
  41. 4 def process_image(uploaded_file)
  42. 17 validate_image!(uploaded_file)
  43. 14 processed_io = PhotoProcessingService.process_upload(uploaded_file)
  44. 14 else: 14 then: 0 raise ApplicationErrors::ImageProcessingError unless processed_io
  45. 14 processed_io
  46. rescue Vips::Error => e
  47. Rails.logger.error "Image processing failed: #{e.message}"
  48. error_message = I18n.t("errors.messages.image_processing_error",
  49. error: e.message)
  50. raise ApplicationErrors::ImageProcessingError, error_message
  51. end
  52. 7 sig { params(uploaded_file: T.untyped).void }
  53. 4 def validate_image!(uploaded_file)
  54. 17 then: 14 else: 3 return if PhotoProcessingService.valid_image?(uploaded_file)
  55. 3 raise ApplicationErrors::NotAnImageError
  56. end
  57. 4 sig { params(exception: StandardError).void }
  58. 4 def handle_image_error(exception)
  59. respond_to do |format|
  60. format.html do
  61. flash[:alert] = exception.message
  62. redirect_back(fallback_location: root_path)
  63. end
  64. format.turbo_stream do
  65. flash.now[:alert] = exception.message
  66. render turbo_stream: turbo_stream.replace("flash",
  67. partial: "shared/flash")
  68. end
  69. end
  70. end
  71. end

app/controllers/concerns/inspection_turbo_streams.rb

97.92% lines covered

91.67% branches covered

48 relevant lines. 47 lines covered and 1 lines missed.
12 total branches, 11 branches covered and 1 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module InspectionTurboStreams
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 private
  7. 4 sig do
  8. 4 params(additional_info: T.nilable(String)).returns(T::Array[T.untyped])
  9. end
  10. 4 def success_turbo_streams(additional_info: nil)
  11. [
  12. 27 mark_complete_section_stream,
  13. save_message_stream(success: true),
  14. assessment_save_message_stream(success: true, additional_info:),
  15. *photo_update_streams
  16. ].compact
  17. end
  18. 5 sig { returns(T::Array[T.untyped]) }
  19. 4 def error_turbo_streams
  20. [
  21. 1 mark_complete_section_stream,
  22. save_message_stream(success: false),
  23. assessment_save_message_stream(success: false)
  24. ]
  25. end
  26. 8 sig { returns(T.untyped) }
  27. 4 def mark_complete_section_stream
  28. 28 turbo_stream.replace(
  29. "mark_complete_section_#{@inspection.id}",
  30. partial: "inspections/mark_complete_section",
  31. locals: {inspection: @inspection}
  32. )
  33. end
  34. 8 sig { params(success: T::Boolean).returns(T.untyped) }
  35. 4 def save_message_stream(success:)
  36. 28 turbo_stream.replace(
  37. "inspection_save_message",
  38. partial: "shared/save_message",
  39. locals: save_message_locals(
  40. success: success,
  41. dom_id: "inspection_save_message"
  42. )
  43. )
  44. end
  45. 4 sig do
  46. 4 params(success: T::Boolean, additional_info: T.nilable(String))
  47. .returns(T.untyped)
  48. end
  49. 4 def assessment_save_message_stream(success:, additional_info: nil)
  50. 28 locals = save_message_locals(success:, dom_id: "form_save_message")
  51. 28 then: 2 else: 26 locals[:additional_info] = additional_info if additional_info
  52. 28 turbo_stream.replace(
  53. "form_save_message",
  54. partial: "shared/save_message",
  55. locals:
  56. )
  57. end
  58. 4 sig do
  59. 4 params(success: T::Boolean, dom_id: String)
  60. .returns(T::Hash[Symbol, T.untyped])
  61. end
  62. 4 def save_message_locals(success:, dom_id:)
  63. 56 then: 54 if success
  64. 54 success_message_locals(dom_id)
  65. else: 2 else
  66. {
  67. 2 dom_id: dom_id,
  68. errors: @inspection.errors.full_messages,
  69. message: t("shared.messages.save_failed")
  70. }
  71. end
  72. end
  73. 8 sig { params(dom_id: String).returns(T::Hash[Symbol, T.untyped]) }
  74. 4 def success_message_locals(dom_id)
  75. 54 current_tab_name = params[:tab].presence || "inspection"
  76. 54 nav_info = helpers.next_tab_navigation_info(@inspection, current_tab_name)
  77. {
  78. 54 dom_id: dom_id,
  79. success: true,
  80. message: t("inspections.messages.updated"),
  81. inspection: @inspection
  82. }.tap do |locals|
  83. 54 then: 52 else: 2 add_navigation_info(locals, nav_info) if nav_info
  84. end
  85. end
  86. 4 sig do
  87. 3 params(
  88. locals: T::Hash[Symbol, T.untyped],
  89. nav_info: T::Hash[Symbol, T.untyped]
  90. ).void
  91. end
  92. 4 def add_navigation_info(locals, nav_info)
  93. 52 locals[:next_tab] = nav_info[:tab]
  94. 52 locals[:skip_incomplete] = nav_info[:skip_incomplete]
  95. 52 then: 50 else: 2 if nav_info[:skip_incomplete]
  96. 50 locals[:incomplete_count] = nav_info[:incomplete_count]
  97. end
  98. end
  99. 8 sig { returns(T::Array[T.untyped]) }
  100. 4 def photo_update_streams
  101. 27 else: 10 then: 17 return [] unless params[:inspection]
  102. 10 %i[photo_1 photo_2 photo_3].filter_map do |photo_field|
  103. 30 then: 30 else: 0 next if params[:inspection][photo_field].blank?
  104. turbo_stream.replace(
  105. "inspection_#{photo_field}_field",
  106. partial: "chobble_forms/file_field_turbo_response",
  107. locals: {
  108. model: @inspection,
  109. field: photo_field,
  110. turbo_frame_id: "inspection_#{photo_field}_field",
  111. i18n_base: "forms.results",
  112. accept: "image/*"
  113. }
  114. )
  115. end
  116. end
  117. end

app/controllers/concerns/public_viewable.rb

79.41% lines covered

70.0% branches covered

34 relevant lines. 27 lines covered and 7 lines missed.
10 total branches, 7 branches covered and 3 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module PublicViewable
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 included do
  7. 8 before_action :check_resource_access, only: %i[show]
  8. end
  9. 4 sig { void }
  10. 4 def show
  11. # Implemented by including controllers, but we call render_show_html
  12. # for HTML format
  13. then: 0 else: 0 if request.format.html?
  14. render_show_html
  15. end
  16. end
  17. 4 private
  18. # Access Rules:
  19. # 1. PDF/JSON/PNG formats: Always allowed for everyone (logged in or not)
  20. # 2. HTML format:
  21. # - Not logged in: Allowed, shows minimal PDF viewer
  22. # - Logged in as owner: Allowed, shows full application view
  23. # - Logged in as non-owner: Allowed, shows minimal PDF viewer
  24. # 3. All other actions/formats: Require ownership
  25. 8 sig { void }
  26. 4 def check_resource_access
  27. # Rule 1: Always allow PDF/JSON/PNG access for everyone
  28. 288 then: 101 else: 187 return if request.format.pdf? || request.format.json? || request.format.png?
  29. # Rule 2: Always allow HTML access (show action decides the view)
  30. 187 then: 181 else: 6 return if request.format.html? && action_name == "show"
  31. # Rule 3: Always allow HEAD requests for federation
  32. 6 then: 6 else: 0 return if request.head?
  33. # Rule 4: All other cases require ownership
  34. check_resource_owner
  35. end
  36. # To be implemented by including controllers
  37. 4 sig { void }
  38. 4 def check_resource_owner
  39. raise NotImplementedError
  40. end
  41. # Determine if current user owns the resource
  42. 4 sig { returns(T::Boolean) }
  43. 4 def owns_resource?
  44. raise NotImplementedError
  45. end
  46. # Render appropriate view for show action
  47. 8 sig { void }
  48. 4 def render_show_html
  49. 167 else: 144 if !logged_in? || !owns_resource?
  50. then: 23 # Show minimal PDF viewer for public access or non-owners
  51. 23 @pdf_title = pdf_filename
  52. 23 @pdf_url = resource_pdf_url
  53. 23 render layout: "pdf_viewer"
  54. end
  55. # Otherwise render normal view for owners (no explicit render needed)
  56. end
  57. # To be implemented by including controllers
  58. 4 sig { returns(String) }
  59. 4 def pdf_filename
  60. raise NotImplementedError
  61. end
  62. 4 sig { returns(String) }
  63. 4 def resource_pdf_url
  64. raise NotImplementedError
  65. end
  66. end

app/controllers/concerns/safety_standards_turbo_streams.rb

100.0% lines covered

100.0% branches covered

17 relevant lines. 17 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module SafetyStandardsTurboStreams
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 private
  7. 4 sig do
  8. 3 params(additional_info: T.nilable(String)).returns(T::Array[T.untyped])
  9. end
  10. 4 def success_turbo_streams(additional_info: nil)
  11. 9 super + safety_standards_turbo_streams
  12. end
  13. 7 sig { returns(T::Array[T.untyped]) }
  14. 4 def safety_standards_turbo_streams
  15. 9 [turbo_stream.replace(
  16. safety_results_frame_id,
  17. partial: safety_results_partial,
  18. locals: {assessment: @assessment}
  19. )]
  20. end
  21. 7 sig { returns(String) }
  22. 4 def safety_results_frame_id
  23. 9 "#{assessment_type}_safety_results"
  24. end
  25. 7 sig { returns(String) }
  26. 4 def safety_results_partial
  27. 9 "assessments/#{assessment_type}_safety_results"
  28. end
  29. end

app/controllers/concerns/session_management.rb

100.0% lines covered

75.0% branches covered

12 relevant lines. 12 lines covered and 0 lines missed.
4 total branches, 3 branches covered and 1 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 module SessionManagement
  4. 4 extend ActiveSupport::Concern
  5. 4 private
  6. 4 def establish_user_session(user)
  7. 609 user_session = user.user_sessions.create!(
  8. ip_address: request.remote_ip,
  9. user_agent: request.user_agent,
  10. last_active_at: Time.current
  11. )
  12. 609 session[:session_token] = user_session.session_token
  13. 609 create_user_session(user)
  14. 609 user_session
  15. end
  16. 4 def terminate_current_session
  17. 29 else: 28 then: 1 return unless session[:session_token]
  18. 28 then: 28 else: 0 UserSession.find_by(session_token: session[:session_token])&.destroy
  19. 28 session.delete(:session_token)
  20. end
  21. end

app/controllers/concerns/turbo_stream_responders.rb

93.33% lines covered

75.0% branches covered

45 relevant lines. 42 lines covered and 3 lines missed.
12 total branches, 9 branches covered and 3 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module TurboStreamResponders
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 private
  7. 7 sig { params(success: T::Boolean, message: String, model: T.nilable(ActiveRecord::Base), additional_streams: T::Array[Turbo::Streams::TagBuilder]).void }
  8. 4 def render_save_message_stream(success:, message:, model: nil, additional_streams: [])
  9. streams = [
  10. 6 turbo_stream.replace(
  11. "form_save_message",
  12. partial: "shared/save_message",
  13. locals: {
  14. message: message,
  15. 6 then: 5 else: 1 type: success ? "success" : "error",
  16. 6 then: 5 else: 1 then: 1 else: 0 then: 1 else: 0 errors: success ? nil : model&.errors&.full_messages
  17. }
  18. )
  19. ]
  20. 6 then: 1 else: 5 streams.concat(additional_streams) if additional_streams.any?
  21. 6 render turbo_stream: streams
  22. end
  23. 8 sig { params(model: ActiveRecord::Base, message_key: T.nilable(String), redirect_path: T.any(String, ActiveRecord::Base, NilClass), additional_streams: T::Array[Turbo::Streams::TagBuilder]).void }
  24. 4 def handle_update_success(model, message_key = nil, redirect_path = nil, additional_streams: [])
  25. 32 message_key ||= "#{model.class.table_name}.messages.updated"
  26. 32 redirect_path ||= model
  27. 32 respond_to do |format|
  28. 32 format.html do
  29. 27 flash[:notice] = I18n.t(message_key)
  30. 27 redirect_to redirect_path
  31. end
  32. 32 format.turbo_stream do
  33. 5 render_save_message_stream(
  34. success: true,
  35. message: I18n.t(message_key),
  36. additional_streams: additional_streams
  37. )
  38. end
  39. end
  40. end
  41. 8 sig { params(model: ActiveRecord::Base, view: Symbol, block: T.nilable(T.proc.params(format: T.untyped).void)).void }
  42. 4 def handle_update_failure(model, view = :edit, &block)
  43. 8 respond_to do |format|
  44. 15 format.html { render view, status: :unprocessable_content }
  45. 8 format.json do
  46. render json: {
  47. status: I18n.t("shared.api.error"),
  48. errors: model.errors.full_messages
  49. }
  50. end
  51. 8 format.turbo_stream do
  52. 1 render_save_message_stream(
  53. success: false,
  54. message: I18n.t("shared.messages.save_failed"),
  55. model: model
  56. )
  57. end
  58. 8 then: 0 else: 8 yield(format) if block_given?
  59. end
  60. end
  61. 8 sig { params(model: ActiveRecord::Base, message_key: T.nilable(String)).void }
  62. 4 def handle_create_success(model, message_key = nil)
  63. 22 message_key ||= "#{model.class.table_name}.messages.created"
  64. 22 respond_to do |format|
  65. 22 format.html do
  66. 22 flash[:notice] = I18n.t(message_key)
  67. 22 redirect_to model
  68. end
  69. 22 format.turbo_stream do
  70. render_save_message_stream(
  71. success: true,
  72. message: I18n.t(message_key)
  73. )
  74. end
  75. end
  76. end
  77. 8 sig { params(model: ActiveRecord::Base, view: Symbol).void }
  78. 4 def handle_create_failure(model, view = :new)
  79. 10 respond_to do |format|
  80. 20 format.html { render view, status: :unprocessable_content }
  81. 10 format.turbo_stream do
  82. render_save_message_stream(
  83. success: false,
  84. message: I18n.t("shared.messages.save_failed"),
  85. model: model
  86. )
  87. end
  88. end
  89. end
  90. end

app/controllers/concerns/user_activity_check.rb

91.67% lines covered

100.0% branches covered

12 relevant lines. 11 lines covered and 1 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module UserActivityCheck
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 private
  7. 8 sig { void }
  8. 4 def require_user_active
  9. 349 then: 337 else: 12 return if current_user.is_active?
  10. 12 flash[:alert] = current_user.inactive_user_message
  11. 12 handle_inactive_user_redirect
  12. end
  13. # Override this method in controllers to provide custom redirect logic
  14. 4 sig { void }
  15. 4 def handle_inactive_user_redirect
  16. raise NotImplementedError
  17. end
  18. end

app/controllers/credentials_controller.rb

100.0% lines covered

100.0% branches covered

33 relevant lines. 33 lines covered and 0 lines missed.
6 total branches, 6 branches covered and 0 branches missed.
    
  1. 4 class CredentialsController < ApplicationController
  2. 4 before_action :require_login
  3. 4 def create
  4. 5 create_options = WebAuthn::Credential.options_for_create(
  5. user: {
  6. id: current_user.webauthn_id,
  7. name: current_user.email
  8. },
  9. exclude: current_user.credentials.pluck(:external_id),
  10. authenticator_selection: {user_verification: "required"}
  11. )
  12. 5 session[:current_registration] = {challenge: create_options.challenge}
  13. 5 respond_to do |format|
  14. 10 format.json { render json: create_options }
  15. end
  16. end
  17. 4 def callback
  18. 12 webauthn_credential = WebAuthn::Credential.from_create(params)
  19. 8 verify_and_save_credential(webauthn_credential)
  20. rescue WebAuthn::Error => e
  21. 4 error_msg = I18n.t("credentials.messages.verification_failed")
  22. 4 render json: "#{error_msg}: #{e.message}",
  23. status: :unprocessable_content
  24. ensure
  25. 12 session.delete(:current_registration)
  26. end
  27. 4 def destroy
  28. 4 credential = current_user.credentials.find(params[:id])
  29. 2 then: 1 if current_user.can_delete_credentials?
  30. 1 credential.destroy
  31. 1 flash[:notice] = I18n.t("credentials.messages.deleted")
  32. else: 1 else
  33. 1 flash[:error] = I18n.t("credentials.messages.cannot_delete_last")
  34. end
  35. 2 redirect_to change_settings_user_path(current_user)
  36. end
  37. 4 private
  38. 4 def verify_and_save_credential(webauthn_credential)
  39. 8 challenge = session[:current_registration]["challenge"]
  40. 8 webauthn_credential.verify(challenge, user_verification: true)
  41. 8 credential = current_user.credentials.find_or_initialize_by(
  42. external_id: Base64.strict_encode64(webauthn_credential.raw_id)
  43. )
  44. 8 credential_attrs = credential_params(webauthn_credential)
  45. # Ensure user_id is set for new records
  46. 8 then: 7 else: 1 credential_attrs[:user_id] = current_user.id if credential.new_record?
  47. 8 then: 5 if credential.update(credential_attrs)
  48. 5 render json: {status: "ok"}, status: :ok
  49. else: 3 else
  50. 3 error_msg = I18n.t("credentials.messages.could_not_add")
  51. 3 render json: error_msg, status: :unprocessable_content
  52. end
  53. end
  54. 4 def credential_params(webauthn_credential)
  55. {
  56. 8 nickname: params[:credential_nickname],
  57. public_key: webauthn_credential.public_key,
  58. sign_count: webauthn_credential.sign_count
  59. }
  60. end
  61. end

app/controllers/enclosed_assessments_controller.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 class EnclosedAssessmentsController < ApplicationController
  2. 4 include AssessmentController
  3. end

app/controllers/errors_controller.rb

81.82% lines covered

25.0% branches covered

22 relevant lines. 18 lines covered and 4 lines missed.
4 total branches, 1 branches covered and 3 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class ErrorsController < ApplicationController
  4. 4 skip_before_action :require_login
  5. 4 skip_before_action :update_last_active_at
  6. 4 def not_found
  7. 1 capture_exception_for_sentry
  8. 1 respond_to do |format|
  9. 2 format.html { render status: :not_found }
  10. 1 format.json do
  11. render json: {error: I18n.t("errors.not_found.title")},
  12. status: :not_found
  13. end
  14. 1 format.any { head :not_found }
  15. end
  16. end
  17. 4 def internal_server_error
  18. 1 capture_exception_for_sentry
  19. 1 respond_to do |format|
  20. 2 format.html { render status: :internal_server_error }
  21. 1 format.json do
  22. render json: {error: I18n.t("errors.internal_server_error.title")},
  23. status: :internal_server_error
  24. end
  25. 1 format.any { head :internal_server_error }
  26. end
  27. end
  28. 4 private
  29. 4 def capture_exception_for_sentry
  30. 2 else: 0 then: 2 return unless Rails.env.production?
  31. exception = request.env["action_dispatch.exception"]
  32. then: 0 else: 0 Sentry.capture_exception(exception) if exception
  33. end
  34. end

app/controllers/fan_assessments_controller.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 class FanAssessmentsController < ApplicationController
  2. 4 include AssessmentController
  3. end

app/controllers/guides_controller.rb

33.33% lines covered

0.0% branches covered

24 relevant lines. 8 lines covered and 16 lines missed.
2 total branches, 0 branches covered and 2 branches missed.
    
  1. 4 class GuidesController < ApplicationController
  2. 4 skip_before_action :require_login
  3. 4 def index
  4. @guides = collect_guides
  5. end
  6. 4 def show
  7. guide_path = params[:path]
  8. metadata_file = guide_screenshots_root.join(guide_path, "metadata.json")
  9. then: 0 if metadata_file.exist?
  10. @guide_data = JSON.parse(metadata_file.read)
  11. @guide_path = guide_path
  12. @guide_title = humanize_guide_title(guide_path)
  13. else: 0 else
  14. redirect_to guides_path, alert: I18n.t("guides.messages.not_found")
  15. end
  16. end
  17. 4 private
  18. 4 def guide_screenshots_root
  19. Rails.public_path.join("guide_screenshots")
  20. end
  21. 4 def collect_guides
  22. guides = []
  23. # Find all metadata.json files
  24. Dir.glob(guide_screenshots_root.join("**", "metadata.json")).each do |metadata_path|
  25. relative_path = Pathname.new(metadata_path).relative_path_from(guide_screenshots_root).dirname.to_s
  26. metadata = JSON.parse(File.read(metadata_path))
  27. guides << {
  28. path: relative_path,
  29. title: humanize_guide_title(relative_path),
  30. screenshot_count: metadata["screenshots"].size,
  31. updated_at: metadata["updated_at"],
  32. first_screenshot: metadata["screenshots"].first
  33. }
  34. end
  35. guides.sort_by { |g| g[:title] }
  36. end
  37. 4 def humanize_guide_title(path)
  38. # Convert spec/features/inspections/inspection_creation_workflow_spec to "Inspection Creation Workflow"
  39. path.split("/").last.gsub(/_spec$/, "").humanize
  40. end
  41. end

app/controllers/inspections_controller.rb

93.77% lines covered

82.22% branches covered

273 relevant lines. 256 lines covered and 17 lines missed.
90 total branches, 74 branches covered and 16 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class InspectionsController < ApplicationController
  4. 4 extend T::Sig
  5. 4 include InspectionTurboStreams
  6. 4 include PublicViewable
  7. 4 include UserActivityCheck
  8. 4 skip_before_action :require_login, only: %i[show]
  9. 4 before_action :check_assessments_enabled
  10. 4 before_action :set_inspection, except: %i[create index]
  11. 4 before_action :check_inspection_owner, except: %i[create index show]
  12. 4 before_action :validate_unit_ownership, only: %i[update]
  13. 4 before_action :redirect_if_complete,
  14. except: %i[create index destroy mark_draft show log]
  15. 4 before_action :require_user_active, only: %i[create edit update]
  16. 4 before_action :validate_inspection_completability, only: %i[show edit]
  17. 4 before_action :no_index
  18. 4 def index
  19. 425 all_inspections = filtered_inspections_query_without_order.to_a
  20. 425 partition_inspections(all_inspections)
  21. 425 @title = build_index_title
  22. 425 @has_any_inspections = all_inspections.any?
  23. 425 respond_to do |format|
  24. 425 format.html
  25. 425 format.csv do
  26. 9 log_inspection_event("exported", nil, "Exported #{@complete_inspections.count} inspections to CSV")
  27. 9 send_inspections_csv
  28. end
  29. end
  30. end
  31. 4 def show
  32. # Handle federation HEAD requests
  33. 153 then: 1 else: 152 return head :ok if request.head?
  34. 152 respond_to do |format|
  35. 250 format.html { render_show_html }
  36. 181 format.pdf { send_inspection_pdf }
  37. 159 format.png { send_inspection_qr_code }
  38. 152 format.json do
  39. 18 render json: InspectionBlueprint.render(@inspection)
  40. end
  41. end
  42. end
  43. 4 def create
  44. 20 unit_id = params[:unit_id] || params.dig(:inspection, :unit_id)
  45. 20 result = InspectionCreationService.new(
  46. current_user,
  47. unit_id: unit_id
  48. ).create
  49. 20 then: 18 if result[:success]
  50. 18 log_inspection_event("created", result[:inspection])
  51. 18 flash[:notice] = result[:message]
  52. 18 redirect_to edit_inspection_path(result[:inspection])
  53. else: 2 else
  54. 2 flash[:alert] = result[:message]
  55. 2 redirect_to result[:redirect_path]
  56. end
  57. end
  58. 4 def edit
  59. 181 validate_tab_parameter
  60. 181 set_previous_inspection
  61. end
  62. 4 def update
  63. 32 previous_attributes = @inspection.attributes.dup
  64. 32 params_to_update = inspection_params
  65. 32 then: 0 else: 32 if @image_processing_error
  66. flash.now[:alert] = @image_processing_error.message
  67. render :edit, status: :unprocessable_content
  68. return
  69. end
  70. 32 then: 30 if @inspection.update(params_to_update)
  71. 30 changed_data = calculate_changes(
  72. previous_attributes,
  73. @inspection.attributes,
  74. inspection_params.keys
  75. )
  76. 30 log_inspection_event("updated", @inspection, nil, changed_data)
  77. 30 handle_successful_update
  78. else: 2 else
  79. 2 handle_failed_update
  80. end
  81. end
  82. 4 def destroy
  83. 6 then: 2 else: 4 if @inspection.complete?
  84. 2 alert_message = I18n.t("inspections.messages.delete_complete_denied")
  85. 2 redirect_to inspection_path(@inspection), alert: alert_message
  86. 2 return
  87. end
  88. # Capture inspection details before deletion for the audit log
  89. inspection_details = {
  90. 4 inspection_date: @inspection.inspection_date,
  91. then: 4 else: 0 unit_serial: @inspection.unit&.serial,
  92. then: 4 else: 0 unit_name: @inspection.unit&.name,
  93. complete_date: @inspection.complete_date
  94. }
  95. 4 @inspection.destroy
  96. # Log the deletion with the inspection details in metadata
  97. 4 Event.log(
  98. user: current_user,
  99. action: "deleted",
  100. resource: @inspection,
  101. details: nil,
  102. metadata: inspection_details
  103. )
  104. 4 redirect_to inspections_path, notice: I18n.t("inspections.messages.deleted")
  105. end
  106. 4 def select_unit
  107. 19 @units = current_user.units
  108. .includes(photo_attachment: :blob)
  109. .search(params[:search])
  110. .by_manufacturer(params[:manufacturer])
  111. .order(:name)
  112. 19 @title = t("inspections.titles.select_unit")
  113. 19 render :select_unit
  114. end
  115. 4 def update_unit
  116. 6 unit = current_user.units.find_by(id: params[:unit_id])
  117. 6 else: 4 then: 2 unless unit
  118. 2 flash[:alert] = t("inspections.errors.invalid_unit")
  119. 2 redirect_to select_unit_inspection_path(@inspection) and return
  120. end
  121. 4 @inspection.unit = unit
  122. 4 then: 4 if @inspection.save
  123. 4 handle_successful_unit_update(unit)
  124. else: 0 else
  125. handle_failed_unit_update
  126. end
  127. end
  128. 4 def handle_successful_unit_update(unit)
  129. 4 log_inspection_event("unit_changed", @inspection, "Unit changed to #{unit.name}")
  130. 4 flash[:notice] = t("inspections.messages.unit_changed", unit_name: unit.name)
  131. 4 redirect_to edit_inspection_path(@inspection)
  132. end
  133. 4 def handle_failed_unit_update
  134. error_messages = @inspection.errors.full_messages.join(", ")
  135. flash[:alert] = t("inspections.messages.unit_change_failed", errors: error_messages)
  136. redirect_to select_unit_inspection_path(@inspection)
  137. end
  138. 4 def complete
  139. 8 validation_errors = @inspection.validate_completeness
  140. 8 then: 1 else: 7 if validation_errors.any?
  141. 1 error_list = validation_errors.join(", ")
  142. 1 flash[:alert] =
  143. t("inspections.messages.cannot_complete", errors: error_list)
  144. 1 redirect_to edit_inspection_path(@inspection)
  145. 1 return
  146. end
  147. 7 @inspection.complete!(current_user)
  148. 6 log_inspection_event("completed", @inspection)
  149. 6 flash[:notice] = t("inspections.messages.marked_complete")
  150. 6 redirect_to @inspection
  151. end
  152. 4 def mark_draft
  153. 6 then: 5 if @inspection.update(complete_date: nil)
  154. 5 log_inspection_event("marked_draft", @inspection)
  155. 5 flash[:notice] = t("inspections.messages.marked_in_progress")
  156. else: 1 else
  157. 1 error_messages = @inspection.errors.full_messages.join(", ")
  158. 1 flash[:alert] = t("inspections.messages.mark_in_progress_failed",
  159. errors: error_messages)
  160. end
  161. 6 redirect_to edit_inspection_path(@inspection)
  162. end
  163. 4 def log
  164. 3 @events = Event.for_resource(@inspection).recent.includes(:user)
  165. 3 @title = I18n.t("inspections.titles.log", inspection: @inspection.id)
  166. end
  167. 4 def inspection_params
  168. 98 base_params = build_base_params
  169. 98 add_assessment_params(base_params)
  170. 98 process_image_params(base_params, :photo_1, :photo_2, :photo_3)
  171. end
  172. 4 private
  173. 4 def check_assessments_enabled
  174. 903 else: 903 then: 0 head :not_found unless ENV["HAS_ASSESSMENTS"] == "true"
  175. end
  176. 4 def partition_inspections(all_inspections)
  177. 425 @draft_inspections = all_inspections
  178. 122 .select { |inspection| inspection.complete_date.nil? }
  179. .sort_by(&:created_at)
  180. 425 @complete_inspections = all_inspections
  181. 122 .select { |inspection| inspection.complete_date.present? }
  182. 64 .sort_by { |inspection| -inspection.created_at.to_i }
  183. end
  184. 4 def send_inspections_csv
  185. 9 csv_data = InspectionCsvExportService.new(@complete_inspections).generate
  186. 8 filename = I18n.t("inspections.export.csv_filename", date: Time.zone.today)
  187. 8 send_data csv_data, filename: filename
  188. end
  189. 4 def validate_tab_parameter
  190. 181 then: 108 else: 73 return if params[:tab].blank?
  191. 73 valid_tabs = helpers.inspection_tabs(@inspection)
  192. 73 then: 72 else: 1 return if valid_tabs.include?(params[:tab])
  193. 1 redirect_to edit_inspection_path(@inspection),
  194. alert: I18n.t("inspections.messages.invalid_tab")
  195. end
  196. 4 def validate_inspection_completability
  197. 336 else: 81 then: 255 return unless @inspection.complete?
  198. 81 then: 79 else: 2 return if @inspection.can_mark_complete?
  199. 2 error_message = I18n.t(
  200. "inspections.errors.invalid_completion_state",
  201. errors: @inspection.completion_errors.join(", ")
  202. )
  203. 2 inspection_errors = @inspection.completion_errors
  204. 2 Rails.logger.error "Inspection #{@inspection.id} is marked complete " \
  205. "but has errors: #{inspection_errors}"
  206. # Only raise error in development/test environments
  207. 2 then: 2 if Rails.env.local?
  208. 2 test_message = "In tests, use create(:inspection, :completed) to avoid this."
  209. 2 raise StandardError, "DATA INTEGRITY ERROR: #{error_message}. #{test_message}"
  210. else
  211. else: 0 # In production, log the error but continue
  212. Rails.logger.error "DATA INTEGRITY ERROR: #{error_message}"
  213. end
  214. end
  215. 4 ASSESSMENT_SYSTEM_ATTRIBUTES = %w[
  216. inspection_id
  217. created_at
  218. updated_at
  219. ].freeze
  220. # Build safe mappings from Inspection::ALL_ASSESSMENT_TYPES
  221. # This ensures mappings stay in sync with the model definition
  222. 4 ASSESSMENT_TAB_MAPPING = Inspection::ALL_ASSESSMENT_TYPES
  223. .each_with_object({}) do |(method_name, _), hash|
  224. # Convert :user_height_assessment to "user_height"
  225. 28 tab_name = method_name.to_s.gsub(/_assessment$/, "")
  226. 28 hash[tab_name] = method_name
  227. end.freeze
  228. 4 ASSESSMENT_CLASS_MAPPING = Inspection::ALL_ASSESSMENT_TYPES
  229. .each_with_object({}) do |(method_name, klass), hash|
  230. # Convert :user_height_assessment to "user_height"
  231. 28 tab_name = method_name.to_s.gsub(/_assessment$/, "")
  232. 28 hash[tab_name] = klass
  233. end.freeze
  234. 4 def build_base_params
  235. 98 params.require(:inspection).permit(*Inspection::USER_EDITABLE_PARAMS)
  236. end
  237. 4 def add_assessment_params(base_params)
  238. 98 Inspection::ALL_ASSESSMENT_TYPES.each_key do |ass_type|
  239. 686 ass_key = "#{ass_type}_attributes"
  240. 686 then: 686 else: 0 next if params[:inspection][ass_key].blank?
  241. ass_params = params[:inspection][ass_key]
  242. permitted_ass_params = assessment_permitted_attributes(ass_type)
  243. base_params[ass_key] = ass_params.permit(*permitted_ass_params)
  244. end
  245. end
  246. 4 def assessment_permitted_attributes(assessment_type)
  247. model_class = "Assessments::#{assessment_type.to_s.camelize}".constantize
  248. model_class.column_name_syms - ASSESSMENT_SYSTEM_ATTRIBUTES
  249. end
  250. 4 def filtered_inspections_query_without_order = current_user.inspections
  251. .includes(:inspector_company, unit: {photo_attachment: {blob: :attachments}})
  252. .search(params[:query])
  253. .filter_by_result(params[:result])
  254. .filter_by_unit(params[:unit_id])
  255. .filter_by_operator(params[:operator])
  256. 4 def no_index = response.set_header("X-Robots-Tag", "noindex,nofollow")
  257. 4 def set_inspection
  258. 452 @inspection = Inspection
  259. .includes(
  260. :user, :inspector_company,
  261. *Inspection::ALL_ASSESSMENT_TYPES.keys,
  262. unit: {photo_attachment: :blob},
  263. photo_1_attachment: :blob,
  264. photo_2_attachment: :blob,
  265. photo_3_attachment: :blob
  266. )
  267. then: 452 else: 0 .find_by(id: params[:id]&.upcase)
  268. 452 else: 437 then: 15 head :not_found unless @inspection
  269. end
  270. 4 def check_inspection_owner
  271. 282 then: 273 else: 9 return if current_user && @inspection.user_id == current_user.id
  272. 9 head :not_found
  273. end
  274. 4 def redirect_if_complete
  275. 257 else: 10 then: 247 return unless @inspection.complete?
  276. 10 flash[:notice] = I18n.t("inspections.messages.cannot_edit_complete")
  277. 10 redirect_to @inspection
  278. end
  279. 4 def build_index_title
  280. 425 title = I18n.t("inspections.titles.index")
  281. 425 else: 10 then: 415 return title unless params[:result]
  282. 10 in: 8 status = case params[:result]
  283. 8 in: 2 in "passed" then I18n.t("inspections.status.passed")
  284. 2 else: 0 in "failed" then I18n.t("inspections.status.failed")
  285. else params[:result]
  286. end
  287. 10 "#{title} - #{status}"
  288. end
  289. 4 def validate_unit_ownership
  290. 35 else: 1 then: 34 return unless inspection_params[:unit_id]
  291. 1 unit = current_user.units.find_by(id: inspection_params[:unit_id])
  292. 1 then: 0 else: 1 return if unit
  293. # Unit ID not found or doesn't belong to user - security issue
  294. 1 flash[:alert] = I18n.t("inspections.errors.invalid_unit")
  295. 1 render :edit, status: :unprocessable_content
  296. end
  297. 4 def handle_successful_update
  298. 30 respond_to do |format|
  299. 30 format.html do
  300. 17 flash[:notice] = I18n.t("inspections.messages.updated")
  301. 17 redirect_to @inspection
  302. end
  303. 30 format.json do
  304. 3 render json: {status: I18n.t("shared.api.success"),
  305. inspection: @inspection}
  306. end
  307. 40 format.turbo_stream { render turbo_stream: success_turbo_streams }
  308. end
  309. end
  310. 4 def handle_failed_update
  311. 2 respond_to do |format|
  312. 2 format.html { render :edit, status: :unprocessable_content }
  313. 3 format.json { render json: {status: I18n.t("shared.api.error"), errors: @inspection.errors.full_messages} }
  314. 3 format.turbo_stream { render turbo_stream: error_turbo_streams }
  315. end
  316. end
  317. 4 def send_inspection_pdf
  318. 29 result = PdfCacheService.fetch_or_generate_inspection_pdf(
  319. @inspection,
  320. debug_enabled: admin_debug_enabled?,
  321. debug_queries: debug_sql_queries
  322. )
  323. 29 @inspection.update(pdf_last_accessed_at: Time.current)
  324. 29 handle_pdf_response(result, pdf_filename)
  325. end
  326. 4 def send_inspection_qr_code
  327. 7 qr_code_png = QrCodeService.generate_qr_code(@inspection)
  328. 7 send_data qr_code_png,
  329. filename: qr_code_filename,
  330. type: "image/png",
  331. disposition: "inline"
  332. end
  333. # PublicViewable implementation
  334. 4 def check_resource_owner
  335. check_inspection_owner
  336. end
  337. 4 def owns_resource?
  338. 87 @inspection && current_user && @inspection.user_id == current_user.id
  339. end
  340. 4 def pdf_filename
  341. 42 then: 42 else: 0 identifier = @inspection.unit&.serial || @inspection.id
  342. 42 I18n.t("inspections.export.pdf_filename", identifier: identifier)
  343. end
  344. 4 def qr_code_filename
  345. 7 then: 7 else: 0 identifier = @inspection.unit&.serial || @inspection.id
  346. 7 I18n.t("inspections.export.qr_filename", identifier: identifier)
  347. end
  348. 4 def resource_pdf_url
  349. 13 inspection_path(@inspection, format: :pdf)
  350. end
  351. 4 def handle_inactive_user_redirect
  352. 7 then: 6 if action_name == "create"
  353. 6 unit_id = params[:unit_id] || params.dig(:inspection, :unit_id)
  354. 6 then: 4 if unit_id.present?
  355. 4 unit = current_user.units.find_by(id: unit_id)
  356. 4 then: 4 else: 0 redirect_to unit ? unit_path(unit) : inspections_path
  357. else: 2 else
  358. 2 redirect_to inspections_path
  359. else: 1 end
  360. 1 then: 1 elsif action_name.in?(%w[edit update]) && @inspection
  361. 1 redirect_to inspection_path(@inspection)
  362. else: 0 else
  363. redirect_to inspections_path
  364. end
  365. end
  366. 4 NOT_COPIED_FIELDS = %i[
  367. complete_date
  368. created_at
  369. id
  370. inspection_date
  371. inspection_id
  372. inspector_company_id
  373. is_seed
  374. passed
  375. pdf_last_accessed_at
  376. unit_id
  377. updated_at
  378. user_id
  379. ].freeze
  380. 4 def set_previous_inspection
  381. 181 then: 177 else: 4 @previous_inspection = @inspection.unit&.last_inspection
  382. 181 then: 156 else: 25 return if !@previous_inspection || @previous_inspection.id == @inspection.id
  383. 25 @prefilled_fields = []
  384. 25 current_object, previous_object, column_name_syms = get_prefill_objects
  385. 25 column_name_syms.each do |field|
  386. 498 then: 173 else: 325 next if NOT_COPIED_FIELDS.include?(field)
  387. 325 then: 325 else: 0 then: 39 else: 286 next if previous_object&.send(field).nil?
  388. 286 else: 188 then: 98 next unless current_object.send(field).nil?
  389. 188 @prefilled_fields << translate_field_name(field)
  390. end
  391. end
  392. 4 def get_prefill_objects
  393. 25 case params[:tab]
  394. when: 13 when "inspection", "", nil
  395. 13 [@inspection, @previous_inspection, Inspection.column_name_syms]
  396. when "results"
  397. # Results tab uses inspection fields directly, not an assessment
  398. # Include all fields shown on results tab: passed, risk_assessment, and photos
  399. when: 3 # NOT_COPIED_FIELDS will filter out fields that shouldn't be prefilled
  400. 3 results_fields = [:passed, :risk_assessment, :photo_1, :photo_2, :photo_3]
  401. 3 [@inspection, @previous_inspection, results_fields]
  402. else: 9 else
  403. 9 assessment_method = ASSESSMENT_TAB_MAPPING[params[:tab]]
  404. 9 assessment_class = ASSESSMENT_CLASS_MAPPING[params[:tab]]
  405. [
  406. 9 @inspection.public_send(assessment_method),
  407. @previous_inspection.public_send(assessment_method),
  408. assessment_class.column_name_syms
  409. ]
  410. end
  411. end
  412. 8 sig { params(field: Symbol).returns String }
  413. 4 def translate_field_name(field)
  414. 188 is_comment = ChobbleForms::FieldUtils.is_comment_field?(field)
  415. 188 is_pass = ChobbleForms::FieldUtils.is_pass_field?(field)
  416. 188 field_base = ChobbleForms::FieldUtils.strip_field_suffix(field)
  417. 188 tab_name = params[:tab] || :inspection
  418. 188 i18n_base = "forms.#{tab_name}.fields"
  419. 188 translated = I18n.t("#{i18n_base}.#{field_base}", default: nil)
  420. 188 translated ||= I18n.t("#{i18n_base}.#{field}")
  421. 188 then: 73 if is_comment
  422. 73 else: 115 translated += " (#{I18n.t("shared.comment")})"
  423. 115 then: 59 else: 56 elsif is_pass
  424. 59 translated += " (#{I18n.t("shared.pass")}/#{I18n.t("shared.fail")})"
  425. end
  426. 188 translated
  427. end
  428. 4 def log_inspection_event(action, inspection, details = nil, changed_data = nil)
  429. 72 else: 72 then: 0 return unless current_user
  430. 72 then: 63 if inspection
  431. 63 Event.log(
  432. user: current_user,
  433. action: action,
  434. resource: inspection,
  435. details: details,
  436. changed_data: changed_data
  437. )
  438. else
  439. else: 9 # For events without a specific inspection (like CSV export)
  440. 9 Event.log_system_event(
  441. user: current_user,
  442. action: action,
  443. details: details,
  444. metadata: {resource_type: "Inspection"}
  445. )
  446. end
  447. rescue => e
  448. Rails.logger.error "Failed to log inspection event: #{e.message}"
  449. end
  450. 4 def calculate_changes(previous_attributes, current_attributes, changed_keys)
  451. 30 changes = {}
  452. 30 changed_keys.map(&:to_s).each do |key|
  453. 81 previous_value = previous_attributes[key]
  454. 81 current_value = current_attributes[key]
  455. 81 else: 35 then: 46 next unless previous_value != current_value
  456. 35 changes[key] = {
  457. "from" => previous_value,
  458. "to" => current_value
  459. }
  460. end
  461. 30 changes.presence
  462. end
  463. end

app/controllers/inspector_companies_controller.rb

100.0% lines covered

100.0% branches covered

28 relevant lines. 28 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. 4 class InspectorCompaniesController < ApplicationController
  2. 4 include TurboStreamResponders
  3. 4 before_action :set_inspector_company, only: %i[
  4. show edit update
  5. ]
  6. 4 before_action :require_login
  7. 4 before_action :require_admin, except: [:show]
  8. 4 def index
  9. 13 @inspector_companies = InspectorCompany
  10. .with_attached_logo
  11. .by_status(params[:active])
  12. .search_by_term(params[:search])
  13. .order(:name)
  14. end
  15. 4 def show
  16. 13 @company_stats = @inspector_company.company_statistics
  17. 13 @recent_inspections = @inspector_company.recent_inspections(5)
  18. end
  19. 4 def new
  20. 13 @inspector_company = InspectorCompany.new
  21. 13 @inspector_company.country = "UK"
  22. end
  23. 4 def create
  24. 16 @inspector_company = InspectorCompany.new(inspector_company_params)
  25. 16 then: 12 if @inspector_company.save
  26. 12 handle_create_success(@inspector_company)
  27. else: 4 else
  28. 4 handle_create_failure(@inspector_company)
  29. end
  30. end
  31. 4 def edit
  32. end
  33. 4 def update
  34. 11 then: 7 if @inspector_company.update(inspector_company_params)
  35. 7 handle_update_success(@inspector_company)
  36. else: 4 else
  37. 4 handle_update_failure(@inspector_company)
  38. end
  39. end
  40. 4 private
  41. 4 def set_inspector_company
  42. 40 @inspector_company = InspectorCompany.find(params[:id])
  43. end
  44. 4 def inspector_company_params
  45. 27 params.require(:inspector_company).permit(
  46. :name, :email, :phone, :address,
  47. :city, :postal_code, :country,
  48. :active, :notes, :logo
  49. )
  50. end
  51. end

app/controllers/materials_assessments_controller.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 class MaterialsAssessmentsController < ApplicationController
  2. 4 include AssessmentController
  3. end

app/controllers/pages_controller.rb

100.0% lines covered

100.0% branches covered

34 relevant lines. 34 lines covered and 0 lines missed.
8 total branches, 8 branches covered and 0 branches missed.
    
  1. 4 class PagesController < ApplicationController
  2. 4 include TurboStreamResponders
  3. 4 skip_before_action :require_login, only: :show
  4. 4 before_action :require_admin, except: :show
  5. 4 before_action :set_page, only: %i[edit update destroy]
  6. 4 def index
  7. 2 @pages = Page.order(:slug)
  8. end
  9. 4 def show
  10. 85 slug = params[:slug] || "/"
  11. 85 @page = Page.pages.find_by(slug: slug)
  12. # If homepage doesn't exist, create a temporary empty page object
  13. 85 then: 45 if @page.nil? && slug == "/"
  14. 45 @page = Page.new(
  15. slug: "/",
  16. content: "",
  17. meta_title: "play-test",
  18. meta_description: ""
  19. else: 40 )
  20. 40 else: 39 elsif @page.nil?
  21. then: 1 # For other missing pages, still raise not found
  22. 1 raise ActiveRecord::RecordNotFound
  23. end
  24. end
  25. 4 def new
  26. 1 @page = Page.new
  27. end
  28. 4 def create
  29. 4 @page = Page.new(page_params)
  30. 4 then: 2 if @page.save
  31. 2 handle_create_success(@page)
  32. else: 2 else
  33. 2 handle_create_failure(@page)
  34. end
  35. end
  36. 4 def edit
  37. end
  38. 4 def update
  39. 3 then: 2 if @page.update(page_params)
  40. 2 handle_update_success(@page)
  41. else: 1 else
  42. 1 handle_update_failure(@page)
  43. end
  44. end
  45. 4 def destroy
  46. 2 @page.destroy
  47. 2 redirect_to pages_path, notice: I18n.t("pages.messages.destroyed")
  48. end
  49. 4 private
  50. 4 def set_page
  51. 6 @page = Page.find_by!(slug: params[:id])
  52. end
  53. 4 def page_params
  54. 7 params.require(:page).permit(
  55. :slug,
  56. :meta_title,
  57. :meta_description,
  58. :link_title,
  59. :content,
  60. :is_snippet
  61. )
  62. end
  63. end

app/controllers/safety_standards_controller.rb

98.06% lines covered

86.49% branches covered

155 relevant lines. 152 lines covered and 3 lines missed.
37 total branches, 32 branches covered and 5 branches missed.
    
  1. # typed: strict
  2. 4 class SafetyStandardsController < ApplicationController
  3. 4 extend T::Sig
  4. 4 skip_before_action :require_login
  5. 4 skip_before_action :verify_authenticity_token, only: [:index]
  6. 4 CALCULATION_TYPES = T.let(%w[anchors slide_runout wall_height user_capacity].freeze, T::Array[String])
  7. 4 API_EXAMPLE_PARAMS = T.let({
  8. anchors: {
  9. type: "anchors",
  10. length: 5.0,
  11. width: 5.0,
  12. height: 3.0
  13. },
  14. slide_runout: {
  15. type: "slide_runout",
  16. platform_height: 2.5
  17. },
  18. wall_height: {
  19. type: "wall_height",
  20. platform_height: 2.0,
  21. user_height: 1.5
  22. },
  23. user_capacity: {
  24. type: "user_capacity",
  25. length: 10.0,
  26. width: 8.0,
  27. negative_adjustment_area: 15.0,
  28. max_user_height: 1.5
  29. }
  30. }.freeze, T::Hash[Symbol, T::Hash[Symbol, T.untyped]])
  31. 4 API_EXAMPLE_RESPONSES = T.let({
  32. anchors: {
  33. passed: true,
  34. status: "Calculation completed successfully",
  35. result: {
  36. value: 8,
  37. value_suffix: "",
  38. breakdown: [
  39. ["Front/back area", "5.0m (W) × 3.0m (H) = 15.0m²"],
  40. ["Sides area", "5.0m (L) × 3.0m (H) = 15.0m²"],
  41. ["Front & back anchor counts", "((15.0 × 114.0 * 1.5) ÷ 1600.0 = 2"],
  42. ["Left & right anchor counts", "((15.0 × 114.0 * 1.5) ÷ 1600.0 = 2"],
  43. ["Calculated total anchors", "(2 + 2) × 2 = 8"]
  44. ]
  45. }
  46. },
  47. slide_runout: {
  48. passed: true,
  49. status: "Calculation completed successfully",
  50. result: {
  51. value: 1.25,
  52. value_suffix: "m",
  53. breakdown: [
  54. ["50% calculation", "2.5m × 0.5 = 1.25m"],
  55. ["Minimum requirement", "0.3m (300mm)"],
  56. ["Base runout", "Maximum of 1.25m and 0.3m = 1.25m"]
  57. ]
  58. }
  59. },
  60. wall_height: {
  61. passed: true,
  62. status: "Calculation completed successfully",
  63. result: {
  64. value: 1.5,
  65. value_suffix: "m",
  66. breakdown: [
  67. ["Height range", "0.6m - 3.0m"],
  68. ["Calculation", "1.5m (user height)"]
  69. ]
  70. }
  71. },
  72. user_capacity: {
  73. passed: true,
  74. status: "Calculation completed successfully",
  75. result: {
  76. length: 10.0,
  77. width: 8.0,
  78. area: 80.0,
  79. negative_adjustment_area: 15.0,
  80. usable_area: 65.0,
  81. max_user_height: 1.5,
  82. capacities: {
  83. users_1000mm: 65,
  84. users_1200mm: 48,
  85. users_1500mm: 39,
  86. users_1800mm: 0
  87. },
  88. breakdown: [
  89. ["Total area", "10m × 8m = 80m²"],
  90. ["Obstacles/adjustments", "- 15m²"],
  91. ["Usable area", "65m²"],
  92. ["Capacity calculations", "Based on usable area"],
  93. ["1m users", "65 ÷ 1 = 65 users"],
  94. ["1.2m users", "65 ÷ 1.3 = 48 users"],
  95. ["1.5m users", "65 ÷ 1.7 = 39 users"],
  96. ["1.8m users", "Not allowed (exceeds height limit)"]
  97. ]
  98. }
  99. }
  100. }.freeze, T::Hash[Symbol, T::Hash[Symbol, T.untyped]])
  101. 8 sig { void }
  102. 4 def index
  103. 128 @calculation_metadata = calculation_metadata
  104. 128 then: 80 if post_request_with_calculation?
  105. 80 handle_calculation_post
  106. else: 48 else
  107. 48 handle_calculation_get
  108. end
  109. end
  110. 4 private
  111. 8 sig { returns(T::Boolean) }
  112. 4 def post_request_with_calculation?
  113. 128 request.post? && params[:calculation].present?
  114. end
  115. 8 sig { void }
  116. 4 def handle_calculation_post
  117. 80 type = params[:calculation][:type]
  118. 80 then: 70 else: 10 calculate_safety_standard if CALCULATION_TYPES.include?(type)
  119. 80 respond_to do |format|
  120. 80 format.turbo_stream
  121. 120 format.json { render json: build_json_response }
  122. 96 format.html { redirect_with_calculation_params }
  123. end
  124. end
  125. 8 sig { void }
  126. 4 def handle_calculation_get
  127. 48 then: 17 else: 31 if params[:calculation].present?
  128. 17 type = params[:calculation][:type]
  129. 17 then: 16 else: 1 calculate_safety_standard if CALCULATION_TYPES.include?(type)
  130. end
  131. 48 respond_to do |format|
  132. 48 format.html
  133. end
  134. end
  135. 7 sig { void }
  136. 4 def redirect_with_calculation_params
  137. 16 redirect_to safety_standards_path(
  138. calculation: params[:calculation].to_unsafe_h
  139. )
  140. end
  141. 8 sig { void }
  142. 4 def calculate_safety_standard
  143. 116 type = params[:calculation][:type]
  144. 116 else: 0 case type
  145. when: 56 when "anchors"
  146. 56 calculate_anchors
  147. when: 23 when "slide_runout"
  148. 23 calculate_slide_runout
  149. when: 25 when "wall_height"
  150. 25 calculate_wall_height
  151. when: 12 when "user_capacity"
  152. 12 calculate_user_capacity
  153. end
  154. end
  155. 8 sig { void }
  156. 4 def calculate_anchors
  157. 56 dimensions = extract_dimensions(:length, :width, :height)
  158. 56 then: 41 if dimensions.values.all?(&:positive?)
  159. 41 @anchors_result = EN14960.calculate_anchors(**dimensions)
  160. else: 15 else
  161. 15 set_error(:anchors, :invalid_dimensions)
  162. end
  163. end
  164. 8 sig { void }
  165. 4 def calculate_slide_runout
  166. 23 height = param_to_float(:platform_height)
  167. 23 has_stop_wall = params[:calculation][:has_stop_wall] == "1"
  168. 23 then: 18 if height.positive?
  169. 18 @slide_runout_result = build_runout_result(height, has_stop_wall)
  170. else: 5 else
  171. 5 set_error(:slide_runout, :invalid_height)
  172. end
  173. end
  174. 8 sig { void }
  175. 4 def calculate_wall_height
  176. 25 platform_height = param_to_float(:platform_height)
  177. 25 user_height = param_to_float(:user_height)
  178. 25 then: 19 if platform_height.positive? && user_height.positive?
  179. 19 @wall_height_result = build_wall_height_result(
  180. platform_height, user_height
  181. )
  182. else: 6 else
  183. 6 set_error(:wall_height, :invalid_height)
  184. end
  185. end
  186. 5 sig { void }
  187. 4 def calculate_user_capacity
  188. 12 length = param_to_float(:length)
  189. 12 width = param_to_float(:width)
  190. 12 max_user_height = param_to_float(:max_user_height)
  191. 12 then: 2 else: 10 max_user_height = nil if max_user_height.zero?
  192. 12 negative_adjustment_area = param_to_float(:negative_adjustment_area)
  193. 12 then: 8 if length.positive? && width.positive?
  194. 8 @user_capacity_result = EN14960.calculate_user_capacity(
  195. length, width, max_user_height, negative_adjustment_area
  196. )
  197. else: 4 else
  198. 4 set_error(:user_capacity, :invalid_dimensions)
  199. end
  200. end
  201. 8 sig { params(keys: Symbol).returns(T::Hash[Symbol, Float]) }
  202. 4 def extract_dimensions(*keys)
  203. 224 keys.index_with { |key| param_to_float(key) }
  204. end
  205. 8 sig { params(key: Symbol).returns(Float) }
  206. 4 def param_to_float(key)
  207. 305 params[:calculation][key].to_f
  208. end
  209. 8 sig { params(type: Symbol, error_key: Symbol).void }
  210. 4 def set_error(type, error_key)
  211. 30 error_msg = t("safety_standards.errors.#{error_key}")
  212. 30 instance_variable_set("@#{type}_error", error_msg)
  213. 30 instance_variable_set("@#{type}_result", nil)
  214. end
  215. 7 sig { params(platform_height: Float, has_stop_wall: T::Boolean).returns(T.untyped) }
  216. 4 def build_runout_result(platform_height, has_stop_wall)
  217. 18 EN14960.calculate_slide_runout(
  218. platform_height,
  219. has_stop_wall: has_stop_wall
  220. )
  221. end
  222. 7 sig { params(platform_height: Float, user_height: Float).returns(T.untyped) }
  223. 4 def build_wall_height_result(platform_height, user_height)
  224. # Workaround for EN14960 v0.4.0 bug where it returns Integer 0 instead of Float 0.0
  225. # when platform_height < 0.6
  226. begin
  227. 19 EN14960.calculate_wall_height(
  228. platform_height, user_height
  229. )
  230. rescue TypeError => e
  231. 2 if e.message.include?("got type Integer with value 0")
  232. then: 2 # Return the expected response for no walls required
  233. 2 EN14960::CalculatorResponse.new(
  234. value: 0.0,
  235. value_suffix: "m",
  236. breakdown: [["No walls required", "Platform height < 0.6m"]]
  237. )
  238. else: 0 else
  239. raise e
  240. end
  241. end
  242. end
  243. 7 sig { returns(T::Hash[Symbol, T.untyped]) }
  244. 4 def build_json_response
  245. 40 type = params[:calculation][:type]
  246. 40 else: 30 then: 10 return invalid_type_response(type) unless CALCULATION_TYPES.include?(type)
  247. 30 build_typed_json_response(type)
  248. end
  249. 6 sig { params(type: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
  250. 4 def invalid_type_response(type)
  251. 10 {
  252. passed: false,
  253. status: t("safety_standards.api.invalid_calculation_type",
  254. type: type || t("safety_standards.api.none_provided")),
  255. result: nil
  256. }
  257. end
  258. 4 sig { params(message: String).returns(T::Hash[Symbol, T.untyped]) }
  259. 4 def error_response(message)
  260. {
  261. passed: false,
  262. status: t("safety_standards.api.calculation_failed", error: message),
  263. result: nil
  264. }
  265. end
  266. 7 sig { params(type: String).returns(T::Hash[Symbol, T.untyped]) }
  267. 4 def build_typed_json_response(type)
  268. 30 calculate_safety_standard
  269. 30 result, error = get_calculation_results(type)
  270. 30 then: 19 if result
  271. 19 build_success_response(type, result)
  272. else: 11 else
  273. 11 build_error_response(error)
  274. end
  275. end
  276. 7 sig { params(type: String).returns([T.nilable(T.untyped), T.nilable(String)]) }
  277. 4 def get_calculation_results(type)
  278. 30 result_var = "@#{type}_result"
  279. 30 error_var = "@#{type}_error"
  280. 30 result = instance_variable_get(result_var)
  281. 30 error = instance_variable_get(error_var)
  282. 30 [result, error]
  283. end
  284. 7 sig { params(type: String, result: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
  285. 4 def build_success_response(type, result)
  286. 19 then: 4 json_result = if type == "user_capacity"
  287. 4 else: 15 build_user_capacity_json(result)
  288. 15 then: 15 elsif result.is_a?(EN14960::CalculatorResponse)
  289. {
  290. 15 value: result.value,
  291. value_suffix: result.value_suffix || "",
  292. breakdown: result.breakdown
  293. }
  294. else: 0 else
  295. result
  296. end
  297. 19 {
  298. passed: true,
  299. status: t("safety_standards.api.calculation_success"),
  300. result: json_result
  301. }
  302. end
  303. 7 sig { params(error: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
  304. 4 def build_error_response(error)
  305. 11 {
  306. passed: false,
  307. status: error || t("safety_standards.api.unknown_error"),
  308. result: nil
  309. }
  310. end
  311. 5 sig { params(result: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
  312. 4 def build_user_capacity_json(result)
  313. 4 else: 4 then: 0 return result unless result.is_a?(EN14960::CalculatorResponse)
  314. 4 length = param_to_float(:length)
  315. 4 width = param_to_float(:width)
  316. 4 max_user_height = param_to_float(:max_user_height)
  317. 4 then: 0 else: 4 max_user_height = nil if max_user_height.zero?
  318. 4 neg_adj = param_to_float(:negative_adjustment_area)
  319. {
  320. 4 length: length,
  321. width: width,
  322. 4 area: (length * width).round(2),
  323. negative_adjustment_area: neg_adj,
  324. 4 usable_area: [(length * width) - neg_adj, 0].max.round(2),
  325. max_user_height: max_user_height,
  326. capacities: result.value,
  327. breakdown: result.breakdown
  328. }
  329. end
  330. 8 sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]]) }
  331. 4 def calculation_metadata
  332. {
  333. 128 anchors: anchor_metadata,
  334. slide_runout: slide_runout_metadata,
  335. wall_height: wall_height_metadata,
  336. user_capacity: user_capacity_metadata
  337. }
  338. end
  339. 8 sig { returns(T::Hash[Symbol, T.untyped]) }
  340. 4 def anchor_metadata
  341. {
  342. 128 title: t("safety_standards.metadata.anchor_title"),
  343. description: t("safety_standards.calculators.anchor.description"),
  344. method_name: :calculate_required_anchors,
  345. module_name: EN14960::Calculators::AnchorCalculator,
  346. example_input: 25.0,
  347. input_unit: "m²",
  348. output_unit: "anchors",
  349. formula_text: t("safety_standards.metadata.anchor_formula"),
  350. standard_reference: t("safety_standards.metadata.standard_reference")
  351. }
  352. end
  353. 8 sig { returns(T::Hash[Symbol, T.untyped]) }
  354. 4 def slide_runout_metadata
  355. {
  356. 128 title: t("safety_standards.metadata.slide_runout_title"),
  357. description: t("safety_standards.metadata.slide_runout_description"),
  358. method_name: :calculate_required_runout,
  359. additional_methods: [:calculate_runout_value],
  360. module_name: EN14960::Calculators::SlideCalculator,
  361. example_input: 2.5,
  362. input_unit: "m",
  363. output_unit: "m",
  364. formula_text: t("safety_standards.metadata.runout_formula"),
  365. standard_reference: t("safety_standards.metadata.standard_reference")
  366. }
  367. end
  368. 8 sig { returns(T::Hash[Symbol, T.untyped]) }
  369. 4 def wall_height_metadata
  370. {
  371. 128 title: t("safety_standards.metadata.wall_height_title"),
  372. description: t("safety_standards.metadata.wall_height_description"),
  373. method_name: :meets_height_requirements?,
  374. module_name: EN14960::Calculators::SlideCalculator,
  375. example_input: {platform_height: 2.0, user_height: 1.5},
  376. input_unit: "m",
  377. output_unit: t("safety_standards.metadata.requirement_text_unit"),
  378. formula_text: t("safety_standards.metadata.wall_height_formula"),
  379. standard_reference: t("safety_standards.metadata.standard_reference")
  380. }
  381. end
  382. 8 sig { returns(T::Hash[Symbol, T.untyped]) }
  383. 4 def user_capacity_metadata
  384. {
  385. 128 title: t("safety_standards.metadata.user_capacity_title"),
  386. description: t("safety_standards.calculators.user_capacity.description"),
  387. method_name: :calculate,
  388. module_name: EN14960::Calculators::UserCapacityCalculator,
  389. example_input: {length: 10.0, width: 8.0},
  390. input_unit: "m",
  391. output_unit: "users",
  392. formula_text: t("safety_standards.metadata.user_capacity_formula"),
  393. standard_reference: t("safety_standards.metadata.standard_reference")
  394. }
  395. end
  396. end

app/controllers/search_controller.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 class SearchController < ApplicationController
  2. 4 skip_before_action :require_login
  3. 4 def index
  4. 6 @federated_sites = Federation.sites(request.host, current_user)
  5. end
  6. end

app/controllers/sessions_controller.rb

77.08% lines covered

66.67% branches covered

48 relevant lines. 37 lines covered and 11 lines missed.
6 total branches, 4 branches covered and 2 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class SessionsController < ApplicationController
  4. 4 include SessionManagement
  5. 4 skip_before_action :require_login,
  6. only: [:new, :create, :destroy, :passkey, :passkey_callback]
  7. 4 before_action :require_logged_out,
  8. only: [:new, :create, :passkey, :passkey_callback]
  9. 4 def new
  10. end
  11. 4 def create
  12. 602 else: 602 then: 0 sleep(rand(0.5..1.0)) unless Rails.env.test?
  13. 602 email = params.dig(:session, :email)
  14. 602 password = params.dig(:session, :password)
  15. 602 then: 594 if (user = authenticate_user(email, password))
  16. 594 handle_successful_login(user)
  17. else: 8 else
  18. 8 flash.now[:alert] = I18n.t("session.login.error")
  19. 8 render :new, status: :unprocessable_content
  20. end
  21. end
  22. 4 def destroy
  23. 22 terminate_current_session
  24. 22 log_out
  25. 22 flash[:notice] = I18n.t("session.logout.success")
  26. 22 redirect_to root_path
  27. end
  28. 4 def passkey
  29. # Get all credentials for this RP to help password managers
  30. 1 all_credentials = Credential.all.map do |cred|
  31. {
  32. id: cred.external_id,
  33. type: "public-key"
  34. }
  35. end
  36. # Initiate passkey authentication
  37. 1 get_options = WebAuthn::Credential.options_for_get(
  38. user_verification: "required",
  39. allow_credentials: all_credentials
  40. )
  41. 1 session[:passkey_authentication] = {challenge: get_options.challenge}
  42. 1 render json: get_options
  43. end
  44. 4 def passkey_callback
  45. 1 webauthn_credential = WebAuthn::Credential.from_get(params)
  46. 1 credential = find_credential(webauthn_credential)
  47. 1 then: 0 if credential
  48. verify_and_sign_in_with_passkey(credential, webauthn_credential)
  49. else: 1 else
  50. 1 render json: {errors: [I18n.t("sessions.messages.passkey_not_found")]},
  51. status: :unprocessable_content
  52. end
  53. end
  54. 4 private
  55. 4 def find_credential(webauthn_credential)
  56. 1 encoded_id = Base64.strict_encode64(webauthn_credential.raw_id)
  57. 1 Credential.find_by(external_id: encoded_id)
  58. end
  59. 4 def verify_and_sign_in_with_passkey(credential, webauthn_credential)
  60. challenge = session[:passkey_authentication]["challenge"]
  61. webauthn_credential.verify(
  62. challenge,
  63. public_key: credential.public_key,
  64. sign_count: credential.sign_count,
  65. user_verification: true
  66. )
  67. credential.update!(sign_count: webauthn_credential.sign_count)
  68. user = User.find(credential.user_id)
  69. # Create session for passkey login
  70. establish_user_session(user)
  71. render json: {status: "ok"}, status: :ok
  72. rescue WebAuthn::Error => e
  73. error_msg = I18n.t("sessions.messages.passkey_login_failed")
  74. render json: "#{error_msg}: #{e.message}",
  75. status: :unprocessable_content
  76. ensure
  77. session.delete(:passkey_authentication)
  78. end
  79. 4 def handle_successful_login(user)
  80. 594 establish_user_session(user)
  81. 594 flash[:notice] = I18n.t("session.login.success")
  82. 594 redirect_to inspections_path
  83. end
  84. end

app/controllers/slide_assessments_controller.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 class SlideAssessmentsController < ApplicationController
  2. 4 include AssessmentController
  3. 4 include SafetyStandardsTurboStreams
  4. end

app/controllers/structure_assessments_controller.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 class StructureAssessmentsController < ApplicationController
  2. 4 include AssessmentController
  3. end

app/controllers/units_controller.rb

94.08% lines covered

86.96% branches covered

152 relevant lines. 143 lines covered and 9 lines missed.
46 total branches, 40 branches covered and 6 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 class UnitsController < ApplicationController
  4. 4 extend T::Sig
  5. 4 include TurboStreamResponders
  6. 4 include PublicViewable
  7. 4 include UserActivityCheck
  8. 4 skip_before_action :require_login, only: %i[show]
  9. 4 before_action :check_assessments_enabled
  10. 4 before_action :set_unit, only: %i[destroy edit log show update]
  11. 4 before_action :check_unit_owner, only: %i[destroy edit update]
  12. 4 before_action :check_log_access, only: %i[log]
  13. 4 before_action :require_user_active, only: %i[create new edit update]
  14. 4 before_action :no_index
  15. 4 def index
  16. 62 @units = filtered_units_query
  17. 62 @title = build_index_title
  18. 62 respond_to do |format|
  19. 62 format.html
  20. 62 format.csv do
  21. 2 log_unit_event("exported", nil, "Exported #{@units.count} units to CSV")
  22. 2 csv_data = UnitCsvExportService.new(@units).generate
  23. 2 send_data csv_data, filename: "units-#{Time.zone.today}.csv"
  24. end
  25. end
  26. end
  27. 4 def show
  28. # Handle federation HEAD requests
  29. 106 then: 1 else: 105 return head :ok if request.head?
  30. 105 @inspections = @unit.inspections
  31. .includes(inspector_company: {logo_attachment: :blob})
  32. .order(inspection_date: :desc)
  33. 105 respond_to do |format|
  34. 174 format.html { render_show_html }
  35. 121 format.pdf { send_unit_pdf }
  36. 109 format.png { send_unit_qr_code }
  37. 105 format.json do
  38. 16 render json: UnitBlueprint.render_with_inspections(@unit)
  39. end
  40. end
  41. end
  42. 4 def new = @unit = Unit.new
  43. 4 def create
  44. 12 @unit = current_user.units.build(unit_params)
  45. 12 then: 0 else: 12 if @image_processing_error
  46. flash.now[:alert] = @image_processing_error.message
  47. handle_create_failure(@unit)
  48. return
  49. end
  50. 12 then: 8 if @unit.save
  51. 8 log_unit_event("created", @unit)
  52. 8 handle_create_success(@unit)
  53. else: 4 else
  54. 4 handle_create_failure(@unit)
  55. end
  56. end
  57. 4 def edit = nil
  58. 4 def update
  59. 5 previous_attributes = @unit.attributes.dup
  60. 5 params_to_update = unit_params
  61. 5 then: 0 else: 5 if @image_processing_error
  62. flash.now[:alert] = @image_processing_error.message
  63. handle_update_failure(@unit)
  64. return
  65. end
  66. 5 if @unit.update(params_to_update)
  67. then: 4 # Calculate what changed
  68. 4 changed_data = calculate_changes(
  69. previous_attributes,
  70. @unit.attributes,
  71. unit_params.keys
  72. )
  73. 4 log_unit_event("updated", @unit, nil, changed_data)
  74. 4 additional_streams = []
  75. 4 else: 4 if params[:unit][:photo].present?
  76. then: 0 # Render just the file field without a new form wrapper
  77. additional_streams << turbo_stream.replace(
  78. "unit_photo_preview",
  79. partial: "chobble_forms/file_field_turbo_response",
  80. locals: {
  81. model: @unit,
  82. field: :photo,
  83. turbo_frame_id: "unit_photo_preview",
  84. i18n_base: "forms.units",
  85. accept: "image/*"
  86. }
  87. )
  88. end
  89. 4 handle_update_success(@unit, nil, nil, additional_streams: additional_streams)
  90. else: 1 else
  91. 1 handle_update_failure(@unit)
  92. end
  93. end
  94. 4 def destroy
  95. # Capture unit details before deletion for the audit log
  96. unit_details = {
  97. 5 name: @unit.name,
  98. serial: @unit.serial,
  99. operator: @unit.operator,
  100. manufacturer: @unit.manufacturer
  101. }
  102. 5 if @unit.destroy
  103. then: 4 # Log the deletion with the unit details in metadata
  104. 4 Event.log(
  105. user: current_user,
  106. action: "deleted",
  107. resource: @unit,
  108. details: nil,
  109. metadata: unit_details
  110. )
  111. 4 flash[:notice] = I18n.t("units.messages.deleted")
  112. 4 redirect_to units_path
  113. else: 1 else
  114. error_message =
  115. 1 @unit.errors.full_messages.first ||
  116. I18n.t("units.messages.delete_failed")
  117. 1 flash[:alert] = error_message
  118. 1 redirect_to @unit
  119. end
  120. end
  121. 4 def log
  122. 2 @events = Event.for_resource(@unit).recent.includes(:user)
  123. 2 @title = I18n.t("units.titles.log", unit: @unit.name)
  124. end
  125. 4 def new_from_inspection
  126. 4 @inspection = current_user.inspections.find_by(id: params[:id])
  127. 4 else: 3 then: 1 unless @inspection
  128. 1 flash[:alert] = I18n.t("units.errors.inspection_not_found")
  129. 1 redirect_to root_path and return
  130. end
  131. 3 then: 1 else: 2 if @inspection.unit
  132. 1 flash[:alert] = I18n.t("units.errors.inspection_has_unit")
  133. 1 redirect_to inspection_path(@inspection) and return
  134. end
  135. 2 @unit = Unit.new(user: current_user)
  136. end
  137. 4 def create_from_inspection
  138. 5 service = UnitCreationFromInspectionService.new(
  139. user: current_user,
  140. inspection_id: params[:id],
  141. unit_params: unit_params
  142. )
  143. 5 then: 2 if service.create
  144. 2 log_unit_event("created", service.unit)
  145. 2 flash[:notice] = I18n.t("units.messages.created_from_inspection")
  146. 2 else: 3 redirect_to edit_inspection_path(service.inspection)
  147. 3 then: 2 elsif service.error_message
  148. 2 flash[:alert] = service.error_message
  149. 2 then: 1 redirect_path = service.inspection ?
  150. 1 else: 1 inspection_path(service.inspection) :
  151. 1 root_path
  152. 2 redirect_to redirect_path
  153. else: 1 else
  154. 1 @unit = service.unit
  155. 1 @inspection = service.inspection
  156. 1 render :new_from_inspection, status: :unprocessable_content
  157. end
  158. end
  159. 4 private
  160. 4 def log_unit_event(action, unit, details = nil, changed_data = nil)
  161. 16 else: 16 then: 0 return unless current_user
  162. 16 then: 14 if unit
  163. 14 Event.log(
  164. user: current_user,
  165. action: action,
  166. resource: unit,
  167. details: details,
  168. changed_data: changed_data
  169. )
  170. else
  171. else: 2 # For events without a specific unit (like CSV export)
  172. 2 Event.log_system_event(
  173. user: current_user,
  174. action: action,
  175. details: details,
  176. metadata: {resource_type: "Unit"}
  177. )
  178. end
  179. rescue => e
  180. Rails.logger.error I18n.t("units.errors.log_failed", message: e.message)
  181. end
  182. 4 def calculate_changes(previous_attributes, current_attributes, changed_keys)
  183. 4 changes = {}
  184. 4 changed_keys.map(&:to_s).each do |key|
  185. 22 previous_value = previous_attributes[key]
  186. 22 current_value = current_attributes[key]
  187. 22 then: 6 else: 16 if previous_value != current_value
  188. 6 changes[key] = {
  189. "from" => previous_value,
  190. "to" => current_value
  191. }
  192. end
  193. end
  194. 4 changes.presence
  195. end
  196. 4 def unit_params
  197. 26 permitted_params = params.require(:unit).permit(*%i[
  198. description
  199. manufacture_date
  200. manufacturer
  201. name
  202. operator
  203. photo
  204. serial
  205. unit_type
  206. ])
  207. 26 process_image_params(permitted_params, :photo)
  208. end
  209. 4 def no_index = response.set_header("X-Robots-Tag", "noindex,nofollow")
  210. 4 def set_unit
  211. 151 @unit = Unit.includes(photo_attachment: :blob)
  212. .find_by(id: params[:id].upcase)
  213. 151 else: 139 unless @unit
  214. then: 12 # Always return 404 for non-existent resources regardless of login status
  215. 12 head :not_found
  216. end
  217. end
  218. 4 def check_unit_owner
  219. 31 else: 28 then: 3 head :not_found unless owns_resource?
  220. end
  221. 4 def check_log_access
  222. # Only unit owners can view logs
  223. 2 else: 2 then: 0 head :not_found unless owns_resource?
  224. end
  225. 4 def check_assessments_enabled
  226. 252 else: 252 then: 0 head :not_found unless ENV["HAS_ASSESSMENTS"] == "true"
  227. end
  228. 4 def send_unit_pdf
  229. # Unit already has photo loaded from set_unit
  230. 16 result = PdfCacheService.fetch_or_generate_unit_pdf(
  231. @unit,
  232. debug_enabled: admin_debug_enabled?,
  233. debug_queries: debug_sql_queries
  234. )
  235. 16 handle_pdf_response(result, "#{@unit.serial}.pdf")
  236. end
  237. 4 def send_unit_qr_code
  238. 4 qr_code_png = QrCodeService.generate_qr_code(@unit)
  239. 4 send_data qr_code_png,
  240. filename: "#{@unit.serial}_QR.png",
  241. type: "image/png",
  242. disposition: "inline"
  243. end
  244. # PublicViewable implementation
  245. 4 def check_resource_owner
  246. check_unit_owner
  247. end
  248. 4 def owns_resource?
  249. 95 @unit && current_user && @unit.user_id == current_user.id
  250. end
  251. 4 def pdf_filename
  252. 10 "#{@unit.serial}.pdf"
  253. end
  254. 4 def resource_pdf_url
  255. 10 unit_path(@unit, format: :pdf)
  256. end
  257. 4 def filtered_units_query
  258. 62 units = current_user.units.includes(photo_attachment: :blob)
  259. 62 units = units.search(params[:query])
  260. 62 then: 3 else: 59 units = units.overdue if params[:status] == "overdue"
  261. 62 units = units.by_manufacturer(params[:manufacturer])
  262. 62 units = units.by_operator(params[:operator])
  263. 62 units.order(created_at: :desc)
  264. end
  265. 4 def build_index_title
  266. 62 title_parts = [I18n.t("units.titles.index")]
  267. 62 then: 3 else: 59 if params[:status] == "overdue"
  268. 3 title_parts << I18n.t("units.status.overdue")
  269. end
  270. 62 then: 7 else: 55 title_parts << params[:manufacturer] if params[:manufacturer].present?
  271. 62 then: 5 else: 57 title_parts << params[:operator] if params[:operator].present?
  272. 62 title_parts.join(" - ")
  273. end
  274. 4 def handle_inactive_user_redirect
  275. 5 redirect_to units_path
  276. end
  277. end

app/controllers/user_height_assessments_controller.rb

94.74% lines covered

68.75% branches covered

38 relevant lines. 36 lines covered and 2 lines missed.
16 total branches, 11 branches covered and 5 branches missed.
    
  1. # typed: false
  2. 4 class UserHeightAssessmentsController < ApplicationController
  3. 4 include AssessmentController
  4. 4 include SafetyStandardsTurboStreams
  5. 4 private
  6. 4 def success_turbo_streams(additional_info: nil)
  7. # Call parent which includes SafetyStandardsTurboStreams
  8. 3 streams = super
  9. # Add our field update streams if any fields were defaulted
  10. 3 then: 3 else: 0 else: 2 then: 1 return streams unless @fields_defaulted_to_zero&.any?
  11. 2 streams + field_update_streams
  12. end
  13. 4 def field_update_streams
  14. 2 form_config = assessment_class.form_fields
  15. 2 @fields_defaulted_to_zero.map do |field|
  16. 8 field_config = find_field_config(form_config, field)
  17. 8 else: 0 then: 8 next unless field_config
  18. build_field_turbo_stream(field, field_config)
  19. end.compact
  20. end
  21. 4 def build_field_turbo_stream(field, field_config)
  22. turbo_stream.replace(
  23. field,
  24. partial: "chobble_forms/field_turbo_response",
  25. locals: {
  26. model: @assessment,
  27. field:,
  28. partial: field_config[:partial],
  29. i18n_base: "forms.user_height",
  30. attributes: field_config[:attributes] || {}
  31. }
  32. )
  33. end
  34. 4 def find_field_config(form_config, field_name)
  35. # The YAML loads field names as strings, not symbols
  36. 8 field_str = field_name.to_s
  37. 8 form_config.each do |fieldset|
  38. 32 fieldset[:fields].each do |field_config|
  39. 72 then: 0 else: 72 return field_config if field_config[:field] == field_str
  40. end
  41. end
  42. 8 nil
  43. end
  44. 4 def preprocess_values
  45. 15 @fields_defaulted_to_zero = []
  46. # The param key matches the model's param_key
  47. 15 param_key = assessment_class.model_name.param_key
  48. 15 else: 15 then: 0 return unless params[param_key]
  49. 15 apply_user_height_defaults(param_key)
  50. end
  51. 4 def apply_user_height_defaults(param_key)
  52. user_height_fields = %w[
  53. 15 users_at_1000mm
  54. users_at_1200mm
  55. users_at_1500mm
  56. users_at_1800mm
  57. ]
  58. 15 user_height_fields.each do |field|
  59. 60 then: 32 else: 28 if params[param_key][field].blank?
  60. 32 params[param_key][field] = "0"
  61. 32 @fields_defaulted_to_zero << field
  62. end
  63. end
  64. end
  65. 4 def build_additional_info
  66. 14 then: 14 else: 0 else: 8 then: 6 return nil unless @fields_defaulted_to_zero&.any?
  67. 8 fields = @fields_defaulted_to_zero
  68. 36 field_names = fields.map { |f| I18n.t("forms.user_height.fields.#{f}") }
  69. 8 I18n.t(
  70. "inspections.messages.user_height_defaults_applied",
  71. fields: field_names.join(", ")
  72. )
  73. end
  74. end

app/controllers/users_controller.rb

90.73% lines covered

79.59% branches covered

151 relevant lines. 137 lines covered and 14 lines missed.
49 total branches, 39 branches covered and 10 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 class UsersController < ApplicationController
  4. 4 include SessionManagement
  5. 4 include TurboStreamResponders
  6. 4 NON_ADMIN_PATHS = %i[
  7. change_settings
  8. change_password
  9. update_settings
  10. update_password
  11. logout_everywhere_else
  12. stop_impersonating
  13. ].freeze
  14. 4 LOGGED_OUT_PATHS = %i[
  15. create
  16. new
  17. ].freeze
  18. 4 skip_before_action :require_login, only: LOGGED_OUT_PATHS
  19. 4 skip_before_action :update_last_active_at, only: [:update_settings]
  20. 4 before_action :set_user, except: %i[index new create]
  21. 4 before_action :require_admin, except: NON_ADMIN_PATHS + LOGGED_OUT_PATHS
  22. 4 before_action :require_correct_user, only: NON_ADMIN_PATHS
  23. 4 def index
  24. 7 @users = apply_sort(User.all)
  25. 7 @inspection_counts = Inspection
  26. .where(user_id: @users.pluck(:id))
  27. .group(:user_id)
  28. .count
  29. end
  30. 4 def new
  31. 7 @user = User.new
  32. end
  33. 4 def create
  34. 11 @user = User.new(user_params)
  35. 11 then: 8 if @user.save
  36. 8 then: 1 else: 7 send_new_user_notifications(@user) if Rails.env.production?
  37. 8 establish_user_session(@user)
  38. 8 flash[:notice] = I18n.t("users.messages.account_created")
  39. 8 redirect_to root_path
  40. else: 3 else
  41. 3 render :new, status: :unprocessable_content
  42. end
  43. end
  44. 4 def edit
  45. end
  46. 4 def update
  47. # Convert empty string to nil for inspection_company_id
  48. 10 then: 4 else: 6 params[:user][:inspection_company_id] = nil if params[:user][:inspection_company_id] == ""
  49. 10 then: 9 if @user.update(user_params)
  50. 9 handle_update_success(@user, "users.messages.user_updated", users_path)
  51. else: 1 else
  52. 1 handle_update_failure(@user)
  53. end
  54. end
  55. 4 def destroy
  56. 1 @user.destroy
  57. 1 flash[:notice] = I18n.t("users.messages.user_deleted")
  58. 1 redirect_to users_path
  59. end
  60. 4 def change_password
  61. end
  62. 4 def update_password
  63. 3 then: 2 if @user.authenticate(params[:user][:current_password])
  64. 2 then: 1 if @user.update(password_params)
  65. 1 flash[:notice] = I18n.t("users.messages.password_updated")
  66. 1 redirect_to root_path
  67. else: 1 else
  68. 1 render :change_password, status: :unprocessable_content
  69. end
  70. else: 1 else
  71. 1 @user.errors.add(:current_password, I18n.t("users.errors.wrong_password"))
  72. 1 render :change_password, status: :unprocessable_content
  73. end
  74. end
  75. 4 extend T::Sig
  76. 5 sig { void }
  77. 4 def impersonate
  78. 6 then: 6 else: 0 session[:original_admin_id] = current_user.id if current_user.admin?
  79. 6 switch_to_user(@user)
  80. 6 flash[:notice] = I18n.t("users.messages.impersonating", email: @user.email)
  81. 6 redirect_to root_path
  82. end
  83. 5 sig { void }
  84. 4 def stop_impersonating
  85. 1 else: 1 then: 0 return redirect_to root_path unless session[:original_admin_id]
  86. 1 admin_user = User.find(session[:original_admin_id])
  87. 1 switch_to_user(admin_user)
  88. 1 session.delete(:original_admin_id)
  89. 1 flash[:notice] = I18n.t("users.messages.stopped_impersonating")
  90. 1 redirect_to root_path
  91. end
  92. 4 def change_settings
  93. end
  94. 4 def update_settings
  95. 14 params_to_update = settings_params
  96. 14 then: 3 else: 11 if @image_processing_error
  97. 3 flash[:alert] = @image_processing_error.message
  98. 3 redirect_to change_settings_user_path(@user)
  99. 3 return
  100. end
  101. 11 then: 10 if @user.update(params_to_update)
  102. 10 additional_streams = []
  103. 10 then: 3 else: 7 if params[:user][:logo].present?
  104. 3 additional_streams << turbo_stream.replace(
  105. "user_logo_field",
  106. partial: "chobble_forms/file_field_turbo_response",
  107. locals: {
  108. model: @user,
  109. field: :logo,
  110. turbo_frame_id: "user_logo_field",
  111. i18n_base: "forms.user_settings",
  112. accept: "image/*"
  113. }
  114. )
  115. end
  116. 10 then: 1 else: 9 if params[:user][:signature].present?
  117. 1 additional_streams << turbo_stream.replace(
  118. "user_signature_field",
  119. partial: "chobble_forms/file_field_turbo_response",
  120. locals: {
  121. model: @user,
  122. field: :signature,
  123. turbo_frame_id: "user_signature_field",
  124. i18n_base: "forms.user_settings",
  125. accept: "image/*"
  126. }
  127. )
  128. end
  129. 10 handle_update_success(
  130. @user,
  131. "users.messages.settings_updated",
  132. change_settings_user_path(@user),
  133. additional_streams: additional_streams
  134. )
  135. else: 1 else
  136. 1 handle_update_failure(@user, :change_settings)
  137. end
  138. end
  139. 4 def verify_rpii
  140. 4 result = @user.verify_rpii_inspector_number
  141. 4 respond_to do |format|
  142. 4 format.html do
  143. 4 then: 2 if result[:valid]
  144. 2 flash[:notice] = I18n.t("users.messages.rpii_verified")
  145. else: 2 else
  146. 2 flash[:alert] = get_rpii_error_message(result)
  147. end
  148. 4 redirect_to edit_user_path(@user)
  149. end
  150. 4 format.turbo_stream do
  151. render turbo_stream: turbo_stream.replace("rpii_verification_result",
  152. partial: "users/rpii_verification_result",
  153. locals: {result: result, user: @user})
  154. end
  155. end
  156. end
  157. 4 def add_seeds
  158. 4 then: 1 if @user.has_seed_data?
  159. 1 flash[:alert] = I18n.t("users.messages.seeds_failed")
  160. else: 3 else
  161. 3 SeedDataService.add_seeds_for_user(@user)
  162. 3 flash[:notice] = I18n.t("users.messages.seeds_added")
  163. end
  164. 4 redirect_to edit_user_path(@user)
  165. end
  166. 4 def delete_seeds
  167. 3 SeedDataService.delete_seeds_for_user(@user)
  168. 3 flash[:notice] = I18n.t("users.messages.seeds_deleted")
  169. 3 redirect_to edit_user_path(@user)
  170. end
  171. 4 def activate
  172. @user.update(active_until: 1000.years.from_now)
  173. flash[:notice] = I18n.t("users.messages.user_activated")
  174. redirect_to edit_user_path(@user)
  175. end
  176. 4 def deactivate
  177. @user.update(active_until: Time.current)
  178. flash[:notice] = I18n.t("users.messages.user_deactivated")
  179. redirect_to edit_user_path(@user)
  180. end
  181. 4 def logout_everywhere_else
  182. # Delete all sessions except the current one
  183. 2 current_token = session[:session_token]
  184. 2 @user.user_sessions.where.not(session_token: current_token).destroy_all
  185. 2 flash[:notice] = I18n.t("users.messages.logged_out_everywhere")
  186. 2 redirect_to change_settings_user_path(@user)
  187. end
  188. 4 private
  189. 7 sig { params(scope: T.untyped).returns(T.untyped) }
  190. 4 def apply_sort(scope)
  191. 7 case params[:sort]
  192. when: 0 when "oldest"
  193. scope.order(created_at: :asc)
  194. when: 0 when "most_active"
  195. scope.order(last_active_at: :desc)
  196. when: 0 when "most_inspections"
  197. scope.left_joins(:inspections)
  198. .group("users.id")
  199. .order("COUNT(inspections.id) DESC")
  200. else: 7 else
  201. 7 scope.order(created_at: :desc) # newest first is default
  202. end
  203. end
  204. 5 sig { params(user: User).void }
  205. 4 def switch_to_user(user)
  206. 7 then: 7 else: 0 terminate_current_session if session[:session_token]
  207. 7 establish_user_session(user)
  208. end
  209. 4 def get_rpii_error_message(result)
  210. 2 case result[:error]
  211. when: 0 when :blank_number
  212. I18n.t("users.messages.rpii_blank_number")
  213. when: 0 when :blank_name
  214. I18n.t("users.messages.rpii_blank_name")
  215. when: 1 when :name_mismatch
  216. 1 inspector = result[:inspector]
  217. 1 I18n.t("users.messages.rpii_name_mismatch",
  218. user_name: @user.name,
  219. inspector_name: inspector[:name])
  220. when: 1 when :not_found
  221. 1 I18n.t("users.messages.rpii_not_found")
  222. else: 0 else
  223. I18n.t("users.messages.rpii_verification_failed")
  224. end
  225. end
  226. 4 def set_user
  227. 122 @user = User.find(params[:id])
  228. end
  229. 4 def user_params
  230. 21 then: 10 else: 11 then: 10 if current_user&.admin?
  231. 10 admin_permitted_params = %i[
  232. active_until email inspection_company_id name password
  233. password_confirmation rpii_inspector_number
  234. ]
  235. 10 else: 11 params.require(:user).permit(admin_permitted_params)
  236. 11 then: 11 elsif action_name == "create"
  237. 11 params.require(:user).permit(:email, :name, :rpii_inspector_number, :password, :password_confirmation)
  238. else: 0 else
  239. params.require(:user).permit(:email, :password, :password_confirmation)
  240. end
  241. end
  242. 4 def require_correct_user
  243. 45 then: 42 else: 3 return if current_user == @user
  244. 3 then: 1 else: 2 action = action_name.include?("password") ? "password" : "settings"
  245. 3 flash[:alert] = I18n.t("users.messages.own_action_only", action: action)
  246. 3 redirect_to root_path
  247. end
  248. 4 def password_params
  249. 2 params.require(:user).permit(:password, :password_confirmation)
  250. end
  251. 4 def settings_params
  252. 14 settings_fields = %i[
  253. address country
  254. logo phone postal_code signature theme
  255. ]
  256. 14 permitted_params = params.require(:user).permit(settings_fields)
  257. 14 process_image_params(permitted_params, :logo, :signature)
  258. end
  259. 4 def send_new_user_notifications(user)
  260. 1 developer_notification = I18n.t("users.messages.new_user_notification",
  261. email: user.email)
  262. 1 NtfyService.notify(developer_notification, channel: :developer)
  263. 1 anonymized_email = helpers.anonymise_email(user.email)
  264. 1 admin_notification = I18n.t("users.messages.new_user_notification",
  265. email: anonymized_email)
  266. 1 NtfyService.notify(admin_notification, channel: :admin)
  267. end
  268. end

app/errors/application_errors.rb

85.71% lines covered

100.0% branches covered

7 relevant lines. 6 lines covered and 1 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 module ApplicationErrors
  2. 4 class NotAnImageError < StandardError
  3. 4 def initialize(message = nil)
  4. 3 super(message || I18n.t("errors.messages.invalid_image_format"))
  5. end
  6. end
  7. 4 class ImageProcessingError < StandardError
  8. 4 def initialize(message = nil)
  9. super(message || I18n.t("errors.messages.image_processing_failed"))
  10. end
  11. end
  12. end

app/helpers/application_helper.rb

96.05% lines covered

100.0% branches covered

76 relevant lines. 73 lines covered and 3 lines missed.
20 total branches, 20 branches covered and 0 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 module ApplicationHelper
  4. 4 extend T::Sig
  5. 4 include ActionView::Helpers::NumberHelper
  6. 8 sig { params(datetime: T.nilable(T.any(Date, Time, DateTime, ActiveSupport::TimeWithZone))).returns(T.nilable(String)) }
  7. 4 then: 299 else: 1 def render_time(datetime) = datetime&.strftime("%b %d, %Y")
  8. 5 sig { params(datetime: T.nilable(T.any(Date, Time, DateTime, ActiveSupport::TimeWithZone))).returns(T.nilable(Date)) }
  9. 4 then: 2 else: 1 def date_for_form(datetime) = datetime&.to_date
  10. 6 sig { params(html_options: T::Hash[Symbol, String], block: T.proc.void).returns(String) }
  11. 4 def scrollable_table(html_options = {}, &block)
  12. 8 content_tag(:div, class: "table-container") do
  13. 8 content_tag(:table, html_options, &block)
  14. end
  15. end
  16. 8 sig { returns(String) }
  17. 4 def effective_theme
  18. 1562 then: 1065 else: 497 ENV["THEME"] || current_user&.theme || "light"
  19. end
  20. 8 sig { returns(T::Boolean) }
  21. 4 def theme_selector_disabled? = ENV["THEME"].present?
  22. 8 sig { returns(String) }
  23. 4 def logo_path
  24. 1562 ENV["LOGO_PATH"] || "logo.svg"
  25. end
  26. 8 sig { returns(String) }
  27. 4 def logo_alt_text
  28. 1562 ENV["LOGO_ALT"] || "play-test logo"
  29. end
  30. 8 sig { returns(T.nilable(String)) }
  31. 4 def left_logo_path
  32. 1562 ENV["LEFT_LOGO_PATH"]
  33. end
  34. 4 sig { returns(String) }
  35. 4 def left_logo_alt
  36. ENV["LEFT_LOGO_ALT"] || "Logo"
  37. end
  38. 8 sig { returns(T.nilable(String)) }
  39. 4 def right_logo_path
  40. 1562 ENV["RIGHT_LOGO_PATH"]
  41. end
  42. 4 sig { returns(String) }
  43. 4 def right_logo_alt
  44. ENV["RIGHT_LOGO_ALT"] || "Logo"
  45. end
  46. 8 sig { params(slug: String).returns(T.any(String, ActiveSupport::SafeBuffer)) }
  47. 4 def page_snippet(slug)
  48. 1562 snippet = Page.snippets.find_by(slug: slug)
  49. 1562 else: 122 then: 1440 return "" unless snippet
  50. 122 raw snippet.content
  51. end
  52. 8 sig { params(name: String, path: String, options: T::Hash[Symbol, T.any(String, Symbol)]).returns(String) }
  53. 4 def nav_link_to(name, path, options = {})
  54. 6590 then: 1011 css_class = if current_page?(path) || controller_matches?(path)
  55. 1011 "active"
  56. else: 5579 else
  57. 5579 ""
  58. end
  59. 6590 link_to name, path, options.merge(class: css_class)
  60. end
  61. 8 sig { params(value: T.untyped).returns(T.untyped) }
  62. 4 def format_numeric_value(value)
  63. 208 else: 202 if value.is_a?(String) &&
  64. value.match?(/\A-?\d*\.?\d+\z/) &&
  65. 6 then: 6 (float_value = Float(value, exception: false))
  66. 6 value = float_value
  67. end
  68. 208 else: 54 then: 154 return value unless value.is_a?(Numeric)
  69. 54 number_with_precision(
  70. value,
  71. precision: 4,
  72. strip_insignificant_zeros: true
  73. )
  74. end
  75. 6 sig { params(email: String).returns(String) }
  76. 4 def anonymise_email(email)
  77. 9 else: 8 then: 1 return email unless email.include?("@")
  78. 8 local_part, domain = email.split("@", 2)
  79. 8 domain_parts = domain.split(".", 2)
  80. 8 anonymised_local = anonymise_string(local_part)
  81. 8 anonymised_domain_name = anonymise_string(domain_parts[0])
  82. 8 then: 7 if domain_parts.length > 1
  83. 7 "#{anonymised_local}@#{anonymised_domain_name}.#{domain_parts[1]}"
  84. else: 1 else
  85. 1 "#{anonymised_local}@#{anonymised_domain_name}"
  86. end
  87. end
  88. 4 private
  89. 6 sig { params(str: String).returns(String) }
  90. 4 def anonymise_string(str)
  91. 16 then: 2 else: 14 return str if str.length <= 2
  92. 14 first_char = str[0]
  93. 14 last_char = str[-1]
  94. 14 middle_length = str.length - 2
  95. 14 "#{first_char}#{"*" * middle_length}#{last_char}"
  96. end
  97. 8 sig { params(path: String).returns(T::Boolean) }
  98. 4 def controller_matches?(path)
  99. 6082 route = Rails.application.routes.recognize_path(path)
  100. 6082 path_controller = route[:controller]
  101. 6082 controller_name == path_controller
  102. rescue ActionController::RoutingError
  103. false
  104. end
  105. end

app/helpers/inspections_helper.rb

97.33% lines covered

85.29% branches covered

75 relevant lines. 73 lines covered and 2 lines missed.
34 total branches, 29 branches covered and 5 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 module InspectionsHelper
  4. 4 extend T::Sig
  5. 5 sig { params(user: User).returns(String) }
  6. 4 def format_inspection_count(user)
  7. 1 count = user.inspections.count
  8. 1 t("inspections.count", count: count)
  9. end
  10. 8 sig { params(inspection: Inspection).returns(String) }
  11. 4 def inspection_result_badge(inspection)
  12. 215 else: 0 case inspection.passed
  13. when: 148 when true
  14. 148 content_tag(:span, t("inspections.status.pass"), class: "pass-badge")
  15. when: 19 when false
  16. 19 content_tag(:span, t("inspections.status.fail"), class: "fail-badge")
  17. when: 48 when nil
  18. 48 content_tag(:span, t("inspections.status.pending"), class: "pending-badge")
  19. end
  20. end
  21. 4 sig {
  22. 4 params(inspection: Inspection).returns(
  23. T::Array[T::Hash[Symbol, T.any(String, Symbol, T::Boolean)]]
  24. )
  25. }
  26. 4 def inspection_actions(inspection)
  27. 88 actions = T.let([], T::Array[T::Hash[Symbol, T.any(String, Symbol, T::Boolean)]])
  28. 88 if inspection.complete?
  29. then: 31 # Complete inspections: Switch to In Progress / Log
  30. 31 actions << {
  31. label: t("inspections.buttons.switch_to_in_progress"),
  32. url: mark_draft_inspection_path(inspection),
  33. method: :patch,
  34. confirm: t("inspections.messages.mark_in_progress_confirm"),
  35. button: true
  36. }
  37. 31 actions << {
  38. label: t("inspections.buttons.log"),
  39. url: log_inspection_path(inspection)
  40. }
  41. else
  42. else: 57 # Incomplete inspections: Update Inspection / Log / Delete Inspection
  43. 57 actions << {
  44. label: t("inspections.buttons.update"),
  45. url: edit_inspection_path(inspection)
  46. }
  47. 57 actions << {
  48. label: t("inspections.buttons.log"),
  49. url: log_inspection_path(inspection)
  50. }
  51. 57 actions << {
  52. label: t("inspections.buttons.delete"),
  53. url: inspection_path(inspection),
  54. method: :delete,
  55. confirm: t("inspections.messages.delete_confirm"),
  56. danger: true
  57. }
  58. end
  59. 88 actions
  60. end
  61. # Tabbed inspection editing helpers
  62. 8 sig { params(inspection: Inspection).returns(T::Array[String]) }
  63. 4 def inspection_tabs(inspection)
  64. 258 inspection.applicable_tabs
  65. end
  66. 8 sig { returns(String) }
  67. 4 def current_tab
  68. 1711 params[:tab].presence || "inspection"
  69. end
  70. 8 sig { params(inspection: Inspection, tab: String).returns(T::Boolean) }
  71. 4 def assessment_complete?(inspection, tab)
  72. 1604 case tab
  73. when "inspection"
  74. when: 237 # For the main inspection tab, check if required fields are filled (excluding passed)
  75. 237 inspection.inspection_tab_incomplete_fields.empty?
  76. when "results"
  77. when: 182 # For results tab, check if passed field is filled (risk_assessment is optional)
  78. 182 inspection.passed.present?
  79. else
  80. else: 1185 # For assessment tabs, check the corresponding assessment
  81. 1185 assessment_method = "#{tab}_assessment"
  82. 1185 assessment = inspection.public_send(assessment_method)
  83. 1185 then: 1185 else: 0 assessment&.complete? || false
  84. end
  85. end
  86. 8 sig { params(inspection: Inspection, tab: String).returns(String) }
  87. 4 def tab_name_with_check(inspection, tab)
  88. 1474 name = t("forms.#{tab}.header")
  89. 1474 then: 548 else: 926 assessment_complete?(inspection, tab) ? "#{name} ✓" : name
  90. end
  91. 8 sig { params(inspection: Inspection, current_tab: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
  92. 4 def next_tab_navigation_info(inspection, current_tab)
  93. # Don't show continue message on results tab
  94. 59 then: 1 else: 58 return nil if current_tab == "results"
  95. 58 all_tabs = inspection.applicable_tabs
  96. 58 current_index = all_tabs.index(current_tab)
  97. 58 else: 58 then: 0 return nil unless current_index
  98. 58 tabs_after = all_tabs[(current_index + 1)..]
  99. # Check if current tab is incomplete
  100. 58 current_tab_incomplete = !assessment_complete?(inspection, current_tab)
  101. # Find first incomplete tab after current (excluding results for now)
  102. 58 next_incomplete = tabs_after.find { |tab|
  103. 76 tab != "results" && !assessment_complete?(inspection, tab)
  104. }
  105. # If current tab is incomplete and there's a next tab available
  106. 58 then: 53 else: 5 if current_tab_incomplete && tabs_after.any?
  107. 53 incomplete_count = incomplete_fields_count(inspection, current_tab)
  108. # If there's an incomplete tab after, user should skip current incomplete
  109. 53 then: 52 else: 1 if next_incomplete
  110. 52 return {tab: next_incomplete, skip_incomplete: true, incomplete_count: incomplete_count}
  111. end
  112. # If results tab is incomplete, user should skip to results
  113. 1 then: 0 else: 1 if tabs_after.include?("results") && inspection.passed.nil?
  114. return {tab: "results", skip_incomplete: true, incomplete_count: incomplete_count}
  115. end
  116. # Don't suggest next tab if it's complete and there are no incomplete tabs
  117. 1 return nil
  118. end
  119. # Current tab is complete, just suggest next incomplete tab
  120. 5 then: 2 else: 3 if next_incomplete
  121. 2 return {tab: next_incomplete, skip_incomplete: false}
  122. end
  123. # Check if results tab is incomplete
  124. 3 then: 1 else: 2 if tabs_after.include?("results") && inspection.passed.nil?
  125. 1 return {tab: "results", skip_incomplete: false}
  126. end
  127. 2 nil
  128. end
  129. 7 sig { params(inspection: Inspection, tab: String).returns(Integer) }
  130. 4 def incomplete_fields_count(inspection, tab)
  131. 59 @incomplete_fields_cache = T.let(@incomplete_fields_cache, T.nilable(T::Hash[String, Integer])) || {}
  132. 59 cache_key = "#{inspection.id}_#{tab}"
  133. 59 @incomplete_fields_cache[cache_key] ||= case tab
  134. when: 30 when "inspection"
  135. 30 inspection.inspection_tab_incomplete_fields.length
  136. when: 2 when "results"
  137. 2 then: 1 else: 1 inspection.passed.nil? ? 1 : 0
  138. else: 1 else
  139. 1 assessment = inspection.public_send("#{tab}_assessment")
  140. 1 then: 1 if assessment
  141. 1 grouped = assessment.incomplete_fields_grouped
  142. 9 grouped.values.sum { |group| group[:fields].length }
  143. else: 0 else
  144. 0
  145. end
  146. end
  147. end
  148. end

app/helpers/pages_helper.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 module PagesHelper
  4. 4 extend T::Sig
  5. end

app/helpers/sessions_helper.rb

100.0% lines covered

94.44% branches covered

58 relevant lines. 58 lines covered and 0 lines missed.
18 total branches, 17 branches covered and 1 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 module SessionsHelper
  4. 4 extend T::Sig
  5. 8 sig { void }
  6. 4 def remember_user
  7. 613 then: 612 else: 1 if session[:session_token]
  8. 612 cookies.permanent.signed[:session_token] = session[:session_token]
  9. end
  10. end
  11. 8 sig { void }
  12. 4 def forget_user
  13. 27 cookies.delete(:session_token)
  14. end
  15. 8 sig { returns(T.nilable(User)) }
  16. 4 def current_user
  17. 19065 @current_user ||= fetch_current_user
  18. end
  19. 4 private
  20. 8 sig { returns(T.nilable(User)) }
  21. 4 def fetch_current_user
  22. 5402 then: 1468 if session[:session_token]
  23. 1468 else: 3934 user_from_session_token
  24. 3934 then: 6 else: 3928 elsif cookies.signed[:session_token]
  25. 6 user_from_cookie_token
  26. end
  27. end
  28. 8 sig { returns(T.nilable(User)) }
  29. 4 def user_from_session_token
  30. 1468 user_session = UserSession.find_by(session_token: session[:session_token])
  31. 1468 then: 1462 if user_session
  32. 1462 user_session.user
  33. else
  34. else: 6 # Session token is invalid, clear session
  35. 6 session.delete(:session_token)
  36. 6 nil
  37. end
  38. end
  39. 6 sig { returns(T.nilable(User)) }
  40. 4 def user_from_cookie_token
  41. 6 token = cookies.signed[:session_token]
  42. 6 else: 6 then: 0 return unless token
  43. 6 user_session = UserSession.find_by(session_token: token)
  44. 6 if user_session
  45. then: 1 # Restore session from cookie
  46. 1 session[:session_token] = token
  47. 1 user_session.user
  48. else
  49. else: 5 # Invalid cookie token, clear it
  50. 5 cookies.delete(:session_token)
  51. 5 nil
  52. end
  53. end
  54. 4 public
  55. 8 sig { returns(T::Boolean) }
  56. 4 def logged_in?
  57. 2477 !current_user.nil?
  58. end
  59. 8 sig { void }
  60. 4 def log_out
  61. 27 session.delete(:session_token)
  62. 27 session.delete(:original_admin_id) # Clear impersonation tracking
  63. 27 forget_user
  64. 27 @current_user = nil
  65. end
  66. 4 sig {
  67. 4 params(
  68. email: T.nilable(String),
  69. password: T.nilable(String)
  70. ).returns(T.nilable(T.any(User, T::Boolean)))
  71. }
  72. 4 def authenticate_user(email, password)
  73. 608 else: 604 then: 4 return nil unless email.present? && password.present?
  74. 604 then: 601 else: 3 User.find_by(email: email.downcase)&.authenticate(password)
  75. end
  76. 8 sig { params(user: User).void }
  77. 4 def create_user_session(user)
  78. 610 remember_user
  79. end
  80. 8 sig { returns(T.nilable(UserSession)) }
  81. 4 def current_session
  82. 1448 else: 1447 then: 1 return unless session[:session_token]
  83. 1447 @current_session ||= UserSession.find_by(
  84. session_token: session[:session_token]
  85. )
  86. end
  87. end

app/helpers/units_helper.rb

100.0% lines covered

100.0% branches covered

23 relevant lines. 23 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 module UnitsHelper
  4. 4 extend T::Sig
  5. 7 sig { params(user: User).returns(T::Array[String]) }
  6. 4 def manufacturer_options(user)
  7. 51 user.units.distinct.pluck(:manufacturer).compact.compact_blank.sort
  8. end
  9. 8 sig { params(user: User).returns(T::Array[String]) }
  10. 4 def operator_options(user)
  11. 156 user.units.distinct.pluck(:operator).compact.compact_blank.sort
  12. end
  13. 6 sig { returns(String) }
  14. 4 def unit_search_placeholder
  15. 28 serial_label = ChobbleForms::FieldUtils.form_field_label(:units, :serial)
  16. 28 name_label = ChobbleForms::FieldUtils.form_field_label(:units, :name)
  17. 28 "#{serial_label} or #{name_label.downcase}"
  18. end
  19. 4 sig {
  20. 4 params(unit: Unit).returns(
  21. T::Array[
  22. T::Hash[Symbol, T.any(String, Symbol, T::Boolean, T::Hash[Symbol, String])]
  23. ]
  24. )
  25. }
  26. 4 def unit_actions(unit)
  27. 92 actions = T.let([
  28. {
  29. label: I18n.t("units.buttons.view"),
  30. url: unit_path(unit, anchor: "inspections")
  31. },
  32. {
  33. label: I18n.t("ui.edit"),
  34. url: edit_unit_path(unit)
  35. },
  36. {
  37. label: I18n.t("units.buttons.pdf_report"),
  38. url: unit_path(unit, format: :pdf)
  39. }
  40. ], T::Array[
  41. T::Hash[Symbol, T.any(String, Symbol, T::Boolean, T::Hash[Symbol, String])]
  42. ])
  43. # Add activity log link for admins and unit owners
  44. 92 then: 77 else: 15 if current_user && (current_user.admin? || unit.user_id == current_user.id)
  45. 77 actions << {
  46. label: I18n.t("units.links.view_log"),
  47. url: log_unit_path(unit)
  48. }
  49. end
  50. 92 then: 79 else: 13 if unit.deletable?
  51. 79 actions << {
  52. label: I18n.t("units.buttons.delete"),
  53. url: unit,
  54. method: :delete,
  55. danger: true,
  56. confirm: I18n.t("units.messages.delete_confirm")
  57. }
  58. end
  59. 92 actions << {
  60. label: I18n.t("units.buttons.add_inspection"),
  61. url: inspections_path,
  62. method: :post,
  63. params: {unit_id: unit.id},
  64. confirm: I18n.t("units.messages.add_inspection_confirm")
  65. }
  66. 92 actions
  67. end
  68. end

app/helpers/users_helper.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
6 total branches, 6 branches covered and 0 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 module UsersHelper
  4. 4 extend T::Sig
  5. 5 sig { params(user: User).returns(String) }
  6. 4 def admin_status(user)
  7. 2 then: 1 else: 1 user.admin? ? "Yes" : "No"
  8. end
  9. 5 sig { params(user: User).returns(String) }
  10. 4 def inspection_count(user)
  11. 3 count = user.inspections.count
  12. 3 then: 1 else: 2 "#{count} #{(count == 1) ? "inspection" : "inspections"}"
  13. end
  14. 5 sig { params(time: T.nilable(T.any(Time, DateTime, ActiveSupport::TimeWithZone))).returns(String) }
  15. 4 def format_job_time(time)
  16. 2 else: 1 then: 1 return "Never" unless time
  17. 1 "#{time_ago_in_words(time)} ago"
  18. end
  19. end

app/jobs/application_job.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 4 class ApplicationJob < ActiveJob::Base
  3. # Automatically retry jobs that encountered a deadlock
  4. # retry_on ActiveRecord::Deadlocked
  5. # Most jobs are safe to ignore if the underlying records are no longer available
  6. # discard_on ActiveJob::DeserializationError
  7. end

app/jobs/s3_backup_job.rb

33.33% lines covered

0.0% branches covered

9 relevant lines. 3 lines covered and 6 lines missed.
4 total branches, 0 branches covered and 4 branches missed.
    
  1. # frozen_string_literal: true
  2. 4 class S3BackupJob < ApplicationJob
  3. 4 queue_as :default
  4. 4 def perform
  5. # Ensure Rails is fully loaded for background jobs
  6. then: 0 else: 0 Rails.application.eager_load! if Rails.env.production?
  7. result = S3BackupService.new.perform
  8. Rails.logger.info "S3BackupJob completed successfully"
  9. Rails.logger.info "Backup location: #{result[:location]}"
  10. Rails.logger.info "Backup size: #{result[:size_mb]} MB"
  11. then: 0 else: 0 Rails.logger.info "Deleted #{result[:deleted_count]} old backups" if result[:deleted_count].positive?
  12. end
  13. end

app/jobs/sentry_test_job.rb

18.75% lines covered

0.0% branches covered

16 relevant lines. 3 lines covered and 13 lines missed.
6 total branches, 0 branches covered and 6 branches missed.
    
  1. # frozen_string_literal: true
  2. 4 class SentryTestJob < ApplicationJob
  3. 4 queue_as :default
  4. 4 def perform(error_type = nil)
  5. service = SentryTestService.new
  6. if error_type
  7. then: 0 # Test a specific error type
  8. service.test_error_type(error_type.to_sym)
  9. Rails.logger.info "SentryTestJob: Sent #{error_type} error to Sentry"
  10. else
  11. else: 0 # Run all tests
  12. result = service.perform
  13. Rails.logger.info "SentryTestJob completed:"
  14. result[:results].each do |test_result|
  15. then: 0 else: 0 status_emoji = (test_result[:status] == "success") ? "✅" : "❌"
  16. Rails.logger.info " #{status_emoji} #{test_result[:test]}: #{test_result[:message]}"
  17. end
  18. Rails.logger.info "Sentry configuration:"
  19. then: 0 else: 0 Rails.logger.info " DSN: #{result[:configuration][:dsn_configured] ? "Configured" : "Not configured"}"
  20. Rails.logger.info " Environment: #{result[:configuration][:environment]}"
  21. Rails.logger.info " Enabled environments: #{result[:configuration][:enabled_environments].join(", ")}"
  22. end
  23. end
  24. end

app/models/application_record.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: false
  2. 4 class ApplicationRecord < ActiveRecord::Base
  3. 4 primary_abstract_class
  4. 4 include ColumnNameSyms
  5. end

app/models/assessments/anchorage_assessment.rb

96.43% lines covered

66.67% branches covered

28 relevant lines. 27 lines covered and 1 lines missed.
6 total branches, 4 branches covered and 2 branches missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: anchorage_assessments
  4. #
  5. # id :integer not null
  6. # anchor_accessories_comment :text
  7. # anchor_accessories_pass :boolean
  8. # anchor_degree_comment :text
  9. # anchor_degree_pass :boolean
  10. # anchor_type_comment :text
  11. # anchor_type_pass :boolean
  12. # num_high_anchors :integer
  13. # num_high_anchors_comment :text
  14. # num_high_anchors_pass :boolean
  15. # num_low_anchors :integer
  16. # num_low_anchors_comment :text
  17. # num_low_anchors_pass :boolean
  18. # pull_strength_comment :text
  19. # pull_strength_pass :boolean
  20. # created_at :datetime not null
  21. # updated_at :datetime not null
  22. # inspection_id :string(8) not null, primary key
  23. #
  24. # Indexes
  25. #
  26. # index_anchorage_assessments_on_inspection_id (inspection_id)
  27. #
  28. # Foreign Keys
  29. #
  30. # inspection_id (inspection_id => inspections.id)
  31. #
  32. # typed: true
  33. # frozen_string_literal: true
  34. 4 class Assessments::AnchorageAssessment < ApplicationRecord
  35. 4 extend T::Sig
  36. 4 include AssessmentLogging
  37. 4 include AssessmentCompletion
  38. 4 include FormConfigurable
  39. 4 include ValidationConfigurable
  40. 4 self.primary_key = "inspection_id"
  41. 4 belongs_to :inspection
  42. 4 after_update :log_assessment_update, if: :saved_changes?
  43. 6 sig { returns(T::Boolean) }
  44. 4 def meets_anchor_requirements?
  45. 2 else: 2 unless total_anchors &&
  46. inspection.width &&
  47. inspection.height &&
  48. then: 0 inspection.length
  49. return false
  50. end
  51. 2 total_anchors >= anchorage_result.value
  52. end
  53. 7 sig { returns(Integer) }
  54. 4 def total_anchors
  55. 8 (num_low_anchors || 0) + (num_high_anchors || 0)
  56. end
  57. 7 sig { returns(T.any(Object, NilClass)) }
  58. 4 def anchorage_result
  59. 14 @anchor_result ||= EN14960.calculate_anchors(
  60. length: inspection.length.to_f,
  61. width: inspection.width.to_f,
  62. height: inspection.height.to_f
  63. )
  64. end
  65. 7 sig { returns(Integer) }
  66. 4 def required_anchors
  67. 12 then: 4 else: 8 return 0 if inspection.volume.blank?
  68. 8 anchorage_result.value
  69. end
  70. 7 sig { returns(T::Array[T.untyped]) }
  71. 4 def anchorage_breakdown
  72. 4 else: 4 then: 0 return [] unless inspection.volume
  73. 4 anchorage_result.breakdown
  74. end
  75. end

app/models/assessments/enclosed_assessment.rb

100.0% lines covered

100.0% branches covered

10 relevant lines. 10 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: enclosed_assessments
  4. #
  5. # id :integer not null
  6. # exit_number :integer
  7. # exit_number_comment :text
  8. # exit_number_pass :boolean
  9. # exit_sign_always_visible_comment :text
  10. # exit_sign_always_visible_pass :boolean
  11. # created_at :datetime not null
  12. # updated_at :datetime not null
  13. # inspection_id :string(8) not null, primary key
  14. #
  15. # Indexes
  16. #
  17. # index_enclosed_assessments_on_inspection_id (inspection_id)
  18. #
  19. # Foreign Keys
  20. #
  21. # inspection_id (inspection_id => inspections.id)
  22. #
  23. # typed: true
  24. # frozen_string_literal: true
  25. 4 class Assessments::EnclosedAssessment < ApplicationRecord
  26. 4 extend T::Sig
  27. 4 include AssessmentLogging
  28. 4 include AssessmentCompletion
  29. 4 include ColumnNameSyms
  30. 4 include FormConfigurable
  31. 4 include ValidationConfigurable
  32. 4 self.primary_key = "inspection_id"
  33. 4 belongs_to :inspection
  34. 4 validates :inspection_id,
  35. uniqueness: true
  36. end

app/models/assessments/fan_assessment.rb

100.0% lines covered

100.0% branches covered

12 relevant lines. 12 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: fan_assessments
  4. #
  5. # id :integer not null
  6. # blower_finger_comment :text
  7. # blower_finger_pass :boolean
  8. # blower_flap_comment :text
  9. # blower_flap_pass :integer
  10. # blower_serial :string
  11. # blower_tube_length :decimal(8, 2)
  12. # blower_tube_length_comment :text
  13. # blower_tube_length_pass :boolean
  14. # blower_visual_comment :text
  15. # blower_visual_pass :boolean
  16. # fan_size_type :text
  17. # number_of_blowers :integer
  18. # pat_comment :text
  19. # pat_pass :integer
  20. # created_at :datetime not null
  21. # updated_at :datetime not null
  22. # inspection_id :string(8) not null, primary key
  23. #
  24. # Indexes
  25. #
  26. # index_fan_assessments_on_inspection_id (inspection_id)
  27. #
  28. # Foreign Keys
  29. #
  30. # inspection_id (inspection_id => inspections.id)
  31. #
  32. # typed: true
  33. # frozen_string_literal: true
  34. 4 class Assessments::FanAssessment < ApplicationRecord
  35. 4 extend T::Sig
  36. 4 include AssessmentLogging
  37. 4 include AssessmentCompletion
  38. 4 include ColumnNameSyms
  39. 4 include FormConfigurable
  40. 4 include ValidationConfigurable
  41. 4 self.primary_key = "inspection_id"
  42. 4 belongs_to :inspection
  43. 4 enum :pat_pass, Inspection::PASS_FAIL_NA, prefix: true
  44. 4 enum :blower_flap_pass, Inspection::PASS_FAIL_NA, prefix: true
  45. 4 validates :inspection_id,
  46. uniqueness: true
  47. end

app/models/assessments/materials_assessment.rb

94.12% lines covered

100.0% branches covered

17 relevant lines. 16 lines covered and 1 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: materials_assessments
  4. #
  5. # id :integer not null
  6. # artwork_comment :text
  7. # artwork_pass :integer
  8. # fabric_strength_comment :text
  9. # fabric_strength_pass :boolean
  10. # fire_retardant_comment :text
  11. # fire_retardant_pass :boolean
  12. # retention_netting_comment :text
  13. # retention_netting_pass :integer
  14. # ropes :integer
  15. # ropes_comment :text
  16. # ropes_pass :integer
  17. # thread_comment :text
  18. # thread_pass :boolean
  19. # windows_comment :text
  20. # windows_pass :integer
  21. # zips_comment :text
  22. # zips_pass :integer
  23. # created_at :datetime not null
  24. # updated_at :datetime not null
  25. # inspection_id :string(8) not null, primary key
  26. #
  27. # Indexes
  28. #
  29. # index_materials_assessments_on_inspection_id (inspection_id)
  30. #
  31. # Foreign Keys
  32. #
  33. # inspection_id (inspection_id => inspections.id)
  34. #
  35. # typed: true
  36. # frozen_string_literal: true
  37. 4 class Assessments::MaterialsAssessment < ApplicationRecord
  38. 4 extend T::Sig
  39. 4 include AssessmentLogging
  40. 4 include AssessmentCompletion
  41. 4 include FormConfigurable
  42. 4 include ValidationConfigurable
  43. 4 self.primary_key = "inspection_id"
  44. 4 belongs_to :inspection
  45. 4 enum :ropes_pass, Inspection::PASS_FAIL_NA
  46. 4 enum :retention_netting_pass, Inspection::PASS_FAIL_NA, prefix: true
  47. 4 enum :zips_pass, Inspection::PASS_FAIL_NA, prefix: true
  48. 4 enum :windows_pass, Inspection::PASS_FAIL_NA, prefix: true
  49. 4 enum :artwork_pass, Inspection::PASS_FAIL_NA, prefix: true
  50. 4 after_update :log_assessment_update, if: :saved_changes?
  51. 4 sig { returns(T::Boolean) }
  52. 4 def ropes_compliant?
  53. EN14960.valid_rope_diameter?(ropes)
  54. end
  55. end

app/models/assessments/slide_assessment.rb

79.31% lines covered

20.0% branches covered

29 relevant lines. 23 lines covered and 6 lines missed.
10 total branches, 2 branches covered and 8 branches missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: slide_assessments
  4. #
  5. # id :integer not null
  6. # clamber_netting_comment :text
  7. # clamber_netting_pass :integer
  8. # runout :decimal(8, 2)
  9. # runout_comment :text
  10. # runout_pass :boolean
  11. # slide_beyond_first_metre_height :decimal(8, 2)
  12. # slide_beyond_first_metre_height_comment :text
  13. # slide_first_metre_height :decimal(8, 2)
  14. # slide_first_metre_height_comment :text
  15. # slide_permanent_roof :boolean
  16. # slide_permanent_roof_comment :text
  17. # slide_platform_height :decimal(8, 2)
  18. # slide_platform_height_comment :text
  19. # slide_wall_height :decimal(8, 2)
  20. # slide_wall_height_comment :text
  21. # slip_sheet_comment :text
  22. # slip_sheet_pass :boolean
  23. # created_at :datetime not null
  24. # updated_at :datetime not null
  25. # inspection_id :string(12) not null, primary key
  26. #
  27. # Indexes
  28. #
  29. # index_slide_assessments_on_inspection_id (inspection_id)
  30. #
  31. # Foreign Keys
  32. #
  33. # inspection_id (inspection_id => inspections.id)
  34. #
  35. # typed: true
  36. # frozen_string_literal: true
  37. 4 class Assessments::SlideAssessment < ApplicationRecord
  38. 4 extend T::Sig
  39. 4 include AssessmentLogging
  40. 4 include AssessmentCompletion
  41. 4 include ColumnNameSyms
  42. 4 include FormConfigurable
  43. 4 include ValidationConfigurable
  44. 4 self.primary_key = "inspection_id"
  45. 4 belongs_to :inspection
  46. 4 enum :clamber_netting_pass, Inspection::PASS_FAIL_NA
  47. 6 sig { returns(T::Boolean) }
  48. 4 def meets_runout_requirements?
  49. 2 else: 2 then: 0 return false unless runout.present? && slide_platform_height.present?
  50. 2 EN14960::Calculators::SlideCalculator.meets_runout_requirements?(
  51. runout.to_f, slide_platform_height.to_f
  52. )
  53. end
  54. 4 sig { returns(T.nilable(Integer)) }
  55. 4 def required_runout_length
  56. then: 0 else: 0 return nil if slide_platform_height.blank?
  57. EN14960::Calculators::SlideCalculator.calculate_runout_value(
  58. slide_platform_height.to_f
  59. )
  60. end
  61. 4 sig { returns(String) }
  62. 4 def runout_compliance_status
  63. then: 0 else: 0 return I18n.t("forms.slide.compliance.not_assessed") if runout.blank?
  64. then: 0 if meets_runout_requirements?
  65. I18n.t("forms.slide.compliance.compliant")
  66. else: 0 else
  67. I18n.t("forms.slide.compliance.non_compliant",
  68. required: required_runout_length)
  69. end
  70. end
  71. 5 sig { returns(T::Boolean) }
  72. 4 def meets_wall_height_requirements?
  73. 4 else: 4 then: 0 return false unless slide_platform_height.present? &&
  74. slide_wall_height.present? && !slide_permanent_roof.nil?
  75. # Check if wall height requirements are met for all preset user heights
  76. 4 [1.0, 1.2, 1.5, 1.8].all? do |user_height|
  77. 16 EN14960::Calculators::SlideCalculator.meets_height_requirements?(
  78. slide_platform_height.to_f,
  79. user_height,
  80. slide_wall_height.to_f,
  81. slide_permanent_roof
  82. )
  83. end
  84. end
  85. end

app/models/assessments/structure_assessment.rb

100.0% lines covered

58.33% branches covered

24 relevant lines. 24 lines covered and 0 lines missed.
12 total branches, 7 branches covered and 5 branches missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: structure_assessments
  4. #
  5. # id :integer not null
  6. # air_loss_comment :text
  7. # air_loss_pass :boolean
  8. # critical_fall_off_height :integer
  9. # critical_fall_off_height_comment :text
  10. # critical_fall_off_height_pass :boolean
  11. # entrapment_comment :text
  12. # entrapment_pass :boolean
  13. # evacuation_time_comment :text
  14. # evacuation_time_pass :boolean
  15. # grounding_comment :text
  16. # grounding_pass :boolean
  17. # markings_comment :text
  18. # markings_pass :boolean
  19. # platform_height :integer
  20. # platform_height_comment :text
  21. # platform_height_pass :boolean
  22. # seam_integrity_comment :text
  23. # seam_integrity_pass :boolean
  24. # sharp_edges_comment :text
  25. # sharp_edges_pass :boolean
  26. # step_ramp_size :integer
  27. # step_ramp_size_comment :text
  28. # step_ramp_size_pass :boolean
  29. # stitch_length_comment :text
  30. # stitch_length_pass :boolean
  31. # straight_walls_comment :text
  32. # straight_walls_pass :boolean
  33. # trough_adjacent_panel_width :integer
  34. # trough_adjacent_panel_width_comment :text
  35. # trough_comment :text
  36. # trough_depth :integer
  37. # trough_depth_comment :string(1000)
  38. # trough_pass :boolean
  39. # unit_pressure :decimal(8, 2)
  40. # unit_pressure_comment :text
  41. # unit_pressure_pass :boolean
  42. # unit_stable_comment :text
  43. # unit_stable_pass :boolean
  44. # created_at :datetime not null
  45. # updated_at :datetime not null
  46. # inspection_id :string(8) not null, primary key
  47. #
  48. # Indexes
  49. #
  50. # index_structure_assessments_on_inspection_id (inspection_id)
  51. #
  52. # Foreign Keys
  53. #
  54. # inspection_id (inspection_id => inspections.id)
  55. #
  56. # typed: true
  57. # frozen_string_literal: true
  58. 4 class Assessments::StructureAssessment < ApplicationRecord
  59. 4 extend T::Sig
  60. 4 include AssessmentLogging
  61. 4 include AssessmentCompletion
  62. 4 include ColumnNameSyms
  63. 4 include FormConfigurable
  64. 4 include ValidationConfigurable
  65. 4 self.primary_key = "inspection_id"
  66. 4 belongs_to :inspection
  67. 4 after_update :log_assessment_update, if: :saved_changes?
  68. 5 sig { returns(T::Boolean) }
  69. 4 def meets_height_requirements?
  70. 20 user_height = inspection.user_height_assessment
  71. 20 else: 20 then: 0 return false unless platform_height.present? &&
  72. then: 20 else: 0 user_height&.containing_wall_height.present?
  73. 20 permanent_roof = permanent_roof_status
  74. 20 then: 0 else: 20 return false if permanent_roof.nil?
  75. # Check if height requirements are met for all preset user heights
  76. 20 [1.0, 1.2, 1.5, 1.8].all? do |height|
  77. 64 EN14960::Calculators::SlideCalculator.meets_height_requirements?(
  78. platform_height / 1000.0, # Convert mm to m
  79. height,
  80. user_height.containing_wall_height.to_f,
  81. permanent_roof
  82. )
  83. end
  84. end
  85. 4 private
  86. 5 sig { returns(T::Boolean) }
  87. 4 def permanent_roof_status
  88. # Permanent roof only matters for platforms 3000mm and above
  89. 20 then: 16 else: 4 return false if platform_height < 3000
  90. # For platforms 3.0m+, check slide assessment if inspection has a slide
  91. 4 else: 4 then: 0 return false unless inspection.has_slide?
  92. 4 then: 4 else: 0 inspection.slide_assessment&.slide_permanent_roof
  93. end
  94. end

app/models/assessments/user_height_assessment.rb

91.3% lines covered

50.0% branches covered

23 relevant lines. 21 lines covered and 2 lines missed.
4 total branches, 2 branches covered and 2 branches missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: user_height_assessments
  4. #
  5. # id :integer not null
  6. # containing_wall_height :decimal(8, 2)
  7. # containing_wall_height_comment :text
  8. # custom_user_height_comment :text
  9. # negative_adjustment :decimal(8, 2)
  10. # negative_adjustment_comment :text
  11. # play_area_length :decimal(8, 2)
  12. # play_area_length_comment :text
  13. # play_area_width :decimal(8, 2)
  14. # play_area_width_comment :text
  15. # users_at_1000mm :integer
  16. # users_at_1200mm :integer
  17. # users_at_1500mm :integer
  18. # users_at_1800mm :integer
  19. # created_at :datetime not null
  20. # updated_at :datetime not null
  21. # inspection_id :string(12) not null, primary key
  22. #
  23. # Indexes
  24. #
  25. # index_user_height_assessments_on_inspection_id (inspection_id)
  26. #
  27. # Foreign Keys
  28. #
  29. # inspection_id (inspection_id => inspections.id)
  30. #
  31. # typed: true
  32. # frozen_string_literal: true
  33. 4 module Assessments
  34. 4 class UserHeightAssessment < ApplicationRecord
  35. 4 extend T::Sig
  36. 4 include AssessmentLogging
  37. 4 include AssessmentCompletion
  38. 4 include ColumnNameSyms
  39. 4 include FormConfigurable
  40. 4 include ValidationConfigurable
  41. 4 self.primary_key = "inspection_id"
  42. 4 belongs_to :inspection
  43. 4 validates :inspection_id,
  44. uniqueness: true
  45. 5 sig { returns(T::Hash[Symbol, T.untyped]) }
  46. 4 def validate_play_area
  47. 5 else: 5 then: 0 return {valid: false, errors: ["Inspection not found"]} unless inspection
  48. 5 unit_length = inspection.length
  49. 5 unit_width = inspection.width
  50. # Check if we have all required measurements
  51. 5 then: 0 else: 5 if [unit_length, unit_width, play_area_length, play_area_width].any?(&:nil?)
  52. return {
  53. valid: false,
  54. errors: ["Missing required measurements for play area validation"],
  55. measurements: {}
  56. }
  57. end
  58. # Use the negative_adjustment value, defaulting to 0 if nil
  59. 5 adjustment = negative_adjustment || 0
  60. # Call the EN14960 validator - convert BigDecimal to Float
  61. 5 EN14960.validate_play_area(
  62. unit_length: unit_length.to_f,
  63. unit_width: unit_width.to_f,
  64. play_area_length: play_area_length.to_f,
  65. play_area_width: play_area_width.to_f,
  66. negative_adjustment_area: adjustment.to_f
  67. )
  68. end
  69. 4 sig { returns(T::Boolean) }
  70. 4 def play_area_valid?
  71. validate_play_area[:valid]
  72. end
  73. end
  74. end

app/models/concerns/assessment_completion.rb

98.57% lines covered

87.5% branches covered

70 relevant lines. 69 lines covered and 1 lines missed.
8 total branches, 7 branches covered and 1 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module AssessmentCompletion
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 SYSTEM_FIELDS = %i[
  7. id
  8. inspection_id
  9. created_at
  10. updated_at
  11. ].freeze
  12. 8 sig { returns(T::Boolean) }
  13. 4 def complete?
  14. 1977 incomplete_fields.empty?
  15. end
  16. 8 sig { returns(T::Array[Symbol]) }
  17. 4 def incomplete_fields
  18. 5077 (self.class.column_name_syms - SYSTEM_FIELDS)
  19. 86524 .reject { |f| f.end_with?("_comment") }
  20. 49259 .select { |f| field_is_incomplete?(f) }
  21. 27663 .reject { |f| field_allows_nil_when_na?(f) }
  22. end
  23. 8 sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]]) }
  24. 4 def incomplete_fields_grouped
  25. 3091 field_to_partial = build_field_to_partial_mapping
  26. 3091 incomplete = incomplete_fields
  27. 3091 group_incomplete_fields(incomplete, field_to_partial)
  28. end
  29. 4 private
  30. 8 sig { returns(T::Hash[Symbol, Symbol]) }
  31. 4 def build_field_to_partial_mapping
  32. 3091 form_config = get_form_config
  33. 3091 field_to_partial = {}
  34. 3091 form_config.each do |section|
  35. 6212 else: 6212 then: 0 next unless section[:fields]
  36. 6212 map_section_fields(section, field_to_partial)
  37. end
  38. 3091 field_to_partial
  39. end
  40. 8 sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
  41. 4 def get_form_config
  42. 3091 self.class.form_fields
  43. rescue
  44. []
  45. end
  46. 4 sig do
  47. 4 params(
  48. section: T::Hash[Symbol, T.untyped],
  49. field_to_partial: T::Hash[Symbol, Symbol]
  50. ).void
  51. end
  52. 4 def map_section_fields(section, field_to_partial)
  53. 6212 section[:fields].each do |field_config|
  54. 25985 field = field_config[:field]
  55. 25985 partial = field_config[:partial]
  56. 25985 field_to_partial[field.to_sym] = partial
  57. # Also map composite fields
  58. 25985 partial_sym = partial.to_sym
  59. 25985 composite_fields = ChobbleForms::FieldUtils
  60. .get_composite_fields(field.to_sym, partial_sym)
  61. 25985 composite_fields.each do |cf|
  62. 39626 field_to_partial[cf.to_sym] = partial
  63. end
  64. end
  65. end
  66. 4 sig do
  67. 4 params(
  68. incomplete: T::Array[Symbol],
  69. field_to_partial: T::Hash[Symbol, Symbol]
  70. ).returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]])
  71. end
  72. 4 def group_incomplete_fields(incomplete, field_to_partial)
  73. 3091 grouped = {}
  74. 3091 processed = Set.new
  75. 3091 incomplete.each do |field|
  76. 19881 then: 2959 else: 16922 next if processed.include?(field)
  77. 16922 process_field_group(
  78. field, incomplete, field_to_partial, grouped, processed
  79. )
  80. end
  81. 3091 grouped
  82. end
  83. 4 sig do
  84. 4 params(
  85. field: Symbol,
  86. incomplete: T::Array[Symbol],
  87. field_to_partial: T::Hash[Symbol, Symbol],
  88. grouped: T::Hash[Symbol, T::Hash[Symbol, T.untyped]],
  89. processed: Set
  90. ).void
  91. end
  92. 4 def process_field_group(
  93. field, incomplete, field_to_partial, grouped, processed
  94. )
  95. 16922 base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
  96. 16922 partial = field_to_partial[field] || field_to_partial[base_field]
  97. # Find all related incomplete fields for this base
  98. 16922 related = incomplete.select do |f|
  99. 203168 ChobbleForms::FieldUtils.strip_field_suffix(f) == base_field
  100. end
  101. 16922 then: 2959 else: 13963 key = (related.size > 1) ? base_field : field
  102. 16922 grouped[key] = {
  103. fields: related,
  104. partial: partial
  105. }
  106. 16922 processed.merge(related)
  107. end
  108. 8 sig { params(field: Symbol).returns(T::Boolean) }
  109. 4 def field_is_incomplete?(field)
  110. 49259 value = send(field)
  111. # Field is incomplete if nil
  112. 49259 value.nil?
  113. end
  114. 8 sig { params(field: Symbol).returns(T::Boolean) }
  115. 4 def field_allows_nil_when_na?(field)
  116. # Pass fields are always required, even if set to "na"
  117. 27663 then: 16319 else: 11344 return false if field.end_with?("_pass")
  118. # Only allow nil for value fields when corresponding _pass field is "na"
  119. 11344 pass_field = "#{field}_pass"
  120. 11344 respond_to?(pass_field) && send(pass_field) == "na"
  121. end
  122. end

app/models/concerns/assessment_logging.rb

100.0% lines covered

100.0% branches covered

10 relevant lines. 10 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module AssessmentLogging
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 included do
  7. 28 after_update :log_assessment_update, if: :saved_changes?
  8. end
  9. 4 private
  10. 8 sig { void }
  11. 4 def log_assessment_update
  12. 4708 assessment_type = self.class.name.underscore.humanize
  13. 4708 inspection.log_audit_action(
  14. "assessment_updated",
  15. inspection.user,
  16. "#{assessment_type} updated"
  17. )
  18. end
  19. end

app/models/concerns/column_name_syms.rb

100.0% lines covered

100.0% branches covered

8 relevant lines. 8 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 module ColumnNameSyms
  4. 4 extend T::Sig
  5. 4 extend ActiveSupport::Concern
  6. 4 class_methods do
  7. 4 extend T::Sig
  8. 8 sig { returns(T::Array[Symbol]) }
  9. 4 def column_name_syms
  10. 5314 column_names.map(&:to_sym).sort
  11. end
  12. end
  13. end

app/models/concerns/custom_id_generator.rb

100.0% lines covered

83.33% branches covered

25 relevant lines. 25 lines covered and 0 lines missed.
6 total branches, 5 branches covered and 1 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module CustomIdGenerator
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. # Standard ID length for all models using CustomIdGenerator
  7. 4 ID_LENGTH = 8
  8. # Ambiguous characters to exclude from IDs
  9. 4 AMBIGUOUS_CHARS = %w[0 O 1 I L].freeze
  10. 4 included do
  11. 27 self.primary_key = "id"
  12. 5165 before_create :generate_custom_id, if: -> { id.blank? }
  13. end
  14. 4 class_methods do
  15. 4 extend T::Sig
  16. 4 sig do
  17. 4 params(scope_conditions: T::Hash[T.untyped, T.untyped]).returns(String)
  18. end
  19. 4 def generate_random_id(scope_conditions = {})
  20. 6530 loop do
  21. 6531 raw_id = SecureRandom.alphanumeric(32).upcase
  22. 6531 filtered_chars = raw_id.chars.reject do |char|
  23. 208992 AMBIGUOUS_CHARS.include?(char)
  24. end
  25. 6531 id = filtered_chars.first(ID_LENGTH).join
  26. 6531 then: 0 else: 6531 next if id.length < ID_LENGTH
  27. 6531 else: 1 then: 6530 break id unless exists?({id: id}.merge(scope_conditions))
  28. end
  29. end
  30. end
  31. 4 private
  32. 8 sig { void }
  33. 4 def generate_custom_id
  34. 6426 then: 1 else: 6425 scope_conditions = respond_to?(:uniqueness_scope) ? uniqueness_scope : {}
  35. 6426 self.id = self.class.generate_random_id(scope_conditions)
  36. end
  37. end

app/models/concerns/form_configurable.rb

100.0% lines covered

50.0% branches covered

21 relevant lines. 21 lines covered and 0 lines missed.
2 total branches, 1 branches covered and 1 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module FormConfigurable
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 class_methods do
  7. 4 extend T::Sig
  8. 8 sig { params(user: T.nilable(User)).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
  9. 4 def form_fields(user: nil)
  10. 3958 @form_fields ||= load_form_config_from_yaml
  11. end
  12. 8 sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
  13. 4 def load_form_config_from_yaml
  14. # Remove namespace and use just the class name
  15. 40 file_name = name.demodulize.underscore
  16. 40 config_path = Rails.root.join("config/forms/#{file_name}.yml")
  17. 40 yaml_content = YAML.load_file(config_path)
  18. 40 yaml_content["form_fields"].map do |fieldset|
  19. 88 fieldset = fieldset.deep_symbolize_keys
  20. # Also symbolize the field and partial values
  21. 88 then: 88 else: 0 if fieldset[:fields]
  22. 88 fieldset[:fields] = fieldset[:fields].map do |field_config|
  23. 320 field_config[:field] = field_config[:field].to_sym
  24. 320 field_config[:partial] = field_config[:partial].to_sym
  25. 320 field_config
  26. end
  27. end
  28. 88 fieldset
  29. end
  30. end
  31. end
  32. end

app/models/concerns/public_field_filtering.rb

100.0% lines covered

100.0% branches covered

12 relevant lines. 12 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. # Shared concern for defining which fields should be excluded from public output
  4. # Used by both PDF generation and JSON serialization to ensure consistency
  5. 4 module PublicFieldFiltering
  6. 4 extend ActiveSupport::Concern
  7. 4 include ColumnNameSyms
  8. # System/metadata fields to exclude from public outputs (shared)
  9. 4 EXCLUDED_FIELDS = %i[
  10. id
  11. created_at
  12. updated_at
  13. pdf_last_accessed_at
  14. user_id
  15. unit_id
  16. inspector_company_id
  17. inspection_id
  18. is_seed
  19. ].freeze
  20. # Additional fields to exclude from PDFs specifically
  21. 4 PDF_EXCLUDED_FIELDS = %i[
  22. complete_date
  23. inspection_date
  24. ].freeze
  25. # Fields excluded from PDFs (combines shared + PDF-specific)
  26. 4 PDF_TOTAL_EXCLUDED_FIELDS = (EXCLUDED_FIELDS + PDF_EXCLUDED_FIELDS).freeze
  27. # Computed fields to exclude from public outputs
  28. 4 EXCLUDED_COMPUTED_FIELDS = %i[
  29. reinspection_date
  30. ].freeze
  31. 4 class_methods do
  32. 4 def public_fields
  33. 3 column_name_syms - EXCLUDED_FIELDS
  34. end
  35. 4 def excluded_fields_for_assessment(_klass_name)
  36. 6 EXCLUDED_FIELDS
  37. end
  38. end
  39. end

app/models/concerns/validation_configurable.rb

97.83% lines covered

76.47% branches covered

46 relevant lines. 45 lines covered and 1 lines missed.
17 total branches, 13 branches covered and 4 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 module ValidationConfigurable
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 included do
  7. # Apply validations when the concern is included
  8. 43 then: 43 else: 0 if ancestors.include?(FormConfigurable)
  9. 43 apply_form_validations
  10. end
  11. end
  12. 4 class_methods do
  13. 4 extend T::Sig
  14. 8 sig { void }
  15. 4 def apply_form_validations
  16. form_config = begin
  17. 43 form_fields
  18. rescue
  19. nil
  20. end
  21. 43 else: 43 then: 0 return unless form_config
  22. 43 form_config.each do |section|
  23. 91 else: 91 then: 0 next unless section[:fields]
  24. 91 section[:fields].each do |field_config|
  25. 329 apply_validation_for_field(field_config)
  26. end
  27. end
  28. end
  29. 4 private
  30. 8 sig { params(field_config: T::Hash[Symbol, T.untyped]).void }
  31. 4 def apply_validation_for_field(field_config)
  32. 329 field = field_config[:field]
  33. 329 attributes = field_config[:attributes] || {}
  34. 329 partial = field_config[:partial]
  35. 329 else: 329 then: 0 return unless field
  36. 329 then: 30 else: 299 if attributes[:required]
  37. 30 validates field, presence: true
  38. end
  39. 329 else: 255 case partial
  40. when: 47 when :decimal_comment, :decimal
  41. 47 apply_decimal_validation(field, attributes)
  42. when: 27 when :number, :number_pass_fail_na_comment
  43. 27 apply_number_validation(field, attributes)
  44. end
  45. end
  46. 8 sig { params(field: Symbol, attributes: T::Hash[Symbol, T.untyped]).void }
  47. 4 def apply_decimal_validation(field, attributes)
  48. 47 options = build_numericality_options(attributes)
  49. 47 validates field, numericality: options, allow_blank: true
  50. end
  51. 8 sig { params(field: Symbol, attributes: T::Hash[Symbol, T.untyped]).void }
  52. 4 def apply_number_validation(field, attributes)
  53. 27 options = build_numericality_options(attributes)
  54. 27 options[:only_integer] = true
  55. 27 validates field, numericality: options, allow_blank: true
  56. end
  57. 8 sig { params(attributes: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
  58. 4 def build_numericality_options(attributes)
  59. 74 options = {}
  60. 74 then: 70 else: 4 if attributes[:min]
  61. 70 options[:greater_than_or_equal_to] = attributes[:min]
  62. end
  63. 74 then: 47 else: 27 if attributes[:max]
  64. 47 options[:less_than_or_equal_to] = attributes[:max]
  65. end
  66. 74 options
  67. end
  68. end
  69. end

app/models/credential.rb

100.0% lines covered

100.0% branches covered

6 relevant lines. 6 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: credentials
  4. #
  5. # id :integer not null, primary key
  6. # nickname :string not null
  7. # public_key :string not null
  8. # sign_count :integer default(0), not null
  9. # created_at :datetime not null
  10. # updated_at :datetime not null
  11. # external_id :string not null
  12. # user_id :string(12) not null
  13. #
  14. # Indexes
  15. #
  16. # index_credentials_on_external_id (external_id) UNIQUE
  17. # index_credentials_on_user_id (user_id)
  18. #
  19. # Foreign Keys
  20. #
  21. # user_id (user_id => users.id)
  22. #
  23. 4 class Credential < ApplicationRecord
  24. 4 belongs_to :user
  25. 4 validates :external_id, :public_key, :nickname, :sign_count, presence: true
  26. 4 validates :external_id, uniqueness: true
  27. 4 validates :sign_count,
  28. numericality: {
  29. only_integer: true,
  30. greater_than_or_equal_to: 0,
  31. 4 less_than_or_equal_to: (2**32) - 1
  32. }
  33. end

app/models/event.rb

93.94% lines covered

50.0% branches covered

33 relevant lines. 31 lines covered and 2 lines missed.
2 total branches, 1 branches covered and 1 branches missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: events
  4. #
  5. # id :integer not null, primary key
  6. # action :string not null
  7. # changed_data :json
  8. # details :text
  9. # metadata :json
  10. # resource_type :string not null
  11. # created_at :datetime not null
  12. # resource_id :string(12)
  13. # user_id :string(12) not null
  14. #
  15. # Indexes
  16. #
  17. # index_events_on_action (action)
  18. # index_events_on_created_at (created_at)
  19. # index_events_on_resource_type_and_resource_id (resource_type,resource_id)
  20. # index_events_on_user_id (user_id)
  21. # index_events_on_user_id_and_created_at (user_id,created_at)
  22. #
  23. # Foreign Keys
  24. #
  25. # user_id (user_id => users.id)
  26. #
  27. # typed: true
  28. # frozen_string_literal: true
  29. 4 class Event < ApplicationRecord
  30. 4 extend T::Sig
  31. 4 belongs_to :user
  32. 4 belongs_to :resource, polymorphic: true, optional: true
  33. 4 validates :action, presence: true
  34. 4 validates :resource_type, presence: true
  35. 4 validates :resource_id, presence: true,
  36. 4817 unless: -> { resource_type == "System" }
  37. # Scopes for common queries
  38. 9 scope :recent, -> { order(created_at: :desc) }
  39. 4 scope :for_user, ->(user) { where(user: user) }
  40. 9 scope :for_resource, ->(resource) { where(resource: resource) }
  41. 4 scope :by_action, ->(action) { where(action: action) }
  42. 4 scope :today, -> { where(created_at: Date.current.all_day) }
  43. 4 scope :this_week, -> { where(created_at: Date.current.all_week) }
  44. # Helper to create events easily
  45. 4 sig do
  46. 4 params(
  47. user: User,
  48. action: String,
  49. resource: ActiveRecord::Base,
  50. details: T.nilable(String),
  51. changed_data: T.nilable(T::Hash[String, T.any(String, Integer, T::Boolean, NilClass)]),
  52. metadata: T.nilable(T::Hash[String, T.any(String, Integer, T::Boolean, NilClass)])
  53. ).returns(Event)
  54. end
  55. 4 def self.log(user:, action:, resource:, details: nil,
  56. changed_data: nil, metadata: nil)
  57. 4802 create!(
  58. user: user,
  59. action: action,
  60. resource_type: resource.class.name,
  61. resource_id: resource.id,
  62. details: details,
  63. changed_data: changed_data,
  64. metadata: metadata
  65. )
  66. end
  67. # Helper for system events that don't have a specific resource
  68. 4 sig do
  69. 3 params(
  70. user: User,
  71. action: String,
  72. details: String,
  73. metadata: T.nilable(T::Hash[String, T.any(String, Integer, T::Boolean, NilClass)])
  74. ).returns(Event)
  75. end
  76. 4 def self.log_system_event(user:, action:, details:, metadata: nil)
  77. 11 create!(
  78. user: user,
  79. action: action,
  80. resource_type: "System",
  81. resource_id: nil,
  82. details: details,
  83. metadata: metadata
  84. )
  85. end
  86. # Formatted description for display
  87. 4 sig { returns(String) }
  88. 4 def description
  89. details || "#{user.email} #{action} #{resource_type} #{resource_id}"
  90. end
  91. # Check if the event was triggered by a specific user
  92. 4 sig { params(check_user: User).returns(T::Boolean) }
  93. 4 def triggered_by?(check_user)
  94. user == check_user
  95. end
  96. # Get the resource object if it still exists
  97. 5 sig { returns(T.nilable(ActiveRecord::Base)) }
  98. 4 def resource_object
  99. 2 else: 2 then: 0 return nil unless resource_type && resource_id
  100. 2 resource_type.constantize.find_by(id: resource_id)
  101. rescue NameError
  102. 1 nil
  103. end
  104. end

app/models/inspection.rb

93.02% lines covered

74.39% branches covered

258 relevant lines. 240 lines covered and 18 lines missed.
82 total branches, 61 branches covered and 21 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. # == Schema Information
  4. #
  5. # Table name: inspections
  6. #
  7. # id :string(8) not null, primary key
  8. # complete_date :datetime
  9. # has_slide :boolean
  10. # height :decimal(8, 2)
  11. # height_comment :string(1000)
  12. # indoor_only :boolean
  13. # inspection_date :datetime
  14. # inspection_type :string default("bouncy_castle"), not null
  15. # is_seed :boolean default(FALSE), not null
  16. # is_totally_enclosed :boolean
  17. # length :decimal(8, 2)
  18. # length_comment :string(1000)
  19. # passed :boolean
  20. # pdf_last_accessed_at :datetime
  21. # risk_assessment :text
  22. # width :decimal(8, 2)
  23. # width_comment :string(1000)
  24. # created_at :datetime not null
  25. # updated_at :datetime not null
  26. # inspector_company_id :string(8)
  27. # unit_id :string(8)
  28. # user_id :string(8) not null
  29. #
  30. # Indexes
  31. #
  32. # index_inspections_on_inspection_type (inspection_type)
  33. # index_inspections_on_inspector_company_id (inspector_company_id)
  34. # index_inspections_on_is_seed (is_seed)
  35. # index_inspections_on_unit_id (unit_id)
  36. # index_inspections_on_user_id (user_id)
  37. #
  38. # Foreign Keys
  39. #
  40. # inspector_company_id (inspector_company_id => inspector_companies.id)
  41. # unit_id (unit_id => units.id)
  42. # user_id (user_id => users.id)
  43. #
  44. 4 class Inspection < ApplicationRecord
  45. 4 extend T::Sig
  46. 4 include CustomIdGenerator
  47. 4 include FormConfigurable
  48. 4 include ValidationConfigurable
  49. 4 PASS_FAIL_NA = {fail: 0, pass: 1, na: 2}.freeze
  50. 4 enum :inspection_type, {
  51. bouncy_castle: "BOUNCY_CASTLE",
  52. bouncing_pillow: "BOUNCING_PILLOW"
  53. }
  54. CASTLE_ASSESSMENT_TYPES = {
  55. 4 user_height_assessment: Assessments::UserHeightAssessment,
  56. slide_assessment: Assessments::SlideAssessment,
  57. structure_assessment: Assessments::StructureAssessment,
  58. anchorage_assessment: Assessments::AnchorageAssessment,
  59. materials_assessment: Assessments::MaterialsAssessment,
  60. enclosed_assessment: Assessments::EnclosedAssessment,
  61. fan_assessment: Assessments::FanAssessment
  62. }.freeze
  63. PILLOW_ASSESSMENT_TYPES = {
  64. 4 fan_assessment: Assessments::FanAssessment
  65. }.freeze
  66. ALL_ASSESSMENT_TYPES =
  67. 4 CASTLE_ASSESSMENT_TYPES.merge(PILLOW_ASSESSMENT_TYPES).freeze
  68. 4 USER_EDITABLE_PARAMS = %i[
  69. has_slide
  70. height
  71. indoor_only
  72. inspection_date
  73. is_totally_enclosed
  74. length
  75. passed
  76. photo_1
  77. photo_2
  78. photo_3
  79. risk_assessment
  80. unit_id
  81. width
  82. ].freeze
  83. REQUIRED_TO_COMPLETE_FIELDS =
  84. 4 USER_EDITABLE_PARAMS - %i[
  85. risk_assessment
  86. ]
  87. 4 belongs_to :user
  88. 4 belongs_to :unit, optional: true
  89. 4 belongs_to :inspector_company, optional: true
  90. 4 has_one_attached :photo_1
  91. 4 has_one_attached :photo_2
  92. 4 has_one_attached :photo_3
  93. 4 has_one_attached :cached_pdf
  94. 4 validate :photos_must_be_images
  95. 4 before_validation :set_inspector_company_from_user, on: :create
  96. 4 before_validation :set_inspection_type_from_unit, on: :create
  97. 4 after_update :invalidate_pdf_cache
  98. 4 after_save :invalidate_unit_pdf_cache
  99. 4 ALL_ASSESSMENT_TYPES.each do |assessment_name, assessment_class|
  100. 28 has_one assessment_name,
  101. class_name: assessment_class.name,
  102. dependent: :destroy
  103. end
  104. # Accept nested attributes for all assessments
  105. 4 accepts_nested_attributes_for(*ALL_ASSESSMENT_TYPES.keys)
  106. # Override assessment getters to auto-create if missing
  107. 4 ALL_ASSESSMENT_TYPES.each do |assessment_name, assessment_class|
  108. # Auto-create version
  109. 28 define_method(assessment_name) do
  110. 14980 super() || assessment_class.find_or_create_by!(inspection: self)
  111. end
  112. # Non-creating version for safe navigation
  113. 28 define_method("#{assessment_name}?") do
  114. 7 then: 3 if association(assessment_name).loaded?
  115. 3 send(assessment_name)
  116. else: 4 else
  117. 4 assessment_class.find_by(inspection: self)
  118. end
  119. end
  120. end
  121. 4 validates :inspection_date, presence: true
  122. # Scopes
  123. 69 scope :seed_data, -> { where(is_seed: true) }
  124. 8 scope :non_seed_data, -> { where(is_seed: false) }
  125. 8 scope :passed, -> { where(passed: true) }
  126. 7 scope :failed, -> { where(passed: false) }
  127. 558 scope :complete, -> { where.not(complete_date: nil) }
  128. 6 scope :draft, -> { where(complete_date: nil) }
  129. 4 scope :search, lambda { |query|
  130. 425 then: 0 if query.present?
  131. joins("LEFT JOIN units ON units.id = inspections.unit_id")
  132. .where(search_conditions, *search_values(query))
  133. else: 425 else
  134. 425 all
  135. end
  136. }
  137. 4 scope :filter_by_result, lambda { |result|
  138. 431 when: 10 else: 417 case result
  139. 10 when: 4 when "passed" then where(passed: true)
  140. 4 when "failed" then where(passed: false)
  141. end
  142. }
  143. 4 scope :filter_by_unit, lambda { |unit_id|
  144. 428 then: 3 else: 425 where(unit_id: unit_id) if unit_id.present?
  145. }
  146. 4 scope :filter_by_operator, lambda { |operator|
  147. 425 then: 0 if operator.present?
  148. joins(:unit).where(units: {operator: operator})
  149. else: 425 else
  150. 425 all
  151. end
  152. }
  153. 4 scope :filter_by_date_range, lambda { |start_date, end_date|
  154. range = start_date..end_date
  155. then: 0 else: 0 where(inspection_date: range) if both_dates_present?(start_date, end_date)
  156. }
  157. 4 scope :overdue, -> { where("inspection_date < ?", Time.zone.today - 1.year) }
  158. # Helper methods for scopes
  159. 4 sig { returns(String) }
  160. 4 def self.search_conditions
  161. "inspections.id LIKE ? OR units.serial LIKE ? OR " \
  162. "units.manufacturer LIKE ? OR units.name LIKE ?"
  163. end
  164. 4 sig { params(query: String).returns(T::Array[String]) }
  165. 4 def self.search_values(query) = Array.new(4) { "%#{query}%" }
  166. 4 sig { params(start_date: T.nilable(T.any(String, Date)), end_date: T.nilable(T.any(String, Date))).returns(T::Boolean) }
  167. 4 def self.both_dates_present?(start_date, end_date) =
  168. start_date.present? && end_date.present?
  169. # Calculated fields
  170. 8 sig { returns(T.nilable(Date)) }
  171. 4 def reinspection_date
  172. 248 then: 16 else: 232 return nil if inspection_date.blank?
  173. 232 (inspection_date + 1.year).to_date
  174. end
  175. 4 sig { returns(T.nilable(Numeric)) }
  176. 4 def area
  177. else: 0 then: 0 return nil unless width && length
  178. width * length
  179. end
  180. 7 sig { returns(T.nilable(Numeric)) }
  181. 4 def volume
  182. 16 else: 12 then: 4 return nil unless width && length && height
  183. 12 width * length * height
  184. end
  185. 8 sig { returns(T::Boolean) }
  186. 4 def complete?
  187. 1632 complete_date.present?
  188. end
  189. 8 sig { returns(T::Hash[Symbol, T.class_of(ApplicationRecord)]) }
  190. 4 def assessment_types
  191. 165 then: 0 else: 165 bouncing_pillow? ? PILLOW_ASSESSMENT_TYPES : CASTLE_ASSESSMENT_TYPES
  192. end
  193. 8 sig { returns(T::Hash[Symbol, T.class_of(ApplicationRecord)]) }
  194. 4 def applicable_assessments
  195. 2102 then: 101 if bouncing_pillow?
  196. 101 pillow_applicable_assessments
  197. else: 2001 else
  198. 2001 castle_applicable_assessments
  199. end
  200. end
  201. 4 private
  202. 8 sig { returns(T::Hash[Symbol, T.class_of(ApplicationRecord)]) }
  203. 4 def castle_applicable_assessments
  204. 2001 CASTLE_ASSESSMENT_TYPES.select do |assessment_key, _|
  205. 14007 case assessment_key
  206. when: 2001 when :slide_assessment
  207. 2001 has_slide?
  208. when: 2001 when :enclosed_assessment
  209. 2001 is_totally_enclosed?
  210. when: 2001 when :anchorage_assessment
  211. 2001 !indoor_only?
  212. else: 8004 else
  213. 8004 true
  214. end
  215. end
  216. end
  217. 5 sig { returns(T::Hash[Symbol, T.class_of(ApplicationRecord)]) }
  218. 4 def pillow_applicable_assessments
  219. 101 PILLOW_ASSESSMENT_TYPES
  220. end
  221. 4 public
  222. # Iterate over only applicable assessments with a block
  223. 8 sig { params(block: T.proc.params(assessment_key: Symbol, assessment_class: T.class_of(ApplicationRecord), assessment: ApplicationRecord).void).void }
  224. 4 def each_applicable_assessment(&block)
  225. 768 applicable_assessments.each do |assessment_key, assessment_class|
  226. 3894 assessment = send(assessment_key)
  227. 3894 then: 3894 else: 0 yield(assessment_key, assessment_class, assessment) if block_given?
  228. end
  229. end
  230. # Check if a specific assessment is applicable
  231. 8 sig { params(assessment_key: Symbol).returns(T::Boolean) }
  232. 4 def assessment_applicable?(assessment_key)
  233. 306 applicable_assessments.key?(assessment_key)
  234. end
  235. # Returns tabs in the order they appear in the UI
  236. 8 sig { returns(T::Array[String]) }
  237. 4 def applicable_tabs
  238. 893 tabs = ["inspection"]
  239. # Get applicable assessments for this inspection type
  240. 6479 applicable = applicable_assessments.keys.map { |k| k.to_s.chomp("_assessment") }
  241. # Add tabs in the correct UI order
  242. 893 ordered_tabs = %w[user_height slide structure anchorage materials fan enclosed]
  243. 893 ordered_tabs.each do |tab|
  244. 6251 then: 5586 else: 665 tabs << tab if applicable.include?(tab)
  245. end
  246. # Add results tab at the end
  247. 893 tabs << "results"
  248. 893 tabs
  249. end
  250. # Advanced methods
  251. 8 sig { returns(T::Boolean) }
  252. 4 def can_be_completed?
  253. 120 unit.present? &&
  254. all_assessments_complete? &&
  255. !passed.nil? &&
  256. inspection_date.present? &&
  257. width.present? &&
  258. length.present? &&
  259. height.present? &&
  260. !has_slide.nil? &&
  261. !is_totally_enclosed.nil? &&
  262. !indoor_only.nil?
  263. end
  264. 4 sig { returns(T::Hash[Symbol, T.any(T::Boolean, T::Array[String])]) }
  265. 4 def completion_status
  266. complete = complete?
  267. all_assessments_complete = all_assessments_complete?
  268. missing_assessments = get_missing_assessments
  269. can_be_completed = can_be_completed?
  270. {
  271. complete:,
  272. all_assessments_complete:,
  273. missing_assessments:,
  274. can_be_completed:
  275. }
  276. end
  277. 8 sig { returns(T::Boolean) }
  278. 4 def can_mark_complete? = can_be_completed?
  279. 5 sig { returns(T::Array[String]) }
  280. 4 def completion_errors
  281. 22 errors = []
  282. 22 then: 0 else: 22 errors << "Unit is required" if unit.blank?
  283. # Get detailed incomplete field information
  284. 22 incomplete_tabs = incomplete_fields
  285. 22 incomplete_tabs.each do |tab_info|
  286. 32 tab_name = tab_info[:name]
  287. 272 incomplete_field_names = tab_info[:fields].map { |f| f[:label] }.join(", ")
  288. 32 errors << "#{tab_name}: #{incomplete_field_names}"
  289. end
  290. 22 errors
  291. end
  292. 5 sig { returns(T::Array[String]) }
  293. 4 def get_missing_assessments
  294. 2 missing = []
  295. # Check for missing unit first
  296. 2 then: 1 else: 1 missing << "Unit" if unit.blank?
  297. # Check for missing assessments using the new helper
  298. 2 each_applicable_assessment do |assessment_key, _, assessment|
  299. 14 then: 14 else: 0 then: 0 else: 14 next if assessment&.complete?
  300. # Get the assessment type without "_assessment" suffix
  301. 14 assessment_type = assessment_key.to_s.sub("_assessment", "")
  302. # Get the name from the form header
  303. 14 missing << I18n.t("forms.#{assessment_type}.header")
  304. end
  305. 2 missing
  306. end
  307. 7 sig { params(user: User).void }
  308. 4 def complete!(user)
  309. 6 update!(complete_date: Time.current)
  310. 6 log_audit_action("completed", user, "Inspection completed")
  311. end
  312. 6 sig { params(user: User).void }
  313. 4 def un_complete!(user)
  314. 3 update!(complete_date: nil)
  315. 3 log_audit_action("marked_incomplete", user, "Inspection completed")
  316. end
  317. 6 sig { returns(T::Array[String]) }
  318. 4 def validate_completeness
  319. 5 assessment_validation_data.filter_map do |name, assessment, message|
  320. # Convert the symbol name (e.g., :slide) to assessment key (e.g., :slide_assessment)
  321. 35 assessment_key = :"#{name}_assessment"
  322. 35 else: 22 then: 13 next unless assessment_applicable?(assessment_key)
  323. 22 then: 0 else: 22 message if assessment.present? && !assessment.complete?
  324. end
  325. end
  326. 8 sig { params(action: String, user: T.nilable(User), details: String).void }
  327. 4 def log_audit_action(action, user, details)
  328. 4716 Event.log(
  329. user: user,
  330. action: action,
  331. resource: self,
  332. details: details
  333. )
  334. rescue => e
  335. # Fallback to logging if Event creation fails
  336. Rails.logger.error("Failed to create event: #{e.message}")
  337. then: 0 else: 0 Rails.logger.info("Inspection #{id}: #{action} by #{user&.email} - #{details}")
  338. end
  339. 8 sig { params(form: T.any(Symbol, String), field: T.any(Symbol, String)).returns(String) }
  340. 4 def field_label(form, field)
  341. 18099 key = "forms.#{form}.fields.#{field}"
  342. # Try the field as-is first
  343. 18099 label = I18n.t(key, default: nil)
  344. # Try removing _pass and/or _comment suffixes
  345. 18099 then: 8911 else: 9188 if label.nil?
  346. 8911 base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
  347. 8911 label = I18n.t("forms.#{form}.fields.#{base_field}", default: nil)
  348. end
  349. # Try adding _pass suffix
  350. 18099 then: 0 else: 18099 label = I18n.t("#{key}_pass", default: nil) if label.nil? && !field.to_s.end_with?("_pass")
  351. # If still not found, raise for the original key
  352. 18099 label || I18n.t(key)
  353. end
  354. 8 sig { returns(T::Array[Symbol]) }
  355. 4 def inspection_tab_incomplete_fields
  356. # Fields required for the inspection tab specifically (excludes passed which is on results tab)
  357. 879 fields = REQUIRED_TO_COMPLETE_FIELDS - [:passed]
  358. 879 fields
  359. 9669 .reject { |f| f.end_with?("_comment") }
  360. 9669 .select { |f| send(f).nil? }
  361. end
  362. 8 sig { returns(T::Array[T::Hash[Symbol, T.any(Symbol, String, T::Array[T::Hash[Symbol, T.any(Symbol, String)]])]]) }
  363. 4 def incomplete_fields
  364. 496 output = []
  365. # Process tabs in the same order as applicable_tabs
  366. 496 applicable_tabs.each do |tab|
  367. 4078 case tab
  368. when "inspection"
  369. when: 496 # Get incomplete fields for the inspection tab (excluding passed)
  370. inspection_tab_fields =
  371. 496 inspection_tab_incomplete_fields
  372. 868 .map { |f| {field: f, label: field_label(:inspection, f)} }
  373. 496 then: 288 else: 208 if inspection_tab_fields.any?
  374. 288 output << {
  375. tab: :inspection,
  376. name: I18n.t("forms.inspection.header"),
  377. fields: inspection_tab_fields
  378. }
  379. end
  380. when "results"
  381. when: 496 # Get incomplete fields for the results tab
  382. 496 results_fields = []
  383. 496 then: 176 else: 320 results_fields << {field: :passed, label: field_label(:results, :passed)} if passed.nil?
  384. 496 then: 176 else: 320 if results_fields.any?
  385. 176 output << {
  386. tab: :results,
  387. name: I18n.t("forms.results.header"),
  388. fields: results_fields
  389. }
  390. end
  391. else
  392. else: 3086 # All other tabs are assessment tabs
  393. 3086 assessment_key = :"#{tab}_assessment"
  394. 3086 then: 3086 else: 0 assessment = send(assessment_key) if respond_to?(assessment_key)
  395. 3086 then: 3086 else: 0 if assessment
  396. 3086 grouped_fields = assessment.incomplete_fields_grouped
  397. 3086 assessment_fields = []
  398. 3086 grouped_fields.each do |base_field, info|
  399. # Determine what's missing
  400. 16883 has_value_missing = info[:fields].include?(base_field)
  401. 16883 has_pass_missing = info[:fields].include?(:"#{base_field}_pass")
  402. # Get the base label
  403. 16883 base_label = field_label(tab.to_sym, base_field)
  404. # Construct the full label
  405. 16883 then: 2958 label = if has_value_missing && has_pass_missing
  406. 2958 else: 13925 "#{base_label} (+ Pass/Fail)"
  407. 13925 then: 0 elsif has_pass_missing
  408. "#{base_label} Pass/Fail"
  409. else: 13925 else
  410. 13925 base_label
  411. end
  412. 16883 assessment_fields << {field: base_field, label: label}
  413. end
  414. 3086 then: 2122 else: 964 if assessment_fields.any?
  415. 2122 output << {
  416. tab: tab.to_sym,
  417. name: I18n.t("forms.#{tab}.header"),
  418. fields: assessment_fields
  419. }
  420. end
  421. end
  422. end
  423. end
  424. 496 output
  425. end
  426. 4 private
  427. 8 sig { void }
  428. 4 def set_inspector_company_from_user
  429. 1575 self.inspector_company_id ||= user.inspection_company_id
  430. end
  431. 8 sig { void }
  432. 4 def set_inspection_type_from_unit
  433. 1575 else: 1561 then: 14 return unless unit
  434. 1561 else: 1561 then: 0 return unless new_record?
  435. # Set inspection type to match unit type
  436. 1561 self.inspection_type = unit.unit_type
  437. end
  438. 8 sig { returns(T::Boolean) }
  439. 4 def all_assessments_complete?
  440. 119 required_assessment_completions.all?
  441. end
  442. 8 sig { returns(T::Array[T::Boolean]) }
  443. 4 def required_assessment_completions
  444. 119 applicable_assessments.map do |assessment_key, _|
  445. 697 then: 697 else: 0 send(assessment_key)&.complete?
  446. end
  447. end
  448. 4 sig { returns(T::Array[ApplicationRecord]) }
  449. 4 def all_assessments
  450. applicable_assessments.map { |assessment_key, _| send(assessment_key) }
  451. end
  452. 4 sig {
  453. 2 returns(
  454. T::Array[
  455. T::Array[T.any(Symbol, ActiveRecord::Base, String)]
  456. ]
  457. )
  458. }
  459. 4 def assessment_validation_data
  460. 5 assessment_types = %i[
  461. anchorage
  462. enclosed
  463. fan
  464. materials
  465. slide
  466. structure
  467. user_height
  468. ]
  469. 5 assessment_types.map do |type|
  470. 35 assessment = send("#{type}_assessment")
  471. 35 message = I18n.t("inspections.validation.#{type}_incomplete")
  472. 35 [type, assessment, message]
  473. end
  474. end
  475. 8 sig { void }
  476. 4 def photos_must_be_images
  477. 1724 [[:photo_1, photo_1], [:photo_2, photo_2], [:photo_3, photo_3]].each do |field_name, photo|
  478. 5172 else: 12 then: 5160 next unless photo.attached?
  479. # Check if blob exists and has content_type
  480. 12 then: 0 else: 12 if photo.blob && !photo.blob.content_type.to_s.start_with?("image/")
  481. errors.add(field_name, I18n.t("activerecord.errors.messages.not_an_image"))
  482. photo.purge
  483. end
  484. end
  485. end
  486. 8 sig { void }
  487. 4 def invalidate_pdf_cache
  488. # Skip cache invalidation if only pdf_last_accessed_at or updated_at changed
  489. 124 changed_attrs = saved_changes.keys
  490. 124 ignorable_attrs = ["pdf_last_accessed_at", "updated_at"]
  491. 124 then: 57 else: 67 return if (changed_attrs - ignorable_attrs).empty?
  492. 67 PdfCacheService.invalidate_inspection_cache(self)
  493. end
  494. 8 sig { void }
  495. 4 def invalidate_unit_pdf_cache
  496. 1695 then: 1681 else: 14 PdfCacheService.invalidate_unit_cache(unit) if unit
  497. end
  498. end

app/models/inspector_company.rb

96.55% lines covered

77.78% branches covered

58 relevant lines. 56 lines covered and 2 lines missed.
18 total branches, 14 branches covered and 4 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. # == Schema Information
  4. #
  5. # Table name: inspector_companies
  6. #
  7. # id :string(8) not null, primary key
  8. # active :boolean default(TRUE)
  9. # address :text not null
  10. # city :string
  11. # country :string default("UK")
  12. # email :string
  13. # name :string not null
  14. # notes :text
  15. # phone :string not null
  16. # postal_code :string
  17. # created_at :datetime not null
  18. # updated_at :datetime not null
  19. #
  20. # Indexes
  21. #
  22. # index_inspector_companies_on_active (active)
  23. #
  24. 4 class InspectorCompany < ApplicationRecord
  25. 4 extend T::Sig
  26. 4 include CustomIdGenerator
  27. 4 include FormConfigurable
  28. 4 include ValidationConfigurable
  29. 4 has_many :inspections, dependent: :destroy
  30. # Override to filter admin-only fields
  31. 4 sig {
  32. 2 params(user: T.nilable(User)).returns(
  33. T::Array[
  34. T::Hash[
  35. Symbol,
  36. T.any(
  37. String,
  38. T::Array[T::Hash[Symbol, T.any(String, Symbol, Integer, T::Boolean, T::Hash[Symbol, T.any(String, Integer, T::Boolean)])]]
  39. )
  40. ]
  41. ]
  42. )
  43. }
  44. 4 def self.form_fields(user: nil)
  45. 39 fields = super
  46. # Remove notes field unless user is admin
  47. 39 then: 39 else: 0 else: 39 then: 0 unless user&.admin?
  48. fields.each do |fieldset|
  49. fieldset[:fields].delete_if { |field| field[:field] == :notes }
  50. end
  51. end
  52. 39 fields
  53. end
  54. # File attachments
  55. 4 has_one_attached :logo
  56. # Validations
  57. 4 validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}, allow_blank: true
  58. # Scopes
  59. 50 scope :active, -> { where(active: true) }
  60. 6 scope :archived, -> { where(active: false) }
  61. 4 scope :by_status, ->(status) {
  62. 19 when: 3 then: 8 else: 11 case status&.to_s
  63. 3 when: 2 when "active" then active
  64. 2 when: 2 when "archived" then archived
  65. 2 else: 12 when "all" then all
  66. 12 else all # Default to all companies when no parameter provided
  67. end
  68. }
  69. 4 scope :search_by_term, ->(term) {
  70. 14 then: 12 else: 2 return all if term.blank?
  71. 2 where("name LIKE ?", "%#{term}%")
  72. }
  73. # Callbacks
  74. 4 before_save :normalize_phone_number
  75. # Methods
  76. # Credentials validation moved to individual inspector level (User model)
  77. 6 sig { returns(String) }
  78. 4 def full_address
  79. 26 [address, city, postal_code].compact.join(", ")
  80. end
  81. 5 sig { returns(Integer) }
  82. 4 def inspection_count
  83. 1 inspections.count
  84. end
  85. 6 sig { params(limit: Integer).returns(ActiveRecord::Relation) }
  86. 4 def recent_inspections(limit = 10)
  87. # Will be enhanced when Unit relationship is added
  88. 13 inspections.order(inspection_date: :desc).limit(limit)
  89. end
  90. 6 sig { params(total: T.nilable(Integer), passed: T.nilable(Integer)).returns(Float) }
  91. 4 def pass_rate(total = nil, passed = nil)
  92. 15 total ||= inspections.count
  93. 15 passed ||= inspections.passed.count
  94. 15 then: 14 else: 1 return 0.0 if total == 0
  95. 1 (passed.to_f / total * 100).round(2)
  96. end
  97. 6 sig { returns(T::Hash[Symbol, T.any(Integer, Float)]) }
  98. 4 def company_statistics
  99. # Use group to get all counts in a single query
  100. 14 counts = inspections.group(:passed).count
  101. 14 passed_count = counts[true] || 0
  102. 14 failed_count = counts[false] || 0
  103. 14 total_count = passed_count + failed_count
  104. {
  105. 14 total_inspections: total_count,
  106. passed_inspections: passed_count,
  107. failed_inspections: failed_count,
  108. pass_rate: pass_rate(total_count, passed_count),
  109. active_since: created_at.year
  110. }
  111. end
  112. 5 sig { returns(T.nilable(ActiveStorage::Attached::One)) }
  113. 4 def logo_url
  114. 1 then: 0 else: 1 logo.attached? ? logo : nil
  115. end
  116. 4 private
  117. 8 sig { void }
  118. 4 def normalize_phone_number
  119. 1817 then: 0 else: 1817 return if phone.blank?
  120. # Remove all non-digit characters
  121. 1817 self.phone = phone.gsub(/\D/, "")
  122. end
  123. end

app/models/page.rb

100.0% lines covered

100.0% branches covered

14 relevant lines. 14 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. # == Schema Information
  4. #
  5. # Table name: pages
  6. #
  7. # content :text
  8. # is_snippet :boolean default(FALSE), not null
  9. # link_title :string
  10. # meta_description :text
  11. # meta_title :string
  12. # slug :string not null, primary key
  13. # created_at :datetime not null
  14. # updated_at :datetime not null
  15. #
  16. 4 class Page < ApplicationRecord
  17. 4 extend T::Sig
  18. 4 include FormConfigurable
  19. 4 include ValidationConfigurable
  20. 4 self.primary_key = "slug"
  21. 4 validates :slug, uniqueness: true
  22. 91 scope :pages, -> { where(is_snippet: false) }
  23. 1568 scope :snippets, -> { where(is_snippet: true) }
  24. 6 sig { returns(String) }
  25. 4 def to_param
  26. 21 slug.presence || "new"
  27. end
  28. # Returns content marked as safe for rendering
  29. # Page content is admin-controlled and contains intentional HTML
  30. 8 sig { returns(ActiveSupport::SafeBuffer) }
  31. 4 def safe_content
  32. 84 content.to_s.html_safe
  33. end
  34. end

app/models/unit.rb

89.58% lines covered

75.0% branches covered

96 relevant lines. 86 lines covered and 10 lines missed.
32 total branches, 24 branches covered and 8 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. # == Schema Information
  4. #
  5. # Table name: units
  6. #
  7. # id :string(8) not null, primary key
  8. # description :string
  9. # is_seed :boolean default(FALSE), not null
  10. # manufacture_date :date
  11. # manufacturer :string
  12. # name :string
  13. # operator :string
  14. # serial :string
  15. # unit_type :string default("bouncy_castle"), not null
  16. # created_at :datetime not null
  17. # updated_at :datetime not null
  18. # user_id :string(8) not null
  19. #
  20. # Indexes
  21. #
  22. # index_units_on_is_seed (is_seed)
  23. # index_units_on_manufacturer_and_serial (manufacturer,serial) UNIQUE
  24. # index_units_on_serial_and_user_id (serial,user_id) UNIQUE
  25. # index_units_on_unit_type (unit_type)
  26. # index_units_on_user_id (user_id)
  27. #
  28. # Foreign Keys
  29. #
  30. # user_id (user_id => users.id)
  31. #
  32. 4 class Unit < ApplicationRecord
  33. 4 extend T::Sig
  34. 4 self.table_name = "units"
  35. 4 include CustomIdGenerator
  36. 4 enum :unit_type, {
  37. bouncy_castle: "BOUNCY_CASTLE",
  38. bouncing_pillow: "BOUNCING_PILLOW"
  39. }
  40. 4 belongs_to :user
  41. 4 has_many :inspections
  42. 298 has_many :complete_inspections, -> { where.not(complete_date: nil) }, class_name: "Inspection"
  43. 108 has_many :draft_inspections, -> { where(complete_date: nil) }, class_name: "Inspection"
  44. # File attachments
  45. 4 has_one_attached :photo
  46. 4 has_one_attached :cached_pdf
  47. 4 validate :photo_must_be_image
  48. # Callbacks
  49. 4 before_create :generate_custom_id
  50. 4 after_update :invalidate_pdf_cache
  51. 4 before_destroy :check_complete_inspections
  52. 4 before_destroy :destroy_draft_inspections
  53. # All fields are required for Units
  54. 4 validates :name, :serial, :description, :manufacturer, :operator, presence: true
  55. 4 validates :serial, uniqueness: {scope: [:user_id]}
  56. # Scopes - enhanced from original Equipment and new Unit functionality
  57. 80 scope :seed_data, -> { where(is_seed: true) }
  58. 8 scope :non_seed_data, -> { where(is_seed: false) }
  59. 4 scope :search, ->(query) {
  60. 86 then: 9 if query.present?
  61. 9 search_term = "%#{query}%"
  62. 9 where(<<~SQL, *([search_term] * 5))
  63. serial LIKE ?
  64. OR name LIKE ?
  65. OR description LIKE ?
  66. OR manufacturer LIKE ?
  67. OR operator LIKE ?
  68. SQL
  69. else: 77 else
  70. 77 all
  71. end
  72. }
  73. 89 then: 10 else: 75 scope :by_manufacturer, ->(manufacturer) { where(manufacturer: manufacturer) if manufacturer.present? }
  74. 70 then: 7 else: 59 scope :by_operator, ->(operator) { where(operator: operator) if operator.present? }
  75. 4 scope :with_recent_inspections, -> {
  76. cutoff_date = EN14960::Constants::REINSPECTION_INTERVAL_DAYS.days.ago
  77. joins(:inspections)
  78. .where(inspections: {inspection_date: cutoff_date..})
  79. .distinct
  80. }
  81. 4 scope :inspection_due, -> {
  82. joins(:inspections)
  83. .merge(Inspection.completed)
  84. .group("units.id")
  85. .having("MAX(inspections.complete_date) + INTERVAL #{EN14960::Constants::REINSPECTION_INTERVAL_DAYS} DAY <= CURRENT_DATE")
  86. }
  87. # Instance methods
  88. 8 sig { returns(T.nilable(Inspection)) }
  89. 4 def last_inspection
  90. 568 @last_inspection ||= inspections.merge(Inspection.complete).order(complete_date: :desc).first
  91. end
  92. 4 sig { returns(String) }
  93. 4 def last_inspection_status
  94. then: 0 else: 0 then: 0 else: 0 last_inspection&.passed? ? "Passed" : "Failed"
  95. end
  96. 4 sig { returns(ActiveRecord::Relation) }
  97. 4 def inspection_history
  98. inspections.includes(:user).order(inspection_date: :desc)
  99. end
  100. 5 sig { returns(T.nilable(Date)) }
  101. 4 def next_inspection_due
  102. 17 else: 14 then: 3 return nil unless last_inspection
  103. 14 (last_inspection.inspection_date + EN14960::Constants::REINSPECTION_INTERVAL_DAYS.days).to_date
  104. end
  105. 5 sig { returns(T::Boolean) }
  106. 4 def inspection_overdue?
  107. 7 else: 6 then: 1 return false unless next_inspection_due
  108. 6 next_inspection_due < Date.current
  109. end
  110. 5 sig { returns(String) }
  111. 4 def compliance_status
  112. 5 else: 3 then: 2 return "Never Inspected" unless last_inspection
  113. 3 then: 1 if inspection_overdue?
  114. 1 else: 2 "Overdue"
  115. 2 then: 1 elsif last_inspection.passed?
  116. 1 "Compliant"
  117. else: 1 else
  118. 1 "Non-Compliant"
  119. end
  120. end
  121. 4 sig {
  122. 1 returns(T::Hash[Symbol, T.any(Integer, T.nilable(Date), T.nilable(String))])
  123. }
  124. 4 def inspection_summary
  125. {
  126. 1 total_inspections: inspections.count,
  127. passed_inspections: inspections.passed.count,
  128. failed_inspections: inspections.failed.count,
  129. then: 0 else: 1 last_inspection_date: last_inspection&.inspection_date,
  130. next_due_date: next_inspection_due,
  131. compliance_status: compliance_status
  132. }
  133. end
  134. 8 sig { returns(T::Boolean) }
  135. 4 def deletable?
  136. 112 !complete_inspections.exists?
  137. end
  138. 4 private
  139. 8 sig { void }
  140. 4 def check_complete_inspections
  141. 53 then: 1 else: 52 if complete_inspections.exists?
  142. 1 errors.add(:base, :has_complete_inspections)
  143. 1 throw(:abort)
  144. end
  145. end
  146. 8 sig { void }
  147. 4 def destroy_draft_inspections
  148. 52 draft_inspections.destroy_all
  149. end
  150. 4 public
  151. 7 sig { returns(ActiveRecord::Relation) }
  152. 4 def self.overdue
  153. # Find units where their most recent inspection is older than the interval
  154. # Using Date.current instead of Date.today for Rails timezone consistency
  155. 8 cutoff_date = Date.current - EN14960::Constants::REINSPECTION_INTERVAL_DAYS.days
  156. 8 joins(:inspections)
  157. .group("units.id")
  158. .having("MAX(inspections.inspection_date) <= ?", cutoff_date)
  159. end
  160. 4 private
  161. 4 sig { void }
  162. 4 def check_for_complete_inspections
  163. then: 0 else: 0 if complete_inspections.exists?
  164. errors.add(:base, :has_complete_inspections)
  165. throw(:abort)
  166. end
  167. end
  168. 8 sig { void }
  169. 4 def photo_must_be_image
  170. 1353 else: 30 then: 1323 return unless photo.attached?
  171. 30 else: 30 then: 0 unless photo.blob.content_type.start_with?("image/")
  172. errors.add(:photo, "must be an image file")
  173. photo.purge
  174. end
  175. end
  176. 8 sig { void }
  177. 4 def invalidate_pdf_cache
  178. # Skip cache invalidation if only updated_at changed
  179. 38 changed_attrs = saved_changes.keys
  180. 38 ignorable_attrs = ["updated_at"]
  181. 38 then: 29 else: 9 return if (changed_attrs - ignorable_attrs).empty?
  182. 9 PdfCacheService.invalidate_unit_cache(self)
  183. end
  184. end

app/models/user.rb

90.91% lines covered

59.38% branches covered

121 relevant lines. 110 lines covered and 11 lines missed.
32 total branches, 19 branches covered and 13 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 require "sorbet-runtime"
  4. # == Schema Information
  5. #
  6. # Table name: users
  7. #
  8. # id :string(8) not null, primary key
  9. # active_until :date
  10. # address :text
  11. # country :string
  12. # email :string
  13. # last_active_at :datetime
  14. # name :string
  15. # password_digest :string
  16. # phone :string
  17. # postal_code :string
  18. # rpii_inspector_number :string
  19. # rpii_verified_date :datetime
  20. # theme :string default("light")
  21. # created_at :datetime not null
  22. # updated_at :datetime not null
  23. # inspection_company_id :string
  24. # webauthn_id :string
  25. #
  26. # Indexes
  27. #
  28. # index_users_on_email (email) UNIQUE
  29. # index_users_on_inspection_company_id (inspection_company_id)
  30. # index_users_on_rpii_inspector_number (rpii_inspector_number) UNIQUE WHERE rpii_inspector_number IS NOT NULL
  31. #
  32. 4 class User < ApplicationRecord
  33. 4 extend T::Sig
  34. 4 include CustomIdGenerator
  35. # Type alias for RPII verification results
  36. 4 RpiiVerificationResult = T.type_alias do
  37. 1 T::Hash[Symbol, T.untyped]
  38. end
  39. 4 has_secure_password
  40. 4 has_many :inspections, dependent: :destroy
  41. 4 has_many :units, dependent: :destroy
  42. 4 has_many :events, dependent: :destroy
  43. 4 has_many :user_sessions, dependent: :destroy
  44. 4 has_many :credentials, dependent: :destroy
  45. 4 has_one_attached :logo
  46. 4 has_one_attached :signature
  47. 4 validate :logo_must_be_image
  48. 4 validate :signature_must_be_image
  49. 4 belongs_to :inspection_company,
  50. class_name: "InspectorCompany",
  51. optional: true
  52. 4 validates :email,
  53. presence: true,
  54. uniqueness: true,
  55. format: {with: URI::MailTo::EMAIL_REGEXP}
  56. 4 validates :password,
  57. presence: true,
  58. length: {minimum: 6},
  59. if: :password_digest_changed?
  60. 4 validates :name,
  61. presence: true,
  62. if: :validate_name?
  63. 4 validates :rpii_inspector_number,
  64. uniqueness: true,
  65. allow_nil: true
  66. 4 validates :theme,
  67. inclusion: {in: %w[default light dark]}
  68. 4 before_save :downcase_email
  69. 4 before_save :normalize_rpii_number
  70. 4 before_create :set_inactive_on_signup
  71. 4 after_initialize do
  72. 4818 self.webauthn_id ||= WebAuthn.generate_user_id
  73. end
  74. 8 sig { returns(T::Boolean) }
  75. 4 def is_active?
  76. 1914 active_until.nil? || active_until > Date.current
  77. end
  78. 5 sig { returns(T::Boolean) }
  79. 4 def can_delete_credentials?
  80. 1 credentials.count > 1
  81. end
  82. 4 alias_method :can_create_inspection?, :is_active?
  83. 7 sig { returns(String) }
  84. 4 def inactive_user_message
  85. 47 I18n.t("users.messages.user_inactive")
  86. end
  87. 8 sig { returns(T::Boolean) }
  88. 4 def admin?
  89. 1553 admin_pattern = ENV["ADMIN_EMAILS_PATTERN"]
  90. 1553 then: 0 else: 1553 return false if admin_pattern.blank?
  91. begin
  92. 1553 regex = Regexp.new(admin_pattern)
  93. 1553 regex.match?(email)
  94. rescue RegexpError
  95. false
  96. end
  97. end
  98. 4 sig do
  99. 4 params(
  100. value: T.nilable(T.any(Date, String, Time, ActiveSupport::TimeWithZone))
  101. ).void
  102. end
  103. 4 def active_until=(value)
  104. 1814 @active_until_explicitly_set = true
  105. 1814 super
  106. end
  107. 8 sig { returns(T::Boolean) }
  108. 4 def has_company?
  109. 26 inspection_company_id.present? || inspection_company.present?
  110. end
  111. 4 sig { returns(T.nilable(String)) }
  112. 4 def display_phone
  113. then: 0 else: 0 has_company? ? inspection_company.phone : phone
  114. end
  115. 4 sig { returns(T.nilable(String)) }
  116. 4 def display_address
  117. then: 0 else: 0 has_company? ? inspection_company.address : address
  118. end
  119. 4 sig { returns(T.nilable(String)) }
  120. 4 def display_country
  121. then: 0 else: 0 has_company? ? inspection_company.country : country
  122. end
  123. 4 sig { returns(T.nilable(String)) }
  124. 4 def display_postal_code
  125. then: 0 else: 0 has_company? ? inspection_company.postal_code : postal_code
  126. end
  127. 5 sig { returns(RpiiVerificationResult) }
  128. 4 def verify_rpii_inspector_number
  129. 4 then: 0 if rpii_inspector_number.blank?
  130. else: 4 return {valid: false, error: :blank_number}
  131. 4 then: 0 else: 4 elsif name.blank?
  132. return {valid: false, error: :blank_name}
  133. end
  134. 4 result = RpiiVerificationService.verify(rpii_inspector_number)
  135. 4 then: 3 if result[:valid]
  136. 3 handle_valid_rpii_result(result[:inspector])
  137. else: 1 else
  138. 1 update(rpii_verified_date: nil)
  139. 1 {valid: false, error: :not_found}
  140. end
  141. end
  142. 7 sig { returns(T::Boolean) }
  143. 4 def rpii_verified?
  144. 44 rpii_verified_date.present?
  145. end
  146. 5 sig { params(inspector: T::Hash[Symbol, T.untyped]).returns(RpiiVerificationResult) }
  147. 4 def handle_valid_rpii_result(inspector)
  148. 3 then: 2 if inspector[:name].present? && names_match?(name, inspector[:name])
  149. 2 update(rpii_verified_date: Time.current)
  150. 2 {valid: true, inspector: inspector}
  151. else: 1 else
  152. 1 update(rpii_verified_date: nil)
  153. 1 {valid: false, error: :name_mismatch, inspector: inspector}
  154. end
  155. end
  156. 8 sig { returns(T::Boolean) }
  157. 4 def has_seed_data?
  158. 68 units.seed_data.exists? || inspections.seed_data.exists?
  159. end
  160. 8 sig { returns(T::Boolean) }
  161. 4 def validate_name?
  162. 3314 new_record? || name_changed?
  163. end
  164. 5 sig { params(user_name: T.nilable(String), inspector_name: T.nilable(String)).returns(T::Boolean) }
  165. 4 def names_match?(user_name, inspector_name)
  166. 3 normalized_user = user_name.to_s.strip.downcase
  167. 3 normalized_inspector = inspector_name.to_s.strip.downcase
  168. 3 then: 1 else: 2 return true if normalized_user == normalized_inspector
  169. 2 user_parts = normalized_user.split(/\s+/)
  170. 2 inspector_parts = normalized_inspector.split(/\s+/)
  171. 5 user_parts.all? { |part| inspector_parts.include?(part) }
  172. end
  173. 8 sig { void }
  174. 4 def downcase_email
  175. 3284 self.email = email.downcase
  176. end
  177. 8 sig { void }
  178. 4 def normalize_rpii_number
  179. 3284 then: 184 else: 3100 self.rpii_inspector_number = nil if rpii_inspector_number.blank?
  180. end
  181. 8 sig { void }
  182. 4 def set_inactive_on_signup
  183. 1763 then: 1755 else: 8 return if instance_variable_get(:@active_until_explicitly_set)
  184. 8 self.active_until = Date.current - 1.day
  185. end
  186. 8 sig { void }
  187. 4 def logo_must_be_image
  188. 3314 else: 6 then: 3308 return unless logo.attached?
  189. 6 then: 6 else: 0 return if logo.blob.content_type.start_with?("image/")
  190. errors.add(:logo, "must be an image file")
  191. logo.purge
  192. end
  193. 8 sig { void }
  194. 4 def signature_must_be_image
  195. 3314 else: 2 then: 3312 return unless signature.attached?
  196. 2 then: 2 else: 0 return if signature.blob.content_type.start_with?("image/")
  197. errors.add(:signature, "must be an image file")
  198. signature.purge
  199. end
  200. end

app/models/user_session.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # == Schema Information
  2. #
  3. # Table name: user_sessions
  4. #
  5. # id :integer not null, primary key
  6. # ip_address :string
  7. # last_active_at :datetime not null
  8. # session_token :string not null
  9. # user_agent :string
  10. # created_at :datetime not null
  11. # updated_at :datetime not null
  12. # user_id :string(12) not null
  13. #
  14. # Indexes
  15. #
  16. # index_user_sessions_on_session_token (session_token) UNIQUE
  17. # index_user_sessions_on_user_id (user_id)
  18. # index_user_sessions_on_user_id_and_last_active_at (user_id,last_active_at)
  19. #
  20. # Foreign Keys
  21. #
  22. # user_id (user_id => users.id)
  23. #
  24. 4 class UserSession < ApplicationRecord
  25. 4 belongs_to :user
  26. 4 validates :session_token, presence: true, uniqueness: true
  27. 4 validates :last_active_at, presence: true
  28. 4 before_validation :generate_session_token, on: :create
  29. 30 scope :active, -> { where("last_active_at > ?", 30.days.ago) }
  30. 30 scope :recent, -> { order(last_active_at: :desc) }
  31. 4 def active? = last_active_at > 30.days.ago
  32. 4 def touch_last_active
  33. 1445 update_column(:last_active_at, Time.current)
  34. end
  35. 4 private
  36. 4 def generate_session_token
  37. 614 self.session_token ||= SecureRandom.urlsafe_base64(32)
  38. end
  39. end

app/serializers/base_assessment_blueprint.rb

75.0% lines covered

100.0% branches covered

4 relevant lines. 3 lines covered and 1 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class BaseAssessmentBlueprint < Blueprinter::Base
  4. # Define public fields from model columns excluding system fields
  5. 4 def self.public_fields_for(klass)
  6. klass.column_name_syms - PublicFieldFiltering::EXCLUDED_FIELDS
  7. end
  8. # Use transformer to format dates consistently
  9. 4 transform JsonDateTransformer
  10. end

app/serializers/inspection_blueprint.rb

97.37% lines covered

75.0% branches covered

38 relevant lines. 37 lines covered and 1 lines missed.
16 total branches, 12 branches covered and 4 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class InspectionBlueprint < Blueprinter::Base
  4. # Define public fields dynamically to avoid database access at load time
  5. 4 def self.define_public_fields
  6. 28 then: 25 else: 3 return if @fields_defined
  7. 3 Inspection.column_name_syms.each do |column|
  8. 66 then: 24 else: 42 next if PublicFieldFiltering::EXCLUDED_FIELDS.include?(column)
  9. 42 then: 6 if %i[inspection_date complete_date].include?(column)
  10. 6 field column do |inspection|
  11. 56 value = inspection.send(column)
  12. 56 then: 53 else: 3 value&.strftime(JsonDateTransformer::API_DATE_FORMAT)
  13. end
  14. else: 36 else
  15. 36 field column
  16. end
  17. end
  18. 3 @fields_defined = true
  19. end
  20. # Override render to ensure fields are defined
  21. 4 def self.render(object, options = {})
  22. 28 define_public_fields
  23. 28 super
  24. end
  25. 4 field :complete do |inspection|
  26. 28 inspection.complete?
  27. end
  28. 4 field :passed do |inspection|
  29. then: 0 else: 0 inspection.passed? if inspection.complete?
  30. end
  31. 4 field :inspector do |inspection|
  32. {
  33. 28 name: inspection.user.name,
  34. rpii_inspector_number: inspection.user.rpii_inspector_number
  35. }
  36. end
  37. 4 field :urls do |inspection|
  38. 28 base_url = ENV["BASE_URL"]
  39. {
  40. 28 report_pdf: "#{base_url}/inspections/#{inspection.id}.pdf",
  41. report_json: "#{base_url}/inspections/#{inspection.id}.json",
  42. qr_code: "#{base_url}/inspections/#{inspection.id}.png"
  43. }
  44. end
  45. 4 field :unit do |inspection|
  46. 28 then: 28 else: 0 if inspection.unit
  47. {
  48. 28 id: inspection.unit.id,
  49. name: inspection.unit.name,
  50. serial: inspection.unit.serial,
  51. manufacturer: inspection.unit.manufacturer,
  52. operator: inspection.unit.operator
  53. }
  54. end
  55. end
  56. 4 field :assessments do |inspection|
  57. 28 assessments = {}
  58. 28 inspection.each_applicable_assessment do |key, klass, assessment|
  59. 184 else: 184 then: 0 next unless assessment
  60. 184 assessment_data = {}
  61. public_fields =
  62. 184 klass.column_name_syms -
  63. PublicFieldFiltering::EXCLUDED_FIELDS
  64. 184 public_fields.each do |field|
  65. 3130 value = assessment.send(field)
  66. 3130 else: 554 then: 2576 assessment_data[field] = value unless value.nil?
  67. end
  68. 184 assessments[key] = assessment_data
  69. end
  70. 28 assessments
  71. end
  72. # Use transformer to format dates
  73. 4 transform JsonDateTransformer
  74. end

app/serializers/json_date_transformer.rb

92.31% lines covered

85.71% branches covered

13 relevant lines. 12 lines covered and 1 lines missed.
7 total branches, 6 branches covered and 1 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class JsonDateTransformer < Blueprinter::Transformer
  4. # ISO 8601 date format for JSON API responses
  5. 4 API_DATE_FORMAT = "%Y-%m-%d"
  6. 4 def transform(hash, _object, _options)
  7. 52 transform_value(hash)
  8. end
  9. 4 def transform_value(value)
  10. 4242 case value
  11. when: 428 when Hash
  12. 4585 value.transform_values { |v| transform_value(v) }
  13. when: 7 when Array
  14. 17 value.map { |v| transform_value(v) }
  15. when: 17 when Date, Time, DateTime
  16. 17 value.strftime(API_DATE_FORMAT)
  17. when String
  18. when: 2116 # Handle string timestamps from ActiveRecord
  19. 2116 then: 0 if /\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.match?(value)
  20. value.split(" ").first # Extract just the date part
  21. else: 2116 else
  22. 2116 value
  23. end
  24. else: 1674 else
  25. 1674 value
  26. end
  27. end
  28. end

app/serializers/unit_blueprint.rb

100.0% lines covered

75.0% branches covered

30 relevant lines. 30 lines covered and 0 lines missed.
16 total branches, 12 branches covered and 4 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class UnitBlueprint < Blueprinter::Base
  4. # Define public fields dynamically to avoid database access at load time
  5. 4 def self.define_public_fields
  6. 24 then: 21 else: 3 return if @fields_defined
  7. 3 Unit.column_name_syms.each do |column|
  8. 36 then: 15 else: 21 next if PublicFieldFiltering::EXCLUDED_FIELDS.include?(column)
  9. 21 then: 3 if %i[manufacture_date].include?(column)
  10. 3 field column do |unit|
  11. 24 value = unit.send(column)
  12. 24 then: 24 else: 0 value&.strftime(JsonDateTransformer::API_DATE_FORMAT)
  13. end
  14. else: 18 else
  15. 18 field column
  16. end
  17. end
  18. 3 @fields_defined = true
  19. end
  20. # Override render to ensure fields are defined
  21. 4 def self.render(object, options = {})
  22. 24 define_public_fields
  23. 24 super
  24. end
  25. # Add URLs (available in all views)
  26. 4 field :urls do |unit|
  27. 24 base_url = ENV["BASE_URL"]
  28. {
  29. 24 report_pdf: "#{base_url}/units/#{unit.id}.pdf",
  30. report_json: "#{base_url}/units/#{unit.id}.json",
  31. qr_code: "#{base_url}/units/#{unit.id}.png"
  32. }
  33. end
  34. # Override render to handle inspection fields conditionally
  35. 4 def self.render_with_inspections(unit)
  36. 23 json = JSON.parse(render(unit, view: :default), symbolize_names: true)
  37. 23 completed = unit.inspections.complete.order(inspection_date: :desc)
  38. 23 then: 7 else: 16 if completed.any?
  39. 7 json[:inspection_history] = completed.map do |inspection|
  40. {
  41. 10 inspection_date: inspection.inspection_date,
  42. passed: inspection.passed,
  43. complete: inspection.complete?,
  44. then: 10 else: 0 inspector_company: inspection.inspector_company&.name
  45. }
  46. end
  47. 7 json[:total_inspections] = completed.count
  48. 7 then: 7 else: 0 json[:last_inspection_date] = completed.first&.inspection_date
  49. 7 then: 7 else: 0 json[:last_inspection_passed] = completed.first&.passed
  50. end
  51. # Apply date transformation
  52. 23 transformer = JsonDateTransformer.new
  53. 23 json = transformer.transform_value(json)
  54. 23 json.to_json
  55. end
  56. # Use transformer to format dates
  57. 4 transform JsonDateTransformer
  58. end

app/services/concerns/s3_backup_operations.rb

100.0% lines covered

90.0% branches covered

40 relevant lines. 40 lines covered and 0 lines missed.
10 total branches, 9 branches covered and 1 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 module S3BackupOperations
  4. 4 extend ActiveSupport::Concern
  5. 4 extend T::Sig
  6. 4 extend T::Helpers
  7. 4 requires_ancestor { Kernel }
  8. 4 private
  9. 5 sig { returns(String) }
  10. 4 def backup_dir = "db_backups"
  11. 5 sig { returns(Integer) }
  12. 4 def backup_retention_days = 60
  13. 5 sig { returns(Pathname) }
  14. 4 def temp_dir = Rails.root.join("tmp/backups")
  15. 5 sig { returns(T.any(String, Pathname)) }
  16. 4 def database_path
  17. 2 db_config = Rails.configuration.database_configuration[Rails.env]
  18. # Handle multi-database configuration
  19. 2 then: 1 else: 1 db_config = db_config["primary"] if db_config.is_a?(Hash) && db_config.key?("primary")
  20. 2 else: 1 then: 1 raise "Database not configured for #{Rails.env}" unless db_config["database"]
  21. 1 path = db_config["database"]
  22. 1 then: 0 else: 1 path.start_with?("/") ? path : Rails.root.join(path)
  23. end
  24. 5 sig { params(timestamp: String).returns(Pathname) }
  25. 4 def create_tar_gz(timestamp)
  26. 1 backup_filename = "database-#{timestamp}.sqlite3"
  27. 1 compressed_filename = "database-#{timestamp}.tar.gz"
  28. 1 source_path = temp_dir.join(backup_filename)
  29. 1 dest_path = temp_dir.join(compressed_filename)
  30. 1 dir_name = File.dirname(source_path)
  31. 1 base_name = File.basename(source_path)
  32. 1 system("tar", "-czf", dest_path.to_s, "-C", dir_name.to_s,
  33. base_name.to_s, exception: true)
  34. 1 dest_path
  35. end
  36. 5 sig { params(service: T.untyped).returns(Integer) }
  37. 4 def cleanup_old_backups(service)
  38. 1 bucket = service.send(:bucket)
  39. 1 cutoff_date = Time.current - backup_retention_days.days
  40. 1 deleted_count = 0
  41. 1 bucket.objects(prefix: "#{backup_dir}/").each do |object|
  42. 3 else: 2 then: 1 next unless object.key.match?(/database-\d{4}-\d{2}-\d{2}\.tar\.gz$/)
  43. 2 then: 1 else: 1 if object.last_modified < cutoff_date
  44. 1 service.delete(object.key)
  45. 1 deleted_count += 1
  46. end
  47. end
  48. 1 deleted_count
  49. end
  50. end

app/services/concerns/s3_helpers.rb

60.0% lines covered

0.0% branches covered

10 relevant lines. 6 lines covered and 4 lines missed.
4 total branches, 0 branches covered and 4 branches missed.
    
  1. # frozen_string_literal: true
  2. 4 module S3Helpers
  3. 4 extend ActiveSupport::Concern
  4. 4 private
  5. 4 def ensure_s3_enabled
  6. else: 0 then: 0 raise "S3 storage is not enabled" unless ENV["USE_S3_STORAGE"] == "true"
  7. end
  8. 4 def validate_s3_config
  9. required_vars = %w[S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY S3_BUCKET]
  10. missing_vars = required_vars.select { |var| ENV[var].blank? }
  11. then: 0 else: 0 raise "Missing S3 config: #{missing_vars.join(", ")}" if missing_vars.any?
  12. end
  13. 4 def get_s3_service = ActiveStorage::Blob.service
  14. end

app/services/image_processor_service.rb

100.0% lines covered

84.62% branches covered

26 relevant lines. 26 lines covered and 0 lines missed.
13 total branches, 11 branches covered and 2 branches missed.
    
  1. 4 class ImageProcessorService
  2. 4 FULL_SIZE = 1200
  3. 4 THUMBNAIL_SIZE = 200
  4. 4 DEFAULT_SIZE = 800
  5. 4 def self.thumbnail(image)
  6. 3 then: 3 else: 0 else: 2 then: 1 return nil unless image&.attached?
  7. 2 image.variant(
  8. format: :jpeg,
  9. resize_to_limit: [THUMBNAIL_SIZE, THUMBNAIL_SIZE],
  10. saver: {quality: 75}
  11. )
  12. end
  13. 4 def self.default(image)
  14. 4 then: 4 else: 0 else: 3 then: 1 return nil unless image&.attached?
  15. 3 image.variant(
  16. format: :jpeg,
  17. resize_to_limit: [DEFAULT_SIZE, DEFAULT_SIZE],
  18. saver: {quality: 75}
  19. )
  20. end
  21. # Calculate actual dimensions after resize_to_limit transformation
  22. # Pass in metadata hash with "width" and "height" keys
  23. # Size can be :full, :thumbnail, or :default (defaults to :default)
  24. 4 def self.calculate_dimensions(metadata, size = :default)
  25. 5 max_size = max_size_for(size)
  26. 5 original_width = metadata["width"].to_f
  27. 5 original_height = metadata["height"].to_f
  28. 5 resize_dimensions(original_width, original_height, max_size)
  29. end
  30. 4 def self.max_size_for(size)
  31. 5 when: 1 case size
  32. 1 when: 2 when :full then FULL_SIZE
  33. 2 else: 2 when :thumbnail then THUMBNAIL_SIZE
  34. 2 else DEFAULT_SIZE
  35. end
  36. end
  37. 4 def self.resize_dimensions(original_width, original_height, max_size)
  38. 5 ratio = max_size / [original_width, original_height].max
  39. 5 then: 4 if ratio < 1
  40. {
  41. 8 width: (original_width * ratio).round,
  42. 4 height: (original_height * ratio).round
  43. }
  44. else: 1 else
  45. 1 {width: original_width.to_i, height: original_height.to_i}
  46. end
  47. end
  48. end

app/services/inspection_creation_service.rb

100.0% lines covered

93.75% branches covered

50 relevant lines. 50 lines covered and 0 lines missed.
16 total branches, 15 branches covered and 1 branches missed.
    
  1. # typed: strict
  2. 4 require "sorbet-runtime"
  3. 4 class InspectionCreationService
  4. 4 extend T::Sig
  5. # Define custom type for service results
  6. 4 ServiceResult = T.type_alias do
  7. 4 T::Hash[Symbol, T.untyped]
  8. end
  9. 8 sig { params(user: User, params: T::Hash[Symbol, T.untyped]).void }
  10. 4 def initialize(user, params = {})
  11. 20 @user = user
  12. 20 @unit_id = params[:unit_id]
  13. end
  14. 8 sig { returns(ServiceResult) }
  15. 4 def create
  16. 20 then: 17 else: 3 unit = find_and_validate_unit if @unit_id.present?
  17. 20 then: 1 else: 19 return invalid_unit_result if @unit_id.present? && unit.nil?
  18. 19 inspection = build_inspection(unit)
  19. 19 then: 18 if inspection.save
  20. 18 notify_if_production(inspection)
  21. 18 success_result(inspection, unit)
  22. else: 1 else
  23. 1 failure_result(inspection)
  24. end
  25. end
  26. 4 private
  27. 8 sig { returns(T.nilable(Unit)) }
  28. 4 def find_and_validate_unit
  29. 17 @user.units.find_by(id: @unit_id)
  30. end
  31. 4 COPY_FROM_LAST_INSPECTION_FIELDS = T.let(
  32. %i[
  33. has_slide
  34. is_totally_enclosed
  35. length
  36. width
  37. height
  38. ].freeze,
  39. T::Array[Symbol]
  40. )
  41. 8 sig { params(unit: T.nilable(Unit)).returns(Inspection) }
  42. 4 def build_inspection(unit)
  43. 19 then: 16 else: 3 last_inspection = unit&.last_inspection
  44. 19 copy_fields = {}
  45. 19 then: 5 else: 14 if last_inspection
  46. 5 copy_fields = COPY_FROM_LAST_INSPECTION_FIELDS.map do |field|
  47. 25 [field, last_inspection.send(field)]
  48. end.to_h
  49. end
  50. 19 @user.inspections.build(
  51. unit: unit,
  52. inspection_date: Date.current,
  53. inspector_company_id: @user.inspection_company_id,
  54. **copy_fields
  55. )
  56. end
  57. 8 sig { params(inspection: Inspection).void }
  58. 4 def notify_if_production(inspection)
  59. 18 else: 1 then: 17 return unless Rails.env.production?
  60. 1 NtfyService.notify("new inspection by #{@user.email}")
  61. end
  62. 5 sig { returns(ServiceResult) }
  63. 4 def invalid_unit_result
  64. 1 {
  65. success: false,
  66. error_type: :invalid_unit,
  67. message: I18n.t("inspections.errors.invalid_unit"),
  68. redirect_path: "/"
  69. }
  70. end
  71. 8 sig { params(inspection: Inspection, unit: T.nilable(Unit)).returns(ServiceResult) }
  72. 4 def success_result(inspection, unit)
  73. 18 then: 2 else: 16 message_key = unit.nil? ? "created_without_unit" : "created"
  74. 18 {
  75. success: true,
  76. inspection: inspection,
  77. message: I18n.t("inspections.messages.#{message_key}"),
  78. redirect_path: "/inspections/#{inspection.id}/edit"
  79. }
  80. end
  81. 5 sig { params(inspection: Inspection).returns(ServiceResult) }
  82. 4 def failure_result(inspection)
  83. 1 error_messages = inspection.errors.full_messages.join(", ")
  84. 1 redirect_path = build_failure_redirect_path(inspection)
  85. 1 {
  86. success: false,
  87. error_type: :validation_failed,
  88. message: I18n.t("inspections.errors.creation_failed",
  89. errors: error_messages),
  90. redirect_path: redirect_path
  91. }
  92. end
  93. 5 sig { params(inspection: Inspection).returns(String) }
  94. 4 def build_failure_redirect_path(inspection)
  95. 1 then: 0 else: 1 inspection.unit.present? ? "/units/#{inspection.unit.id}" : "/"
  96. end
  97. end

app/services/inspection_csv_export_service.rb

100.0% lines covered

88.0% branches covered

38 relevant lines. 38 lines covered and 0 lines missed.
25 total branches, 22 branches covered and 3 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 require "sorbet-runtime"
  4. 4 require "csv"
  5. 4 class InspectionCsvExportService
  6. 4 extend T::Sig
  7. 4 sig do
  8. 2 params(
  9. inspections: T.any(
  10. ActiveRecord::Relation,
  11. T::Array[Inspection]
  12. )
  13. ).void
  14. end
  15. 4 def initialize(inspections)
  16. 9 @inspections = inspections
  17. end
  18. 6 sig { returns(String) }
  19. 4 def generate
  20. 8 CSV.generate(headers: true) do |csv|
  21. 8 csv << headers
  22. 8 @inspections.each do |inspection|
  23. 9 csv << row_data(inspection)
  24. end
  25. end
  26. end
  27. 4 private
  28. 6 sig { returns(T::Array[String]) }
  29. 4 def headers
  30. 17 excluded_columns = %i[user_id inspector_company_id unit_id]
  31. 17 inspection_columns = Inspection.column_name_syms - excluded_columns
  32. 17 headers = inspection_columns
  33. 17 headers += %i[unit_name unit_serial unit_manufacturer unit_operator unit_description]
  34. 17 headers += %i[inspector_company_name]
  35. 17 headers += %i[inspector_user_email]
  36. 17 headers += %i[complete]
  37. 17 headers
  38. end
  39. 6 sig { params(inspection: Inspection).returns(T::Array[T.untyped]) }
  40. 4 def row_data(inspection)
  41. 9 headers.map do |header|
  42. 243 in: 9 case header
  43. 9 in: 9 then: 8 else: 1 in :unit_name then inspection.unit&.name
  44. 9 in: 9 then: 8 else: 1 in :unit_serial then inspection.unit&.serial
  45. 9 in: 9 then: 8 else: 1 in :unit_manufacturer then inspection.unit&.manufacturer
  46. 9 in: 9 then: 8 else: 1 in :unit_operator then inspection.unit&.operator
  47. 9 in: 9 then: 8 else: 1 in :unit_description then inspection.unit&.description
  48. 9 in: 9 then: 9 else: 0 in :inspector_company_name then inspection.inspector_company&.name
  49. 9 in: 9 then: 9 else: 0 in :inspector_user_email then inspection.user&.email
  50. 9 else: 171 in :complete then inspection.complete?
  51. 171 then: 171 else: 0 else inspection.send(header) if inspection.respond_to?(header)
  52. end
  53. end
  54. end
  55. end

app/services/json_serializer_service.rb

54.55% lines covered

40.0% branches covered

22 relevant lines. 12 lines covered and 10 lines missed.
10 total branches, 4 branches covered and 6 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. # Wrapper service for backward compatibility with tests
  4. # Now delegates to Blueprinter serializers
  5. 4 class JsonSerializerService
  6. 4 def self.format_value(value)
  7. case value
  8. when: 0 when Date, Time, DateTime
  9. value.strftime(JsonDateTransformer::API_DATE_FORMAT)
  10. else: 0 else
  11. value
  12. end
  13. end
  14. 4 def self.serialize_unit(unit, include_inspections: true)
  15. 8 else: 8 then: 0 return nil unless unit
  16. 8 then: 7 json_str = if include_inspections
  17. 7 UnitBlueprint.render_with_inspections(unit)
  18. else: 1 else
  19. 1 UnitBlueprint.render(unit, view: :default)
  20. end
  21. 8 JSON.parse(json_str, symbolize_names: true)
  22. end
  23. 4 def self.serialize_inspection(inspection)
  24. 10 else: 10 then: 0 return nil unless inspection
  25. 10 JSON.parse(InspectionBlueprint.render(inspection), symbolize_names: true)
  26. end
  27. 4 def self.serialize_assessment(assessment, klass)
  28. excluded = PublicFieldFiltering::EXCLUDED_FIELDS
  29. assessment_fields = klass.column_name_syms - excluded
  30. data = {}
  31. assessment_fields.each do |field|
  32. value = assessment.send(field)
  33. else: 0 then: 0 data[field.to_sym] = format_value(value) unless value.nil?
  34. end
  35. data
  36. end
  37. end

app/services/ntfy_service.rb

93.1% lines covered

66.67% branches covered

29 relevant lines. 27 lines covered and 2 lines missed.
6 total branches, 4 branches covered and 2 branches missed.
    
  1. # typed: false
  2. 4 require "net/http"
  3. 4 class NtfyService
  4. 4 class << self
  5. 4 def notify(message, channel: :developer)
  6. 8 Thread.new do
  7. 8 send_notifications(message, channel)
  8. rescue => e
  9. Rails.logger.error("NtfyService error: #{e.message}")
  10. ensure
  11. 8 ActiveRecord::Base.connection_pool.release_connection
  12. end
  13. end
  14. 4 private
  15. 4 def send_notifications(message, channel)
  16. 8 channels = determine_channels(channel)
  17. 14 channels.each { |ch| send_to_channel(message, ch) }
  18. end
  19. 4 def determine_channels(channel)
  20. 8 case channel
  21. when: 6 when :developer
  22. 6 [ENV["NTFY_CHANNEL_DEVELOPER"]].compact
  23. when: 1 when :admin
  24. 1 [ENV["NTFY_CHANNEL_ADMIN"]].compact
  25. when: 1 when :both
  26. 1 [ENV["NTFY_CHANNEL_DEVELOPER"], ENV["NTFY_CHANNEL_ADMIN"]].compact
  27. else: 0 else
  28. []
  29. end
  30. end
  31. 4 def send_to_channel(message, channel_url)
  32. 6 then: 0 else: 6 return if channel_url.blank?
  33. 6 uri = URI.parse("https://ntfy.sh/#{channel_url}")
  34. 6 http = Net::HTTP.new(uri.host, uri.port)
  35. 6 http.use_ssl = true
  36. 6 request = Net::HTTP::Post.new(uri.path)
  37. 6 request["Title"] = "play-test notification"
  38. 6 request["Priority"] = "high"
  39. 6 request["Tags"] = "warning"
  40. 6 request.body = message
  41. 6 http.request(request)
  42. end
  43. end
  44. end

app/services/pdf_cache_service.rb

100.0% lines covered

84.85% branches covered

102 relevant lines. 102 lines covered and 0 lines missed.
33 total branches, 28 branches covered and 5 branches missed.
    
  1. # typed: strict
  2. 4 require "sorbet-runtime"
  3. 4 class PdfCacheService
  4. 4 extend T::Sig
  5. 4 CacheResult = Struct.new(:type, :data, keyword_init: true)
  6. # type: :redirect or :pdf_data
  7. # data: URL string for redirect, or PDF binary data
  8. 4 class << self
  9. 4 extend T::Sig
  10. 4 sig do
  11. 3 params(inspection: Inspection, options: T.untyped)
  12. .returns(CacheResult)
  13. end
  14. 4 def fetch_or_generate_inspection_pdf(inspection, **options)
  15. # Never cache incomplete inspections
  16. 38 else: 6 then: 32 unless caching_enabled? && inspection.complete?
  17. 32 return generate_pdf_result(inspection, :inspection, **options)
  18. end
  19. 6 fetch_or_generate(inspection, :inspection, **options)
  20. end
  21. 7 sig { params(unit: Unit, options: T.untyped).returns(CacheResult) }
  22. 4 def fetch_or_generate_unit_pdf(unit, **options)
  23. 18 else: 2 then: 16 return generate_pdf_result(unit, :unit, **options) unless caching_enabled?
  24. 2 fetch_or_generate(unit, :unit, **options)
  25. end
  26. 8 sig { params(inspection: Inspection).void }
  27. 4 def invalidate_inspection_cache(inspection)
  28. 68 invalidate_cache(inspection)
  29. end
  30. 8 sig { params(unit: Unit).void }
  31. 4 def invalidate_unit_cache(unit)
  32. 1690 invalidate_cache(unit)
  33. end
  34. 4 private
  35. 4 sig do
  36. 1 params(
  37. record: T.any(Inspection, Unit),
  38. type: Symbol,
  39. options: T.untyped
  40. ).returns(CacheResult)
  41. end
  42. 4 def fetch_or_generate(record, type, **options)
  43. 8 valid_cache = record.cached_pdf.attached? &&
  44. cached_pdf_valid?(record.cached_pdf, record)
  45. 8 then: 2 if valid_cache
  46. 2 Rails.logger.info "PDF cache hit for #{type} #{record.id}"
  47. 2 then: 1 if redirect_to_s3?
  48. 1 url = generate_signed_url(record.cached_pdf)
  49. 1 CacheResult.new(type: :redirect, data: url)
  50. else: 1 else
  51. 1 CacheResult.new(type: :stream, data: record.cached_pdf)
  52. end
  53. else: 6 else
  54. 6 Rails.logger.info "PDF cache miss for #{type} #{record.id}"
  55. 6 generate_and_cache(record, type, **options)
  56. end
  57. end
  58. 4 sig do
  59. 1 params(
  60. record: T.any(Inspection, Unit),
  61. type: Symbol,
  62. options: T.untyped
  63. ).returns(CacheResult)
  64. end
  65. 4 def generate_and_cache(record, type, **options)
  66. 6 result = generate_pdf_result(record, type, **options)
  67. 6 store_cached_pdf(record, result.data)
  68. 6 result
  69. end
  70. 4 sig do
  71. 3 params(
  72. record: T.any(Inspection, Unit),
  73. type: Symbol,
  74. options: T.untyped
  75. ).returns(CacheResult)
  76. end
  77. 4 def generate_pdf_result(record, type, **options)
  78. 54 else: 0 pdf_document = case type
  79. when: 36 when :inspection
  80. 36 PdfGeneratorService.generate_inspection_report(record, **options)
  81. when: 18 when :unit
  82. 18 PdfGeneratorService.generate_unit_report(record, **options)
  83. end
  84. 54 CacheResult.new(type: :pdf_data, data: pdf_document.render)
  85. end
  86. 8 sig { params(record: T.any(Inspection, Unit)).void }
  87. 4 def invalidate_cache(record)
  88. 1758 else: 13 then: 1744 return unless caching_enabled?
  89. 13 then: 2 else: 11 record.cached_pdf.purge if record.cached_pdf.attached?
  90. end
  91. 8 sig { returns(T::Boolean) }
  92. 4 def caching_enabled?
  93. 1814 pdf_cache_from_date.present?
  94. end
  95. 5 sig { params(attachment: T.untyped).returns(String) }
  96. 4 def generate_signed_url(attachment)
  97. # Generate a signed URL that expires in 1 hour
  98. # The URL includes a timestamp in the signed parameters
  99. 1 attachment.blob.url(expires_in: 1.hour, disposition: "inline")
  100. end
  101. 8 sig { returns(T.nilable(Date)) }
  102. 4 def pdf_cache_from_date
  103. 1786 @pdf_cache_from_date ||= begin
  104. 1786 date_string = ENV["PDF_CACHE_FROM"]
  105. 1786 then: 1785 else: 1 return nil if date_string.blank?
  106. 1 Date.parse(date_string)
  107. rescue ArgumentError
  108. 1 error_msg = "Invalid PDF_CACHE_FROM date format: #{date_string}. "
  109. 1 error_msg += "Expected format: YYYY-MM-DD"
  110. 1 raise ArgumentError, error_msg
  111. end
  112. end
  113. 4 sig do
  114. 1 params(
  115. attachment: T.untyped,
  116. record: T.any(Inspection, Unit)
  117. ).returns(T::Boolean)
  118. end
  119. 4 def cached_pdf_valid?(attachment, record)
  120. 6 then: 6 else: 0 else: 6 then: 0 return false unless attachment.blob&.created_at
  121. 6 cache_created_at = attachment.blob.created_at
  122. 6 cache_threshold = pdf_cache_from_date.beginning_of_day
  123. # Check if cache is newer than the threshold date
  124. 6 else: 5 then: 1 return false unless cache_created_at > cache_threshold
  125. # Check if user assets were updated after cache
  126. 5 !user_assets_updated_after?(record.user, cache_created_at)
  127. end
  128. 4 sig do
  129. 1 params(
  130. user: T.nilable(User),
  131. cache_created_at: T.any(ActiveSupport::TimeWithZone, Date, Time)
  132. ).returns(T::Boolean)
  133. end
  134. 4 def user_assets_updated_after?(user, cache_created_at)
  135. 5 else: 5 then: 0 return false unless user
  136. 5 then: 1 else: 4 if attachment_updated_after?(user.signature, cache_created_at)
  137. 1 Rails.logger.info "User signature updated after PDF cache"
  138. 1 return true
  139. end
  140. 4 then: 2 else: 2 if attachment_updated_after?(user.logo, cache_created_at)
  141. 2 Rails.logger.info "User logo updated after PDF cache"
  142. 2 return true
  143. end
  144. 2 false
  145. end
  146. 4 sig do
  147. 1 params(
  148. attachment: T.untyped,
  149. reference_time: T.any(ActiveSupport::TimeWithZone, Date, Time)
  150. ).returns(T::Boolean)
  151. end
  152. 4 def attachment_updated_after?(attachment, reference_time)
  153. 9 then: 9 else: 0 attachment&.attached? &&
  154. attachment.blob.created_at > reference_time
  155. end
  156. 4 sig do
  157. 1 params(
  158. record: T.any(Inspection, Unit),
  159. pdf_data: String
  160. ).void
  161. end
  162. 4 def store_cached_pdf(record, pdf_data)
  163. # Purge old cached PDF if exists
  164. 2 then: 1 else: 1 record.cached_pdf.purge if record.cached_pdf.attached?
  165. # Store new cached PDF
  166. 2 type_name = record.class.name.downcase
  167. 2 filename = "#{type_name}_#{record.id}_cached_#{Time.current.to_i}.pdf"
  168. # Create a StringIO with proper positioning
  169. 2 io = StringIO.new(pdf_data)
  170. 2 io.rewind
  171. 2 record.cached_pdf.attach(
  172. io: io,
  173. filename: filename,
  174. content_type: "application/pdf"
  175. )
  176. end
  177. 5 sig { returns(T::Boolean) }
  178. 4 def redirect_to_s3?
  179. 2 ActiveModel::Type::Boolean.new.cast(ENV["REDIRECT_TO_S3_PDFS"])
  180. end
  181. end
  182. end

app/services/pdf_generator_service.rb

90.22% lines covered

60.0% branches covered

92 relevant lines. 83 lines covered and 9 lines missed.
30 total branches, 18 branches covered and 12 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class PdfGeneratorService
  4. 4 include Configuration
  5. 4 def self.generate_inspection_report(inspection, debug_enabled: false, debug_queries: [])
  6. 41 require "prawn/table"
  7. 41 Prawn::Document.new(page_size: "A4", page_layout: :portrait) do |pdf|
  8. 41 Configuration.setup_pdf_fonts(pdf)
  9. # Initialize array to collect all assessment blocks
  10. 41 assessment_blocks = []
  11. # Header section
  12. 41 HeaderGenerator.generate_inspection_pdf_header(pdf, inspection)
  13. # Unit details section
  14. 41 generate_inspection_unit_details(pdf, inspection)
  15. # Risk assessment section (if present)
  16. 41 generate_risk_assessment_section(pdf, inspection)
  17. # Generate all assessment sections in the correct UI order from applicable_tabs
  18. 41 generate_assessments_in_ui_order(inspection, assessment_blocks)
  19. # Render footer and photo first to measure actual space used
  20. 41 cursor_before_footer = pdf.cursor
  21. # Disclaimer footer (only on first page)
  22. 41 DisclaimerFooterRenderer.render_disclaimer_footer(pdf, inspection.user)
  23. 41 disclaimer_height = DisclaimerFooterRenderer.measure_footer_height(pdf)
  24. # Add unit photo in bottom right corner
  25. 41 photo_height = ImageProcessor.measure_unit_photo_height(pdf, inspection.unit, 4)
  26. 41 then: 40 else: 1 then: 40 else: 1 ImageProcessor.add_unit_photo_footer(pdf, inspection.unit, 4) if inspection.unit&.photo
  27. # Reset cursor to render assessments with proper space accounting
  28. 41 pdf.move_cursor_to(cursor_before_footer)
  29. # Render all collected assessments in newspaper-style columns
  30. 41 render_assessment_blocks_in_columns(pdf, assessment_blocks, disclaimer_height, photo_height)
  31. # Add DRAFT watermark overlay for draft inspections (except in test env)
  32. 41 then: 0 else: 41 Utilities.add_draft_watermark(pdf) if !inspection.complete? && !Rails.env.test?
  33. # Add photos page if photos are attached
  34. 41 PhotosRenderer.generate_photos_page(pdf, inspection)
  35. # Add debug info page if enabled (admins only)
  36. 41 then: 0 else: 41 DebugInfoRenderer.add_debug_info_page(pdf, debug_queries) if debug_enabled && debug_queries.present?
  37. end
  38. end
  39. 4 def self.generate_unit_report(unit, debug_enabled: false, debug_queries: [])
  40. 25 require "prawn/table"
  41. # Preload all inspections once to avoid N+1 queries
  42. 25 completed_inspections = unit.inspections
  43. .includes(:user, inspector_company: {logo_attachment: :blob})
  44. .complete
  45. .order(inspection_date: :desc)
  46. 25 last_inspection = completed_inspections.first
  47. 25 Prawn::Document.new(page_size: "A4", page_layout: :portrait) do |pdf|
  48. 25 Configuration.setup_pdf_fonts(pdf)
  49. 25 HeaderGenerator.generate_unit_pdf_header(pdf, unit)
  50. 25 generate_unit_details_with_inspection(pdf, unit, last_inspection)
  51. 25 generate_unit_inspection_history_with_data(pdf, unit, completed_inspections)
  52. # Disclaimer footer (only on first page)
  53. 25 DisclaimerFooterRenderer.render_disclaimer_footer(pdf, unit.user)
  54. # Add unit photo in bottom right corner (for unit PDFs, always use 3 columns)
  55. 25 then: 25 else: 0 ImageProcessor.add_unit_photo_footer(pdf, unit, 3) if unit.photo
  56. # Add debug info page if enabled (admins only)
  57. 25 then: 0 else: 25 DebugInfoRenderer.add_debug_info_page(pdf, debug_queries) if debug_enabled && debug_queries.present?
  58. end
  59. end
  60. 4 def self.generate_inspection_unit_details(pdf, inspection)
  61. 41 unit = inspection.unit
  62. 41 else: 40 then: 1 return unless unit
  63. 40 unit_data = TableBuilder.build_unit_details_table_with_inspection(unit, inspection, :inspection)
  64. 40 TableBuilder.create_unit_details_table(pdf, I18n.t("pdf.inspection.equipment_details"), unit_data)
  65. # Hide the table entirely when no unit is associated
  66. end
  67. 4 def self.generate_unit_details(pdf, unit)
  68. unit_data = TableBuilder.build_unit_details_table(unit, :unit)
  69. TableBuilder.create_unit_details_table(pdf, I18n.t("pdf.unit.details"), unit_data)
  70. end
  71. 4 def self.generate_unit_details_with_inspection(pdf, unit, _last_inspection)
  72. 25 unit_data = TableBuilder.build_unit_details_table(unit, :unit)
  73. 25 TableBuilder.create_unit_details_table(pdf, I18n.t("pdf.unit.details"), unit_data)
  74. end
  75. 4 def self.generate_unit_inspection_history(pdf, unit)
  76. # Check for completed inspections - preload associations to avoid N+1 queries
  77. # Since all inspections belong to the same unit, we don't need to reload the unit
  78. completed_inspections = unit.inspections
  79. .includes(:user, inspector_company: {logo_attachment: :blob})
  80. .complete
  81. .order(inspection_date: :desc)
  82. then: 0 if completed_inspections.empty?
  83. TableBuilder.create_nice_box_table(pdf, I18n.t("pdf.unit.inspection_history"),
  84. [[I18n.t("pdf.unit.no_completed_inspections"), ""]])
  85. else: 0 else
  86. TableBuilder.create_inspection_history_table(pdf, I18n.t("pdf.unit.inspection_history"), completed_inspections)
  87. end
  88. end
  89. 4 def self.generate_unit_inspection_history_with_data(pdf, _unit, completed_inspections)
  90. 25 then: 23 if completed_inspections.empty?
  91. 23 TableBuilder.create_nice_box_table(pdf, I18n.t("pdf.unit.inspection_history"),
  92. [[I18n.t("pdf.unit.no_completed_inspections"), ""]])
  93. else: 2 else
  94. 2 TableBuilder.create_inspection_history_table(pdf, I18n.t("pdf.unit.inspection_history"), completed_inspections)
  95. end
  96. end
  97. 4 def self.generate_risk_assessment_section(pdf, inspection)
  98. 41 then: 0 else: 41 return if inspection.risk_assessment.blank?
  99. 41 pdf.text I18n.t("pdf.inspection.risk_assessment"), size: HEADER_TEXT_SIZE, style: :bold
  100. 41 pdf.stroke_horizontal_rule
  101. 41 pdf.move_down 10
  102. # Create a text box constrained to 4 lines with shrink_to_fit
  103. 41 line_height = 10 * 1.2 # Normal font size * line height multiplier
  104. 41 max_height = line_height * 4 # 4 lines max
  105. 41 pdf.text_box inspection.risk_assessment,
  106. at: [0, pdf.cursor],
  107. width: pdf.bounds.width,
  108. height: max_height,
  109. size: 10,
  110. overflow: :shrink_to_fit,
  111. min_font_size: 5
  112. 41 pdf.move_down max_height + 15
  113. end
  114. 4 def self.generate_assessments_in_ui_order(inspection, assessment_blocks)
  115. # Get the UI order from applicable_tabs (excluding non-assessment tabs)
  116. 41 ui_ordered_tabs = inspection.applicable_tabs - %w[inspection results]
  117. 41 ui_ordered_tabs.each do |tab_name|
  118. 271 assessment_key = :"#{tab_name}_assessment"
  119. 271 else: 271 then: 0 next unless inspection.assessment_applicable?(assessment_key)
  120. 271 assessment = inspection.send(assessment_key)
  121. 271 else: 271 then: 0 next unless assessment
  122. # Build blocks for this assessment and add to the main array
  123. 271 blocks = AssessmentBlockBuilder.build_from_assessment(tab_name, assessment)
  124. 271 assessment_blocks.concat(blocks)
  125. end
  126. end
  127. 4 def self.render_assessment_blocks_in_columns(pdf, assessment_blocks, disclaimer_height, photo_height)
  128. 41 then: 0 else: 41 return if assessment_blocks.empty?
  129. 41 pdf.text I18n.t("pdf.inspection.assessments_section"), size: 12, style: :bold
  130. 41 pdf.stroke_horizontal_rule
  131. 41 pdf.move_down 15
  132. # Calculate available height accounting for disclaimer footer only
  133. 41 then: 41 available_height = if pdf.page_number == 1
  134. 41 pdf.cursor - disclaimer_height
  135. else: 0 else
  136. pdf.cursor
  137. end
  138. # Check if we have enough space for at least some content
  139. 41 min_content_height = 100 # Minimum height for meaningful content
  140. 41 then: 0 else: 41 if available_height < min_content_height
  141. pdf.start_new_page
  142. available_height = pdf.cursor
  143. 0 # No footer on new pages
  144. end
  145. # Render assessments using the column layout with measured footer space
  146. 41 renderer = AssessmentColumns.new(assessment_blocks, available_height, photo_height)
  147. 41 renderer.render(pdf)
  148. 41 pdf.move_down 20
  149. end
  150. # Helper methods for backward compatibility and testing
  151. 4 def self.truncate_text(text, max_length)
  152. 3 Utilities.truncate_text(text, max_length)
  153. end
  154. 4 def self.format_pass_fail(value)
  155. 3 Utilities.format_pass_fail(value)
  156. end
  157. 4 def self.format_measurement(value, unit = "")
  158. 3 Utilities.format_measurement(value, unit)
  159. end
  160. end

app/services/pdf_generator_service/assessment_block.rb

100.0% lines covered

100.0% branches covered

15 relevant lines. 15 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 class PdfGeneratorService
  2. 4 class AssessmentBlock
  3. 4 attr_reader :type, :pass_fail, :name, :value, :comment
  4. 4 def initialize(type:, pass_fail: nil, name: nil, value: nil, comment: nil)
  5. 3745 @type = type
  6. 3745 @pass_fail = pass_fail
  7. 3745 @name = name
  8. 3745 @value = value
  9. 3745 @comment = comment
  10. end
  11. 4 def header?
  12. 21803 type == :header
  13. end
  14. 4 def value?
  15. 107 type == :value
  16. end
  17. 4 def comment?
  18. 92 type == :comment
  19. end
  20. end
  21. end

app/services/pdf_generator_service/assessment_block_builder.rb

100.0% lines covered

85.11% branches covered

93 relevant lines. 93 lines covered and 0 lines missed.
47 total branches, 40 branches covered and 7 branches missed.
    
  1. # typed: false
  2. 4 class PdfGeneratorService
  3. 4 class AssessmentBlockBuilder
  4. 4 include Configuration
  5. 4 def self.build_from_assessment(assessment_type, assessment)
  6. 291 new(assessment_type, assessment).build
  7. end
  8. 4 def initialize(assessment_type, assessment)
  9. 313 @assessment_type = assessment_type
  10. 313 @assessment = assessment
  11. 313 @not_applicable_fields = get_not_applicable_fields
  12. end
  13. 4 def build
  14. 295 blocks = []
  15. # Add header block
  16. 295 blocks << AssessmentBlock.new(
  17. type: :header,
  18. name: I18n.t("forms.#{@assessment_type}.header")
  19. )
  20. # Process fields
  21. 295 ordered_fields = get_form_config_fields
  22. 295 field_groups = group_assessment_fields(ordered_fields)
  23. 295 field_groups.each do |base, fields|
  24. # Skip if this is a not-applicable field with value 0
  25. 2511 main_field = fields[:base] || fields[:pass]
  26. 2511 then: 3 else: 2508 if main_field && field_is_not_applicable?(main_field)
  27. 3 next
  28. end
  29. # Add value block
  30. 2508 then: 2465 if main_field
  31. 2465 value = @assessment.send(main_field)
  32. 2465 label = get_field_label(fields)
  33. 2465 pass_value = determine_pass_value(fields, main_field, value)
  34. 2465 is_pass_field = main_field.to_s.end_with?("_pass")
  35. # For boolean fields that aren't pass/fail fields
  36. 2465 is_bool_non_pass = [true, false].include?(value) &&
  37. !is_pass_field && pass_value.nil?
  38. 2465 then: 24 blocks << if is_bool_non_pass
  39. 24 AssessmentBlock.new(
  40. type: :value,
  41. name: label,
  42. 24 then: 0 else: 24 value: value ? I18n.t("shared.yes") : I18n.t("shared.no")
  43. )
  44. else: 2441 else
  45. 2441 AssessmentBlock.new(
  46. type: :value,
  47. pass_fail: pass_value,
  48. name: label,
  49. 2441 then: 1280 else: 1161 value: is_pass_field ? nil : value
  50. )
  51. else: 43 end
  52. 43 else: 0 elsif fields[:comment]
  53. then: 43 # Handle standalone comment fields (no base or pass field)
  54. 43 label = get_field_label(fields)
  55. 43 comment = @assessment.send(fields[:comment])
  56. 43 else: 19 if comment.present?
  57. then: 24 # Add a label block for the standalone comment
  58. 24 blocks << AssessmentBlock.new(
  59. type: :value,
  60. name: label,
  61. value: nil
  62. )
  63. end
  64. end
  65. # Add comment block if present
  66. 2508 then: 2213 else: 295 if fields[:comment]
  67. 2213 comment = @assessment.send(fields[:comment])
  68. 2213 then: 954 else: 1259 if comment.present?
  69. 954 blocks << AssessmentBlock.new(
  70. type: :comment,
  71. comment: comment
  72. )
  73. end
  74. end
  75. end
  76. 295 blocks
  77. end
  78. 4 private
  79. 4 def get_form_config_fields
  80. 298 else: 298 then: 0 return [] unless @assessment.class.respond_to?(:form_fields)
  81. 298 form_config = @assessment.class.form_fields
  82. 298 ordered_fields = []
  83. 298 form_config.each do |section|
  84. 609 section[:fields].each do |field_config|
  85. 2554 field_name = field_config[:field]
  86. 2554 partial_name = field_config[:partial]
  87. # Get composite fields first to check if any exist
  88. 2554 composite_fields = ChobbleForms::FieldUtils.get_composite_fields(field_name, partial_name)
  89. # Skip if neither the base field nor any composite fields exist
  90. 2554 has_base = @assessment.respond_to?(field_name)
  91. 4755 has_composites = composite_fields.any? { |cf| @assessment.respond_to?(cf) }
  92. 2554 else: 2554 then: 0 next unless has_base || has_composites
  93. # Add base field if it exists
  94. 2554 then: 1263 else: 1291 ordered_fields << field_name if has_base
  95. # Add composite fields that exist
  96. 2554 composite_fields.each do |composite_field|
  97. 3917 then: 3917 else: 0 ordered_fields << composite_field if @assessment.respond_to?(composite_field)
  98. end
  99. end
  100. end
  101. 298 ordered_fields
  102. end
  103. 4 def group_assessment_fields(field_keys)
  104. 299 field_keys.each_with_object({}) do |field, groups|
  105. 5123 field_str = field.to_s
  106. 5123 else: 5120 then: 3 next unless @assessment.respond_to?(field_str)
  107. 5120 base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
  108. 5120 groups[base_field] ||= {}
  109. 5120 when: 1708 field_type = case field_str
  110. 1708 when: 2221 when /pass$/ then :pass
  111. 2221 else: 1191 when /comment$/ then :comment
  112. 1191 else :base
  113. end
  114. 5120 groups[base_field][field_type] = field
  115. end
  116. end
  117. 4 def get_field_label(fields)
  118. # Try in order: base field, pass field, comment field
  119. 2512 then: 1186 if fields[:base]
  120. 1186 else: 1326 field_label(fields[:base])
  121. 1326 elsif fields[:pass]
  122. then: 1281 # For pass fields, use the base field name for the label
  123. 1281 base_name = ChobbleForms::FieldUtils.strip_field_suffix(fields[:pass])
  124. 1281 else: 45 field_label(base_name)
  125. 45 elsif fields[:comment]
  126. then: 44 # For standalone comment fields, use the base field name
  127. 44 base_name = ChobbleForms::FieldUtils.strip_field_suffix(fields[:comment])
  128. 44 field_label(base_name)
  129. else: 1 else
  130. 1 raise "No valid fields found: #{fields}"
  131. end
  132. end
  133. 4 def field_label(field_name)
  134. 2511 I18n.t!("forms.#{@assessment_type}.fields.#{field_name}")
  135. end
  136. 4 def determine_pass_value(fields, main_field, value)
  137. 2468 then: 1704 else: 764 return @assessment.send(fields[:pass]) if fields[:pass]
  138. 764 then: 0 else: 764 return value if main_field.to_s.end_with?("_pass")
  139. 764 nil
  140. end
  141. 4 def get_not_applicable_fields
  142. 314 else: 314 then: 0 return [] unless @assessment.class.respond_to?(:form_fields)
  143. 314 @assessment.class.form_fields
  144. 640 .flat_map { |section| section[:fields] }
  145. 2727 then: 1135 else: 1592 .select { |field| field[:attributes]&.dig(:add_not_applicable) }
  146. 53 .map { |field| field[:field].to_sym }
  147. end
  148. 4 def field_is_not_applicable?(field)
  149. 2471 else: 48 then: 2423 return false unless @not_applicable_fields.include?(field)
  150. 48 value = @assessment.send(field)
  151. # Field is not applicable if it has add_not_applicable and value is 0
  152. 48 value.present? && value.to_i == 0
  153. end
  154. end
  155. end

app/services/pdf_generator_service/assessment_block_renderer.rb

94.12% lines covered

73.91% branches covered

51 relevant lines. 48 lines covered and 3 lines missed.
23 total branches, 17 branches covered and 6 branches missed.
    
  1. 4 class PdfGeneratorService
  2. 4 class AssessmentBlockRenderer
  3. 4 include Configuration
  4. 4 ASSESSMENT_MARGIN_AFTER_TITLE = 3
  5. 4 ASSESSMENT_TITLE_SIZE = 9
  6. 4 ASSESSMENT_FIELD_TEXT_SIZE = 7
  7. # Calculate column width (1/4 of page width minus spacing)
  8. 4 PAGE_WIDTH = 595.28 - (2 * 36) # A4 width minus margins
  9. 4 TOTAL_SPACER_WIDTH = Configuration::ASSESSMENT_COLUMN_SPACER * 3
  10. 4 COLUMN_WIDTH = (PAGE_WIDTH - TOTAL_SPACER_WIDTH) / 4.0
  11. 4 def initialize(font_size: ASSESSMENT_FIELD_TEXT_SIZE)
  12. 198 @font_size = font_size
  13. end
  14. 4 def render_fragments(block)
  15. 21772 case block.type
  16. when: 1564 when :header
  17. 1564 render_header_fragments(block)
  18. when: 13066 when :value
  19. 13066 render_value_fragments(block)
  20. when: 7142 when :comment
  21. 7142 render_comment_fragments(block)
  22. else: 0 else
  23. raise ArgumentError, "Unknown block type: #{block.type}"
  24. end
  25. end
  26. 4 def font_size_for(block)
  27. 21772 then: 1564 else: 20208 block.header? ? ASSESSMENT_TITLE_SIZE : @font_size
  28. end
  29. 4 def height_for(block, pdf)
  30. 18313 fragments = render_fragments(block)
  31. 18313 then: 0 else: 18313 return 0 if fragments.empty?
  32. 18313 font_size = font_size_for(block)
  33. # Convert fragments to formatted text array
  34. 18313 formatted_text = fragments.map do |fragment|
  35. 28240 styles = []
  36. 28240 then: 17804 else: 10436 styles << :bold if fragment[:bold]
  37. 28240 then: 6205 else: 22035 styles << :italic if fragment[:italic]
  38. {
  39. 28240 text: fragment[:text],
  40. styles: styles,
  41. color: fragment[:color]
  42. }
  43. end
  44. # Use height_of_formatted to get the actual height with wrapping
  45. 18313 base_height = pdf.height_of_formatted(
  46. formatted_text,
  47. width: COLUMN_WIDTH,
  48. size: font_size
  49. )
  50. # Add 33% of font size as spacing
  51. 18313 spacing = (font_size * 0.33).round(1)
  52. 18313 base_height + spacing
  53. end
  54. 4 private
  55. 4 def render_header_fragments(block)
  56. 1564 text = block.name || block.value
  57. 1564 [{text: text, bold: true, color: "000000"}]
  58. end
  59. 4 def render_value_fragments(block)
  60. 13066 fragments = []
  61. # Add pass/fail indicator if present
  62. 13066 then: 6577 else: 6489 if !block.pass_fail.nil?
  63. 6577 when: 6577 indicator, color = case block.pass_fail
  64. 6577 when: 0 when true, "pass" then [I18n.t("shared.pass_pdf"), Configuration::PASS_COLOR]
  65. else: 0 when false, "fail" then [I18n.t("shared.fail_pdf"), Configuration::FAIL_COLOR]
  66. else [I18n.t("shared.na_pdf"), Configuration::NA_COLOR]
  67. end
  68. 6577 fragments << {text: "#{indicator} ", bold: true, color: color}
  69. end
  70. # Add field name
  71. 13066 then: 13066 else: 0 if block.name
  72. 13066 fragments << {text: block.name, bold: true, color: "000000"}
  73. end
  74. # Add value if present and not a pass/fail field
  75. 13066 then: 4887 else: 8179 if block.value && !block.name.to_s.end_with?("_pass")
  76. 4887 fragments << {text: ": #{block.value}", bold: false, color: "000000"}
  77. end
  78. 13066 fragments
  79. end
  80. 4 def render_comment_fragments(block)
  81. 7142 then: 0 else: 7142 return [] if block.comment.blank?
  82. 7142 [{text: block.comment, bold: false, color: Configuration::HEADER_COLOR, italic: true}]
  83. end
  84. end
  85. end

app/services/pdf_generator_service/assessment_columns.rb

95.24% lines covered

92.31% branches covered

84 relevant lines. 80 lines covered and 4 lines missed.
13 total branches, 12 branches covered and 1 branches missed.
    
  1. 4 class PdfGeneratorService
  2. 4 class AssessmentColumns
  3. # Include configuration for column spacing and font sizes
  4. 4 include Configuration
  5. 4 attr_reader :assessment_blocks, :assessment_results_height, :photo_height
  6. 4 def initialize(assessment_blocks, assessment_results_height, photo_height)
  7. 41 @assessment_blocks = assessment_blocks
  8. 41 @assessment_results_height = assessment_results_height
  9. 41 @photo_height = photo_height
  10. end
  11. 4 def render(pdf)
  12. # Try progressively smaller font sizes
  13. 41 font_size = Configuration::ASSESSMENT_FIELD_TEXT_SIZE_PREFERRED
  14. 41 min_font_size = Configuration::MIN_ASSESSMENT_FONT_SIZE
  15. 41 body: 154 while font_size >= min_font_size
  16. 154 then: 41 else: 113 if content_fits_with_font_size?(pdf, font_size)
  17. 41 render_with_font_size(pdf, font_size)
  18. 41 return true
  19. end
  20. 113 font_size -= 1
  21. end
  22. # If we still can't fit, render with minimum font size anyway
  23. render_with_font_size(pdf, min_font_size)
  24. false
  25. end
  26. 4 private
  27. 4 def content_fits_with_font_size?(pdf, font_size)
  28. # Calculate total content height
  29. 154 total_height = calculate_total_content_height(font_size, pdf)
  30. # Calculate column capacity
  31. 154 columns = calculate_column_boxes(pdf)
  32. 770 total_capacity = columns.sum { |col| col[:height] }
  33. 154 total_height <= total_capacity
  34. end
  35. 4 def calculate_total_content_height(font_size, pdf)
  36. 154 renderer = AssessmentBlockRenderer.new(font_size: font_size)
  37. 154 total_height = 0
  38. 154 @assessment_blocks.each do |block|
  39. # Add height for this block using actual PDF document
  40. 14847 total_height += renderer.height_for(block, pdf)
  41. end
  42. 154 total_height
  43. end
  44. 4 def render_with_font_size(pdf, font_size)
  45. # Calculate column dimensions
  46. 41 columns = calculate_column_boxes(pdf)
  47. # Save the starting position
  48. 41 start_y = pdf.cursor
  49. # Track content placement across columns
  50. 41 content_blocks = prepare_content_blocks(pdf, font_size)
  51. 41 place_content_in_columns(pdf, content_blocks, columns, start_y, font_size)
  52. # Move cursor to end of assessment area
  53. 41 pdf.move_cursor_to(start_y - assessment_results_height)
  54. end
  55. 4 def prepare_content_blocks(pdf, font_size)
  56. 41 blocks = []
  57. 41 renderer = AssessmentBlockRenderer.new(font_size: font_size)
  58. 41 @assessment_blocks.each do |block|
  59. # Get rendered fragments and height for this block
  60. 3459 fragments = renderer.render_fragments(block)
  61. 3459 height = renderer.height_for(block, pdf)
  62. 3459 blocks << {
  63. type: block.type,
  64. fragments: fragments,
  65. height: height,
  66. font_size: renderer.font_size_for(block)
  67. }
  68. end
  69. 41 blocks
  70. end
  71. 4 def place_content_in_columns(pdf, content_blocks, columns, start_y, font_size)
  72. 41 current_column = 0
  73. 41 column_y = start_y
  74. 41 content_blocks.each do |content|
  75. # Check if we need to move to next column
  76. 3399 then: 3399 if current_column < columns.size
  77. 3399 available = column_y - (start_y - columns[current_column][:height])
  78. 3399 else: 3277 if available < content[:height]
  79. then: 122 # Move to next column
  80. 122 current_column += 1
  81. 122 column_y = start_y
  82. # Stop if we run out of columns
  83. 122 then: 3 else: 119 break if current_column >= columns.size
  84. end
  85. else: 0 else
  86. break
  87. end
  88. # Render content in current column
  89. 3396 column = columns[current_column]
  90. 3396 render_content_at_position(pdf, content, column, column_y, font_size)
  91. # Update position
  92. 3396 column_y -= content[:height]
  93. end
  94. end
  95. 4 def render_content_at_position(pdf, content, column, y_pos, font_size)
  96. # Save original state
  97. 3396 original_y = pdf.cursor
  98. 3396 original_fill_color = pdf.fill_color
  99. # Calculate actual x position
  100. 3396 actual_x = column[:x]
  101. # Use the font size from the content block if available
  102. 3396 text_size = content[:font_size] || font_size
  103. # Convert fragments to formatted text array for proper wrapping
  104. 3396 formatted_text = content[:fragments].map do |fragment|
  105. 4894 styles = []
  106. 4894 then: 3340 else: 1554 styles << :bold if fragment[:bold]
  107. 4894 then: 913 else: 3981 styles << :italic if fragment[:italic]
  108. {
  109. 4894 text: fragment[:text],
  110. styles: styles,
  111. color: fragment[:color]
  112. }
  113. end
  114. # Render as single formatted text box for proper wrapping
  115. 3396 pdf.formatted_text_box(
  116. formatted_text,
  117. at: [actual_x, y_pos],
  118. width: column[:width],
  119. size: text_size,
  120. overflow: :truncate
  121. )
  122. # Restore original state
  123. 3396 pdf.fill_color original_fill_color
  124. 3396 pdf.move_cursor_to original_y
  125. end
  126. 4 def calculate_column_boxes(pdf)
  127. 195 total_spacer_width = Configuration::ASSESSMENT_COLUMN_SPACER * 3
  128. 195 column_width = (pdf.bounds.width - total_spacer_width) / 4.0
  129. 195 columns = []
  130. # First three columns - full height
  131. 195 3.times do |i|
  132. 585 x = i * (column_width + Configuration::ASSESSMENT_COLUMN_SPACER)
  133. 585 columns << {
  134. x: x,
  135. y: pdf.cursor,
  136. width: column_width,
  137. height: assessment_results_height
  138. }
  139. end
  140. # Fourth column - reduced by photo height
  141. 195 fourth_column_height = [assessment_results_height - photo_height - 5, 0].max # 5pt buffer
  142. 195 columns << {
  143. 195 x: 3 * (column_width + Configuration::ASSESSMENT_COLUMN_SPACER),
  144. y: pdf.cursor,
  145. width: column_width,
  146. height: fourth_column_height
  147. }
  148. 195 columns
  149. end
  150. 4 def calculate_line_height(font_size)
  151. font_size * 1.2
  152. end
  153. end
  154. end

app/services/pdf_generator_service/configuration.rb

100.0% lines covered

100.0% branches covered

66 relevant lines. 66 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class PdfGeneratorService
  4. 4 module Configuration
  5. # Unit table constants
  6. 4 UNIT_NAME_MAX_LENGTH = 30
  7. 4 UNIT_TABLE_CELL_PADDING = [6, 4].freeze
  8. 4 UNIT_TABLE_TEXT_SIZE = 9
  9. # General text and spacing constants
  10. 4 HEADER_TEXT_SIZE = 12
  11. 4 HEADER_SPACING = 8
  12. 4 STATUS_TEXT_SIZE = 14
  13. 4 STATUS_SPACING = 15
  14. 4 SECTION_TITLE_SIZE = 14
  15. 4 COMMENTS_PADDING = 20
  16. # Header table constants
  17. 4 LOGO_HEIGHT = 50
  18. 4 HEADER_TABLE_PADDING = [5, 0].freeze
  19. 4 LOGO_COLUMN_WIDTH_RATIO = 1.0 / 3.0
  20. # Table constants
  21. 4 TABLE_CELL_PADDING = [5, 10].freeze
  22. 4 TABLE_FIRST_COLUMN_WIDTH = 150
  23. 4 NICE_TABLE_CELL_PADDING = [4, 8].freeze
  24. 4 NICE_TABLE_TEXT_SIZE = 10
  25. # Inspection history table styling
  26. 4 HISTORY_TABLE_TEXT_SIZE = 8
  27. 4 HISTORY_TABLE_HEADER_COLOR = "F5F5F5"
  28. 4 HISTORY_TABLE_ROW_COLOR = "FAFAFA"
  29. 4 HISTORY_TABLE_ALT_ROW_COLOR = "F0F0F0"
  30. 4 PASS_COLOR = "008000" # Green
  31. 4 FAIL_COLOR = "CC0000" # Red
  32. 4 NA_COLOR = "4169E1" # Royal Blue
  33. 4 HEADER_COLOR = "663399" # Purple
  34. 4 SUBTITLE_COLOR = "666666" # Gray
  35. # Inspection history table column widths
  36. 4 HISTORY_DATE_COLUMN_WIDTH = 90 # Date column (DD/MM/YYYY) - slightly wider
  37. 4 HISTORY_RESULT_COLUMN_WIDTH = 45 # Result column (PASS/FAIL) - narrower
  38. 4 HISTORY_INSPECTOR_WIDTH_PERCENT = 0.5 # 50% of remaining space
  39. 4 HISTORY_LOCATION_WIDTH_PERCENT = 0.5 # 50% of remaining space
  40. # Assessment layout constants
  41. 4 ASSESSMENT_COLUMNS_COUNT = 4
  42. 4 ASSESSMENT_COLUMN_SPACER = 10
  43. 4 ASSESSMENT_TITLE_SIZE = 9
  44. 4 ASSESSMENT_FIELD_TEXT_SIZE = 7
  45. 4 ASSESSMENT_FIELD_TEXT_SIZE_PREFERRED = 10
  46. 4 MIN_ASSESSMENT_FONT_SIZE = 3
  47. 4 ASSESSMENT_BLOCK_SPACING = 8
  48. # QR code size is 3 lines of header text (12pt * 1.5 line height * 3 lines)
  49. 4 QR_CODE_SIZE = (HEADER_TEXT_SIZE * 1.5 * 3).round
  50. 4 QR_CODE_MARGIN = 0 # No margin - align with page edge
  51. 4 QR_CODE_BOTTOM_OFFSET = HEADER_SPACING # Match header spacing from top
  52. # Unit photo constants
  53. 4 UNIT_PHOTO_X_OFFSET = 130
  54. 4 UNIT_PHOTO_WIDTH = 120
  55. 4 UNIT_PHOTO_HEIGHT = 90
  56. # Watermark constants
  57. 4 WATERMARK_TRANSPARENCY = 0.4
  58. 4 WATERMARK_TEXT_SIZE = 24
  59. 4 WATERMARK_WIDTH = 100
  60. 4 WATERMARK_HEIGHT = 60
  61. # Photos page constants
  62. 4 PHOTO_MAX_HEIGHT_PERCENT = 0.25 # 25% of page height
  63. 4 PHOTO_SPACING = 20 # Space between photos
  64. 4 PHOTO_LABEL_SIZE = 10 # Size of "Photo 1", "Photo 2", "Photo 3" text
  65. 4 PHOTO_LABEL_SPACING = 5 # Space between photo and label
  66. # Disclaimer footer constants
  67. 4 DISCLAIMER_HEADER_SIZE = HEADER_TEXT_SIZE # Match existing header style
  68. 4 DISCLAIMER_TEXT_SIZE = 10
  69. 4 DISCLAIMER_TEXT_LINES = 4 # Height in lines of text for disclaimer
  70. 4 TEXT_LINE_HEIGHT = DISCLAIMER_TEXT_SIZE * 1.5 # Standard line height multiplier
  71. 4 DISCLAIMER_TEXT_HEIGHT = DISCLAIMER_TEXT_LINES * TEXT_LINE_HEIGHT # Total disclaimer text height
  72. 4 FOOTER_INTERNAL_PADDING = 10 # Padding between elements within footer
  73. 4 FOOTER_VERTICAL_PADDING = 15 # Bottom padding for footer
  74. 4 FOOTER_TOP_PADDING = 30 # Top padding for footer (about 2 lines)
  75. FOOTER_HEIGHT =
  76. 4 FOOTER_TOP_PADDING +
  77. DISCLAIMER_HEADER_SIZE +
  78. FOOTER_INTERNAL_PADDING +
  79. DISCLAIMER_TEXT_HEIGHT +
  80. FOOTER_VERTICAL_PADDING # Total footer height
  81. 4 DISCLAIMER_TEXT_WIDTH_PERCENT = 0.75 # Disclaimer text takes 75% of width
  82. 4 def self.setup_pdf_fonts(pdf)
  83. 66 font_path = Rails.root.join("app/assets/fonts")
  84. 66 pdf.font_families.update(
  85. "NotoSans" => {
  86. normal: "#{font_path}/NotoSans-Regular.ttf",
  87. bold: "#{font_path}/NotoSans-Bold.ttf",
  88. italic: "#{font_path}/NotoSans-Regular.ttf",
  89. bold_italic: "#{font_path}/NotoSans-Bold.ttf"
  90. },
  91. "NotoEmoji" => {
  92. normal: "#{font_path}/NotoEmoji-Regular.ttf"
  93. }
  94. )
  95. 66 pdf.font "NotoSans"
  96. end
  97. end
  98. end

app/services/pdf_generator_service/debug_info_renderer.rb

14.81% lines covered

0.0% branches covered

27 relevant lines. 4 lines covered and 23 lines missed.
4 total branches, 0 branches covered and 4 branches missed.
    
  1. 4 class PdfGeneratorService
  2. 4 class DebugInfoRenderer
  3. 4 include Configuration
  4. 4 def self.add_debug_info_page(pdf, queries)
  5. then: 0 else: 0 return if queries.blank?
  6. # Start a new page for debug info
  7. pdf.start_new_page
  8. # Header
  9. pdf.text I18n.t("debug.title"), size: HEADER_TEXT_SIZE, style: :bold
  10. pdf.stroke_horizontal_rule
  11. pdf.move_down 10
  12. # Summary info
  13. pdf.text "#{I18n.t("debug.query_count")}: #{queries.size}", size: NICE_TABLE_TEXT_SIZE
  14. pdf.move_down 10
  15. # Build table data
  16. table_data = [
  17. [I18n.t("debug.query"), I18n.t("debug.duration"), I18n.t("debug.name")]
  18. ]
  19. queries.each do |query|
  20. table_data << [
  21. query[:sql],
  22. "#{query[:duration]} ms",
  23. query[:name] || ""
  24. ]
  25. end
  26. # Create the table
  27. pdf.table(table_data, width: pdf.bounds.width) do |t|
  28. # Header row styling
  29. t.row(0).background_color = "333333"
  30. t.row(0).text_color = "FFFFFF"
  31. t.row(0).font_style = :bold
  32. # General styling
  33. t.cells.borders = [:bottom]
  34. t.cells.border_color = "DDDDDD"
  35. t.cells.padding = TABLE_CELL_PADDING
  36. t.cells.size = 8
  37. # Column widths
  38. t.columns(0).width = pdf.bounds.width * 0.6 # Query column gets most space
  39. t.columns(1).width = pdf.bounds.width * 0.2 # Duration
  40. t.columns(2).width = pdf.bounds.width * 0.2 # Name
  41. # Alternating row colors
  42. (1..table_data.length - 1).each do |i|
  43. then: 0 else: 0 t.row(i).background_color = i.odd? ? "FFFFFF" : "F5F5F5"
  44. end
  45. end
  46. end
  47. end
  48. end

app/services/pdf_generator_service/disclaimer_footer_renderer.rb

100.0% lines covered

84.62% branches covered

44 relevant lines. 44 lines covered and 0 lines missed.
26 total branches, 22 branches covered and 4 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class PdfGeneratorService
  4. 4 class DisclaimerFooterRenderer
  5. 4 include Configuration
  6. 4 def self.render_disclaimer_footer(pdf, user)
  7. 70 else: 69 then: 1 return unless should_render_footer?(pdf)
  8. # Save current position
  9. 69 original_y = pdf.cursor
  10. # Move to footer position
  11. 69 footer_y = FOOTER_HEIGHT
  12. 69 pdf.move_cursor_to footer_y
  13. # Create bounding box for footer
  14. 69 bounding_box_width = pdf.bounds.width
  15. 69 bounding_box_at = [0, pdf.cursor]
  16. 69 pdf.bounding_box(bounding_box_at,
  17. width: bounding_box_width,
  18. height: FOOTER_HEIGHT) do
  19. # Add top padding
  20. 69 pdf.move_down FOOTER_TOP_PADDING
  21. 69 render_footer_content(pdf, user)
  22. end
  23. # Restore position
  24. 69 pdf.move_cursor_to original_y
  25. end
  26. 4 def self.measure_footer_height(pdf)
  27. 45 else: 43 then: 2 return 0 unless should_render_footer?(pdf)
  28. 43 FOOTER_HEIGHT
  29. end
  30. 4 def self.should_render_footer?(pdf)
  31. # Only render on first page
  32. 111 pdf.page_number == 1
  33. end
  34. 4 def self.render_footer_content(pdf, user)
  35. # Render disclaimer header
  36. 72 render_disclaimer_header(pdf)
  37. 72 pdf.move_down FOOTER_INTERNAL_PADDING
  38. # Check what content we have
  39. 72 then: 72 else: 0 then: 72 else: 0 has_signature = user&.signature&.attached?
  40. 72 then: 2 else: 0 then: 2 else: 0 has_user_logo = ENV["PDF_LOGO"].present? && user&.logo&.attached?
  41. 72 pdf.bounds.width
  42. first_row = [
  43. 72 pdf.make_cell(
  44. content: I18n.t("pdf.disclaimer.text"),
  45. size: DISCLAIMER_TEXT_SIZE,
  46. inline_format: true,
  47. valign: :top,
  48. 72 then: 3 else: 69 padding: [0, (has_signature || has_user_logo) ? 10 : 0, 0, 0]
  49. )
  50. ]
  51. 72 then: 2 else: 70 if has_signature
  52. 2 first_row << pdf.make_cell(
  53. image: StringIO.new(user.signature.download),
  54. fit: [100, DISCLAIMER_TEXT_HEIGHT],
  55. width: 100,
  56. borders: %i[top bottom left right],
  57. border_color: "CCCCCC",
  58. border_width: 1,
  59. padding: 5,
  60. 2 then: 1 else: 1 padding_right: has_user_logo ? 10 : 5,
  61. padding_left: 5
  62. )
  63. end
  64. 72 then: 2 else: 70 if has_user_logo
  65. 2 first_row << pdf.make_cell(
  66. image: StringIO.new(user.logo.download),
  67. fit: [1000, DISCLAIMER_TEXT_HEIGHT],
  68. borders: [],
  69. padding: [0, 0, 0, 10]
  70. )
  71. end
  72. 72 then: 2 else: 70 if has_signature
  73. 2 caption_row = [pdf.make_cell(content: "", borders: [], padding: 0)]
  74. 2 caption_row << pdf.make_cell(
  75. content: I18n.t("pdf.signature.caption"),
  76. size: DISCLAIMER_TEXT_SIZE,
  77. align: :center,
  78. borders: [],
  79. 2 then: 1 else: 1 padding: [5, has_user_logo ? 10 : 5, 0, 5]
  80. )
  81. 2 then: 1 else: 1 caption_row << pdf.make_cell(content: "", borders: [], padding: 0) if has_user_logo
  82. end
  83. 72 first_row.length
  84. 72 table_data = [first_row]
  85. # table_data << caption_row if has_signature
  86. 72 pdf.table(table_data) do |t|
  87. 66 t.cells.borders = []
  88. end
  89. end
  90. 4 def self.render_disclaimer_header(pdf)
  91. 68 pdf.text I18n.t("pdf.disclaimer.header"),
  92. size: DISCLAIMER_HEADER_SIZE,
  93. style: :bold
  94. 68 pdf.stroke_horizontal_rule
  95. end
  96. end
  97. end

app/services/pdf_generator_service/header_generator.rb

79.22% lines covered

61.11% branches covered

77 relevant lines. 61 lines covered and 16 lines missed.
18 total branches, 11 branches covered and 7 branches missed.
    
  1. # typed: false
  2. 4 class PdfGeneratorService
  3. 4 class HeaderGenerator
  4. 4 include Configuration
  5. 4 def self.generate_inspection_pdf_header(pdf, inspection)
  6. 41 create_inspection_header(pdf, inspection)
  7. # Generate QR code in top left corner
  8. 41 ImageProcessor.generate_qr_code_header(pdf, inspection)
  9. end
  10. 4 def self.create_inspection_header(pdf, inspection)
  11. 41 inspector_user = inspection.user
  12. 41 report_id_text = build_report_id_text(inspection)
  13. 41 status_text, status_color = build_status_text_and_color(inspection)
  14. 41 render_header_with_logo(pdf, inspector_user) do |logo_width|
  15. 41 render_inspection_text_section(pdf, inspection, report_id_text,
  16. status_text, status_color, logo_width)
  17. end
  18. 41 pdf.move_down Configuration::STATUS_SPACING
  19. end
  20. 4 def self.generate_unit_pdf_header(pdf, unit)
  21. 25 create_unit_header(pdf, unit)
  22. # Generate QR code in top left corner
  23. 25 ImageProcessor.generate_qr_code_header(pdf, unit)
  24. end
  25. 4 def self.create_unit_header(pdf, unit)
  26. 25 user = unit.user
  27. 25 unit_id_text = build_unit_id_text(unit)
  28. 25 render_header_with_logo(pdf, user) do |logo_width|
  29. 25 render_unit_text_section(pdf, unit, unit_id_text, logo_width)
  30. end
  31. 25 pdf.move_down Configuration::STATUS_SPACING
  32. end
  33. 4 class << self
  34. 4 private
  35. 4 def build_report_id_text(inspection)
  36. 41 "#{I18n.t("pdf.inspection.fields.report_id")}: #{inspection.id}"
  37. end
  38. 4 def build_status_text_and_color(inspection)
  39. 41 else: 0 case inspection.passed
  40. when: 37 when true
  41. 37 [I18n.t("pdf.inspection.passed"), Configuration::PASS_COLOR]
  42. when: 4 when false
  43. 4 [I18n.t("pdf.inspection.failed"), Configuration::FAIL_COLOR]
  44. when: 0 when nil
  45. [I18n.t("pdf.inspection.in_progress"), Configuration::NA_COLOR]
  46. end
  47. end
  48. 4 def build_unit_id_text(unit)
  49. 25 "#{I18n.t("pdf.unit.fields.unit_id")}: #{unit.id}"
  50. end
  51. 4 def render_header_with_logo(pdf, user)
  52. 66 logo_width, logo_data, logo_attachment = prepare_logo(user)
  53. 66 pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width) do
  54. 66 yield(logo_width)
  55. 66 then: 0 else: 66 if logo_data
  56. render_logo_section(pdf, logo_data, logo_width, logo_attachment)
  57. end
  58. end
  59. end
  60. 4 def prepare_logo(user)
  61. # Check if PDF_LOGO env variable is set to override user logo
  62. 66 then: 0 else: 66 if ENV["PDF_LOGO"].present?
  63. logo_filename = ENV["PDF_LOGO"]
  64. logo_path = Rails.root.join("app", "assets", "images", logo_filename)
  65. logo_data = File.read(logo_path, mode: "rb")
  66. logo_height = Configuration::LOGO_HEIGHT
  67. logo_width = logo_height * 2 + 10
  68. return [logo_width, logo_data, nil]
  69. end
  70. 66 then: 66 else: 0 then: 66 else: 0 else: 0 then: 66 return [0, nil, nil] unless user&.logo&.attached?
  71. logo_data = user.logo.download
  72. logo_height = Configuration::LOGO_HEIGHT
  73. logo_width = logo_height * 2 + 10
  74. [logo_width, logo_data, user.logo]
  75. end
  76. 4 def render_inspection_text_section(pdf, inspection, report_id_text,
  77. status_text, status_color, logo_width)
  78. # Shift text to the right to accommodate QR code
  79. 41 qr_offset = Configuration::QR_CODE_SIZE + Configuration::HEADER_SPACING
  80. 41 width = pdf.bounds.width - logo_width - qr_offset
  81. 41 pdf.bounding_box([qr_offset, pdf.bounds.top], width: width) do
  82. 41 pdf.text report_id_text, size: Configuration::HEADER_TEXT_SIZE,
  83. style: :bold
  84. 41 pdf.text status_text, size: Configuration::HEADER_TEXT_SIZE,
  85. style: :bold,
  86. color: status_color
  87. 41 expiry_label = I18n.t("pdf.inspection.fields.expiry_date")
  88. 41 expiry_value = Utilities.format_date(inspection.reinspection_date)
  89. 41 pdf.text "#{expiry_label}: #{expiry_value}",
  90. size: Configuration::HEADER_TEXT_SIZE, style: :bold
  91. end
  92. end
  93. 4 def render_unit_text_section(pdf, unit, unit_id_text, logo_width)
  94. # Shift text to the right to accommodate QR code
  95. 25 qr_offset = Configuration::QR_CODE_SIZE + Configuration::HEADER_SPACING
  96. 25 width = pdf.bounds.width - logo_width - qr_offset
  97. 25 pdf.bounding_box([qr_offset, pdf.bounds.top], width: width) do
  98. 25 pdf.text unit_id_text, size: Configuration::HEADER_TEXT_SIZE,
  99. style: :bold
  100. 25 expiry_label = I18n.t("pdf.unit.fields.expiry_date")
  101. 25 then: 2 else: 23 then: 2 expiry_value = if unit.last_inspection&.reinspection_date
  102. 2 Utilities.format_date(unit.last_inspection.reinspection_date)
  103. else: 23 else
  104. 23 I18n.t("pdf.unit.fields.na")
  105. end
  106. 25 pdf.text "#{expiry_label}: #{expiry_value}",
  107. size: Configuration::HEADER_TEXT_SIZE, style: :bold
  108. # Add extra line of spacing to match 3-line QR code height
  109. 25 pdf.move_down Configuration::HEADER_TEXT_SIZE * 1.5
  110. end
  111. end
  112. 4 def render_logo_section(pdf, logo_data, logo_width, logo_attachment)
  113. x_position = pdf.bounds.width - logo_width + 10
  114. pdf.bounding_box([x_position, pdf.bounds.top],
  115. width: logo_width - 10) do
  116. pdf.image StringIO.new(logo_data), height: Configuration::LOGO_HEIGHT,
  117. position: :right
  118. end
  119. rescue Prawn::Errors::UnsupportedImageType => e
  120. raise ImageError.build_detailed_error(e, logo_attachment)
  121. end
  122. end
  123. end
  124. end

app/services/pdf_generator_service/image_error.rb

100.0% lines covered

100.0% branches covered

17 relevant lines. 17 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 class PdfGeneratorService
  2. 4 class ImageError
  3. 4 def self.build_detailed_error(original_error, attachment)
  4. 1 blob = attachment.blob
  5. 1 details = extract_image_details(blob, attachment)
  6. 1 service_url = build_service_url(blob)
  7. 1 detailed_message = format_error_message(
  8. original_error, details, service_url
  9. )
  10. 1 original_error.class.new(detailed_message)
  11. end
  12. 4 def self.extract_image_details(blob, attachment)
  13. 1 record = attachment.record
  14. {
  15. 1 filename: blob.filename.to_s,
  16. byte_size: blob.byte_size,
  17. content_type: blob.content_type,
  18. record_type: record.class.name,
  19. record_id: record.try(:serial) || record.try(:id) || "unknown"
  20. }
  21. end
  22. 4 def self.build_service_url(blob)
  23. 1 "/rails/active_storage/blobs/#{blob.signed_id}/#{blob.filename}"
  24. end
  25. 4 def self.format_error_message(original_error, details, service_url)
  26. 1 size_kb = (details[:byte_size] / 1024.0).round(2)
  27. <<~MESSAGE
  28. 1 #{original_error.message}
  29. Image details:
  30. Filename: #{details[:filename]}
  31. Size: #{details[:byte_size]} bytes (#{size_kb} KB)
  32. Content-Type: #{details[:content_type]}
  33. Record: #{details[:record_type]} #{details[:record_id]}
  34. ActiveStorage URL: #{service_url}
  35. MESSAGE
  36. end
  37. 4 private_class_method :extract_image_details, :build_service_url,
  38. :format_error_message
  39. end
  40. end

app/services/pdf_generator_service/image_orientation_processor.rb

77.78% lines covered

100.0% branches covered

9 relevant lines. 7 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 4 class PdfGeneratorService
  2. 4 class ImageOrientationProcessor
  3. 4 require "vips"
  4. # Process image to handle EXIF orientation data
  5. 4 def self.process_with_orientation(image)
  6. # Vips automatically handles EXIF orientation
  7. # Just return the image as a buffer
  8. 2 image.write_to_buffer(".png")
  9. end
  10. # Get image dimensions after applying EXIF orientation correction
  11. 4 def self.get_dimensions(image)
  12. # Vips automatically applies EXIF orientation
  13. [image.width, image.height]
  14. end
  15. # Check if image needs orientation correction
  16. 4 def self.needs_orientation_correction?(image)
  17. # Vips handles orientation automatically, so always return false
  18. false
  19. end
  20. end
  21. end

app/services/pdf_generator_service/image_processor.rb

89.66% lines covered

72.22% branches covered

58 relevant lines. 52 lines covered and 6 lines missed.
18 total branches, 13 branches covered and 5 branches missed.
    
  1. 4 class PdfGeneratorService
  2. 4 class ImageProcessor
  3. 4 require "vips"
  4. 4 include Configuration
  5. 4 def self.generate_qr_code_header(pdf, entity)
  6. 69 qr_code_png = QrCodeService.generate_qr_code(entity)
  7. # Position QR code at top left of page
  8. 68 qr_width, qr_height = PositionCalculator.qr_code_dimensions
  9. # Use pdf.bounds.top to position from top of page
  10. image_options = {
  11. 68 at: [0, pdf.bounds.top],
  12. width: qr_width,
  13. height: qr_height
  14. }
  15. 68 pdf.image StringIO.new(qr_code_png), image_options
  16. end
  17. 4 def self.add_unit_photo_footer(pdf, unit, column_count = 3)
  18. 69 then: 69 else: 0 then: 69 else: 0 else: 8 then: 61 return unless unit&.photo&.blob
  19. # Calculate photo position in bottom right corner
  20. 8 pdf_width = pdf.bounds.width
  21. # Calculate photo dimensions based on column count
  22. 8 attachment = unit.photo
  23. 8 image = create_image(attachment)
  24. 8 dimensions = calculate_footer_photo_dimensions(pdf, image, column_count)
  25. 8 photo_width, photo_height = dimensions
  26. # Position photo in bottom right corner
  27. 8 photo_x = pdf_width - photo_width
  28. # Account for footer height on first page
  29. 8 photo_y = calculate_photo_y(pdf, photo_height)
  30. 8 render_processed_image(pdf, image, photo_x, photo_y,
  31. photo_width, photo_height, attachment)
  32. rescue Prawn::Errors::UnsupportedImageType => e
  33. raise ImageError.build_detailed_error(e, attachment)
  34. end
  35. 4 def self.measure_unit_photo_height(pdf, unit, column_count = 3)
  36. 41 then: 40 else: 1 then: 40 else: 1 else: 2 then: 39 return 0 unless unit&.photo&.blob
  37. 2 attachment = unit.photo
  38. 2 image = create_image(attachment)
  39. 2 dimensions = calculate_footer_photo_dimensions(pdf, image, column_count)
  40. 2 _photo_width, photo_height = dimensions
  41. 2 then: 0 else: 2 if photo_height <= 0
  42. raise I18n.t("pdf_generator.errors.zero_photo_height", unit_id: unit.id)
  43. end
  44. 2 photo_height
  45. rescue Prawn::Errors::UnsupportedImageType => e
  46. raise ImageError.build_detailed_error(e, attachment)
  47. end
  48. 4 def self.process_image_with_orientation(attachment)
  49. 2 image = create_image(attachment)
  50. 2 ImageOrientationProcessor.process_with_orientation(image)
  51. end
  52. 4 def self.calculate_footer_photo_dimensions(pdf, image, column_count = 3)
  53. 10 original_width = image.width
  54. 10 original_height = image.height
  55. # Calculate column width based on PDF width and column count
  56. # Account for column spacers
  57. 10 spacer_count = column_count - 1
  58. 10 spacer_width = Configuration::ASSESSMENT_COLUMN_SPACER
  59. 10 total_spacer_width = spacer_width * spacer_count
  60. 10 column_width = (pdf.bounds.width - total_spacer_width) / column_count.to_f
  61. # Photo width equals one column width
  62. 10 photo_width = column_width.round
  63. # Calculate height maintaining aspect ratio
  64. 10 then: 0 if original_width.zero? || original_height.zero?
  65. photo_height = photo_width
  66. else: 10 else
  67. 10 aspect_ratio = original_width.to_f / original_height.to_f
  68. 10 photo_height = (photo_width / aspect_ratio).round
  69. end
  70. 10 [photo_width, photo_height]
  71. end
  72. 4 def self.render_processed_image(pdf, image, x, y, width, height, attachment)
  73. # Vips automatically handles EXIF orientation
  74. 8 processed_image = image.write_to_buffer(".png")
  75. image_options = {
  76. 8 at: [x, y],
  77. width: width,
  78. height: height
  79. }
  80. 8 pdf.image StringIO.new(processed_image), image_options
  81. rescue Prawn::Errors::UnsupportedImageType => e
  82. raise ImageError.build_detailed_error(e, attachment)
  83. end
  84. 4 def self.create_image(attachment)
  85. 12 image_data = attachment.blob.download
  86. 12 Vips::Image.new_from_buffer(image_data, "")
  87. end
  88. 4 def self.calculate_photo_y(pdf, photo_height)
  89. 8 then: 8 if pdf.page_number == 1
  90. 8 Configuration::FOOTER_HEIGHT +
  91. Configuration::QR_CODE_BOTTOM_OFFSET + photo_height
  92. else: 0 else
  93. Configuration::QR_CODE_BOTTOM_OFFSET + photo_height
  94. end
  95. end
  96. end
  97. end

app/services/pdf_generator_service/photos_renderer.rb

100.0% lines covered

100.0% branches covered

63 relevant lines. 63 lines covered and 0 lines missed.
6 total branches, 6 branches covered and 0 branches missed.
    
  1. 4 class PdfGeneratorService
  2. 4 class PhotosRenderer
  3. 4 def self.generate_photos_page(pdf, inspection)
  4. 46 else: 4 then: 42 return unless has_photos?(inspection)
  5. 4 pdf.start_new_page
  6. 4 add_photos_header(pdf)
  7. 4 max_photo_height = calculate_max_photo_height(pdf)
  8. 4 process_all_photos(pdf, inspection, max_photo_height)
  9. end
  10. 4 def self.has_photos?(inspection)
  11. 48 inspection.photo_1.attached? ||
  12. inspection.photo_2.attached? ||
  13. inspection.photo_3.attached?
  14. end
  15. 4 def self.add_photos_header(pdf)
  16. header_options = {
  17. 5 size: Configuration::HEADER_TEXT_SIZE,
  18. style: :bold
  19. }
  20. 5 pdf.text I18n.t("pdf.inspection.photos_section"), header_options
  21. 5 pdf.stroke_horizontal_rule
  22. 5 pdf.move_down 15
  23. end
  24. 4 def self.calculate_max_photo_height(pdf)
  25. 4 height_percent = Configuration::PHOTO_MAX_HEIGHT_PERCENT
  26. 4 pdf.bounds.height * height_percent
  27. end
  28. 4 def self.process_all_photos(pdf, inspection, max_photo_height)
  29. 9 current_y = pdf.cursor
  30. 9 photo_fields.each do |photo_field, label|
  31. 26 photo = inspection.send(photo_field)
  32. 26 else: 18 then: 8 next unless photo.attached?
  33. 18 current_y = handle_page_break_if_needed(
  34. pdf, current_y, max_photo_height
  35. )
  36. 18 render_photo(pdf, photo, label, max_photo_height)
  37. 17 current_y = pdf.cursor - Configuration::PHOTO_SPACING
  38. 17 pdf.move_down Configuration::PHOTO_SPACING
  39. end
  40. end
  41. 4 def self.photo_fields
  42. [
  43. 13 [:photo_1, I18n.t("pdf.inspection.fields.photo_1_label")],
  44. [:photo_2, I18n.t("pdf.inspection.fields.photo_2_label")],
  45. [:photo_3, I18n.t("pdf.inspection.fields.photo_3_label")]
  46. ]
  47. end
  48. 4 def self.handle_page_break_if_needed(pdf, current_y, max_photo_height)
  49. 7 needed_space = calculate_needed_space(max_photo_height)
  50. 7 then: 2 if current_y < needed_space
  51. 2 pdf.start_new_page
  52. 2 pdf.cursor
  53. else: 5 else
  54. 5 current_y
  55. end
  56. end
  57. 4 def self.calculate_needed_space(max_photo_height)
  58. 8 label_size = Configuration::PHOTO_LABEL_SIZE
  59. 8 label_spacing = Configuration::PHOTO_LABEL_SPACING
  60. 8 photo_spacing = Configuration::PHOTO_SPACING
  61. 8 max_photo_height + label_size + label_spacing + photo_spacing
  62. end
  63. 4 def self.render_photo(pdf, photo, label, max_height)
  64. 13 photo.blob.download
  65. 13 processed_image = ImageProcessor.process_image_with_orientation(photo)
  66. 11 image_width, image_height = calculate_photo_dimensions_from_blob(
  67. photo, pdf.bounds.width, max_height
  68. )
  69. 11 x_position = (pdf.bounds.width - image_width) / 2
  70. 11 render_image_to_pdf(
  71. pdf, processed_image, x_position, image_width, image_height, photo
  72. )
  73. 10 add_photo_label(pdf, label, image_height)
  74. rescue Prawn::Errors::UnsupportedImageType => e
  75. 3 raise ImageError.build_detailed_error(e, photo)
  76. end
  77. 4 def self.calculate_photo_dimensions_from_blob(photo, max_width, max_height)
  78. 12 original_width = photo.blob.metadata[:width].to_f
  79. 12 original_height = photo.blob.metadata[:height].to_f
  80. 12 width_scale = max_width / original_width
  81. 12 height_scale = max_height / original_height
  82. 12 scale = [width_scale, height_scale].min
  83. 12 [original_width * scale, original_height * scale]
  84. end
  85. 4 def self.render_image_to_pdf(pdf, image_data, x_position, width, height,
  86. photo)
  87. image_options = {
  88. 12 at: [x_position, pdf.cursor],
  89. width: width,
  90. height: height
  91. }
  92. 12 pdf.image StringIO.new(image_data), image_options
  93. rescue Prawn::Errors::UnsupportedImageType => e
  94. 1 raise ImageError.build_detailed_error(e, photo)
  95. end
  96. 4 def self.add_photo_label(pdf, label, image_height)
  97. 6 pdf.move_down image_height + Configuration::PHOTO_LABEL_SPACING
  98. label_options = {
  99. 6 size: Configuration::PHOTO_LABEL_SIZE,
  100. align: :center
  101. }
  102. 6 pdf.text label, label_options
  103. end
  104. end
  105. end

app/services/pdf_generator_service/position_calculator.rb

100.0% lines covered

100.0% branches covered

41 relevant lines. 41 lines covered and 0 lines missed.
10 total branches, 10 branches covered and 0 branches missed.
    
  1. 4 class PdfGeneratorService
  2. 4 class PositionCalculator
  3. 4 include Configuration
  4. # Calculate QR code position in top left corner
  5. # QR code's top-left corner aligns with page top left, matching header spacing
  6. 4 def self.qr_code_position(pdf_bounds_width, pdf_page_number = 1)
  7. 4 x = QR_CODE_MARGIN
  8. # In Prawn, Y coordinates are from bottom, so we need to calculate from page top
  9. # This positions the QR code at the very top of the page
  10. 4 y = QR_CODE_SIZE
  11. 4 [x, y]
  12. end
  13. # Calculate photo position aligned with QR code
  14. # Photo width is twice QR code size, height maintains aspect ratio
  15. # Photo's bottom-right corner aligns with QR code's bottom-right corner
  16. 4 def self.photo_footer_position(qr_x, qr_y, photo_width = nil, photo_height = nil)
  17. 5 photo_width ||= QR_CODE_SIZE * 2
  18. 5 photo_height ||= photo_width # Default to square if no height provided
  19. # Photo's right edge aligns with QR's right edge (both align with table right edge)
  20. 5 photo_x = qr_x + QR_CODE_SIZE - photo_width
  21. # Photo's bottom edge aligns with QR's bottom edge (both match header spacing)
  22. 5 photo_y = qr_y - QR_CODE_SIZE + photo_height
  23. 5 [photo_x, photo_y]
  24. end
  25. # Calculate photo dimensions for footer (width = 2x QR size, height maintains aspect ratio)
  26. # Note: original_width and original_height should be post-EXIF-rotation dimensions
  27. 4 def self.footer_photo_dimensions(original_width, original_height)
  28. 4 footer_photo_dimensions_with_multiplier(original_width, original_height, 2.0)
  29. end
  30. # Calculate photo dimensions with custom width multiplier
  31. 4 def self.footer_photo_dimensions_with_multiplier(original_width, original_height, width_multiplier)
  32. 4 target_width = (QR_CODE_SIZE * width_multiplier).round
  33. 4 then: 1 else: 3 return [target_width, target_width] if original_width.zero? || original_height.zero?
  34. 3 aspect_ratio = calculate_aspect_ratio(original_width, original_height)
  35. 3 target_height = (target_width / aspect_ratio).round
  36. 3 [target_width, target_height]
  37. end
  38. # Get QR code dimensions
  39. 4 def self.qr_code_dimensions
  40. 69 [QR_CODE_SIZE, QR_CODE_SIZE]
  41. end
  42. # Check if coordinates are within PDF bounds
  43. 4 def self.within_bounds?(x, y, width, height, pdf_bounds_width, pdf_bounds_height)
  44. 9 x >= 0 &&
  45. y >= 0 &&
  46. 7 (x + width) <= pdf_bounds_width &&
  47. 5 (y + height) <= pdf_bounds_height
  48. end
  49. # Calculate aspect ratio for image fitting
  50. 4 def self.calculate_aspect_ratio(original_width, original_height)
  51. 15 then: 1 else: 14 return 1.0 if original_height.zero?
  52. 14 original_width.to_f / original_height.to_f
  53. end
  54. # Calculate dimensions to fit within constraints while maintaining aspect ratio
  55. 4 def self.fit_dimensions(original_width, original_height, max_width, max_height)
  56. 9 then: 2 else: 7 return [max_width, max_height] if original_width.zero? || original_height.zero?
  57. # If original already fits within constraints, return original dimensions
  58. 7 then: 1 else: 6 if original_width <= max_width && original_height <= max_height
  59. 1 return [original_width, original_height]
  60. end
  61. 6 aspect_ratio = calculate_aspect_ratio(original_width, original_height)
  62. # Try fitting by width first
  63. 6 fitted_width = max_width
  64. 6 fitted_height = (fitted_width / aspect_ratio).round
  65. # If height is too big, fit by height instead
  66. 6 then: 2 else: 4 if fitted_height > max_height
  67. 2 fitted_height = max_height
  68. 2 fitted_width = (fitted_height * aspect_ratio).round
  69. end
  70. 6 [fitted_width, fitted_height]
  71. end
  72. end
  73. end

app/services/pdf_generator_service/table_builder.rb

93.67% lines covered

85.71% branches covered

158 relevant lines. 148 lines covered and 10 lines missed.
56 total branches, 48 branches covered and 8 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class PdfGeneratorService
  4. 4 class TableBuilder
  5. 4 include Configuration
  6. 4 def self.create_pdf_table(pdf, data)
  7. table = pdf.table(data, width: pdf.bounds.width) do |t|
  8. t.cells.borders = []
  9. t.cells.padding = TABLE_CELL_PADDING
  10. t.columns(0).font_style = :bold
  11. t.columns(0).width = TABLE_FIRST_COLUMN_WIDTH
  12. t.row(0..data.length - 1).background_color = "EEEEEE"
  13. t.row(0..data.length - 1).borders = [:bottom]
  14. t.row(0..data.length - 1).border_color = "DDDDDD"
  15. end
  16. then: 0 else: 0 yield table if block_given?
  17. table
  18. end
  19. 4 def self.create_nice_box_table(pdf, title, data)
  20. 23 pdf.text title, size: HEADER_TEXT_SIZE, style: :bold
  21. 23 pdf.stroke_horizontal_rule
  22. 23 pdf.move_down 10
  23. 23 table = pdf.table(data, width: pdf.bounds.width) do |t|
  24. 23 t.cells.borders = []
  25. 23 t.cells.padding = NICE_TABLE_CELL_PADDING
  26. 23 t.cells.size = NICE_TABLE_TEXT_SIZE
  27. 23 t.columns(0).font_style = :bold
  28. 23 t.columns(0).width = TABLE_FIRST_COLUMN_WIDTH
  29. 23 t.row(0..data.length - 1).background_color = "EEEEEE"
  30. 23 t.row(0..data.length - 1).borders = [:bottom]
  31. 23 t.row(0..data.length - 1).border_color = "DDDDDD"
  32. end
  33. 23 then: 0 else: 23 yield table if block_given?
  34. 23 pdf.move_down 15
  35. 23 table
  36. end
  37. 4 def self.create_unit_details_table(pdf, title, data)
  38. 65 pdf.text title, size: HEADER_TEXT_SIZE, style: :bold
  39. 65 pdf.stroke_horizontal_rule
  40. 65 pdf.move_down 10
  41. 65 table = create_styled_unit_table(pdf, data)
  42. 65 then: 0 else: 65 yield table if block_given?
  43. 65 pdf.move_down 15
  44. 65 table
  45. end
  46. 4 def self.create_styled_unit_table(pdf, data)
  47. 65 is_unit_pdf = data.first.length == 2
  48. 65 pdf.table(data, width: pdf.bounds.width) do |t|
  49. 65 apply_unit_table_base_styling(t, data.length)
  50. 65 apply_unit_table_column_styling(t, is_unit_pdf, pdf.bounds.width)
  51. end
  52. end
  53. 4 def self.apply_unit_table_base_styling(table, row_count)
  54. 65 table.cells.borders = []
  55. 65 table.cells.padding = UNIT_TABLE_CELL_PADDING
  56. 65 table.cells.size = UNIT_TABLE_TEXT_SIZE
  57. 65 table.row(0..row_count - 1).background_color = "EEEEEE"
  58. 65 table.row(0..row_count - 1).borders = [:bottom]
  59. 65 table.row(0..row_count - 1).border_color = "DDDDDD"
  60. end
  61. 4 def self.apply_unit_table_column_styling(table, is_unit_pdf, pdf_width)
  62. 65 table.columns(0).font_style = :bold
  63. 65 then: 25 if is_unit_pdf
  64. 25 table.columns(0).width = I18n.t("pdf.table.unit_label_column_width_left")
  65. else: 40 else
  66. 40 apply_four_column_styling(table, pdf_width)
  67. end
  68. end
  69. 4 def self.apply_four_column_styling(table, pdf_width)
  70. 40 table.columns(2).font_style = :bold
  71. 40 left_width = I18n.t("pdf.table.unit_label_column_width_left")
  72. 40 right_width = I18n.t("pdf.table.unit_label_column_width_right")
  73. 40 table.columns(0).width = left_width
  74. 40 table.columns(2).width = right_width
  75. 40 remaining_width = pdf_width - (left_width + right_width)
  76. 40 table.columns(1).width = remaining_width / 2
  77. 40 table.columns(3).width = remaining_width / 2
  78. end
  79. 4 def self.create_inspection_history_table(pdf, title, inspections)
  80. 2 pdf.text title, size: HEADER_TEXT_SIZE, style: :bold
  81. 2 pdf.stroke_horizontal_rule
  82. 2 pdf.move_down 10
  83. 2 table_data = build_inspection_history_data(inspections)
  84. 2 table = create_styled_history_table(pdf, table_data)
  85. 2 pdf.move_down 15
  86. 2 table
  87. end
  88. 4 def self.build_inspection_history_data(inspections)
  89. header = [
  90. 5 I18n.t("pdf.unit.fields.date"),
  91. I18n.t("pdf.unit.fields.result"),
  92. I18n.t("pdf.unit.fields.inspector")
  93. ]
  94. 5 data_rows = inspections.map do |inspection|
  95. [
  96. 10 Utilities.format_date(inspection.inspection_date),
  97. inspection_result_text(inspection),
  98. inspector_text(inspection)
  99. ]
  100. end
  101. 5 [header] + data_rows
  102. end
  103. 4 def self.inspection_result_text(inspection)
  104. 12 then: 7 if inspection.passed
  105. 7 I18n.t("shared.pass_pdf")
  106. else: 5 else
  107. 5 I18n.t("shared.fail_pdf")
  108. end
  109. end
  110. 4 def self.inspector_text(inspection)
  111. 13 inspector_name = inspection.user.name
  112. 13 rpii_number = inspection.user.rpii_inspector_number
  113. 13 then: 11 if rpii_number.present?
  114. 11 I18n.t("pdf.unit.fields.inspector_with_rpii",
  115. name: inspector_name,
  116. rpii_label: I18n.t("pdf.inspection.fields.rpii_inspector_no"),
  117. rpii_number: rpii_number)
  118. else: 2 else
  119. 2 inspector_name
  120. end
  121. end
  122. 4 def self.create_styled_history_table(pdf, table_data)
  123. 2 pdf.table(table_data, width: pdf.bounds.width) do |t|
  124. 2 apply_history_table_base_styling(t)
  125. 2 apply_history_table_row_styling(t, table_data)
  126. 2 apply_history_table_column_widths(t, pdf.bounds.width)
  127. end
  128. end
  129. 4 def self.apply_history_table_base_styling(table)
  130. 2 table.cells.padding = NICE_TABLE_CELL_PADDING
  131. 2 table.cells.size = HISTORY_TABLE_TEXT_SIZE
  132. 2 table.cells.border_width = 0.5
  133. 2 table.cells.border_color = "CCCCCC"
  134. 2 table.row(0).background_color = HISTORY_TABLE_HEADER_COLOR
  135. 2 table.row(0).font_style = :bold
  136. end
  137. 4 def self.apply_history_table_row_styling(table, table_data)
  138. 2 (1...table_data.length).each do |i|
  139. 6 apply_row_background_color(table, i)
  140. 6 apply_result_cell_styling(table, i, table_data[i][1])
  141. end
  142. end
  143. 4 def self.apply_row_background_color(table, row_index)
  144. 6 then: 3 color = if row_index.odd?
  145. 3 HISTORY_TABLE_ROW_COLOR
  146. else: 3 else
  147. 3 HISTORY_TABLE_ALT_ROW_COLOR
  148. end
  149. 6 table.row(row_index).background_color = color
  150. end
  151. 4 def self.apply_result_cell_styling(table, row_index, result_text)
  152. 6 result_cell = table.row(row_index).column(1)
  153. 6 then: 4 if result_text == I18n.t("shared.pass_pdf")
  154. 4 result_cell.text_color = PASS_COLOR
  155. 4 else: 2 result_cell.font_style = :bold
  156. 2 then: 2 else: 0 elsif result_text == I18n.t("shared.fail_pdf")
  157. 2 result_cell.text_color = FAIL_COLOR
  158. 2 result_cell.font_style = :bold
  159. end
  160. end
  161. 4 def self.apply_history_table_column_widths(table, pdf_width)
  162. 2 date_width = HISTORY_DATE_COLUMN_WIDTH
  163. 2 result_width = HISTORY_RESULT_COLUMN_WIDTH
  164. 2 inspector_width = pdf_width - date_width - result_width
  165. 2 table.column_widths = [date_width, result_width, inspector_width]
  166. end
  167. 4 def self.build_unit_details_table(unit, context)
  168. # Get dimensions from last inspection if available
  169. 30 last_inspection = unit.last_inspection
  170. 30 then: 26 if context == :unit
  171. 26 build_unit_details_table_for_unit_pdf(unit, last_inspection)
  172. else: 4 else
  173. 4 build_unit_details_table_with_inspection(unit, last_inspection, context)
  174. end
  175. end
  176. 4 def self.build_unit_details_table_for_unit_pdf(unit, last_inspection)
  177. 26 dimensions = []
  178. 26 then: 2 else: 24 if last_inspection
  179. 2 then: 2 else: 0 if last_inspection.width.present?
  180. 2 dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :width).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.width)}"
  181. end
  182. 2 then: 2 else: 0 if last_inspection.length.present?
  183. 2 dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :length).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.length)}"
  184. end
  185. 2 then: 2 else: 0 if last_inspection.height.present?
  186. 2 dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :height).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.height)}"
  187. end
  188. end
  189. 26 then: 2 else: 24 dimensions_text = dimensions.any? ? dimensions.join(" ") : ""
  190. # Build simple two-column table for unit PDFs
  191. [
  192. 26 [ChobbleForms::FieldUtils.form_field_label(:units, :name),
  193. Utilities.truncate_text(unit.name, UNIT_NAME_MAX_LENGTH)],
  194. [ChobbleForms::FieldUtils.form_field_label(:units, :manufacturer), unit.manufacturer],
  195. [ChobbleForms::FieldUtils.form_field_label(:units, :operator), unit.operator],
  196. [ChobbleForms::FieldUtils.form_field_label(:units, :serial), unit.serial],
  197. [I18n.t("pdf.inspection.fields.size_m"), dimensions_text]
  198. ]
  199. end
  200. 4 def self.build_unit_details_table_with_inspection(unit, last_inspection, context)
  201. 44 dimensions = []
  202. 44 then: 40 else: 4 if last_inspection
  203. 40 then: 25 else: 15 if last_inspection.width.present?
  204. 25 dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :width).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.width)}"
  205. end
  206. 40 then: 25 else: 15 if last_inspection.length.present?
  207. 25 dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :length).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.length)}"
  208. end
  209. 40 then: 25 else: 15 if last_inspection.height.present?
  210. 25 dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :height).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.height)}"
  211. end
  212. end
  213. 44 then: 25 else: 19 dimensions_text = dimensions.any? ? dimensions.join(" ") : ""
  214. # Get inspector details from current inspection (for inspection PDF) or last inspection (for unit PDF)
  215. 44 then: 43 inspection = if context == :inspection
  216. 43 last_inspection
  217. else: 1 else
  218. 1 unit.last_inspection
  219. end
  220. 44 then: 40 else: 4 then: 40 else: 4 inspector_name = inspection&.user&.name
  221. 44 then: 40 else: 4 then: 40 else: 4 rpii_number = inspection&.user&.rpii_inspector_number
  222. # Combine inspector name with RPII number if present
  223. 44 then: 38 inspector_text = if rpii_number.present?
  224. 38 "#{inspector_name} (#{I18n.t("pdf.inspection.fields.rpii_inspector_no")} #{rpii_number})"
  225. else: 6 else
  226. 6 inspector_name
  227. end
  228. 44 then: 40 else: 4 then: 40 else: 4 issued_date = if inspection&.inspection_date
  229. 40 Utilities.format_date(inspection.inspection_date)
  230. end
  231. # Build the table rows
  232. [
  233. [
  234. 44 ChobbleForms::FieldUtils.form_field_label(:units, :name),
  235. Utilities.truncate_text(unit.name, UNIT_NAME_MAX_LENGTH),
  236. I18n.t("pdf.inspection.fields.inspected_by"),
  237. inspector_text
  238. ],
  239. [
  240. ChobbleForms::FieldUtils.form_field_label(:units, :description),
  241. unit.description,
  242. ChobbleForms::FieldUtils.form_field_label(:units, :manufacturer),
  243. unit.manufacturer
  244. ],
  245. [
  246. I18n.t("pdf.inspection.fields.size_m"),
  247. dimensions_text,
  248. ChobbleForms::FieldUtils.form_field_label(:units, :operator),
  249. unit.operator
  250. ],
  251. [
  252. ChobbleForms::FieldUtils.form_field_label(:units, :serial),
  253. unit.serial,
  254. I18n.t("pdf.inspection.fields.issued_date"),
  255. issued_date
  256. ]
  257. ]
  258. end
  259. end
  260. end

app/services/pdf_generator_service/utilities.rb

67.74% lines covered

100.0% branches covered

31 relevant lines. 21 lines covered and 10 lines missed.
13 total branches, 13 branches covered and 0 branches missed.
    
  1. 4 class PdfGeneratorService
  2. 4 class Utilities
  3. 4 include Configuration
  4. 4 def self.truncate_text(text, max_length)
  5. 77 then: 3 else: 74 return "" if text.nil?
  6. 74 then: 3 else: 71 (text.length > max_length) ? "#{text[0...max_length]}..." : text
  7. end
  8. 4 def self.format_dimension(value)
  9. 85 then: 1 else: 84 return "" if value.nil?
  10. 84 value.to_s.sub(/\.0$/, "")
  11. end
  12. 4 def self.format_date(date)
  13. 95 then: 1 else: 94 return I18n.t("pdf.inspection.fields.na") if date.nil?
  14. 94 date.strftime("%-d %B, %Y")
  15. end
  16. 4 def self.format_pass_fail(value)
  17. 7 when: 2 case value
  18. 2 when: 2 when true then I18n.t("shared.pass_pdf")
  19. 2 else: 3 when false then I18n.t("shared.fail_pdf")
  20. 3 else I18n.t("pdf.inspection.fields.na")
  21. end
  22. end
  23. 4 def self.format_measurement(value, unit = "")
  24. 7 then: 3 else: 4 return I18n.t("pdf.inspection.fields.na") if value.nil?
  25. 4 "#{value}#{unit}"
  26. end
  27. 4 def self.add_draft_watermark(pdf)
  28. # Add 3x3 grid of DRAFT watermarks to each page
  29. (1..pdf.page_count).each do |page_num|
  30. pdf.go_to_page(page_num)
  31. pdf.transparent(WATERMARK_TRANSPARENCY) do
  32. pdf.fill_color "FF0000"
  33. # 3x3 grid positions
  34. y_positions = [0.10, 0.30, 0.50, 0.70, 0.9].map { |pct| pdf.bounds.height * pct }
  35. x_positions = [0.15, 0.50, 0.85].map { |pct| pdf.bounds.width * pct - (WATERMARK_WIDTH / 2) }
  36. y_positions.each do |y|
  37. x_positions.each do |x|
  38. pdf.text_box I18n.t("pdf.inspection.watermark.draft"),
  39. at: [x, y],
  40. width: WATERMARK_WIDTH,
  41. height: WATERMARK_HEIGHT,
  42. size: WATERMARK_TEXT_SIZE,
  43. style: :bold,
  44. align: :center,
  45. valign: :top
  46. end
  47. end
  48. end
  49. pdf.fill_color "000000"
  50. end
  51. end
  52. end
  53. end

app/services/photo_processing_service.rb

95.12% lines covered

55.0% branches covered

41 relevant lines. 39 lines covered and 2 lines missed.
20 total branches, 11 branches covered and 9 branches missed.
    
  1. 4 class PhotoProcessingService
  2. 4 require "vips"
  3. # Process uploaded photo data: resize to max 1200px, convert to JPEG 75%
  4. 4 def self.process_upload_data(image_data, original_filename = "photo")
  5. 20 then: 0 else: 20 return nil if image_data.blank?
  6. begin
  7. 20 image = Vips::Image.new_from_buffer(image_data, "")
  8. 19 image = resize_image(image)
  9. 19 then: 0 else: 19 image = add_white_background(image) if image.has_alpha?
  10. 19 processed_data = image.jpegsave_buffer(Q: 75, strip: true)
  11. 19 processed_filename = change_extension_to_jpg(original_filename)
  12. {
  13. 19 io: StringIO.new(processed_data),
  14. filename: processed_filename,
  15. content_type: "image/jpeg"
  16. }
  17. rescue => e
  18. 1 Rails.logger.error "Photo processing failed: #{e.message}"
  19. 1 nil
  20. end
  21. end
  22. 4 def self.process_upload(uploaded_file)
  23. 14 then: 0 else: 14 return nil if uploaded_file.blank?
  24. 14 then: 14 else: 0 uploaded_file.rewind if uploaded_file.respond_to?(:rewind)
  25. 14 process_upload_data(uploaded_file.read, uploaded_file.original_filename)
  26. end
  27. # Validate that data is a processable image
  28. 4 def self.valid_image_data?(image_data)
  29. 21 then: 2 else: 19 return false if image_data.blank?
  30. 19 image = Vips::Image.new_from_buffer(image_data, "")
  31. # Try to get basic image properties to ensure it's valid
  32. 15 image.width && image.height
  33. 15 true
  34. rescue Vips::Error
  35. 4 false
  36. end
  37. 4 def self.valid_image?(uploaded_file)
  38. 17 then: 0 else: 17 return false if uploaded_file.blank?
  39. 17 then: 17 else: 0 uploaded_file.rewind if uploaded_file.respond_to?(:rewind)
  40. 17 data = uploaded_file.read
  41. 17 then: 17 else: 0 uploaded_file.rewind if uploaded_file.respond_to?(:rewind)
  42. 17 valid_image_data?(data)
  43. end
  44. 4 def self.change_extension_to_jpg(filename)
  45. 19 then: 0 else: 19 return "photo.jpg" if filename.blank?
  46. 19 basename = File.basename(filename, ".*")
  47. 19 "#{basename}.jpg"
  48. end
  49. 4 def self.resize_image(image)
  50. 19 max_size = ImageProcessorService::FULL_SIZE
  51. 19 else: 19 then: 0 return image unless image.width > max_size || image.height > max_size
  52. 19 scale = [max_size.to_f / image.width, max_size.to_f / image.height].min
  53. 19 image.resize(scale)
  54. end
  55. 4 def self.add_white_background(image)
  56. background = Vips::Image.black(image.width, image.height).add(255)
  57. background.composite2(image, :over)
  58. end
  59. 4 private_class_method :change_extension_to_jpg, :resize_image,
  60. :add_white_background
  61. end

app/services/qr_code_service.rb

100.0% lines covered

75.0% branches covered

31 relevant lines. 31 lines covered and 0 lines missed.
4 total branches, 3 branches covered and 1 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. 4 class QrCodeService
  4. 4 extend T::Sig
  5. 8 sig { params(record: T.any(Inspection, Unit)).returns(T.nilable(String)) }
  6. 4 def self.generate_qr_code(record)
  7. 80 require "rqrcode"
  8. # Create QR code for the report URL using the shorter format
  9. 80 then: 48 if record.is_a?(Inspection)
  10. 48 else: 32 generate_inspection_qr_code(record)
  11. 32 then: 32 else: 0 elsif record.is_a?(Unit)
  12. 32 generate_unit_qr_code(record)
  13. end
  14. end
  15. 8 sig { params(inspection: Inspection).returns(String) }
  16. 4 def self.generate_inspection_qr_code(inspection)
  17. 49 require "rqrcode"
  18. 49 base_url = T.must(ENV["BASE_URL"])
  19. 48 url = "#{base_url}/inspections/#{inspection.id}"
  20. 48 generate_qr_code_from_url(url)
  21. end
  22. 8 sig { params(unit: Unit).returns(String) }
  23. 4 def self.generate_unit_qr_code(unit)
  24. 33 require "rqrcode"
  25. 33 base_url = T.must(ENV["BASE_URL"])
  26. 32 url = "#{base_url}/units/#{unit.id}"
  27. 32 generate_qr_code_from_url(url)
  28. end
  29. 8 sig { params(url: String).returns(String) }
  30. 4 def self.generate_qr_code_from_url(url)
  31. # Create QR code with optimized options for chunkier appearance
  32. 82 qrcode = RQRCode::QRCode.new(url, qr_code_options)
  33. 82 qrcode.as_png(png_options).to_blob
  34. end
  35. 8 sig { returns(T::Hash[Symbol, T.untyped]) }
  36. 4 def self.qr_code_options
  37. 83 {
  38. # Use lower error correction level for fewer modules (chunkier code)
  39. # :l - 7% error correction (lowest, largest modules)
  40. # :m - 15% error correction
  41. # :q - 25% error correction
  42. # :h - 30% error correction (highest, smallest modules)
  43. level: :m
  44. }
  45. end
  46. 8 sig { returns(T::Hash[Symbol, T.untyped]) }
  47. 4 def self.png_options
  48. 83 {
  49. bit_depth: 1,
  50. border_modules: 0, # No border for proper alignment
  51. color_mode: ChunkyPNG::COLOR_GRAYSCALE,
  52. color: "black",
  53. file: nil,
  54. fill: "white",
  55. module_px_size: 8, # Larger modules
  56. resize_exactly_to: false,
  57. resize_gte_to: false,
  58. size: 300
  59. }
  60. end
  61. end

app/services/rpii_verification_service.rb

40.0% lines covered

0.0% branches covered

80 relevant lines. 32 lines covered and 48 lines missed.
18 total branches, 0 branches covered and 18 branches missed.
    
  1. # typed: strict
  2. # frozen_string_literal: true
  3. # Service to verify RPII inspector numbers using the official API
  4. 4 require "net/http"
  5. 4 require "uri"
  6. 4 require "json"
  7. 4 class RpiiVerificationService
  8. 4 extend T::Sig
  9. 4 BASE_URL = "https://www.playinspectors.com/wp-admin/admin-ajax.php"
  10. 4 USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
  11. "AppleWebKit/537.36 (KHTML, like Gecko) " \
  12. "Chrome/91.0.4472.124 Safari/537.36"
  13. 4 InspectorInfo = T.type_alias do
  14. {
  15. name: T.nilable(String),
  16. number: T.nilable(String),
  17. qualifications: T.nilable(String),
  18. id: T.nilable(String),
  19. raw_value: String
  20. }
  21. end
  22. 4 VerificationResult = T.type_alias do
  23. {
  24. valid: T::Boolean,
  25. inspector: T.nilable(InspectorInfo)
  26. }
  27. end
  28. 4 class << self
  29. 4 extend T::Sig
  30. 4 sig do
  31. params(inspector_number: T.nilable(T.any(String, Integer)))
  32. .returns(T::Array[InspectorInfo])
  33. end
  34. 4 def search(inspector_number)
  35. then: 0 else: 0 return [] if inspector_number.blank?
  36. response = make_api_request(inspector_number)
  37. then: 0 if response.code == "200"
  38. parse_response(JSON.parse(response.body))
  39. else: 0 else
  40. log_error(response)
  41. []
  42. end
  43. end
  44. 4 sig do
  45. params(inspector_number: T.nilable(T.any(String, Integer)))
  46. .returns(VerificationResult)
  47. end
  48. 4 def verify(inspector_number)
  49. results = search(inspector_number)
  50. then: 0 else: 0 inspector = results.find { |r| r[:number]&.to_s == inspector_number.to_s }
  51. then: 0 if inspector
  52. {valid: true, inspector: inspector}
  53. else: 0 else
  54. {valid: false, inspector: nil}
  55. end
  56. end
  57. 4 private
  58. 4 sig do
  59. params(inspector_number: T.any(String, Integer))
  60. .returns(Net::HTTPResponse)
  61. end
  62. 4 def make_api_request(inspector_number)
  63. uri = URI.parse(BASE_URL)
  64. http = Net::HTTP.new(uri.host, uri.port)
  65. http.use_ssl = true
  66. request = build_request(uri.path, inspector_number)
  67. http.request(request)
  68. end
  69. 4 sig do
  70. params(path: String, inspector_number: T.any(String, Integer))
  71. .returns(Net::HTTP::Post)
  72. end
  73. 4 def build_request(path, inspector_number)
  74. request = Net::HTTP::Post.new(path)
  75. request["User-Agent"] = USER_AGENT
  76. content_type = "application/x-www-form-urlencoded; charset=UTF-8"
  77. request["Content-Type"] = content_type
  78. request["X-Requested-With"] = "XMLHttpRequest"
  79. request.body = URI.encode_www_form({
  80. action: "check_inspector_ajax",
  81. search: inspector_number.to_s.strip
  82. })
  83. request
  84. end
  85. 4 sig { params(response: Net::HTTPResponse).void }
  86. 4 def log_error(response)
  87. error_msg = "RPII verification failed: #{response.code}"
  88. Rails.logger.error "#{error_msg} - #{response.body}"
  89. end
  90. 4 sig { params(response: T.untyped).returns(T::Array[InspectorInfo]) }
  91. 4 def parse_response(response)
  92. suggestions = extract_suggestions(response)
  93. else: 0 then: 0 return [] unless suggestions.is_a?(Array)
  94. suggestions.map { |item| parse_inspector_item(item) }
  95. end
  96. 4 sig { params(response: T.untyped).returns(T.untyped) }
  97. 4 def extract_suggestions(response)
  98. then: 0 if response.is_a?(Hash) && response["suggestions"]
  99. else: 0 response["suggestions"]
  100. then: 0 elsif response.is_a?(Array)
  101. response
  102. else: 0 else
  103. []
  104. end
  105. end
  106. 4 sig { params(item: T.untyped).returns(InspectorInfo) }
  107. 4 def parse_inspector_item(item)
  108. value = item["value"] || ""
  109. data = item["data"]
  110. then: 0 if /^(.+?)\s*\((.+?)\)$/.match?(value)
  111. else: 0 parse_name_qualifications_format(value, data)
  112. then: 0 elsif /^(.+?)\s*\((\d+)\)\s*-\s*(.+)$/.match?(value)
  113. parse_name_number_qualifications_format(value, data)
  114. else: 0 else
  115. {
  116. raw_value: value,
  117. number: data,
  118. id: data
  119. }
  120. end
  121. end
  122. 4 sig { params(value: String, data: T.untyped).returns(InspectorInfo) }
  123. 4 def parse_name_qualifications_format(value, data)
  124. {
  125. name: T.must($1).strip,
  126. number: data, # The data field contains the inspector number
  127. qualifications: T.must($2).strip,
  128. id: data,
  129. raw_value: value
  130. }
  131. end
  132. 4 sig { params(value: String, data: T.untyped).returns(InspectorInfo) }
  133. 4 def parse_name_number_qualifications_format(value, data)
  134. {
  135. name: T.must($1).strip,
  136. number: $2,
  137. qualifications: T.must($3).strip,
  138. id: data,
  139. raw_value: value
  140. }
  141. end
  142. end
  143. end

app/services/s3_backup_service.rb

12.5% lines covered

0.0% branches covered

32 relevant lines. 4 lines covered and 28 lines missed.
6 total branches, 0 branches covered and 6 branches missed.
    
  1. # typed: false
  2. # frozen_string_literal: true
  3. 4 class S3BackupService
  4. 4 include S3Helpers
  5. 4 include S3BackupOperations
  6. 4 def perform
  7. ensure_s3_enabled
  8. validate_s3_config
  9. service = get_s3_service
  10. FileUtils.mkdir_p(temp_dir)
  11. timestamp = Time.current.strftime("%Y-%m-%d")
  12. backup_filename = "database-#{timestamp}.sqlite3"
  13. temp_backup_path = temp_dir.join(backup_filename)
  14. s3_key = "#{backup_dir}/database-#{timestamp}.tar.gz"
  15. # Create SQLite backup
  16. Rails.logger.info "Creating database backup..."
  17. system("sqlite3", database_path.to_s, ".backup '#{temp_backup_path}'", exception: true)
  18. Rails.logger.info "Database backup created successfully"
  19. # Compress the backup
  20. Rails.logger.info "Compressing backup..."
  21. temp_compressed_path = create_tar_gz(timestamp)
  22. Rails.logger.info "Backup compressed successfully"
  23. # Upload to S3
  24. Rails.logger.info "Uploading to S3 (#{s3_key})..."
  25. File.open(temp_compressed_path, "rb") do |file|
  26. service.upload(s3_key, file)
  27. end
  28. Rails.logger.info "Backup uploaded to S3 successfully"
  29. # Clean up old backups
  30. Rails.logger.info "Cleaning up old backups..."
  31. deleted_count = cleanup_old_backups(service)
  32. then: 0 else: 0 Rails.logger.info "Deleted #{deleted_count} old backups" if deleted_count.positive?
  33. backup_size_mb = (File.size(temp_compressed_path) / 1024.0 / 1024.0).round(2)
  34. Rails.logger.info "Database backup completed successfully!"
  35. Rails.logger.info "Backup location: #{s3_key}"
  36. Rails.logger.info "Backup size: #{backup_size_mb} MB"
  37. {
  38. success: true,
  39. location: s3_key,
  40. size_mb: backup_size_mb,
  41. deleted_count: deleted_count
  42. }
  43. ensure
  44. then: 0 else: 0 FileUtils.rm_f(temp_backup_path) if defined?(temp_backup_path)
  45. then: 0 else: 0 FileUtils.rm_f(temp_dir.join("database-#{timestamp}.tar.gz")) if defined?(timestamp)
  46. end
  47. end

app/services/seed_data_service.rb

97.69% lines covered

83.87% branches covered

130 relevant lines. 127 lines covered and 3 lines missed.
31 total branches, 26 branches covered and 5 branches missed.
    
  1. # typed: strict
  2. 4 class SeedDataService
  3. 4 extend T::Sig
  4. 4 CASTLE_IMAGE_COUNT = T.let(5, Integer)
  5. 4 UNIT_COUNT = T.let(20, Integer)
  6. 4 INSPECTION_COUNT = T.let(5, Integer)
  7. 4 INSPECTION_INTERVAL_DAYS = T.let(364, Integer)
  8. 4 INSPECTION_OFFSET_RANGE = T.let(0..365, T::Range[Integer])
  9. 4 INSPECTION_DURATION_RANGE = T.let(1..4, T::Range[Integer])
  10. 4 HIGH_PASS_RATE = T.let(0.95, Float)
  11. 4 NORMAL_PASS_RATE = T.let(0.85, Float)
  12. # Stefan-variant owner names as per existing seeds
  13. STEFAN_OWNER_NAMES = [
  14. 4 "Stefan's Bouncers",
  15. "Steph's Castles",
  16. "Steve's Inflatables",
  17. "Stefano's Party Hire",
  18. "Stef's Fun Factory",
  19. "Stefan Family Inflatables",
  20. "Stephan's Adventure Co",
  21. "Estephan Events",
  22. "Steff's Soft Play"
  23. ].freeze
  24. 4 class << self
  25. 4 extend T::Sig
  26. 5 sig { params(user: User, unit_count: Integer, inspection_count: Integer).returns(T::Boolean) }
  27. 4 def add_seeds_for_user(user, unit_count: UNIT_COUNT, inspection_count: INSPECTION_COUNT)
  28. 12 then: 1 else: 11 raise "User already has seed data" if user.has_seed_data?
  29. 11 ActiveRecord::Base.transaction do
  30. 11 Rails.logger.info I18n.t("seed_data.logging.starting_creation", user_id: user.id)
  31. 11 ensure_castle_blobs_exist
  32. 11 Rails.logger.info I18n.t("seed_data.logging.castle_images_found", count: @castle_images.size)
  33. 11 create_seed_units_for_user(user, unit_count, inspection_count)
  34. 10 Rails.logger.info I18n.t("seed_data.logging.creation_completed")
  35. end
  36. 10 true
  37. end
  38. 5 sig { params(user: User).returns(T::Boolean) }
  39. 4 def delete_seeds_for_user(user)
  40. 5 ActiveRecord::Base.transaction do
  41. # Delete inspections first (due to foreign key constraints)
  42. 5 user.inspections.seed_data.destroy_all
  43. # Then delete units with preloaded attachments to avoid N+1
  44. 4 user.units.seed_data.includes(photo_attachment: :blob, cached_pdf_attachment: :blob).destroy_all
  45. end
  46. 4 true
  47. end
  48. 4 private
  49. 5 sig { void }
  50. 4 def ensure_castle_blobs_exist
  51. 11 @castle_images = T.let([], T::Array[T::Hash[Symbol, T.untyped]])
  52. 11 (1..CASTLE_IMAGE_COUNT).each do |i|
  53. 55 filename = "castle-#{i}.jpg"
  54. 55 filepath = Rails.root.join("app/assets/castles", filename)
  55. 55 else: 55 then: 0 next unless File.exist?(filepath)
  56. # Read and cache the file content in memory
  57. 55 @castle_images << {
  58. filename: filename,
  59. content: File.read(filepath, mode: "rb") # Read in binary mode for images
  60. }
  61. end
  62. # If no castle images found, don't fail - just log
  63. 11 then: 0 else: 11 Rails.logger.warn I18n.t("seed_data.logging.no_castle_images") if @castle_images.empty?
  64. end
  65. 5 sig { params(user: User, unit_count: Integer, inspection_count: Integer).void }
  66. 4 def create_seed_units_for_user(user, unit_count, inspection_count)
  67. # Mix of unit types similar to existing seeds
  68. unit_configs = [
  69. 11 {name: "Medieval Castle Bouncer", manufacturer: "Airquee Manufacturing Ltd", width: 4.5, length: 4.5, height: 3.5},
  70. {name: "Giant Party Castle", manufacturer: "Bouncy Castle Boys", width: 9.0, length: 9.0, height: 4.5},
  71. {name: "Princess Castle with Slide", manufacturer: "Jump4Joy Inflatables", width: 5.5, length: 7.0, height: 4.0, has_slide: true},
  72. {name: "Toddler Soft Play Centre", manufacturer: "Custom Inflatables UK", width: 6.0, length: 6.0, height: 2.5, is_totally_enclosed: true},
  73. {name: "Assault Course Challenge", manufacturer: "Inflatable World Ltd", width: 3.0, length: 12.0, height: 3.5, has_slide: true},
  74. {name: "Mega Slide Experience", manufacturer: "Airquee Manufacturing Ltd", width: 5.0, length: 15.0, height: 7.5, has_slide: true},
  75. {name: "Gladiator Duel Platform", manufacturer: "Happy Hop Europe", width: 6.0, length: 6.0, height: 1.5},
  76. {name: "Double Bungee Run", manufacturer: "Party Castle Manufacturers", width: 4.0, length: 10.0, height: 2.5}
  77. ]
  78. # Pre-generate all unit IDs to avoid N+1 queries
  79. 11 unit_ids = generate_unit_ids_batch(user, unit_count)
  80. # Pre-load existing unit IDs to avoid repeated existence checks
  81. 11 existing_ids = user.units.pluck(:id).to_set
  82. 11 unit_count.times do |i|
  83. 150 config = unit_configs[i % unit_configs.length]
  84. 150 unit = create_unit_from_config(user, config, i, unit_ids[i], existing_ids)
  85. # Make half of units have incomplete most recent inspection
  86. 149 should_have_incomplete_inspection = i.even?
  87. 149 create_inspections_for_unit(unit, user, config, inspection_count, has_incomplete_recent: should_have_incomplete_inspection)
  88. end
  89. end
  90. 5 sig { params(user: User, count: Integer).returns(T::Array[String]) }
  91. 4 def generate_unit_ids_batch(user, count)
  92. 11 ids = []
  93. 11 existing_ids = user.units.pluck(:id).to_set
  94. 11 count.times do
  95. 169 loop do
  96. 169 id = SecureRandom.alphanumeric(CustomIdGenerator::ID_LENGTH).upcase
  97. 169 else: 0 then: 169 unless existing_ids.include?(id)
  98. 169 ids << id
  99. 169 existing_ids << id
  100. 169 break
  101. end
  102. end
  103. end
  104. 11 ids
  105. end
  106. 5 sig { params(user: User, config: T::Hash[Symbol, T.untyped], index: Integer, unit_id: String, existing_ids: T::Set[String]).returns(Unit) }
  107. 4 def create_unit_from_config(user, config, index, unit_id, existing_ids)
  108. 150 unit = user.units.build(
  109. id: unit_id,
  110. name: "#{config[:name]} ##{index + 1}",
  111. serial: "SEED-#{Date.current.year}-#{SecureRandom.hex(4).upcase}",
  112. description: generate_description(config[:name]),
  113. manufacturer: config[:manufacturer],
  114. operator: STEFAN_OWNER_NAMES.sample,
  115. is_seed: true
  116. )
  117. 150 unit.save!
  118. # Attach random castle image if available
  119. # For test environment, skip images as castle files don't exist
  120. 149 then: 0 else: 149 if @castle_images.any? && !Rails.env.test?
  121. castle_image = @castle_images.sample
  122. # Create a new attachment - ActiveStorage will dedupe the blob automatically
  123. unit.photo.attach(
  124. io: StringIO.new(castle_image[:content]),
  125. filename: castle_image[:filename],
  126. content_type: "image/jpeg"
  127. )
  128. end
  129. 149 unit
  130. end
  131. 5 sig { params(name: String).returns(String) }
  132. 4 def generate_description(name)
  133. 150 case name
  134. when: 73 when /Castle/
  135. 73 I18n.t("seed_data.descriptions.traditional_castle")
  136. when: 14 when /Slide/
  137. 14 I18n.t("seed_data.descriptions.combination_slide")
  138. when: 21 when /Soft Play/
  139. 21 I18n.t("seed_data.descriptions.soft_play")
  140. when: 14 when /Assault Course/
  141. 14 I18n.t("seed_data.descriptions.assault_course")
  142. when: 14 when /Gladiator/
  143. 14 I18n.t("seed_data.descriptions.gladiator")
  144. when: 14 when /Bungee/
  145. 14 I18n.t("seed_data.descriptions.bungee_run")
  146. else: 0 else
  147. I18n.t("seed_data.descriptions.default")
  148. end
  149. end
  150. 5 sig { params(unit: Unit, user: User, config: T::Hash[Symbol, T.untyped], inspection_count: Integer, has_incomplete_recent: T::Boolean).void }
  151. 4 def create_inspections_for_unit(unit, user, config, inspection_count, has_incomplete_recent: false)
  152. 149 offset_days = rand(INSPECTION_OFFSET_RANGE)
  153. 149 inspection_count.times do |i|
  154. 718 create_single_inspection(unit, user, config, offset_days, i, has_incomplete_recent)
  155. end
  156. end
  157. 5 sig { params(unit: Unit, user: User, config: T::Hash[Symbol, T.untyped], offset_days: Integer, index: Integer, has_incomplete_recent: T::Boolean).void }
  158. 4 def create_single_inspection(unit, user, config, offset_days, index, has_incomplete_recent)
  159. 718 inspection_date = calculate_inspection_date(offset_days, index)
  160. 718 passed = determine_pass_status(index)
  161. 718 is_complete = !(index == 0 && has_incomplete_recent)
  162. 718 inspection = user.inspections.create!(
  163. build_inspection_attributes(unit, user, config, inspection_date, passed, is_complete)
  164. )
  165. 718 create_assessments_for_inspection(inspection, unit, config, passed: passed)
  166. end
  167. 5 sig { params(offset_days: Integer, index: Integer).returns(Date) }
  168. 4 def calculate_inspection_date(offset_days, index)
  169. 718 days_ago = offset_days + (index * INSPECTION_INTERVAL_DAYS)
  170. 718 Date.current - days_ago.days
  171. end
  172. 5 sig { params(index: Integer).returns(T::Boolean) }
  173. 4 def determine_pass_status(index)
  174. 718 then: 149 else: 569 (index == 0) ? (rand < HIGH_PASS_RATE) : (rand < NORMAL_PASS_RATE)
  175. end
  176. 5 sig { params(unit: Unit, user: User, config: T::Hash[Symbol, T.untyped], inspection_date: Date, passed: T::Boolean, is_complete: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
  177. 4 def build_inspection_attributes(unit, user, config, inspection_date, passed, is_complete)
  178. {
  179. 718 unit: unit,
  180. inspector_company: user.inspection_company,
  181. inspection_date: inspection_date,
  182. 718 then: 642 complete_date: is_complete ?
  183. 642 else: 76 inspection_date.to_time + rand(INSPECTION_DURATION_RANGE).hours :
  184. 76 nil,
  185. is_seed: true,
  186. 718 then: 642 else: 76 passed: is_complete ? passed : nil,
  187. risk_assessment: generate_risk_assessment(passed),
  188. # Copy dimensions from config
  189. width: config[:width],
  190. length: config[:length],
  191. height: config[:height],
  192. has_slide: config[:has_slide] || false,
  193. is_totally_enclosed: config[:is_totally_enclosed] || false,
  194. indoor_only: [true, false].sample
  195. }
  196. end
  197. 5 sig { params(passed: T::Boolean).returns(String) }
  198. 4 def generate_risk_assessment(passed)
  199. 718 then: 637 if passed
  200. [
  201. 637 "Unit inspected and found to be in good operational condition. All safety features functioning correctly. Suitable for continued use with standard supervision requirements.",
  202. "Comprehensive safety assessment completed. Unit meets all EN 14960:2019 requirements. No significant hazards identified. Regular maintenance schedule should be maintained.",
  203. "Risk assessment indicates low risk profile. All structural elements secure, adequate ventilation present, and safety markings clearly visible. Recommend continued operation with routine checks.",
  204. "Safety evaluation satisfactory. Anchoring system robust, materials show no signs of degradation. Unit provides safe environment for users within specified age and height limits.",
  205. "Full risk assessment completed with no critical issues identified. Minor wear noted on high-traffic areas but within acceptable limits. Unit certified safe for public use.",
  206. "Detailed inspection reveals unit maintains structural integrity. All seams intact, proper inflation pressure maintained. Risk level assessed as minimal with appropriate supervision.",
  207. "Unit passes comprehensive safety review. Emergency exits clearly marked and functional. Blower system operating within specifications. Low risk rating assigned.",
  208. "Risk evaluation complete. Unit demonstrates good stability under load conditions. Safety padding adequate where required. Suitable for continued commercial operation."
  209. ].sample
  210. else: 81 else
  211. [
  212. 81 "Risk assessment identifies multiple safety concerns requiring immediate attention. Unit should not be used until repairs completed and re-inspected. High risk rating assigned.",
  213. "Critical safety deficiencies noted during inspection. Structural integrity compromised in several areas. Unit poses unacceptable risk to users and must be withdrawn from service.",
  214. "Significant hazards identified including inadequate anchoring and material degradation. Risk level unacceptable for public use. Comprehensive repairs required before recertification.",
  215. "Safety assessment failed. Multiple non-conformances with EN 14960:2019 identified. Unit presents substantial risk of injury. Recommend immediate decommissioning or major refurbishment.",
  216. "High risk factors present including compromised seams and insufficient inflation. Unit unsafe for operation. Client advised to cease use pending extensive remedial work.",
  217. "Risk evaluation reveals dangerous conditions. Emergency exits partially obstructed, significant wear to load-bearing elements. Unit fails safety standards and requires urgent attention.",
  218. "Assessment indicates elevated risk profile due to equipment failures and material defects. Unit not suitable for use. Full replacement of critical components necessary."
  219. ].sample
  220. end
  221. end
  222. 5 sig { params(inspection: Inspection, unit: Unit, config: T::Hash[Symbol, T.untyped], passed: T::Boolean).void }
  223. 4 def create_assessments_for_inspection(inspection, unit, config, passed: true)
  224. 718 is_incomplete = inspection.complete_date.nil?
  225. 718 inspection.each_applicable_assessment do |assessment_key, assessment_class, _|
  226. 3584 assessment_type = assessment_key.to_s.sub(/_assessment$/, "")
  227. 3584 create_assessment(
  228. inspection,
  229. assessment_key,
  230. assessment_type,
  231. passed,
  232. is_incomplete
  233. )
  234. end
  235. end
  236. 5 sig { params(inspection: Inspection, assessment_key: Symbol, assessment_type: String, passed: T::Boolean, is_incomplete: T::Boolean).void }
  237. 4 def create_assessment(
  238. inspection,
  239. assessment_key,
  240. assessment_type,
  241. passed,
  242. is_incomplete
  243. )
  244. 3584 fields = SeedData.send("#{assessment_type}_fields", passed: passed)
  245. 3584 then: 718 else: 2866 if assessment_key == :user_height_assessment && inspection.length && inspection.width
  246. 718 fields[:play_area_length] = inspection.length * 0.8
  247. 718 fields[:play_area_width] = inspection.width * 0.8
  248. end
  249. 3584 fields = randomly_remove_fields(fields, is_incomplete)
  250. 3584 inspection.send(assessment_key).update!(fields)
  251. end
  252. 5 sig { params(fields: T::Hash[Symbol, T.untyped], is_incomplete: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
  253. 4 def randomly_remove_fields(fields, is_incomplete)
  254. 3584 else: 386 then: 3198 return fields unless is_incomplete
  255. 386 else: 197 then: 189 return fields unless rand(0..1) == 0 # empty 50% of assessments
  256. 2919 fields.keys.each { |field| fields[field] = nil }
  257. 197 fields
  258. end
  259. end
  260. end

app/services/sentry_test_service.rb

13.04% lines covered

0.0% branches covered

23 relevant lines. 3 lines covered and 20 lines missed.
4 total branches, 0 branches covered and 4 branches missed.
    
  1. # frozen_string_literal: true
  2. 4 class SentryTestService
  3. 4 def perform
  4. results = []
  5. # Test 1: Send a test message
  6. begin
  7. Sentry.capture_message("Test message from Rails app")
  8. results << {test: "message", status: "success", message: "Test message sent to Sentry"}
  9. rescue => e
  10. results << {test: "message", status: "failed", message: "Failed to send test message: #{e.message}"}
  11. end
  12. # Test 2: Send a test exception
  13. begin
  14. 1 / 0
  15. rescue ZeroDivisionError => e
  16. Sentry.capture_exception(e)
  17. results << {test: "exception", status: "success", message: "Test exception sent to Sentry"}
  18. end
  19. # Test 3: Send exception with extra context
  20. begin
  21. Sentry.with_scope do |scope|
  22. scope.set_context("test_info", {
  23. source: "SentryTestService",
  24. timestamp: Time.current.iso8601,
  25. rails_env: Rails.env
  26. })
  27. scope.set_tags(test_type: "integration_test")
  28. raise "This is a test error with context"
  29. end
  30. rescue => e
  31. Sentry.capture_exception(e)
  32. results << {test: "exception_with_context", status: "success", message: "Test exception with context sent to Sentry"}
  33. end
  34. # Return results and configuration info
  35. {
  36. results: results,
  37. configuration: {
  38. dsn_configured: Sentry.configuration.dsn.present?,
  39. environment: Sentry.configuration.environment,
  40. enabled_environments: Sentry.configuration.enabled_environments
  41. }
  42. }
  43. end
  44. 4 def test_error_type(error_type)
  45. case error_type
  46. when :database_not_found
  47. when: 0 # Simulate database not found error
  48. Sentry.capture_message("Test: Database file not found", level: "error", extra: {
  49. database_path: "/nonexistent/database.sqlite3",
  50. test_type: "simulated_error"
  51. })
  52. when :missing_config
  53. when: 0 # Simulate missing configuration error
  54. Sentry.capture_message("Test: Missing S3 configuration", level: "error", extra: {
  55. missing_vars: ["S3_ENDPOINT", "S3_BUCKET"],
  56. test_type: "simulated_error"
  57. })
  58. when :generic_exception
  59. when: 0 # Raise and capture a generic exception
  60. begin
  61. raise StandardError, "This is a test exception from SentryTestService"
  62. rescue => e
  63. Sentry.capture_exception(e, extra: {
  64. test_type: "generic_exception",
  65. source: "SentryTestService#test_error_type"
  66. })
  67. end
  68. else: 0 else
  69. raise ArgumentError, "Unknown error type: #{error_type}"
  70. end
  71. end
  72. end

app/services/unit_creation_from_inspection_service.rb

100.0% lines covered

100.0% branches covered

36 relevant lines. 36 lines covered and 0 lines missed.
8 total branches, 8 branches covered and 0 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 class UnitCreationFromInspectionService
  4. 4 extend T::Sig
  5. 4 sig { returns(T::Array[String]) }
  6. 4 attr_reader :errors
  7. 6 sig { params(user: User, inspection_id: String, unit_params: ActionController::Parameters).void }
  8. 4 def initialize(user:, inspection_id:, unit_params:)
  9. 5 @user = user
  10. 5 @inspection_id = inspection_id
  11. 5 @unit_params = unit_params
  12. 5 @errors = []
  13. end
  14. 6 sig { returns(T::Boolean) }
  15. 4 def create
  16. 5 else: 3 then: 2 return false unless validate_inspection
  17. 3 @unit = @user.units.build(@unit_params)
  18. 3 then: 2 if @unit.save
  19. 2 @inspection.update!(unit: @unit)
  20. 2 true
  21. else: 1 else
  22. 1 false
  23. end
  24. end
  25. 6 sig { returns(T.nilable(Inspection)) }
  26. 4 attr_reader :inspection
  27. 6 sig { returns(T.nilable(Unit)) }
  28. 4 attr_reader :unit
  29. 5 sig { returns(T.nilable(String)) }
  30. 4 def error_message
  31. 5 @errors.first
  32. end
  33. 4 private
  34. 6 sig { returns(T::Boolean) }
  35. 4 def validate_inspection
  36. 5 @inspection = @user.inspections.find_by(id: @inspection_id)
  37. 5 else: 4 then: 1 unless @inspection
  38. 1 @errors << I18n.t("units.errors.inspection_not_found")
  39. 1 return false
  40. end
  41. 4 then: 1 else: 3 if @inspection.unit
  42. 1 @errors << I18n.t("units.errors.inspection_has_unit")
  43. 1 return false
  44. end
  45. 3 true
  46. end
  47. end

app/services/unit_csv_export_service.rb

100.0% lines covered

100.0% branches covered

12 relevant lines. 12 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: true
  2. # frozen_string_literal: true
  3. 4 class UnitCsvExportService
  4. 4 extend T::Sig
  5. 4 ATTRIBUTES = %w[id name manufacturer serial].freeze
  6. 5 sig { params(units: ActiveRecord::Relation).void }
  7. 4 def initialize(units)
  8. 2 @units = units
  9. end
  10. 5 sig { returns(String) }
  11. 4 def generate
  12. 2 CSV.generate(headers: true) do |csv|
  13. 2 csv << ATTRIBUTES
  14. 2 @units.order(created_at: :desc).each do |unit|
  15. 5 csv << ATTRIBUTES.map { |attr| unit.send(attr) }
  16. end
  17. end
  18. end
  19. end

lib/code_standards_checker.rb

97.87% lines covered

88.24% branches covered

141 relevant lines. 138 lines covered and 3 lines missed.
34 total branches, 30 branches covered and 4 branches missed.
    
  1. # Reusable code standards checker for both rake tasks and hooks
  2. 4 class CodeStandardsChecker
  3. 4 HARDCODED_STRINGS_ALLOWED_PATHS = %w[/lib/ /seeds/ /spec/ /test/].freeze
  4. 4 def initialize(max_method_lines: 20, max_file_lines: 500, max_line_length: 80)
  5. 31 @max_method_lines = max_method_lines
  6. 31 @max_file_lines = max_file_lines
  7. 31 @max_line_length = max_line_length
  8. end
  9. 4 def check_file(file_path)
  10. 25 else: 20 then: 5 return [] unless File.exist?(file_path) && file_path.end_with?(".rb")
  11. 20 relative_path = file_path.sub(Rails.root.join("").to_s, "")
  12. 20 file_content = File.read(file_path)
  13. 20 file_lines = file_content.lines
  14. 20 violations = []
  15. 20 violations.concat(check_file_length(relative_path, file_lines))
  16. 20 violations.concat(check_line_lengths(relative_path, file_lines))
  17. 20 violations.concat(check_method_lengths(relative_path, file_path))
  18. 20 violations.concat(check_hardcoded_strings(relative_path, file_lines))
  19. 20 violations
  20. end
  21. 4 def check_multiple_files(file_paths)
  22. 1 all_violations = []
  23. 1 file_paths.each do |file_path|
  24. 2 all_violations.concat(check_file(file_path))
  25. end
  26. 1 all_violations
  27. end
  28. 4 def format_violations(violations, show_summary: true)
  29. 5 then: 1 else: 4 return "✅ All files meet code standards!" if violations.empty?
  30. 4 output = []
  31. 20 violations_by_type = violations.group_by { |v| v[:type] }
  32. 4 output.concat(format_violations_by_type(violations_by_type))
  33. 4 then: 3 else: 1 output.concat(format_summary(violations)) if show_summary
  34. 4 output.join("\n")
  35. end
  36. 4 private
  37. 4 def format_violations_by_type(violations_by_type)
  38. 4 output = []
  39. 4 violations_by_type.each do |type, type_violations|
  40. 16 type_name = type.to_s.upcase.tr("_", " ")
  41. 16 output << "#{type_name} VIOLATIONS (#{type_violations.length}):"
  42. 16 output << "-" * 50
  43. 16 type_violations.each do |violation|
  44. 16 line_ref = violation[:line_number] || ""
  45. 16 output << "#{violation[:file]}:#{line_ref} #{violation[:message]}"
  46. end
  47. 16 output << ""
  48. end
  49. 4 output
  50. end
  51. 4 def format_summary(violations)
  52. [
  53. 3 "=" * 80,
  54. "TOTAL: #{violations.length} violations found"
  55. ]
  56. end
  57. 4 def check_file_length(relative_path, file_lines)
  58. 20 else: 1 then: 19 return [] unless file_lines.length > @max_file_lines
  59. [{
  60. 1 file: relative_path,
  61. type: :file_length,
  62. message: "#{file_lines.length} lines (max #{@max_file_lines})"
  63. }]
  64. end
  65. 4 def check_line_lengths(relative_path, file_lines)
  66. 20 violations = []
  67. 20 file_lines.each_with_index do |line, index|
  68. 1197 else: 4 then: 1193 next unless line.chomp.length > @max_line_length
  69. 4 violations << {
  70. file: relative_path,
  71. type: :line_length,
  72. line_number: index + 1,
  73. length: line.chomp.length,
  74. message: build_line_length_message(index + 1, line.chomp.length)
  75. }
  76. end
  77. 20 violations
  78. end
  79. 4 def check_method_lengths(relative_path, file_path)
  80. 20 methods = extract_methods_from_file(file_path)
  81. 41 long_methods = methods.select { |m| m[:length] > @max_method_lines }
  82. 20 long_methods.map do |method|
  83. {
  84. 4 file: relative_path,
  85. type: :method_length,
  86. line_number: method[:start_line],
  87. message: build_method_length_message(method)
  88. }
  89. end
  90. end
  91. 4 def check_hardcoded_strings(relative_path, file_lines)
  92. 20 then: 0 else: 20 return [] if skip_hardcoded_strings?(relative_path)
  93. 20 violations = []
  94. 20 file_lines.each_with_index do |line, index|
  95. 1197 line_violations = check_line_for_hardcoded_strings(
  96. relative_path, line, index + 1
  97. )
  98. 1197 violations.concat(line_violations)
  99. end
  100. 20 violations
  101. end
  102. 4 def check_line_for_hardcoded_strings(relative_path, line, line_number)
  103. 1197 stripped = line.strip
  104. 1197 then: 1096 else: 101 return [] if should_skip_line?(stripped)
  105. 101 hardcoded_strings = extract_quoted_strings(stripped)
  106. 101 hardcoded_strings.filter_map do |string|
  107. 21 else: 10 then: 11 next unless should_flag_string?(string)
  108. {
  109. 10 file: relative_path,
  110. type: :hardcoded_string,
  111. line_number: line_number,
  112. message: build_hardcoded_string_message(line_number, string)
  113. }
  114. end
  115. end
  116. 4 def skip_hardcoded_strings?(relative_path)
  117. 20 allowed_path = HARDCODED_STRINGS_ALLOWED_PATHS.any? do |path|
  118. 80 relative_path.include?(path)
  119. end
  120. 20 allowed_path || relative_path.include?("seed_data_service.rb")
  121. end
  122. 4 def should_skip_line?(stripped)
  123. 1197 stripped.start_with?("#") ||
  124. stripped.match?(/\/.*\//) ||
  125. stripped.include?("I18n.t") ||
  126. stripped.include?("Rails.logger") ||
  127. stripped.include?("puts") ||
  128. stripped.include?("print")
  129. end
  130. 4 def extract_quoted_strings(stripped)
  131. 101 strings = stripped.scan(/"([^"]*)"/).flatten
  132. 101 strings += stripped.scan(/'([^']*)'/).flatten
  133. 101 strings
  134. end
  135. 4 def should_flag_string?(string)
  136. 21 else: 21 then: 0 return false unless string.match?(/\w/)
  137. 21 then: 9 else: 12 return false if technical_string?(string)
  138. 12 then: 0 else: 12 return false if string.length < 3
  139. 12 then: 2 else: 10 return false if string.match?(/^#\{.*\}$/)
  140. 10 string.match?(/[A-Z].*[a-z]/) || string.include?(" ")
  141. end
  142. 4 def technical_string?(string)
  143. 21 string.match?(/^[a-z_]+$/) ||
  144. string.match?(/^[A-Z_]+$/) ||
  145. string.match?(/^[a-z]+\.[a-z]+/) ||
  146. string.match?(/^\//) ||
  147. string.match?(/^[a-z]+_[a-z]+_path$/) ||
  148. string.match?(/^\w+:/)
  149. end
  150. 4 def build_line_length_message(line_number, length)
  151. 4 "Line #{line_number}: #{length} chars (max #{@max_line_length})"
  152. end
  153. 4 def build_method_length_message(method)
  154. 4 name = method[:name]
  155. 4 length = method[:length]
  156. 4 "Method '#{name}' is #{length} lines (max #{@max_method_lines})"
  157. end
  158. 4 def build_hardcoded_string_message(line_number, string)
  159. 10 "Line #{line_number}: Hardcoded string '#{string}' - use I18n.t() instead"
  160. end
  161. 4 def extract_methods_from_file(file_path)
  162. 20 content = File.read(file_path)
  163. 20 methods = []
  164. 20 parser_state = {current_method: nil, indent_level: 0, method_start_line: 0}
  165. 20 content.lines.each_with_index do |line, index|
  166. 1197 process_line_for_methods(
  167. line,
  168. index + 1,
  169. methods,
  170. parser_state,
  171. file_path
  172. )
  173. end
  174. 20 finalize_last_method(methods, parser_state, content, file_path)
  175. 20 methods
  176. end
  177. 4 def process_line_for_methods(line, line_number, methods, state, file_path)
  178. 1197 stripped = line.strip
  179. 1197 then: 21 if method_definition?(stripped)
  180. 21 save_current_method(methods, state, line_number, file_path)
  181. 21 else: 1176 start_new_method(stripped, line, line_number, state)
  182. 1176 else: 1157 elsif method_end?(
  183. state[:current_method],
  184. stripped,
  185. line,
  186. state[:indent_level]
  187. then: 19 )
  188. 19 finish_current_method(methods, state, line_number, file_path)
  189. end
  190. end
  191. 4 def save_current_method(methods, state, line_number, file_path)
  192. 21 else: 0 then: 21 return unless state[:current_method]
  193. add_method_to_list(methods, state, line_number, file_path)
  194. end
  195. 4 def start_new_method(stripped, line, line_number, state)
  196. 21 state[:current_method] = extract_method_name(stripped)
  197. 21 state[:method_start_line] = line_number
  198. 21 state[:indent_level] = line.match(/^(\s*)/)[1].length
  199. end
  200. 4 def finish_current_method(methods, state, line_number, file_path)
  201. 19 add_method_to_list(methods, state, line_number, file_path)
  202. 19 state[:current_method] = nil
  203. end
  204. 4 def finalize_last_method(methods, state, content, file_path)
  205. 20 else: 2 then: 18 return unless state[:current_method]
  206. 2 end_line = content.lines.length
  207. 2 add_method_to_list(methods, state, end_line, file_path)
  208. end
  209. 4 def add_method_to_list(methods, state, end_line, file_path)
  210. 21 methods << build_method_info(
  211. state[:current_method], state[:method_start_line], end_line, file_path
  212. )
  213. end
  214. 4 def method_definition?(stripped)
  215. 1197 /^(private|protected|public\s+)?def\s+/.match?(stripped)
  216. end
  217. 4 def method_end?(current_method, stripped, line, indent_level)
  218. 1176 else: 135 then: 1041 return false unless current_method && !stripped.empty?
  219. 135 current_indent = line.match(/^(\s*)/)[1].length
  220. 135 stripped == "end" && current_indent <= indent_level
  221. end
  222. 4 def extract_method_name(stripped)
  223. 21 stripped.match(/def\s+([^\s\(]+)/)[1]
  224. end
  225. 4 def build_method_info(method_name, start_line, end_line, file_path)
  226. {
  227. 21 name: method_name,
  228. start_line: start_line,
  229. end_line: end_line,
  230. length: end_line - start_line + 1,
  231. file: file_path
  232. }
  233. end
  234. 4 def build_final_method_info(method_name, start_line, content, file_path)
  235. end_line = content.lines.length
  236. {
  237. name: method_name,
  238. start_line: start_line,
  239. end_line: end_line,
  240. length: end_line - start_line + 1,
  241. file: file_path
  242. }
  243. end
  244. end

lib/erb_lint_runner.rb

13.51% lines covered

0.0% branches covered

74 relevant lines. 10 lines covered and 64 lines missed.
16 total branches, 0 branches covered and 16 branches missed.
    
  1. # frozen_string_literal: true
  2. 4 require "open3"
  3. # Runs erb_lint on files one at a time with progress output
  4. # rubocop:disable Rails/Output
  5. 4 class ErbLintRunner
  6. 4 def initialize(autocorrect: false, verbose: false)
  7. @autocorrect = autocorrect
  8. @verbose = verbose
  9. @processed = 0
  10. @total_violations = 0
  11. @failed_files = []
  12. end
  13. 4 def run_on_all_files
  14. erb_files = find_erb_files
  15. puts "Found #{erb_files.length} ERB files to lint..."
  16. puts "=" * 80
  17. erb_files.each_with_index do |file, index|
  18. process_file(file, index + 1, erb_files.length)
  19. end
  20. print_summary
  21. @failed_files.empty?
  22. end
  23. 4 def run_on_files(files)
  24. puts "Linting #{files.length} ERB files..."
  25. puts "=" * 80
  26. files.each_with_index do |file, index|
  27. process_file(file, index + 1, files.length)
  28. end
  29. print_summary
  30. @failed_files.empty?
  31. end
  32. 4 private
  33. 4 def find_erb_files
  34. patterns = ["**/*.erb", "**/*.html.erb"]
  35. exclude_dirs = ["vendor", "node_modules", "tmp", "public"]
  36. files = []
  37. patterns.each do |pattern|
  38. Dir.glob(Rails.root.join(pattern).to_s).each do |file|
  39. relative_path = file.sub(Rails.root.to_s + "/", "")
  40. then: 0 else: 0 next if exclude_dirs.any? { |dir| relative_path.start_with?(dir) }
  41. files << relative_path
  42. end
  43. end
  44. files.uniq.sort
  45. end
  46. 4 def process_file(file, current, total)
  47. print "[#{current}/#{total}] #{file.ljust(60)} "
  48. $stdout.flush
  49. start_time = Time.now.to_f
  50. # Use Open3 for safer command execution
  51. cmd_args = ["bundle", "exec", "erb_lint", file]
  52. then: 0 else: 0 cmd_args << "--autocorrect" if @autocorrect
  53. output, status = Open3.capture2e(*cmd_args)
  54. success = status.success?
  55. elapsed = (Time.now.to_f - start_time).round(2)
  56. then: 0 if success
  57. puts "✅ (#{elapsed}s)"
  58. else: 0 else
  59. violations = extract_violation_count(output)
  60. @total_violations += violations
  61. @failed_files << {file:, violations:, output:}
  62. # Show slow linter warning if it took too long
  63. then: 0 if elapsed > 5.0
  64. puts "❌ #{violations} violation(s) (#{elapsed}s) ⚠️ SLOW"
  65. then: 0 else: 0 if @verbose
  66. puts " Slow file details:"
  67. puts output.lines.grep(/\A\s*\d+:\d+/).first(3).map { |line| " #{line.strip}" }
  68. end
  69. else: 0 else
  70. puts "❌ #{violations} violation(s) (#{elapsed}s)"
  71. end
  72. end
  73. @processed += 1
  74. rescue => e
  75. puts "💥 Error: #{e.message}"
  76. @failed_files << {file:, violations: 0, output: e.message}
  77. end
  78. 4 def extract_violation_count(output)
  79. # erb_lint output format: "1 error(s) were found"
  80. match = output.match(/(\d+) error\(s\) were found/)
  81. then: 0 else: 0 match ? match[1].to_i : 1
  82. end
  83. 4 def print_summary
  84. puts "=" * 80
  85. puts "\nSUMMARY:"
  86. puts "Processed: #{@processed} files"
  87. puts "Failed: #{@failed_files.length} files"
  88. puts "Total violations: #{@total_violations}"
  89. then: 0 if @failed_files.any?
  90. puts "\nFailed files:"
  91. @failed_files.each do |failure|
  92. puts " #{failure[:file]} (#{failure[:violations]} violation(s))"
  93. end
  94. then: 0 else: 0 if !@autocorrect
  95. puts "\nTo fix these issues, run:"
  96. puts " rake code_standards:erb_lint_fix"
  97. end
  98. else: 0 else
  99. puts "\n✅ All ERB files passed linting!"
  100. end
  101. end
  102. end
  103. # rubocop:enable Rails/Output

lib/i18n_usage_tracker.rb

95.95% lines covered

70.83% branches covered

74 relevant lines. 71 lines covered and 3 lines missed.
24 total branches, 17 branches covered and 7 branches missed.
    
  1. # Module to track I18n key usage during test runs
  2. 4 module I18nUsageTracker
  3. 4 class << self
  4. 4 attr_accessor :tracking_enabled
  5. 4 attr_reader :used_keys
  6. 4 def reset!
  7. 53 @used_keys = Set.new
  8. 53 @tracking_enabled = false
  9. end
  10. 4 def track_key(key, options = {})
  11. 26 else: 24 then: 2 return unless tracking_enabled && key
  12. # Handle both string and symbol keys
  13. 24 key_string = key.to_s
  14. # Skip Rails internal keys and error keys
  15. 24 then: 8 else: 16 return if key_string.start_with?("errors.", "activerecord.", "activemodel.", "helpers.", "number.", "date.", "time.", "support.")
  16. # Track the full key
  17. 16 @used_keys << key_string
  18. # Also track parent keys for nested translations
  19. # e.g., for "users.messages.created", also track "users.messages" and "users"
  20. 16 parts = key_string.split(".")
  21. 16 (1...parts.length).each do |i|
  22. 13 parent_key = parts[0...i].join(".")
  23. 13 else: 0 then: 13 @used_keys << parent_key unless parent_key.empty?
  24. end
  25. # Track keys used with scope option
  26. 16 then: 4 else: 12 if options[:scope]
  27. 4 scope = Array(options[:scope]).join(".")
  28. 4 full_key = "#{scope}.#{key_string}"
  29. 4 @used_keys << full_key
  30. # Track parent keys for scoped translations
  31. 4 full_parts = full_key.split(".")
  32. 4 (1...full_parts.length).each do |i|
  33. 7 parent_key = full_parts[0...i].join(".")
  34. 7 else: 0 then: 7 @used_keys << parent_key unless parent_key.empty?
  35. end
  36. end
  37. end
  38. 4 def all_locale_keys
  39. 9 @all_locale_keys ||= begin
  40. 1 keys = Set.new
  41. # Load all locale files
  42. 1 locale_files = Rails.root.glob("config/locales/**/*.yml")
  43. 1 locale_files.each do |file|
  44. 44 yaml_content = YAML.load_file(file)
  45. # Process each locale (en, es, etc.)
  46. 44 yaml_content.each do |locale, content|
  47. 44 extract_keys_from_hash(content, [], keys)
  48. end
  49. end
  50. 1 keys
  51. end
  52. end
  53. 4 def unused_keys
  54. 5 all_locale_keys - used_keys
  55. end
  56. 4 def usage_report
  57. 2 total_keys = all_locale_keys.size
  58. 2 used_count = used_keys.size
  59. 2 unused_count = unused_keys.size
  60. {
  61. 2 total_keys: total_keys,
  62. used_keys: used_count,
  63. unused_keys: unused_count,
  64. 2 usage_percentage: (used_count.to_f / total_keys * 100).round(2),
  65. unused_key_list: unused_keys.sort
  66. }
  67. end
  68. 4 private
  69. 4 def extract_keys_from_hash(hash, current_path, keys)
  70. 402 hash.each do |key, value|
  71. 1768 new_path = current_path + [key.to_s]
  72. 1768 full_key = new_path.join(".")
  73. 1768 then: 356 if value.is_a?(Hash)
  74. 356 keys << full_key
  75. 356 extract_keys_from_hash(value, new_path, keys)
  76. else: 1412 else
  77. 1412 keys << full_key
  78. end
  79. end
  80. end
  81. end
  82. # Reset on initialization
  83. 4 reset!
  84. # Add at_exit hook to save tracking results if enabled
  85. 4 at_exit do
  86. 4 then: 0 else: 4 if tracking_enabled && used_keys.any?
  87. Rails.root.join("tmp/i18n_tracking_results.json").write(used_keys.to_a.to_json)
  88. end
  89. end
  90. end
  91. # Monkey patch I18n.t to track usage
  92. 4 module I18n
  93. 4 class << self
  94. 4 alias_method :original_t, :t
  95. 4 alias_method :original_translate, :translate
  96. 4 def t(key, **options)
  97. 48733 then: 2 else: 48731 I18nUsageTracker.track_key(key, options) if I18nUsageTracker.tracking_enabled
  98. 48733 original_t(key, **options)
  99. end
  100. 4 def translate(key, **options)
  101. 54353 then: 1 else: 54352 I18nUsageTracker.track_key(key, options) if I18nUsageTracker.tracking_enabled
  102. 54353 original_translate(key, **options)
  103. end
  104. end
  105. end
  106. # Also track Rails view helpers
  107. 4 then: 4 else: 0 if defined?(ActionView::Helpers::TranslationHelper)
  108. 4 module ActionView::Helpers::TranslationHelper
  109. 4 alias_method :original_t, :t
  110. 4 alias_method :original_translate, :translate
  111. 4 def t(key, **options)
  112. 44740 then: 0 else: 44740 I18nUsageTracker.track_key(key, options) if I18nUsageTracker.tracking_enabled
  113. 44740 original_t(key, **options)
  114. end
  115. 4 def translate(key, **options)
  116. then: 0 else: 0 I18nUsageTracker.track_key(key, options) if I18nUsageTracker.tracking_enabled
  117. original_translate(key, **options)
  118. end
  119. end
  120. end

lib/rubocop/cop/custom/ternary_line_breaks.rb

100.0% lines covered

100.0% branches covered

26 relevant lines. 26 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module RuboCop
  3. 1 module Cop
  4. 1 module Custom
  5. # Enforces line breaks after ? and : in ternary operators when
  6. # line exceeds 80 characters
  7. #
  8. # @example
  9. # # bad (when line > 80 chars)
  10. # result = condition == 2 ? long_true_value : long_false_value
  11. #
  12. # # good
  13. # result = condition == 2 ?
  14. # long_true_value :
  15. # long_false_value
  16. #
  17. 1 class TernaryLineBreaks < Base
  18. 1 extend AutoCorrector
  19. 1 MSG = "Break ternary operator across multiple lines " \
  20. "when line exceeds 80 characters"
  21. 1 MAX_LINE_LENGTH = 80
  22. 1 def on_if(node)
  23. 16 else: 15 then: 1 return unless node.ternary?
  24. 15 else: 6 then: 9 return unless line_too_long?(node)
  25. 6 add_offense(node) do |corrector|
  26. 6 autocorrect(corrector, node)
  27. end
  28. end
  29. 1 private
  30. 1 def line_too_long?(node)
  31. 15 line = processed_source.lines[node.first_line - 1]
  32. 15 line.length > MAX_LINE_LENGTH
  33. end
  34. 1 def autocorrect(corrector, node)
  35. 6 condition = node.condition
  36. 6 if_branch = node.if_branch
  37. 6 else_branch = node.else_branch
  38. # Get the indentation of the line containing the ternary
  39. 6 indent = processed_source.lines[node.first_line - 1][/\A\s*/]
  40. 6 nested_indent = "#{indent} "
  41. # Build the corrected version
  42. 6 corrected = "#{condition.source} ?\n"
  43. 6 corrected << "#{nested_indent}#{if_branch.source} :\n"
  44. 6 corrected << "#{nested_indent}#{else_branch.source}"
  45. 6 corrector.replace(node, corrected)
  46. end
  47. end
  48. end
  49. end
  50. end

lib/s3_rake_helpers.rb

22.58% lines covered

0.0% branches covered

31 relevant lines. 7 lines covered and 24 lines missed.
6 total branches, 0 branches covered and 6 branches missed.
    
  1. # frozen_string_literal: true
  2. 4 require_relative "../app/services/concerns/s3_backup_operations"
  3. # Helper methods for S3 operations in rake tasks
  4. # These wrap the S3Helpers module methods to work in rake context
  5. 4 module S3RakeHelpers
  6. 4 include S3BackupOperations
  7. 4 def ensure_s3_enabled
  8. then: 0 else: 0 return if ENV["USE_S3_STORAGE"] == "true"
  9. error_msg = "S3 storage is not enabled. Set USE_S3_STORAGE=true in your .env file"
  10. Rails.logger.debug { "❌ #{error_msg}" }
  11. raise StandardError, error_msg
  12. end
  13. 4 def validate_s3_config
  14. required_vars = %w[S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY S3_BUCKET]
  15. missing_vars = required_vars.select { |var| ENV[var].blank? }
  16. then: 0 else: 0 if missing_vars.any?
  17. error_msg = "Missing required S3 environment variables: #{missing_vars.join(", ")}"
  18. Rails.logger.debug { "❌ #{error_msg}" }
  19. Sentry.capture_message(error_msg, level: "error", extra: {
  20. missing_vars: missing_vars,
  21. task: caller_locations(1, 1)[0].label,
  22. environment: Rails.env
  23. })
  24. raise StandardError, error_msg
  25. end
  26. end
  27. 4 def get_s3_service
  28. service = ActiveStorage::Blob.service
  29. else: 0 then: 0 unless service.is_a?(ActiveStorage::Service::S3Service)
  30. error_msg = "Active Storage is not configured to use S3. Current service: #{service.class.name}"
  31. Rails.logger.debug { "❌ #{error_msg}" }
  32. raise StandardError, error_msg
  33. end
  34. service
  35. end
  36. 4 def handle_s3_errors
  37. yield
  38. rescue Aws::S3::Errors::ServiceError => e
  39. Rails.logger.debug { "\n❌ S3 Error: #{e.message}" }
  40. Sentry.capture_exception(e)
  41. raise
  42. rescue => e
  43. Rails.logger.debug { "\n❌ Unexpected error: #{e.message}" }
  44. Sentry.capture_exception(e)
  45. raise
  46. end
  47. end

lib/seed_data.rb

100.0% lines covered

100.0% branches covered

78 relevant lines. 78 lines covered and 0 lines missed.
36 total branches, 36 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # This module provides field mappings for assessments
  3. # Used by both seeds and tests to ensure consistency
  4. 4 module SeedData
  5. 4 def self.check_passed?(inspection_passed)
  6. 18438 then: 16254 else: 2184 return true if inspection_passed
  7. 2184 rand < 0.9
  8. end
  9. 4 def self.check_passed_integer?(inspection_passed)
  10. 5460 then: 4818 else: 642 return :pass if inspection_passed
  11. 642 then: 593 else: 49 (rand < 0.9) ? :pass : :fail
  12. end
  13. 4 def self.user_fields
  14. {
  15. 17 email: "test#{SecureRandom.hex(8)}@example.com",
  16. password: "password123",
  17. password_confirmation: "password123",
  18. name: "Test User #{SecureRandom.hex(4)}",
  19. rpii_inspector_number: nil # Optional field
  20. }
  21. end
  22. 4 def self.unit_fields
  23. {
  24. 10 name: "Bouncy Castle #{%w[Mega Super Fun Party Adventure].sample} #{SecureRandom.hex(4)}",
  25. serial: "BC-#{Date.current.year}-#{SecureRandom.hex(4).upcase}",
  26. manufacturer: ["ABC Inflatables", "XYZ Bounce Co", "Fun Factory", "Party Products Ltd"].sample,
  27. operator: ["Rental Company #{SecureRandom.hex(2)}", "Party Hire #{SecureRandom.hex(2)}", "Events Ltd #{SecureRandom.hex(2)}"].sample,
  28. manufacture_date: Date.current - rand(365..1825).days,
  29. description: "Commercial grade inflatable bouncy castle suitable for events"
  30. }
  31. end
  32. 4 def self.inspection_fields(passed: true)
  33. {
  34. 15 inspection_date: Date.current,
  35. is_totally_enclosed: [true, false].sample,
  36. has_slide: [true, false].sample,
  37. indoor_only: [true, false].sample,
  38. width: rand(4.0..8.0).round(1),
  39. length: rand(5.0..10.0).round(1),
  40. height: rand(3.0..6.0).round(1)
  41. }
  42. end
  43. 4 def self.results_fields(passed: true)
  44. {
  45. 4 passed: passed,
  46. risk_assessment: "Low risk - all safety features functional and tested"
  47. }
  48. end
  49. 4 def self.anchorage_fields(passed: true)
  50. fields = {
  51. 368 num_low_anchors: rand(6..12),
  52. num_high_anchors: rand(4..8),
  53. num_low_anchors_pass: check_passed?(passed),
  54. num_high_anchors_pass: check_passed?(passed),
  55. anchor_accessories_pass: check_passed?(passed),
  56. anchor_degree_pass: check_passed?(passed),
  57. anchor_type_pass: check_passed?(passed),
  58. pull_strength_pass: check_passed?(passed)
  59. }
  60. 368 else: 326 then: 42 fields[:anchor_type_comment] = "Some wear visible on anchor points" unless passed
  61. 368 fields
  62. end
  63. 4 def self.structure_fields(passed: true)
  64. {
  65. 734 seam_integrity_pass: check_passed?(passed),
  66. air_loss_pass: check_passed?(passed),
  67. straight_walls_pass: check_passed?(passed),
  68. sharp_edges_pass: check_passed?(passed),
  69. unit_stable_pass: check_passed?(passed),
  70. stitch_length_pass: check_passed?(passed),
  71. step_ramp_size_pass: check_passed?(passed),
  72. platform_height_pass: check_passed?(passed),
  73. critical_fall_off_height_pass: check_passed?(passed),
  74. unit_pressure_pass: check_passed?(passed),
  75. trough_pass: check_passed?(passed),
  76. entrapment_pass: check_passed?(passed),
  77. markings_pass: check_passed?(passed),
  78. grounding_pass: check_passed?(passed),
  79. unit_pressure: rand(1.0..3.0).round(1),
  80. step_ramp_size: rand(200..400),
  81. platform_height: rand(500..1500),
  82. critical_fall_off_height: rand(500..2000),
  83. trough_depth: rand(30..80),
  84. trough_adjacent_panel_width: rand(300..1000),
  85. evacuation_time_pass: check_passed?(passed),
  86. 734 then: 648 seam_integrity_comment: if passed
  87. 648 "All seams in good condition"
  88. else: 86 else
  89. 86 "Minor thread loosening noted"
  90. end,
  91. stitch_length_comment: "Measured at regular intervals",
  92. platform_height_comment: "Platform height acceptable for age group"
  93. }
  94. end
  95. 4 def self.materials_fields(passed: true)
  96. fields = {
  97. 742 ropes: rand(18..45),
  98. ropes_pass: check_passed_integer?(passed),
  99. retention_netting_pass: check_passed_integer?(passed),
  100. zips_pass: check_passed_integer?(passed),
  101. windows_pass: check_passed_integer?(passed),
  102. artwork_pass: check_passed_integer?(passed),
  103. thread_pass: check_passed?(passed),
  104. fabric_strength_pass: check_passed?(passed),
  105. fire_retardant_pass: check_passed?(passed)
  106. }
  107. 742 then: 657 if passed
  108. 657 fields[:fabric_strength_comment] = "Fabric in good condition"
  109. else: 85 else
  110. 85 fields[:ropes_comment] = "Rope shows signs of wear"
  111. 85 fields[:fabric_strength_comment] = "Minor surface wear noted"
  112. end
  113. 742 fields
  114. end
  115. 4 def self.fan_fields(passed: true)
  116. {
  117. 734 blower_flap_pass: check_passed_integer?(passed),
  118. blower_finger_pass: check_passed?(passed),
  119. blower_visual_pass: check_passed?(passed),
  120. pat_pass: check_passed_integer?(passed),
  121. blower_serial: "FAN-#{SecureRandom.hex(6).upcase}",
  122. number_of_blowers: 1,
  123. blower_tube_length: rand(2.0..5.0).round(1),
  124. blower_tube_length_pass: check_passed?(passed),
  125. 734 then: 649 fan_size_type: if passed
  126. 649 "Fan operating correctly at optimal pressure"
  127. else: 85 else
  128. 85 "Fan requires servicing"
  129. end,
  130. 734 then: 649 blower_flap_comment: if passed
  131. 649 "Flap mechanism functioning correctly"
  132. else: 85 else
  133. 85 "Flap sticking occasionally"
  134. end,
  135. 734 then: 649 blower_finger_comment: if passed
  136. 649 "Guard secure, no finger trap hazards"
  137. else: 85 else
  138. 85 "Guard needs tightening"
  139. end,
  140. 734 then: 649 blower_visual_comment: if passed
  141. 649 "Visual inspection satisfactory"
  142. else: 85 else
  143. 85 "Some wear visible on housing"
  144. end,
  145. 734 then: 649 pat_comment: if passed
  146. 649 "PAT test valid until #{(Date.current + 6.months).strftime("%B %Y")}"
  147. else: 85 else
  148. 85 "PAT test overdue"
  149. end
  150. }
  151. end
  152. 4 def self.user_height_fields(passed: true)
  153. {
  154. 733 containing_wall_height: rand(1.0..2.0).round(1),
  155. users_at_1000mm: rand(0..5),
  156. users_at_1200mm: rand(2..8),
  157. users_at_1500mm: rand(4..10),
  158. users_at_1800mm: rand(2..6),
  159. custom_user_height_comment: "Sample custom height comments",
  160. play_area_length: rand(3.0..10.0).round(1),
  161. play_area_width: rand(3.0..8.0).round(1),
  162. negative_adjustment: rand(0..2.0).round(1),
  163. containing_wall_height_comment: "Measured from base to top of wall",
  164. play_area_length_comment: "Effective play area after deducting obstacles",
  165. play_area_width_comment: "Width measured at narrowest point"
  166. }
  167. end
  168. 4 def self.slide_fields(passed: true)
  169. 282 platform_height = rand(2.0..6.0).round(1)
  170. # Use the actual SafetyStandard calculation for consistency
  171. 282 required_runout = EN14960.calculate_slide_runout(platform_height).value
  172. 282 then: 235 runout = if passed
  173. 235 (required_runout + rand(0.5..1.5)).round(1)
  174. else: 47 else
  175. 47 fail_margin = rand(0.1..0.3)
  176. 47 (required_runout - fail_margin)
  177. end
  178. {
  179. 282 slide_platform_height: platform_height,
  180. slide_wall_height: rand(1.0..2.0).round(1),
  181. runout: runout,
  182. slide_first_metre_height: rand(0.3..0.8).round(1),
  183. slide_beyond_first_metre_height: rand(0.8..1.5).round(1),
  184. clamber_netting_pass: check_passed_integer?(passed),
  185. runout_pass: check_passed?(passed),
  186. slip_sheet_pass: check_passed?(passed),
  187. slide_permanent_roof: false,
  188. 282 then: 235 slide_platform_height_comment: if passed
  189. 235 "Platform height compliant with EN 14960:2019"
  190. else: 47 else
  191. 47 "Platform height exceeds recommended limits"
  192. end,
  193. slide_wall_height_comment: "Wall height measured from slide bed",
  194. 282 then: 235 runout_comment: if passed
  195. 235 "Runout area clear and adequate"
  196. else: 47 else
  197. 47 "Runout area needs extending"
  198. end,
  199. 282 then: 235 clamber_netting_comment: if passed
  200. 235 "Netting secure with no gaps"
  201. else: 47 else
  202. 47 "Some gaps in netting need attention"
  203. end,
  204. 282 then: 235 slip_sheet_comment: if passed
  205. 235 "Slip sheet in good condition"
  206. else: 47 else
  207. 47 "Slip sheet showing wear"
  208. end
  209. }
  210. end
  211. 4 def self.enclosed_fields(passed: true)
  212. {
  213. 114 exit_number: rand(1..3),
  214. exit_number_pass: check_passed?(passed),
  215. exit_sign_always_visible_pass: check_passed?(passed),
  216. 114 then: 95 exit_number_comment: if passed
  217. 95 "Number of exits compliant with unit size"
  218. else: 19 else
  219. 19 "Additional exit required"
  220. end,
  221. 114 then: 95 exit_sign_always_visible_comment: if passed
  222. 95 "Exit signs visible from all points"
  223. else: 19 else
  224. 19 "Exit signs obscured from some angles"
  225. end
  226. }
  227. end
  228. end

lib/test_data_helpers.rb

100.0% lines covered

100.0% branches covered

18 relevant lines. 18 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # typed: false
  2. # Shared test data helpers for generating realistic British data
  3. # Used by both factories and seeds for non-critical test data generation
  4. 4 module TestDataHelpers
  5. # Generate realistic UK mobile numbers (07xxx xxx xxx - 11 digits)
  6. 4 def self.british_phone_number
  7. 22 "07#{rand(100..999)} #{rand(100..999)} #{rand(100..999)}"
  8. end
  9. # Generate realistic UK postcodes
  10. 4 def self.british_postcode
  11. 22 prefixes = %w[SW SE NW N E W EC WC B M L G EH CF BS OX CB]
  12. 22 prefix = prefixes.sample
  13. 22 letters = ("A".."Z").to_a
  14. 22 "#{prefix}#{rand(1..20)} #{rand(1..9)}#{letters.sample}#{letters.sample}"
  15. end
  16. # Generate realistic UK street addresses
  17. 4 def self.british_address
  18. streets = %w[
  19. 22 High\ Street
  20. Church\ Lane
  21. Victoria\ Road
  22. King's\ Road
  23. Queen\ Street
  24. Park\ Avenue
  25. Station\ Road
  26. London\ Road
  27. Market\ Square
  28. The\ Green
  29. ]
  30. 22 numbers = (1..200).to_a
  31. 22 "#{numbers.sample} #{streets.sample}"
  32. end
  33. # Common British cities
  34. BRITISH_CITIES = %w[
  35. 4 London
  36. Birmingham
  37. Manchester
  38. Leeds
  39. Liverpool
  40. Newcastle
  41. Bristol
  42. Sheffield
  43. Nottingham
  44. Leicester
  45. Oxford
  46. Cambridge
  47. Brighton
  48. Southampton
  49. Edinburgh
  50. Glasgow
  51. Cardiff
  52. Belfast
  53. ].freeze
  54. 4 def self.british_city
  55. 28 BRITISH_CITIES.sample
  56. end
  57. # Generate a British company name variation
  58. 4 def self.british_company_name(base_name)
  59. 15 suffixes = ["Ltd", "UK", "Services", "Solutions", "Group", "& Co"]
  60. 15 "#{base_name} #{suffixes.sample}"
  61. end
  62. end